Revise specs against approval-engine's approver-surface requirements

approval-engine replied to INFD-IN-0001 with docs/approver-surface-requirements.md
(31da1af, 5203f46) and two corrections. Several of my requirements were wrong or
incomplete; revised rather than appended to.

Corrected:
- PR-02 listed only approval:approve. Wrong — the surface also needs
  approval:read to fetch what it renders. As drafted it would have shipped a
  client able to submit an entry it could never display. That changes a
  registration key-cape has already implemented, so it is their call (open
  question A).
- NC-03 implied approval-engine refuses non-human approver entries. It does not;
  only /consume is principal-restricted, and the operator service client holds
  approval:approve. Enforcement of "humans bind, agents draft" is therefore ours
  alone, and is auditable via schema v4's entries[].principal_type — never from
  the shape of subject_id.

Added:
- PR-04 assurance shape. approval-engine persists it verbatim and accepts an
  empty object, so it is the only place MFA survives into the approval record.
  Needs auth method, acr/amr, auth_time, agreed with key-cape.
- PR-05 entitlement. A 200 from the engine is not permission to view; we owe
  access-engine a check before rendering. Consuming a decision, not making one.
- PR-06 response mapping, including 409 duplicate_approver rendered as SUCCESS
  (a browser double-submit is routine and the first entry stands) and 503 as
  fail-closed.
- PR-07 and a matching INTENT wrongness condition: never render `approved` as
  permission to act. That is a PDP in the browser wearing UI copy.
- L-05, L-06 and EvidenceModel 8b: view_hash cannot ride into the entry — the
  POST discards its body by design — so Stage 1 correlates by (approval_id,
  subject, approved_at). DoD-3 is satisfied by the triple, not by a stored hash.

PRD open question 1 is answered by construction: there is no inbox endpoint and
there will not be one, so the approvals-inbox shape is foreclosed upstream.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V3W1dQG7GFFM9d94jFx7iR

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 1565372@bnt-lap001
Assistant-Session: 16bb2f25-b34c-49ef-8e94-5fec3567a568
This commit is contained in:
tegwick 2026-09-09 14:20:13 +02:00
parent 23fba00b1b
commit 08b190928f
5 changed files with 210 additions and 17 deletions

View file

@ -189,6 +189,53 @@ between the two. Three candidate outcomes and their costs are set out in
avoided is both digests shipping with no stated authority rule, leaving the
estate with two canonicalizations of one act.
## 8b. Where `view_hash` actually lives (Stage 1)
**It is not in the approval entry, by the engine's deliberate design.**
`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>`). The
reasoning is sound and we adopt it rather than argue it: a caller-supplied
approver field is provenance, not evidence, and that entry is currently the one
record containing no caller-supplied data.
Stage 1 arrangement, requiring no change to `approval-engine`:
1. this repository holds the presentation record and computes both hashes;
2. it emits that record to `audit-core` as its own evidence;
3. correlation to the approval entry is by **`(approval_id, subject,
approved_at)`** — all three returned by the engine after a successful entry.
**Consequence for an auditor, stated plainly:** someone holding only the
approval object cannot reach the presentation. They need this surface's record
or `audit-core` as well. `GOAL.md` DoD-3 — "the approval entry is
reconstructable from a `view_hash`" — is satisfied by the correlation triple,
not by a hash stored on the entry.
Putting the binding *inside* the approval object would be a real change request
against `approval-engine` (an optional, verified-alongside-token `view_hash` on
the entry) and it is doctrine before it is a change request, so it goes to
`gate-house` first. Not raised at Stage 1; recorded here so the option is not
lost.
### `assurance` is the only carrier of authentication facts
`approval-engine` persists the token's `assurance` claim verbatim and
interprets nothing — an empty `{}` is *accepted* and evidences nothing. It is
therefore the single place where "this was bound under MFA" survives into the
approval record. The shape must carry authentication method, `acr`/`amr` and
`auth_time` at minimum, and `informed-decision` and `key-cape` own it between
them. Nothing downstream reconstructs it.
### Principal type is verified evidence; `subject_id` is not
Schema v4 records `entries[].principal_type` from the verified token. Design
principle 10 (humans bind, agents draft) is enforceable from the evidence chain
by reading that field. Never infer it from the shape of `subject_id` — a naming
convention is not a verified claim. Entries written before v4 are `null` and
must not be read as `human`.
## 9. Signed attributes (L4+, horizon)
When AES/QES arrives, the signed attributes carry `memo_id`, `memo_version`,

View file

@ -3,8 +3,10 @@
**Repo:** informed-decision
**Stage:** 1 — the L3 approval approver surface
**Workplan task:** `INFD-WP-0001-T03`
**Status:** draft, pending review against the current `approval-engine`,
`key-cape`, `access-engine` and `audit-core` contracts
**Status:** draft. **Revised 2026-09-09** against
`approval-engine/docs/approver-surface-requirements.md` (commits `31da1af`,
`5203f46`) and two corrections `approval-engine` sent with it. Revised
requirements are marked **[rev-1]**
**Companion specs:** `UseCaseCatalog.md`, `ArchitectureBlueprint.md` (gated on
`INFD-WP-0001-T02`), `EvidenceModel.md`
@ -81,13 +83,82 @@ assurance level of its own.
authentication failure yields no session.
`trace: DoD-2, P-9, GOAL invariant "Identity is imported"`
**PR-02 — Token shape is fixed by the resource server.**
Tokens presented to `approval-engine` carry `aud=approval-engine`,
`principal_type: human`, `tenant: tenant:platform`, scope `approval:approve`.
The surface never requests `approval:consume`.
**PR-02 [rev-1] — Token shape is fixed by the resource server.**
Tokens presented to `approval-engine` carry `aud=approval-engine` (the resource
server, never the OAuth client id), `principal_type: human`, `tenant:
tenant:platform` compared by **exact string equality** (no alias, no prefix
handling — `platform` and `tenant:coulomb` are refused), non-empty `sub` and
`scope`, `assurance` as a non-null JSON object, and RS256 verification against
the issuer's `/jwks`. The access token is the evidence; `id_token` is never
presented here.
Scopes required: **`approval:read`** to fetch the approval and its claim, and
**`approval:approve`** to record an entry. The surface never holds
`approval:consume`.
*Pass:* a token request including `approval:consume` fails a build-time check;
`approval-engine`'s verifier accepts the issued token.
`trace: approval-engine docs/keycape-service-registrations.md; DoD-2`
`approval-engine`'s verifier accepts the issued token on both routes.
`trace: approval-engine docs/approver-surface-requirements.md §1-2; DoD-2`
*Correction from the first draft:* this requirement previously listed only
`approval:approve`. That was wrong and would have shipped a surface able to
submit an entry it was never able to display — `key-cape` specified the human
client as `[openid, approval:approve]`. Raised as `approval-engine` open
question A; see PR-05.
**PR-04 [rev-1] — `assurance` carries authentication facts in a documented
shape.**
`approval-engine` persists the token's `assurance` claim verbatim into the
approver entry and interprets nothing. It is therefore the **only** place where
"this approval was bound under MFA" survives into the approval record. An empty
object `{}` is *accepted* by the engine and evidences nothing.
The shape must carry, at minimum: authentication method, `acr`/`amr`, and
`auth_time`. `informed-decision` and `key-cape` own it between them.
*Pass:* a documented `assurance` shape exists and is agreed with `key-cape`; an
entry produced by this surface allows MFA to be established from the approval
record alone.
`trace: approval-engine §4 open question B and its 2026-09-09 correction`
**PR-05 [rev-1] — Entitlement to view is checked before rendering.**
`approval-engine` returning `200` is **not** a statement that this human is
entitled to see the approval — it never answers "may this actor do X". The
surface owes `access-engine` a check before rendering an approval to a person.
Consuming a decision is not rendering one: this requirement does not make the
surface a PDP, and satisfying it must not be done by local judgment.
*Pass:* no approval is rendered without a decision obtained from
`access-engine`; no local rule substitutes for it when `access-engine` is
unavailable (fail closed — see PR-06).
`trace: approval-engine §3; DoD-8; W-"It starts deciding"`
**PR-06 [rev-1] — Engine responses map to the vocabulary, and are not swallowed.**
| Status | Reason | Required surface behaviour |
| --- | --- | --- |
| 401 | `unauthenticated` | re-authenticate; never retry blindly |
| 403 | `forbidden` | wrong scope/tenant; surface as a boundary error, not a decline |
| 404 | `not_found` | no such approval in this tenant |
| 409 | `duplicate_approver` | **render as SUCCESS.** This subject already has an entry; the first stands |
| 409 | `conflict` | approval is terminal (revoked/superseded/consumed/expired); the act is no longer available |
| 422 | `unprocessable` | malformed request — a defect in this surface |
| 503 | `store_unavailable` | **fail closed.** Never present success |
A browser double-submit producing `duplicate_approver` is routine; rendering it
as a failure is a lie to the approver.
*Pass:* each row has a test; `duplicate_approver` produces a success state and
`503` produces no success state under any timing.
`trace: approval-engine §7`
**PR-07 [rev-1] — `approved` is never rendered as permission to act.**
`approved` is a state of an object. `approval-engine` actively refuses to
serialize a decision (`_assert_not_decision`). A surface that renders "approved"
as "you may now do the thing" has re-implemented a PDP in the browser.
Approval status is read from the response body, never inferred from a `200`.
*Pass:* no copy or affordance in the surface presents approval status as
authorization; status is read from the response object.
`trace: approval-engine §7; W-"It starts deciding"`
**PR-03 — The surface displays which identity and which scope are being used.**
Before any binding act, the approver sees the identity they are acting as and
@ -120,13 +191,24 @@ sections. This is a legal exposure, not a copy preference.
reviewed and signed off explicitly as a requirement, not left to implementation.
`trace: P-8, W-"Highlights shrink the legal object"; exploration open question 5`
**PR-13 — Every render creates a presentation record.**
**PR-13 [rev-1] — Every render creates a presentation record.**
Carrying memo id, memo version, locale, UI release, timestamp, principal, and
the computed `view_hash` and `awareness_hash`.
*Pass:* rendering a memo twice creates two presentation records; a disposition
references exactly one.
`trace: DoD-3, DoD-5, guard G_PRES`
*Constraint discovered 2026-09-09:* `POST /entries` **reads the request body and
discards it**. Approver identity, `assurance` and `evidence_ref` come only from
the verified token, deliberately — a caller-supplied approver field is
provenance, not evidence. So `view_hash` cannot ride into the approval entry.
Stage 1 therefore holds the presentation record here, emits it to `audit-core`,
and correlates to the entry by **`(approval_id, subject, approved_at)`**, all
three returned by the engine after a successful entry. Putting the hash *inside*
the approval object is a change request against `approval-engine` and is
doctrine first — it goes to `gate-house`. `GOAL.md` DoD-3 is satisfied by the
correlation triple, not by a hash stored on the entry.
**PR-14 — A disposition binds to a presentation of the same memo version.**
A bind attempt against a stale version is refused, not silently upgraded.
*Pass:* incrementing the memo version invalidates outstanding presentations; a
@ -289,8 +371,19 @@ by behavioural telemetry.
Decline is not hidden behind a secondary menu. No pre-checked acknowledgments.
No confirm-shaming copy.
**PR-73 — No auto-approval, no delegation-to-agent, no "approve all".** Bulk
selection may exist for reading; it must not exist for binding.
**PR-73 [rev-1] — No auto-approval, no delegation-to-agent, no "approve all".**
Bulk selection may exist for reading; it must not exist for binding.
*Correction:* the first draft assumed `approval-engine` would refuse a non-human
approver entry. It does not — only `/consume` is restricted by principal type,
and the `approval-engine-operator` service client holds `approval:approve`. So a
non-human principal **can** supply approver evidence today. Whether it should is
`gate-house` doctrine. Enforcement of "humans bind, agents draft" is therefore
this surface's own obligation, and is *auditable* via schema v4's
`entries[].principal_type`, which records the verified value from the token.
Read that field; **never** infer it from the shape of `subject_id`, which is a
naming convention, not a verified claim. Entries written before v4 are `null`
and must not be read as `human`.
**PR-74 — No approval-state caching.** The surface never stores or serves whether
an approval is valid. It asks `approval-engine`.
@ -343,14 +436,29 @@ organizational is implemented or claimed.
Filed as `INFD-IN-0001` R3. Until ruled, the surface computes `view_hash` and
does not assert any relationship to the approval claim's digest.
**L-05 — `view_hash` is not inside the approval entry.** By the engine's
deliberate design (PR-13). Correlation is by `(approval_id, subject,
approved_at)`. An auditor holding only the approval object cannot reach the
presentation without also holding this surface's record or `audit-core`.
**L-06 — A memo `return` is invisible to `approval-engine`.** `accept` is the
only disposition that becomes a `POST …/entries`. `return`, `discuss`,
`escalate` and the rest are dispositions of a *memo* and are not represented in
that engine at all. Correct separation, but it means the approval object alone
does not show that a human sent the question back.
---
## 7. Open questions for review
1. Does the approver need to see *other* pending memos to prioritise, and does
that reintroduce the approvals-inbox shape this repository is supposed to
avoid? Proposed answer: a list is permitted; a list that is the product is
not.
1. ~~Does the approver need to see other pending memos?~~ **Answered
2026-09-09 by construction.** `approval-engine` exposes get-by-id only: no
list, no search, no "awaiting me" query, and there will not be one. Approval
ids reach this surface from whatever requested the approval, never by
polling. A listing surface would itself be an authorization question and
would need scoping by something other than that engine's judgment. The
approvals-inbox shape is foreclosed upstream, which is the outcome this
repository wanted anyway.
2. What does the surface do when `approval-engine` is reachable but
`audit-core` is not? Fail closed on binding, or bind and queue? The
`ArchitectureBlueprint` must answer this per-dependency (T05).

View file

@ -144,6 +144,25 @@ supplied by the requester.
**Verbs:** `accept` (submits the approval entry), `decline`, `return` with coded
reasons, `discuss`, `forward`, `escalate`, `comment`.
**Only `accept` reaches the engine.** `approval-engine` models entries against
an approval object and knows nothing of memo dispositions. `accept` becomes a
`POST /v1/approvals/{id}/entries`; `return`, `discuss`, `escalate` and the rest
are memo-level and must not be represented in that engine at all. This is the
correct separation, and it means the approval object alone does not show that a
human sent the question back — see `EvidenceModel.md` §8 and PRD L-06.
**Routes used:** `GET /v1/approvals/{id}` and `GET /v1/approvals/{id}/claim`
(scope `approval:read`), `POST /v1/approvals/{id}/entries` (scope
`approval:approve`). Never `/consume`. Any path containing `check` or ending in
`/authorize` returns 404 by design — there is no "may this person approve"
surface to call.
**Entitlement:** a `200` from the engine is not a statement that this human may
see the approval. `access-engine` is asked before rendering (PR-05).
**No inbox.** The engine exposes get-by-id only — no list, no search, no
"awaiting me". Approval ids arrive from whatever requested the approval.
**Boundary — the part that must not drift:**
- The surface calls `approval-engine`'s approval-entry mutation with a token
@ -217,10 +236,24 @@ of informedness the model has.
### NC-03 — Agent attempts a disposition
**Attempt:** a principal with `actor.kind = agent` submits any binding verb.
**Required behaviour:** refused at the API, not merely hidden in the UI.
**Required behaviour:** refused at *this repository's* API, not merely hidden in
the UI.
**Invariant protected:** humans bind, agents draft.
`guard G_NOAGENT · P-10 · W-"An agent binds"`
**Corrected 2026-09-09.** The first draft implied the refusal was also
`approval-engine`'s. It is not: that engine restricts only `/consume` by
principal type, and its `approval-engine-operator` service client holds
`approval:approve`, so a non-human principal can supply approver evidence there
today. Whether it should is `gate-house` doctrine, not ours.
Two consequences. First, enforcement of P-10 is **this surface's own
obligation** — there is no upstream backstop. Second, it is *auditable*:
schema v4 records the verified `entries[].principal_type` from the token. Read
that field. **Never** infer the answer from the shape of `subject_id`, which is
a naming convention, not a verified claim; entries predating v4 are `null` and
must not be read as `human`.
### NC-04 — Tenant switch requiring a new bind
**Attempt:** after an L0/L1 bind, the subject selects a tenant marked