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
193 lines
8.6 KiB
Markdown
193 lines
8.6 KiB
Markdown
# 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.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.
|
||
|
||
**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.
|