From 08b190928f3d4ae25c18247193a02e6465967642 Mon Sep 17 00:00:00 2001 From: tegwick Date: Wed, 9 Sep 2026 14:20:13 +0200 Subject: [PATCH] Revise specs against approval-engine's approver-surface requirements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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 --- INTENT.md | 5 + WORK-RECORDS.md | 2 +- docs/specs/EvidenceModel.md | 47 ++++++++ docs/specs/ProductRequirementsDocument.md | 138 +++++++++++++++++++--- docs/specs/UseCaseCatalog.md | 35 +++++- 5 files changed, 210 insertions(+), 17 deletions(-) diff --git a/INTENT.md b/INTENT.md index 5223b8a..6cf03c1 100644 --- a/INTENT.md +++ b/INTENT.md @@ -217,6 +217,11 @@ never evaluates. presented as binding only those three sections, the UI has produced a lie. - **An agent binds.** If any automation can complete a disposition on a principal's behalf, the identity half is fiction. +- **It renders `approved` as permission to act.** `approved` is a state of an + object, not authorization. `approval-engine` actively refuses to serialize a + decision. A surface that presents approval status as "you may now do the + thing" has re-implemented a PDP in the browser — the same failure as an + authorization endpoint, wearing UI copy instead of an API. - **It reimplements `approval-engine`.** Caching approval validity, inferring consumption from a decision record, or holding approval current-state here breaks `GH-DEC-2026-003` and the atomicity contract. diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index 36a11c2..a0bfe6c 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -14,7 +14,7 @@ | task | INFD-WP-0001-T03 | done | — | workplans/INFD-WP-0001-founding-specs-and-approver-ui-ownership.md | | task | INFD-WP-0001-T04 | done | — | workplans/INFD-WP-0001-founding-specs-and-approver-ui-ownership.md | | task | INFD-WP-0001-T05 | todo | — | workplans/INFD-WP-0001-founding-specs-and-approver-ui-ownership.md | -| task | INFD-WP-0001-T06 | todo | — | workplans/INFD-WP-0001-founding-specs-and-approver-ui-ownership.md | +| task | INFD-WP-0001-T06 | progress | — | workplans/INFD-WP-0001-founding-specs-and-approver-ui-ownership.md | | task | INFD-WP-0001-T07 | todo | — | workplans/INFD-WP-0001-founding-specs-and-approver-ui-ownership.md | | task | INFD-WP-0001-T08 | todo | — | workplans/INFD-WP-0001-founding-specs-and-approver-ui-ownership.md | | intake | INFD-IN-0001 | open | blue | intakes/intakes.md | diff --git a/docs/specs/EvidenceModel.md b/docs/specs/EvidenceModel.md index 714429d..3475f58 100644 --- a/docs/specs/EvidenceModel.md +++ b/docs/specs/EvidenceModel.md @@ -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:`). 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`, diff --git a/docs/specs/ProductRequirementsDocument.md b/docs/specs/ProductRequirementsDocument.md index 9a10bd7..963578d 100644 --- a/docs/specs/ProductRequirementsDocument.md +++ b/docs/specs/ProductRequirementsDocument.md @@ -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). diff --git a/docs/specs/UseCaseCatalog.md b/docs/specs/UseCaseCatalog.md index fef4e60..fd6ee5c 100644 --- a/docs/specs/UseCaseCatalog.md +++ b/docs/specs/UseCaseCatalog.md @@ -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