gate-house/docs/contracts/approval-consumption.md
tegwick d97e8efa6e Collect v0.8 assent findings; correct GH-DEC-2026-008 for implementability
Four findings returned so far, all from access-engine and
approval-engine. F1 is the serious one: GH-DEC-2026-008 as written
mandated a comparison that could never pass, because a claim travelling
inside a hashed request cannot name the digest of the request containing
it. A fail-closed consumer obeying it would have denied destroy
permanently — the ruling and its own fail-closed requirement compounded
rather than cancelled.

Ruling and its four obligations stand; the comparison target is corrected
to the PDP's published exclusion-scoped digest, verified in flex-auth's
schema and canonical.go before amending. A consumer must not guess the
exclusion rule, and until a PDP publishes one the path is fail-closed
rather than complete.

F2 adds the general property access-engine flagged as a near miss it was
not asking to have written: an evidence-bearing input may be excluded
from a correspondence digest but never from the replay identity.

The round record notes what this says about the process. GH-DEC-2026-008
was correct in substance, argued from doctrine, and verified against
another repository's schema before issue — and none of that caught a
defect three repositories found within hours of building on it.

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 15:28:01 +02:00

209 lines
9.7 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 binding correspondence, amended for implementability)
**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.
**The correspondence is a digest, and it is the PDP's to define.** Amended by
`GH-DEC-2026-008`. A consumer on this path MUST verify that the approval's recorded
PDP digest equals the digest the PDP publishes **for the request with the approval
evidence excluded** — `binding.approval_binding_digest` in `access-engine` — and MUST
NOT use a claim carrying no such digest.
The comparison is **not** against the full request digest. Where the claim travels
inside the hashed request, embedding it changes that request's digest, so a digest
recorded at issue can never equal the final one: a hash cycle whose resolution is
forced, not chosen. The first form of this rule mandated a check that could never
pass, and a fail-closed consumer obeying it would have denied the action permanently.
A consumer MUST NOT guess the exclusion rule. A digest computed under an assumed rule
produces a confident wrong answer, and comparing two digests derived under different
rules fails open toward accepting a claim bound to a different request. Until a PDP
publishes its exclusion rule, this path is correctly fail-closed rather than complete.
An **evidence-bearing input may be excluded from a correspondence digest but never
from the replay identity** — two requests differing only in which approval was
presented decide differently, so one digest cannot serve both purposes. 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.