gate-house/docs/contracts/approval-consumption.md
tegwick f0f888ca7e 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

8.6 KiB
Raw Permalink Blame 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, 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

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.

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

POST /v1/approvals/{id}/consume
{
  "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). 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), 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.