approval-engine/docs/approver-surface-requirements.md

205 lines
11 KiB
Markdown
Raw Normal View History

# 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.
**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:
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. 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-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`.