# 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`, amended by `GH-DEC-2026-005` (step-1 artifact and split validation) **Date:** 2026-09-06 **Statute:** `net-kingdom/canon/standards/security-layer-model_v0.7.md` §9.4, §9.7.3–4, §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. ## 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": "", "decision_id": "" } ``` `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.