An entry stored subject_id, assurance and evidence_ref but nothing about what kind of principal bound the approval, and subject_id is a naming convention rather than a verified claim. /entries is not restricted by principal type — only /consume is — and the approval-engine-operator client holds approval:approve, so a service can supply approver evidence today. Whether it may is gate-house doctrine; that it is legible is ours. Add entries.principal_type, populate it from the verified token, surface it on the object and the audit evidence path (not the claim, which stays least-disclosure), and migrate v3 stores leaving legacy rows null rather than back-filling a claim nobody made. Also corrects two statements in the requirements issued to informed-decision: agent tokens are not barred from approval:approve, and an empty assurance object is accepted rather than refused. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HyybaE7DUXrWYrhbnESCTe Assistant: claude-code Assistant-Model: opus Assistant-Process: 1275879@bnt-lap001 Assistant-Session: eb464208-f821-41b2-bc5a-a6c33d92a8ad
11 KiB
Requirements on the approver surface (informed-decision)
Status: issued by approval-engine 2026-09-09, against APPROVAL-WP-0002-T01.
Addressed to informed-decision (INFD-WP-0001), copied to key-cape
(KEY-WP-0013-T02).
informed-decision has claimed the browser-facing approver surface that
docs/keycape-service-registrations.md named as an unassigned dependency on
2026-09-08. This file states what this engine requires of that surface, and —
more usefully — the three places where the current contract does not yet fit
and someone has to decide rather than guess.
This engine owns the approval object and its lifecycle. It does not decide
whether an actor may approve (access-engine), does not author approval
doctrine (gate-house), and does not authenticate anyone (key-cape). Nothing
below asks informed-decision to take on any of those either.
1. The surface we actually expose
There are exactly two routes an approver surface needs, and one it must never call:
| Route | Method | Scope | Purpose |
|---|---|---|---|
/v1/approvals/{id} |
GET | approval:read |
fetch the approval object to render |
/v1/approvals/{id}/claim |
GET | approval:read |
fetch the published claim |
/v1/approvals/{id}/entries |
POST | approval:approve |
record one approver's entry |
/v1/approvals/{id}/consume |
POST | approval:consume |
never — service/agent principals only, refused in code for principal_type: human |
approval_engine/api.py rejects any path containing check or ending in
/authorize with 404 on purpose. There is no "may this person approve"
surface here and there will not be one.
2. Hard requirements on the access token
The token presented as Authorization: Bearer on every call must verify against
the deployed issuer's /jwks (RS256) and carry:
aud— exactlyapproval-engine(the resource server, never the OAuth client id)tenant— exactlytenant:platform, compared with exact string equality against the store tenant before any object lookup (approval_engine/api.py:66). No alias, no normalisation, no prefix handling.platformandtenant:coulombare refused (tests/test_auth.py::test_near_miss_tenant_spellings_are_forbidden).principal_type—humanfor an approver.serviceandagentare the only values accepted at/consume;humanis refused there.sub— non-empty; this becomes the approver's identity in the entry. It is the only thing that identifies the approver: see §5.scope— non-empty, and containing the exact scope for the route.assurance— a JSON object, and non-null. See §4.iss,exp,iat— verified, 30s leeway.roles— may be empty, but the claim must be present and well-formed.
The access token is what we validate. id_token appears nowhere in this
codebase; the ID token belongs to the login client and is never evidence here.
Correction to our 2026-09-09 message. We said an agent token cannot hold
approval:approve under the requested registrations. That is wrong twice: the
approval-engine-operator service client holds approval:approve, and this
engine does not restrict /entries by principal type at all — only /consume
is restricted, to service/agent. So a non-human principal can supply
approver evidence today.
Whether it should be able to is approval doctrine and belongs to
gate-house, not to us and not to you. What we have done instead is make it
visible: schema v4 records the verified principal_type on every entry
(tests/test_auth.py::test_entry_records_the_verified_principal_type,
::test_non_human_approver_is_recorded_as_such). Your design principle 10
("humans bind, agents draft") is therefore enforceable in the evidence chain
rather than assumed — read entries[].principal_type and do not infer the
answer from the shape of subject_id. Entries written before v4 are null and
must not be read as human.
3. Open question A — the human client cannot read the approval it renders
key-cape specified the human client as allowedScopes exactly
[openid, approval:approve], with every other approval grant deliberately
excluded. But rendering a decision memo requires GET /v1/approvals/{id}, which
requires approval:read. As specified, the approver surface can submit an
entry it was never able to display.
Two ways out, and this engine does not get to pick:
- Add
approval:readto the human client. Simplest, and keeps the read attributable to the human who saw it — which matters, becauseinformed-decision's whole thesis is evidence of what this person was shown. A read performed under a different identity weakens that. - Read through a confidential service client owned by
informed-decision(backend-for-frontend), with the human token used only for the entry.
If (2) is chosen, note the trap explicitly: this engine answering 200 to a
service client is not a statement that the human on the other side is
entitled to see that approval. approval-engine never answers "may this actor
do X". Under (2), informed-decision owes an access-engine check before
rendering; under (1) it still owes one, but at least the read is not
impersonated. We recommend (1).
This needs a key-cape decision, since it changes the registration they have
already implemented.
4. Open question B — assurance is what we record, so it must mean something
add_entry persists the verified assurance claim verbatim
(approval_engine/api.py, store.add_entry), serialized as sorted JSON. This
engine validates only that it is an object; it does not interpret the contents.
That makes assurance the single place where "this approval was bound under
MFA" survives into the approval record. key-cape specified mfaRequired: true
on the human client — the requirement here is that the resulting token's
assurance object actually carries that fact in a stable, documented shape
(authentication method, acr/amr, auth_time at minimum), rather than
leaving MFA as a property of the login that nothing downstream can see.
informed-decision and key-cape own that shape between them. To be exact
about what this engine does and does not do: an empty assurance object
({}) is accepted — validation checks only that the claim is present and is
an object. So an empty assurance is not refused here; it simply produces an
entry that evidences nothing about how the person authenticated. If MFA has to
be provable from the approval record, the shape has to carry it, because
nothing downstream reconstructs it.
5. Open question C — view_hash has nowhere to go today
POST /v1/approvals/{id}/entries reads the request body and discards it.
Approver identity, assurance and evidence_ref come only from the verified
token; evidence_ref is jwt-sha256:<digest of the presented token>. This is
deliberate — an API-supplied approver field is provenance, not evidence — but it
means informed-decision's view_hash cannot currently ride into the approval
entry.
For v1 the workable arrangement, requiring no change here:
informed-decisionholds the presentation record and computesview_hash/awareness_hash;- it emits that record to
audit-coreas its own evidence; - correlation to the approval entry is by
(approval_id, subject, approved_at), all three of which this engine returns in the object after a successful entry.
If the estate decides the binding must be inside the approval object — that an
entry is not admissible without the hash of the view that produced it — that is
a real change request against this engine (an optional, verified-alongside-token
view_hash on the entry), and it is doctrine, so it goes to gate-house
before it goes to us. We are not going to add an unauthenticated free-text field
to the one record that currently contains no caller-supplied data.
6. There is no inbox endpoint, by construction
This engine exposes get-by-id only. There is no GET /v1/approvals list, no
search, and no "approvals awaiting me" query. An approvals inbox is named twice
in this repo's INTENT.md Non-Goals.
So informed-decision must receive approval ids from whatever requested the
approval (or from gate-house doctrine), not by polling this engine. If a
listing surface is genuinely needed, raise it as a change request — but note
that "which approvals may this person see" is an authorization question, so any
such endpoint would have to be scoped by something other than this engine's own
judgment.
7. Behaviour the surface must handle
Map these to the disposition vocabulary rather than swallowing them:
| Status | Reason code | Means |
|---|---|---|
| 401 | unauthenticated |
missing/expired/unverifiable token — re-auth, never retry blindly |
| 403 | forbidden |
wrong scope, wrong tenant, or human principal at /consume |
| 404 | not_found |
no such approval in this tenant's store |
| 409 | duplicate_approver |
this subject already has an entry — the person is done, not failed |
| 409 | conflict |
approval is in a terminal status (revoked, superseded, consumed, expired) — cannot add entries |
| 422 | unprocessable |
malformed request |
| 503 | store_unavailable |
store down; fail closed, do not present success |
duplicate_approver deserves particular care: a double-submit from a browser is
routine, and rendering it as an error is a lie. The first entry stands.
An entry that satisfies required_count flips the approval to approved in the
same transaction; the response body is the updated object. Read the status from
the response rather than inferring it from the fact that the POST returned 200.
The engine never returns a decision. _assert_not_decision actively refuses
to serialize one. approved is a state of an object, not permission to act; the
permission question is access-engine's. A surface that renders "approved" as
"you may now do the thing" has re-implemented a PDP in the browser.
8. What we need back, and what we do not
We need nothing from informed-decision to unblock our own work — T01's
repository half is complete. What key-cape needs, and what only
informed-decision can now supply, is the pair it asked for on 2026-09-08:
- the exact
client_idstring, and - the full callback URI, scheme and path included.
Redirects are matched exactly at /authorize, so a near-miss fails closed.
Those two strings are informed-decision's to choose and, once chosen, to keep
stable — they are deployment inputs, not implementation details, and changing
one silently breaks the login rather than degrading it.
We do not need to review the UI, the disposition vocabulary, or the memo schema.
return, discuss, escalate and the rest are dispositions of a memo; this
engine models only entries against an approval object and knows nothing of them.
That separation is correct and we would rather it stayed that way — but it means
a memo return must not be represented here as anything at all, and a memo
accept is the only disposition that becomes a POST …/entries.