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:
parent
23fba00b1b
commit
08b190928f
5 changed files with 210 additions and 17 deletions
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue