diff --git a/docs/approver-surface-requirements.md b/docs/approver-surface-requirements.md new file mode 100644 index 0000000..b01275b --- /dev/null +++ b/docs/approver-surface-requirements.md @@ -0,0 +1,189 @@ +# 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` — exactly `approval-engine` (the resource server, never the OAuth client id) +- `tenant` — exactly `tenant: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. `platform` and + `tenant:coulomb` are refused (`tests/test_auth.py::test_near_miss_tenant_spellings_are_forbidden`). +- `principal_type` — `human` for an approver. `service` and `agent` are the only + values accepted at `/consume`; `human` is 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: + +1. **Add `approval:read` to the human client.** Simplest, and keeps the read + attributable to the human who saw it — which matters, because + `informed-decision`'s whole thesis is evidence of *what this person was + shown*. A read performed under a different identity weakens that. +2. **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:`. 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-decision` holds the presentation record and computes + `view_hash` / `awareness_hash`; +- it emits that record to `audit-core` as 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: + +1. the exact `client_id` string, and +2. 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`.