190 lines
10 KiB
Markdown
190 lines
10 KiB
Markdown
|
|
# 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`.
|