Adopt approval-engine's outbox wire as Gate House doctrine, specify the heartbeat-and-reconciliation detection surface, and settle consumption ordering: the PEP consumes by CAS before the side effect. T05's §11 check is written here and queued for statute v0.8. Assistant: grok Assistant-Session: 01a04d89-aaa5-7443-945e-b3055cd4b7e4
148 lines
6.3 KiB
Markdown
148 lines
6.3 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`
|
||
**Date:** 2026-08-29
|
||
**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.
|
||
|
||
## 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.
|