diff --git a/docs/contracts/approval-consumption.md b/docs/contracts/approval-consumption.md new file mode 100644 index 0000000..1860eb8 --- /dev/null +++ b/docs/contracts/approval-consumption.md @@ -0,0 +1,148 @@ +# 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": "", + "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. diff --git a/docs/contracts/approval-emission-detection.md b/docs/contracts/approval-emission-detection.md new file mode 100644 index 0000000..4b19952 --- /dev/null +++ b/docs/contracts/approval-emission-detection.md @@ -0,0 +1,111 @@ +# Approval emission detection surface + +**Owner:** gate-house +**Source:** approval-engine +**Archive:** audit-core +**Observer:** kings-guard (silence-as-signal; offered) +**Status:** normative for GH-WP-0002-T04 +**Date:** 2026-08-29 +**Statute:** `net-kingdom/canon/standards/security-layer-model_v0.7.md` §9.6 +**Source declaration:** `approval-engine/cadence.yaml` + +A hash chain proves accepted records were not altered or truncated. It proves +nothing about an event never emitted. Atomicity (the outbox contract) prevents +accidental omission. This surface is what makes a *silent* loss visible. + +It does not prevent a compromised source from suppressing. That residual is +stated in §9.6 and is not closed here. + +## Form + +Approval evidence is **load-bearing** and **low-volume**. Rate monitoring is +forbidden: a handful of revocations a month has no rate to drop below, and +suppression is indistinguishable from a quiet month. + +The required form is **heartbeat plus reconciliation**. Reference instance: +`approval-engine/cadence.yaml` and `approval-engine/docs/emission-cadence.md`. + +### Heartbeat + +A signed positive claim: *nothing to report*, together with per-class +transition counts since the previous heartbeat (or since process start on +the first). The claim can itself go missing, which is the point. + +| Field | Value | +| --- | --- | +| Class | `heartbeat` | +| Interval | 24 hours (SHOULD also be emittable on demand) | +| Assertion | `nothing-to-report` | +| Counts | `{issuance, use, supersession, revocation}` of committed outbox rows | +| Missing heartbeat | **finding** | + +Heartbeat rows use the outbox table and the same at-least-once drain. They are +not coupled to an object mutation. + +### Reconciliation + +Compare `approval-engine`'s committed outbox counts per class to `audit-core`'s +accepted event counts for `source=approval-engine` and the corresponding +`action`. Divergence is a **finding**, not a log line. + +| Source class | `audit-core` `action` | +| --- | --- | +| `issuance` | `approval.issuance` | +| `use` | `approval.use` | +| `supersession` | `approval.supersession` | +| `revocation` | `approval.revocation` | +| `heartbeat` | `approval.heartbeat` | + +Undrained local rows are lag, not yet a divergence. A row with `drained_at` set +that `audit-core` does not hold is the omission case §9.6 names. + +### Lag bound + +| Bound | Default (reference) | Exceed | +| --- | --- | --- | +| Outbox depth | 100 undrained rows | **finding** | +| Outbox age | 1 hour for the oldest undrained row | **finding** | + +These detect a stuck drain. They are not a completeness proof. + +## What is a finding + +- No heartbeat arrives for more than one interval. +- `audit-core` count for a class is less than the source's drained count for + that class. +- Outbox depth or age exceeds the lag bound. + +None of these are rate drops. None of these are informational logs. + +`kings-guard` consumes the finding as observation of the stream, not of its +contents. Until it reports that it is watching in production, no argument may +assume this surface is live (§12). The source MUST still publish the +declaration; a detector that is not yet watching does not excuse an undeclared +source. + +## Who owns which half + +| Half | Owner | +| --- | --- | +| Publish cadence and expose committed counts / lag | approval-engine | +| Hold accepted events and answer per-source per-class counts | audit-core | +| Raise silence, divergence, and lag as findings | kings-guard | +| Name the form and the residual | gate-house | + +## Conformance check (queued for statute v0.8 §11) + +GH-WP-0002-T05. Mechanically checkable, in the same list as the existing §11 +bullets: + +- every repository catalogued as an evidence source, or that emits + **load-bearing** evidence, declares its emission guarantee in a + machine-readable form at a path named in its layer declaration: + - **load-bearing:** local transactional outbox (this contract's shape) **and** + a heartbeat-or-reconciliation cadence declaration; + - **attributive, deliberately non-atomic:** the named trade, and a statement + that completeness is not claimed. + +A new engine catalogued under §9.4 / §4 as an evidence source that ships +without this declaration reintroduces the approval omission gap silently. +Until v0.8 lands, this paragraph is doctrine; `approval-engine`'s +`cadence.yaml` plus `layer.yaml` is the reference form. diff --git a/docs/contracts/approval-outbox.md b/docs/contracts/approval-outbox.md new file mode 100644 index 0000000..d2ff8e0 --- /dev/null +++ b/docs/contracts/approval-outbox.md @@ -0,0 +1,105 @@ +# Approval-engine transactional outbox contract + +**Owner:** gate-house +**Implementer:** approval-engine +**Evidence engine:** audit-core +**Status:** normative for GH-WP-0002-T02 +**Date:** 2026-08-29 +**Statute:** `net-kingdom/canon/standards/security-layer-model_v0.7.md` §9.4, §9.6 +**Failure mode:** `GH-DEC-2026-002` +**Source draft:** `approval-engine/docs/outbox-contract.md` (adopted) + +This is the wire. Boundary and locality are in the statute and in +`approval-engine/INTENT.md`. An implementer MUST NOT satisfy this contract by +emitting synchronously to `audit-core` inside the state-change transaction. +That path is atomic and is forbidden: an audit outage would become an inability +to revoke. + +## Rule + +Every issuance, use, supersession, and revocation **inserts an outbox row in +the same transaction** that mutates the approval object. The durable queue +lives in `approval-engine`'s own store. Drain is asynchronous, at-least-once. +`audit-core` dedupes on `event_id`; a replay does not fork the chain. + +If the outbox insert cannot be committed, the mutation does not commit. +Emit-after-commit is a defect. + +Heartbeat rows (see [`approval-emission-detection.md`](approval-emission-detection.md)) +use the same table and the same at-least-once drain. They are not coupled to an +object mutation. + +## Event classes + +| Class | When | `audit-core` `action` | +| --- | --- | --- | +| `issuance` | object becomes `approved` (threshold met) | `approval.issuance` | +| `use` | object becomes `consumed` (public consume under [`approval-consumption.md`](approval-consumption.md)) | `approval.use` | +| `supersession` | object becomes `superseded` | `approval.supersession` | +| `revocation` | object becomes `revoked` | `approval.revocation` | +| `heartbeat` | signed *nothing to report* plus counts | `approval.heartbeat` | + +Expiry is a clock crossing, persisted on observation, and is **not** an +emitted class. The validity window is already on the object. + +## Outbox row + +| Field | Type | Notes | +| --- | --- | --- | +| `event_id` | UUID | Stable. Drain retries reuse it. | +| `class` | enum above | | +| `approval_id` | UUID or null | Null only for heartbeat. | +| `payload` | object | The `audit-core.event.v1alpha1` record, ready to POST. | +| `created_at` | RFC 3339 UTC | | +| `drained_at` | RFC 3339 UTC or null | Set after a successful `audit-core` ack. | + +## Payload (`audit-core` v1alpha1) + +```json +{ + "schema_version": "audit-core.event.v1alpha1", + "event_id": "", + "observed_at": "", + "tenant": "platform", + "scope": "netkingdom-approvals", + "source": "approval-engine", + "actor": "", + "action": "approval.revocation", + "resource": "approval:", + "outcome": "success", + "reason": null, + "details": { + "class": "revocation", + "approval_id": "", + "binding_digest": "sha256:…", + "request_digest": null, + "superseded_by": null + } +} +``` + +No secret values. `details` may add non-secret identifiers; it MUST NOT add a +validity verdict for consumers to branch on. `audit-core` MUST NOT expose an +approval-validity query; this payload does not invite one. + +For `use` events, `details.request_digest` is the digest the PEP presented at +consume (see the consumption contract). It is null on other classes. + +## Drain + +1. Select undrained rows, oldest first. +2. POST each payload to `audit-core`. +3. On success, set `drained_at`. +4. On failure, leave the row; retry later. **Do not** roll back the object + mutation — it already committed with the row. + +An `audit-core` outage therefore cannot block a revocation. This engine's +own store being unavailable can, and should: the change could not have been +recorded anyway. That is the whole of `GH-DEC-2026-002`. + +## Forbidden shapes + +- `audit-core` HTTP (or any client) inside `BEGIN` … `COMMIT` of a mutation. +- Best-effort publish after commit with no row. +- A second, non-local queue as the durability mechanism. +- Deduping in `approval-engine` instead of relying on `event_id` at `audit-core`. diff --git a/history/2026-08-29-approval-evidence-integrity-contracts.md b/history/2026-08-29-approval-evidence-integrity-contracts.md new file mode 100644 index 0000000..ce33fc4 --- /dev/null +++ b/history/2026-08-29-approval-evidence-integrity-contracts.md @@ -0,0 +1,57 @@ +# Approval evidence integrity — contracts and rulings + +**Recorded:** 2026-08-29 +**Workplan:** GH-WP-0002 +**Audience:** approval-engine, access-engine / flex-auth, audit-core, kings-guard, secrets-engine, ops-warden +**Status:** Reference note. The normative artifacts are the contracts under +`docs/contracts/` and the decision records GH-DEC-2026-002 and GH-DEC-2026-003. + +--- + +## Why this landed as contracts, not a statute bump + +`approval-engine` shipped the object, the local outbox, and a cadence +declaration, and stopped at a public consume because GH-WP-0002-T06 was unset. +`FLEX-WP-0017-T05` is wait on the same unset. The workplan's remaining tasks +were doctrine, not another version of the layer model. + +v0.7 stays the accepted statute. Two protocol clarifications and one §11 +bullet are queued for v0.8 rather than silently rewritten into an accepted +text: + +1. §9.7.3 — consumption is never inferred from a decision record (stands); + the PEP consumes *before* the protected side effect (clarifies the + protocol the paragraph currently implies the other way). +2. §16 — "who marks an approval consumed" is no longer open. +3. §11 — evidence sources declare their emission guarantee (GH-WP-0002-T05). + +Inbox items that also belong in v0.8 (pep-stance inventory rows, the §13 +table becoming a pointer into `maturity-engine`, `railiance-master`'s own-voice +declaration) are not mixed into this workplan. + +## What each task produced + +| Task | Artifact | +| --- | --- | +| T02 | `docs/contracts/approval-outbox.md` — adopts `approval-engine`'s wire | +| T03 | GH-DEC-2026-002 — fail-closed only on the local store | +| T04 | `docs/contracts/approval-emission-detection.md` — heartbeat + reconciliation | +| T05 | detection contract, last section — queued for v0.8 §11 | +| T06 | `docs/contracts/approval-consumption.md` and GH-DEC-2026-003 | + +## The two rulings in one line each + +- **Revocation:** if the local outbox insert cannot commit, the revocation + does not commit. An `audit-core` outage MUST NOT block a revocation. + Synchronous emission to `audit-core` inside the mutation is forbidden. +- **Consumption:** the PEP consumes by CAS before the side effect. The PDP + never mutates. There is no unconsume. Same-digest retry is idempotent; + a different digest against a consumed object is conflict. + +## Assent still required of the implementers, not of the estate + +These contracts bind `approval-engine` and `access-engine` because they asked +for them and were blocked on them. They do not move a vocabulary away from a +repository that currently uses it, so they do not follow the GH-DEC-2026-001 +assent pattern. Contest is still welcome: a protocol that cannot be +implemented is a success of the loop, not a courtesy. diff --git a/workplans/GH-WP-0002-approval-evidence-integrity.md b/workplans/GH-WP-0002-approval-evidence-integrity.md index 083c346..6a7f37a 100644 --- a/workplans/GH-WP-0002-approval-evidence-integrity.md +++ b/workplans/GH-WP-0002-approval-evidence-integrity.md @@ -45,8 +45,8 @@ state_hub_task_id: "73a8feb0-02bd-5191-bdfd-82bcc42c6756" Specify the transactional-outbox contract for `approval-engine`: same transaction as the object mutation, queue local to the engine, at-least-once into the outbox since `audit-core` dedupes on event id and a replay does not -fork the chain. Boundary and locality are in `approval-engine/INTENT.md` and -§9.4; the wire contract is not yet written. +fork the chain. Done — `docs/contracts/approval-outbox.md` adopts +`approval-engine`'s wire (`docs/outbox-contract.md`) as Gate House doctrine. ```task id: GH-WP-0002-T03 @@ -56,10 +56,10 @@ state_hub_task_id: "1fc8fd3c-7af4-50e7-a07c-f45016f8b1f3" ``` Decide the revocation failure mode explicitly: fail closed, or proceed with a -detectable gap. Both are defensible; undecided is not, and an implementation -accident is the worst outcome. Note v0.5's local-outbox rule narrows this -considerably — fail-closed now triggers only when the engine's own store is -down — but the ruling is still owed. +detectable gap. Done — GH-DEC-2026-002. Fail-closed only when +`approval-engine`'s own store cannot insert the outbox row. An `audit-core` +outage MUST NOT block a revocation. Synchronous emission inside the mutation +is forbidden. Proceed-with-gap is rejected for load-bearing approval evidence. ```task id: GH-WP-0002-T04 @@ -70,8 +70,11 @@ state_hub_task_id: "3d62dbf9-786e-58c1-bd87-fb43658ca865" Give the gap a detection surface: outbox depth and age, or reconciliation of `approval-engine` object counts against `audit-core` event counts per class. -Today nothing would surface a silent loss. Relate to §9.6's silence-as-signal -rule, which `kings-guard` offered to implement at its own layer. +Done — `docs/contracts/approval-emission-detection.md`. Form is heartbeat plus +reconciliation, not rate monitoring; lag bounds on outbox depth and age. +`approval-engine/cadence.yaml` is the reference source declaration. +`kings-guard` remains the observer; until it reports watching in production, +the declaration is still required. ```task id: GH-WP-0002-T05 @@ -82,6 +85,11 @@ state_hub_task_id: "2a2a73ea-2778-59d2-94ef-7a70a22bb169" Add a §11 conformance check so the next engine catalogued as an evidence source declares its emission guarantee rather than reintroducing this silently. +Done as doctrine — the check is the last section of +`docs/contracts/approval-emission-detection.md`. Queued for statute v0.8 §11 +rather than patched into accepted v0.7. Load-bearing sources declare a local +outbox plus heartbeat-or-reconciliation; attributive non-atomic sources declare +the trade and do not claim completeness. ```task id: GH-WP-0002-T06 @@ -93,5 +101,7 @@ state_hub_task_id: "250cb9b3-713d-5607-ad1b-225e339693bf" Settle the consumption ordering contract between `approval-engine` and `access-engine` — who signals consumed, at what point relative to the decision, and the handling of an allow never consumed, a double consumption by racing -callers, and consumption after a failed action. Raised by `flex-auth`; required -before `FLEX-WP-0017` T05. Recorded unresolved in `approval-engine/INTENT.md`. +callers, and consumption after a failed action. Done — GH-DEC-2026-003 and +`docs/contracts/approval-consumption.md`. The PEP consumes by CAS before the +side effect; the PDP never mutates; there is no unconsume. Unblocks +`APPROVAL-WP-0001-T05` and `FLEX-WP-0017-T05`.