approval-engine/docs/approver-surface-requirements.md
tegwick 31da1af5e4 Record the verified principal type on approver entries (schema v4)
An entry stored subject_id, assurance and evidence_ref but nothing about
what kind of principal bound the approval, and subject_id is a naming
convention rather than a verified claim. /entries is not restricted by
principal type — only /consume is — and the approval-engine-operator
client holds approval:approve, so a service can supply approver evidence
today. Whether it may is gate-house doctrine; that it is legible is ours.

Add entries.principal_type, populate it from the verified token, surface
it on the object and the audit evidence path (not the claim, which stays
least-disclosure), and migrate v3 stores leaving legacy rows null rather
than back-filling a claim nobody made.

Also corrects two statements in the requirements issued to
informed-decision: agent tokens are not barred from approval:approve, and
an empty assurance object is accepted rather than refused.

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
2026-09-09 14:09:54 +02:00

11 KiB

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_typehuman 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.