informed-decision (INFD-WP-0001) has claimed the browser-facing approver UI this repo disowned. Write down what this engine requires of it, and name the three places the current contract does not fit: the human client lacks approval:read for the object it must render, the assurance claim is the only place MFA survives into the record and must be given a shape, and view_hash has nowhere to ride into an entry whose body is discarded by design. Also record that there is no inbox endpoint and never will be. 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
10 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.
Agents may assemble a memo but must never complete a binding act — that is
informed-decision's own design principle 10, and it matches this engine: an
agent token cannot hold approval:approve under the requested registrations,
and an entry recorded under an agent subject would be indistinguishable from a
human's in the evidence chain.
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. This engine will
store whatever they agree on and will not silently accept an empty object as
evidence of anything.
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.