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:
parent
b46b0f2666
commit
5203f46e47
1 changed files with 189 additions and 0 deletions
189
docs/approver-surface-requirements.md
Normal file
189
docs/approver-surface-requirements.md
Normal 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`.
|
||||
Loading…
Add table
Add a link
Reference in a new issue