gate-house/docs/contracts/approval-consumption.md

194 lines
8.6 KiB
Markdown
Raw Normal View History

# Approval consumption ordering
**Owner:** gate-house
**PIP:** approval-engine
**PDP:** access-engine (currently flex-auth)
**Status:** normative for GH-WP-0002-T06; recorded as `GH-DEC-2026-003`,
Close the binding-correspondence gap as GH-DEC-2026-008 access-engine raised, and declined to solve locally, a hole in the split GH-DEC-2026-005 ruled on. approval-claim verification item 4 is a disjunction and neither limb delivers "approved for THIS request" on the PDP path: limb one requires translating between two engines' vocabularies and no mapping is published, limb two (pdp_digest) is optional. Where the digest is absent a consumer can hold valid_now true, receive an ALLOW, consume and act with nothing establishing that approval and decision concern the same action and target. Ruled: the PDP digest is the correspondence and is required on that path; a claim without one fails closed; the native limb survives only for consumers already in approval-engine's vocabulary, including T-06. No mapping is published — a translation can be wrong while still producing a confident answer, it fails open, it would be owned by neither engine, and recomputing another layer's binding is the re-derivation GH-DEC-2026-005 already forbids. The cost is stated: an approval issued without a bound CheckRequest is unusable on this path, which is correct behaviour. Also: adopted hub row b606e8ce as canonical for GH-DEC-2026-005 rather than registering a duplicate; recorded approval-engine's narrowing of the approver-threshold consequence (distinctness is a UNIQUE storage invariant, so the PEP stopped checking that the engine applied its own invariant, not whether dual control could be forged); and drafted A7/T08, a §11 marking obligation and §12 consumer rule for derived summaries, after four instances in one week across four repositories. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WtJBr77gMFLrN93iEevqQJ Assistant: claude-code Assistant-Model: opus Assistant-Process: 425128@bnt-lap001 Assistant-Session: f5944d8b-dac4-4e1a-87eb-8b3d8f314a63
2026-09-06 09:32:20 +02:00
amended by `GH-DEC-2026-005` (step-1 artifact and split validation) and
`GH-DEC-2026-008` (the PDP digest is the binding correspondence)
**Date:** 2026-09-06
**Statute:** `net-kingdom/canon/standards/security-layer-model_v0.7.md` §9.4, §9.7.34, §16
**Blocks:** `APPROVAL-WP-0001-T05`, `FLEX-WP-0017-T05`
This contract settles who marks an approval consumed, at what point relative
to the decision, and who owns each of the three named failure modes. Nothing
in `approval-engine` may implement a public consume by guessing this.
## Sequence
```text
1. PIP GET /v1/approvals/{id}/claim → valid_now (a fact, not permission)
2. PDP Check / decide → ALLOW | DENY | …
3. PEP POST /v1/approvals/{id}/consume → CAS valid → consumed
4. PEP protected side effect → only after consume succeeds
```
The PDP **never mutates**. Staff **never** calls consume. `audit-core` **never**
calls consume. The signaler is the PEP-shaped consumer that is about to cause
the protected side effect named in the binding — today `secrets-engine` for
OpenBao writes, `ops-warden` for the SSH signing lane, and any other consumer
companion §5 already treats as PEP-shaped.
Holding a claim with `valid_now: true`, or an ALLOW against that claim, is not
authority to act. The consume is.
## What each step's artifact is
Amended by `GH-DEC-2026-005`.
**Step 1 serves the approval-claim** (`approval-engine/docs/approval-claim.md`).
That is the artifact `valid_now` belongs to, and it is the only object the claim
endpoint serves. flex-auth's proposed `ActionAuthorization` is **not** required
here, is not served here, and MUST NOT be served from this endpoint: a step-1
response cannot contain a step-2 decision.
**A PEP validates across both artifacts it already fetches**, and each against
the layer that owns its data:
| Artifact | Source | What the PEP checks |
| --- | --- | --- |
| approval-claim | step 1, `approval-engine` (PIP) | binding digest, validity window, consumption state, observation freshness, issuer |
| `DecisionEnvelope` | step 2, `access-engine` (PDP) | exact `CheckRequest` match, policy package and version pin |
A PIP MUST NOT republish the PDP's decision, and a PEP MUST NOT accept the
approval fact from the decision artifact or the decision from the approval
artifact. Composing the two into one served object is a separate, post-decision
artifact at a separate endpoint if it is ever ratified — not a change to the
claim.
**Claim provenance is not State Hub.** A PEP MUST NOT require
`provenance.authority == "state-hub"` on the claim. State Hub is a read model and
holds no runtime approval authority; the issuer to check is the engine that
served the claim.
Close the binding-correspondence gap as GH-DEC-2026-008 access-engine raised, and declined to solve locally, a hole in the split GH-DEC-2026-005 ruled on. approval-claim verification item 4 is a disjunction and neither limb delivers "approved for THIS request" on the PDP path: limb one requires translating between two engines' vocabularies and no mapping is published, limb two (pdp_digest) is optional. Where the digest is absent a consumer can hold valid_now true, receive an ALLOW, consume and act with nothing establishing that approval and decision concern the same action and target. Ruled: the PDP digest is the correspondence and is required on that path; a claim without one fails closed; the native limb survives only for consumers already in approval-engine's vocabulary, including T-06. No mapping is published — a translation can be wrong while still producing a confident answer, it fails open, it would be owned by neither engine, and recomputing another layer's binding is the re-derivation GH-DEC-2026-005 already forbids. The cost is stated: an approval issued without a bound CheckRequest is unusable on this path, which is correct behaviour. Also: adopted hub row b606e8ce as canonical for GH-DEC-2026-005 rather than registering a duplicate; recorded approval-engine's narrowing of the approver-threshold consequence (distinctness is a UNIQUE storage invariant, so the PEP stopped checking that the engine applied its own invariant, not whether dual control could be forged); and drafted A7/T08, a §11 marking obligation and §12 consumer rule for derived summaries, after four instances in one week across four repositories. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WtJBr77gMFLrN93iEevqQJ Assistant: claude-code Assistant-Model: opus Assistant-Process: 425128@bnt-lap001 Assistant-Session: f5944d8b-dac4-4e1a-87eb-8b3d8f314a63
2026-09-06 09:32:20 +02:00
**The correspondence is the PDP digest.** Amended by `GH-DEC-2026-008`. A consumer
on this path MUST verify that `claim.binding.pdp_digest` equals the decision's
`NewDecisionBinding.request_digest`, and MUST NOT use a claim that carries no
`pdp_digest`. Recomputing `approval-engine`'s native `binding.digest` from a
`CheckRequest` is **not** a permitted fallback here: it requires translating between
two engines' vocabularies, no mapping is published, and a wrong translation fails
open by silently accepting a claim approved for something else. The native limb
survives only for consumers already in `approval-engine`'s vocabulary, including the
`T-06` assurance case.
Without this, `valid_now: true` plus an ALLOW establishes *approved* and *permitted*
but never *approved for this request*, which is the property the binding exists to
carry.
## Why consume is before the side effect
§9.7.3 says consumption MUST NOT be inferred from a decision record. That
stands. A decision record proves an intent to act, not an act. The consume
mutation is the act's evidence at this engine.
The same paragraph currently reads as if the *action* must precede the
*consume call*. That reading is a protocol, and it is the wrong one. If the
PEP acts and then consumes, two racing PEPs can both observe `valid_now`,
both receive ALLOW, and both act; CAS then prevents only the second
*record*, not the second *side effect*. Single consumption would be theatre.
**The PEP MUST obtain a successful consume before the protected side effect.**
In-flight duplicate ALLOWs are expected; the CAS serializes use. A later
CheckRequest sees `valid_now: false` and cannot mint a new ALLOW against the
same object.
This is a protocol clarification of §9.7.3, not a retraction of the forensic
claim. It lands in statute v0.8. Until then this contract governs the
implementers who were blocked on it.
## Consume request
```text
POST /v1/approvals/{id}/consume
```
```json
{
"request_digest": "<canonical request digest from the decision binding>",
"decision_id": "<optional; the ALLOW this consume discharges>"
}
```
`request_digest` is the same digest companion §5.2 already uses as the
mechanical replay test (`NewDecisionBinding.request_digest` in flex-auth).
Go's `json.Marshal` of a CheckRequest is **not** this digest. Compare to the
binding the PDP already computed, not to a re-serialized CheckRequest.
The mutation is compare-and-swap:
| Current status | Digest on the object | Result |
| --- | --- | --- |
| `approved`, inside validity window | none | consumed; store this digest; emit `use` |
| `consumed` | same digest | **idempotent success** — same logical request, a retry |
| `consumed` | different digest | **conflict** — already used for another request |
| `revoked` / `superseded` / `expired` / outside window / not `approved` | — | **conflict**; MUST NOT act |
The `use` outbox row is inserted in the same transaction as the status change
([`approval-outbox.md`](approval-outbox.md)). A failed insert rolls the
consume back. An `audit-core` outage does not.
There is **no unconsume**. There is no reserve/release. An approval authorizes
one *attempt*, not one *success*.
## The three failure modes
### 1. Allow rendered, never consumed
**Cause.** PEP crash or abandonment between ALLOW and consume.
**Effect.** The object stays `approved`. A later PEP can still consume it,
inside the remaining validity window and inside the ALLOW's own lifetime
(§9.7.1). That is a delayed first use, not a replay of a completed one.
**Owner.** The PEP is obligated to consume before the side effect. The
**approval-engine** does not auto-consume from the existence of a decision.
The **PDP** bounds the window with the decision lifetime. Detection of a
stale ALLOW with no matching `use` is a finding on this surface
([`approval-emission-detection.md`](approval-emission-detection.md)), not a
consume.
### 2. Double consumption by racing callers
**Cause.** Two PEPs, two ALLOWs in flight, both presenting consume.
**Effect.** CAS: one success; the other is idempotent success if and only if
the digest matches, otherwise conflict.
**Owner.** `approval-engine` performs the CAS. The PEP that receives conflict
MUST NOT perform the side effect. The PDP that rendered the second ALLOW has
done nothing wrong — it decided on a still-valid claim.
Same-digest idempotent success is a retry of one logical request. Companion
§5.2 already permits reusing an ALLOW inside its binding and lifetime. The
side effect MUST itself be idempotent under that digest; that is the PEP's
obligation, not this engine's.
### 3. Consumed, then the authorized action fails
**Cause.** Consume succeeded; the protected side effect then failed or was
never reached (crash after consume).
**Effect.** The approval is spent. Retry requires a new approval object.
**Owner.** Accepted here as the cost of closing (2). Unconsume would reopen
replay. A flake is an issuance problem, not a reason to make consumption
reversible. The PEP MAY request a new approval; it MUST NOT infer that the
spent object is still valid.
## What this is not
- Not a second decision. Consume answers "did this engine accept the use",
never "may this actor do X".
- Not an inference from `audit-core`. The archive reports what it received.
- Not a PDP duty. `access-engine` reads the claim and never mutates.
- Not implemented until `approval-engine` wires `POST /consume` to the CAS
that already exists internally, with the digest column this contract adds.
## Acceptance
`APPROVAL-WP-0001-T05` may implement the public consume against this
document. `FLEX-WP-0017-T05` may require the PEP (secrets-engine, for that
task) to consume before the OpenBao call. Canon `T-06` consume-side replay —
a second, different digest against a consumed object — becomes in-scope with
that implementation.