State approval-engine's requirements on the informed-decision approver surface

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
This commit is contained in:
tegwick 2026-09-09 12:48:34 +02:00
parent b46b0f2666
commit 5203f46e47

View file

@ -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:<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-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`.