Finish approval engine spine

Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a05e2e-805b-7042-a750-71f473bceea2
This commit is contained in:
tegwick 2026-09-01 23:45:48 +02:00
parent e7c210bf56
commit c3f1dfbc07
18 changed files with 526 additions and 75 deletions

View file

@ -185,8 +185,10 @@ frontmatter and `layer.yaml`.
- Answer "is this approval valid for this exact binding, and has it been used?" - Answer "is this approval valid for this exact binding, and has it been used?"
Never "may this actor do X". Never "may this actor do X".
- Do not implement consumption until `GH-WP-0002-T06` / `APPROVAL-WP-0001-T05` - Consumption follows `GH-DEC-2026-003` and
settles the contract with `access-engine`. `gate-house/docs/contracts/approval-consumption.md`: the PEP consumes by CAS
before the protected side effect; same-digest retries are idempotent,
different digests conflict, and there is no unconsume.
- The outbox is local. Do not emit synchronously to `audit-core` inside a - The outbox is local. Do not emit synchronously to `audit-core` inside a
mutation transaction. mutation transaction.
- Approval evidence is load-bearing. Atomicity covers crash, not compromise. - Approval evidence is load-bearing. Atomicity covers crash, not compromise.

View file

@ -215,23 +215,21 @@ proposed, not assigned), this engine publishes the contract at its own
boundary and yields to the schema when it exists. Inventing a permanent local boundary and yields to the schema when it exists. Inventing a permanent local
shape is the drift §17 exists to prevent. shape is the drift §17 exists to prevent.
## Consumption Ordering — Unresolved ## Consumption Ordering — Resolved by GH-DEC-2026-003
The decision precedes the action, and the action precedes consumption. Three The decision precedes consumption, and consumption precedes the protected
failure modes are named, and each needs an owner (standard §9.7.4, §16): side effect. Gate House settled this in `GH-DEC-2026-003` and
`docs/contracts/approval-consumption.md`. Three failure modes are named:
- an allow rendered against an approval that is then never consumed; - an allow rendered against an approval that is then never consumed;
- an approval consumed twice by racing callers; - an approval consumed twice by racing callers;
- an approval consumed after the action it authorized has already failed. - an approval consumed after the action it authorized has already failed.
`approval-engine` performs the mutation, because `access-engine` never mutates. The PEP signals consumption by presenting the decision binding's canonical
But *who signals consumption, at what point relative to the decision, and what request digest to this engine. approval-engine performs the CAS before the PEP
happens on each of the three states above* is a contract between the two engines acts; `access-engine` never mutates. Same-digest retries are idempotent success,
and is **not yet settled**. It is recorded in the standard's §16, in different digests conflict, and a failure after consume leaves the approval
`GH-WP-0002-T06`, and is required before `FLEX-WP-0017` T05. Raised by spent. There is no unconsume.
`flex-auth`.
Nothing here may be implemented by guessing that contract.
## What approval-engine Does Not Own ## What approval-engine Does Not Own
@ -306,9 +304,9 @@ word under standard §8, and this engine must never claim it.
- **It claims the outbox closes omission.** Atomicity covers crash. Treating it - **It claims the outbox closes omission.** Atomicity covers crash. Treating it
as covering a compromised source, or skipping the heartbeat, is the v0.6 as covering a compromised source, or skipping the heartbeat, is the v0.6
overclaim this engine must not reintroduce. overclaim this engine must not reintroduce.
- **It implements consumption by guessing.** The three races are named and - **It diverges from the consumption contract.** PDP mutation,
unowned. Code that picks a side is a contract with `access-engine` that action-before-consume, different-digest reuse, or unconsume violates
`access-engine` has not assented to. `GH-DEC-2026-003`.
- **It cites observation or containment that has not happened.** Nothing is - **It cites observation or containment that has not happened.** Nothing is
observed in production, and nothing can be contained automatically observed in production, and nothing can be contained automatically
(companion §10). (companion §10).
@ -329,5 +327,6 @@ word under standard §8, and this engine must never claim it.
rendered against approval A for request R cannot be replayed for request R'; rendered against approval A for request R cannot be replayed for request R';
7. load-bearing classes declare a heartbeat or reconciliation, and divergence 7. load-bearing classes declare a heartbeat or reconciliation, and divergence
from `audit-core`'s event count is a finding; from `audit-core`'s event count is a finding;
8. consumption is not implemented until `GH-WP-0002-T06` settles the contract; 8. consumption implements `GH-DEC-2026-003`, including atomic use evidence,
same-digest idempotency, different-digest conflict, and no unconsume;
9. `FLEX-WP-0017` T03 and T05 are unblocked. 9. `FLEX-WP-0017` T03 and T05 are unblocked.

View file

@ -21,6 +21,7 @@ Flexibility here would be a defect. Graded, evidence-based progression belongs t
See [INTENT.md](INTENT.md) and [SCOPE.md](SCOPE.md). Declaration: [layer.yaml](layer.yaml). See [INTENT.md](INTENT.md) and [SCOPE.md](SCOPE.md). Declaration: [layer.yaml](layer.yaml).
Claim: [docs/approval-claim.md](docs/approval-claim.md). Claim: [docs/approval-claim.md](docs/approval-claim.md).
Consume: [docs/approval-consumption.md](docs/approval-consumption.md).
Origin: `flex-auth` `FLEX-DEC-2026-001`, raised while assenting to the security Origin: `flex-auth` `FLEX-DEC-2026-001`, raised while assenting to the security
layer model. layer model.
@ -29,4 +30,6 @@ make test
python3 -m approval_engine.cli serve --db approvals.sqlite python3 -m approval_engine.cli serve --db approvals.sqlite
``` ```
There is no public `consume`. That waits on `GH-WP-0002-T06`. `POST /v1/approvals/{id}/consume` implements `GH-DEC-2026-003`: the PEP
atomically spends the approval before the protected side effect. Same-digest
retries are idempotent; a different digest conflicts.

View file

@ -20,8 +20,8 @@ whether the action is permitted, does not author the policy that requires an
approval, and does not archive the trail. approval, and does not archive the trail.
The first cut is the spine that makes Canon `T-06 — Approval Replay` passable The first cut is the spine that makes Canon `T-06 — Approval Replay` passable
and unblocks `FLEX-WP-0017` T03/T05 — and nothing that has to guess a contract and supplies the approval object and consumption mutation needed by protected
the other side has not assented to. systems. Consumption follows the assented `GH-DEC-2026-003` contract.
## In Scope ## In Scope
@ -34,6 +34,8 @@ the other side has not assented to.
`superseded / revoked / expired` as terminal exits from `valid`. `superseded / revoked / expired` as terminal exits from `valid`.
- Atomic supersession and single consumption (compare-and-swap, never - Atomic supersession and single consumption (compare-and-swap, never
read-then-write). read-then-write).
- A PEP-called consume mutation before the protected side effect, with
same-request idempotency, different-request conflict, and no unconsume.
- Revocation that does not require the holder's cooperation and is effective - Revocation that does not require the holder's cooperation and is effective
at the next use. at the next use.
- A local transactional outbox in this engine's own store; no synchronous - A local transactional outbox in this engine's own store; no synchronous
@ -64,9 +66,9 @@ the other side has not assented to.
- A general state-machine service for other concepts. - A general state-machine service for other concepts.
- PEP shape, unreachable-engine stance maps, Railiance `rail-*` / `rapp-*` / - PEP shape, unreachable-engine stance maps, Railiance `rail-*` / `rapp-*` /
`reef-*` axes. An approval is not a workload (statute §20.1). `reef-*` axes. An approval is not a workload (statute §20.1).
- Consumption signaling relative to the decision, until `GH-WP-0002-T06` - Any consumption protocol other than `GH-DEC-2026-003`, including PDP
settles it with `access-engine`. Guessing that contract is out of scope mutation, action-before-consume, unconsume, reserve/release, or inferring use
even as a prototype. from a decision record.
## Relevant When ## Relevant When
@ -89,7 +91,8 @@ the other side has not assented to.
- The need is a workflow inbox, a meeting, or a notification surface. - The need is a workflow inbox, a meeting, or a notification surface.
- The need is graded readiness or a maturity ladder. - The need is graded readiness or a maturity ladder.
- The work is mapping Railiance operational axes onto security objects. - The work is mapping Railiance operational axes onto security objects.
- Consumption ordering is being "solved" from this side alone. - A caller wants to consume without presenting the PDP decision binding's
canonical request digest.
## Current State ## Current State
@ -97,8 +100,8 @@ the other side has not assented to.
outbox, WSGI introspection API, claim contract. Not a production deploy. outbox, WSGI introspection API, claim contract. Not a production deploy.
- Layer declaration: INTENT frontmatter + `layer.yaml`. Cadence declared in - Layer declaration: INTENT frontmatter + `layer.yaml`. Cadence declared in
`cadence.yaml`. No Tooling contacts. `cadence.yaml`. No Tooling contacts.
- Consumption is not a public API (`APPROVAL-WP-0001-T05` waits on - Consumption is public at `POST /v1/approvals/{id}/consume` under
`GH-WP-0002-T06`). `GH-DEC-2026-003`; the endpoint is a lifecycle mutation, never a decision.
- Taxonomy request-claim schema is still unassigned; the local claim yields. - Taxonomy request-claim schema is still unassigned; the local claim yields.
- Work: `APPROVAL-WP-0001`. Tests: `make test`. - Work: `APPROVAL-WP-0001`. Tests: `make test`.
@ -158,8 +161,8 @@ type: api
title: Approval object lifecycle title: Approval object lifecycle
description: > description: >
Create, collect authenticated entries with distinct-approver counting, Create, collect authenticated entries with distinct-approver counting,
atomically supersede, and revoke without holder cooperation. No public atomically supersede, consume with request-digest idempotency, and revoke
consume until consumption ordering is settled. without holder cooperation.
keywords: [approval, state-machine, cas, revocation, supersession] keywords: [approval, state-machine, cas, revocation, supersession]
``` ```

View file

@ -8,13 +8,20 @@
| Kind | ID | Status | Lane | Source | | Kind | ID | Status | Lane | Source |
| --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- |
| workplan | APPROVAL-WP-0001 | active | — | workplans/APPROVAL-WP-0001-v07-alignment-and-engine-spine.md | | workplan | APPROVAL-WP-0001 | finished | — | workplans/APPROVAL-WP-0001-v07-alignment-and-engine-spine.md |
| workplan | APPROVAL-WP-0002 | proposed | — | workplans/APPROVAL-WP-0002-production-readiness-and-consumer-adoption.md |
| task | APPROVAL-WP-0001-T01 | done | — | workplans/APPROVAL-WP-0001-v07-alignment-and-engine-spine.md | | task | APPROVAL-WP-0001-T01 | done | — | workplans/APPROVAL-WP-0001-v07-alignment-and-engine-spine.md |
| task | APPROVAL-WP-0001-T02 | done | — | workplans/APPROVAL-WP-0001-v07-alignment-and-engine-spine.md | | task | APPROVAL-WP-0001-T02 | done | — | workplans/APPROVAL-WP-0001-v07-alignment-and-engine-spine.md |
| task | APPROVAL-WP-0001-T03 | done | — | workplans/APPROVAL-WP-0001-v07-alignment-and-engine-spine.md | | task | APPROVAL-WP-0001-T03 | done | — | workplans/APPROVAL-WP-0001-v07-alignment-and-engine-spine.md |
| task | APPROVAL-WP-0001-T04 | done | — | workplans/APPROVAL-WP-0001-v07-alignment-and-engine-spine.md | | task | APPROVAL-WP-0001-T04 | done | — | workplans/APPROVAL-WP-0001-v07-alignment-and-engine-spine.md |
| task | APPROVAL-WP-0001-T05 | wait | — | workplans/APPROVAL-WP-0001-v07-alignment-and-engine-spine.md | | task | APPROVAL-WP-0001-T05 | done | — | workplans/APPROVAL-WP-0001-v07-alignment-and-engine-spine.md |
| task | APPROVAL-WP-0001-T06 | done | — | workplans/APPROVAL-WP-0001-v07-alignment-and-engine-spine.md | | task | APPROVAL-WP-0001-T06 | done | — | workplans/APPROVAL-WP-0001-v07-alignment-and-engine-spine.md |
| task | APPROVAL-WP-0001-T07 | done | — | workplans/APPROVAL-WP-0001-v07-alignment-and-engine-spine.md | | task | APPROVAL-WP-0001-T07 | done | — | workplans/APPROVAL-WP-0001-v07-alignment-and-engine-spine.md |
| task | APPROVAL-WP-0001-T08 | done | — | workplans/APPROVAL-WP-0001-v07-alignment-and-engine-spine.md | | task | APPROVAL-WP-0001-T08 | done | — | workplans/APPROVAL-WP-0001-v07-alignment-and-engine-spine.md |
| task | APPROVAL-WP-0001-T09 | done | — | workplans/APPROVAL-WP-0001-v07-alignment-and-engine-spine.md | | task | APPROVAL-WP-0001-T09 | done | — | workplans/APPROVAL-WP-0001-v07-alignment-and-engine-spine.md |
| task | APPROVAL-WP-0002-T01 | todo | — | workplans/APPROVAL-WP-0002-production-readiness-and-consumer-adoption.md |
| task | APPROVAL-WP-0002-T02 | todo | — | workplans/APPROVAL-WP-0002-production-readiness-and-consumer-adoption.md |
| task | APPROVAL-WP-0002-T03 | todo | — | workplans/APPROVAL-WP-0002-production-readiness-and-consumer-adoption.md |
| task | APPROVAL-WP-0002-T04 | todo | — | workplans/APPROVAL-WP-0002-production-readiness-and-consumer-adoption.md |
| task | APPROVAL-WP-0002-T05 | todo | — | workplans/APPROVAL-WP-0002-production-readiness-and-consumer-adoption.md |
| intake | APPROVAL-IN-0001 | open | blue | intakes/intakes.md |

View file

@ -1,4 +1,4 @@
"""HTTP surface. Introspection and mutation; never a decision; never consume.""" """HTTP surface. Introspection and lifecycle mutation; never a decision."""
from __future__ import annotations from __future__ import annotations
@ -87,8 +87,6 @@ class App:
if len(parts) >= 3 and parts[0] == "v1" and parts[1] == "approvals": if len(parts) >= 3 and parts[0] == "v1" and parts[1] == "approvals":
approval_id = parts[2] approval_id = parts[2]
rest = parts[3:] rest = parts[3:]
if rest == ["consume"] or path.endswith("/consume"):
return 404, {"error": "not_found", "message": "consume is not implemented"}
if not rest and method == "GET": if not rest and method == "GET":
return 200, self.engine.get(approval_id).as_dict() return 200, self.engine.get(approval_id).as_dict()
if rest == ["claim"] and method == "GET": if rest == ["claim"] and method == "GET":
@ -107,7 +105,14 @@ class App:
if rest == ["supersede"] and method == "POST": if rest == ["supersede"] and method == "POST":
data = _read_json(environ) data = _read_json(environ)
return 200, self.engine.supersede(approval_id, data.get("successor_id")) return 200, self.engine.supersede(approval_id, data.get("successor_id"))
if "check" in path or path.endswith("/authorize") or path.endswith("/consume"): if rest == ["consume"] and method == "POST":
data = _read_json(environ)
return 200, self.engine.consume(
approval_id,
data.get("request_digest"),
decision_id=data.get("decision_id"),
)
if "check" in path or path.endswith("/authorize"):
return 404, {"error": "not_found", "message": "no such surface"} return 404, {"error": "not_found", "message": "no such surface"}
return 404, {"error": "not_found", "message": path} return 404, {"error": "not_found", "message": path}

View file

@ -1,8 +1,8 @@
"""SQLite-backed approval object, closed machine, local outbox. """SQLite-backed approval object, closed machine, local outbox.
All mutations run in BEGIN IMMEDIATE and insert the outbox row before COMMIT. All mutations run in BEGIN IMMEDIATE and insert the outbox row before COMMIT.
There is no public consume; _cas_consume exists only so use-class emission Consumption follows GH-DEC-2026-003: the PEP presents the decision binding's
can be tested without guessing GH-WP-0002-T06. request digest before the protected side effect.
""" """
from __future__ import annotations from __future__ import annotations
@ -50,6 +50,9 @@ CREATE TABLE IF NOT EXISTS approvals (
expires_at TEXT NOT NULL, expires_at TEXT NOT NULL,
required_count INTEGER NOT NULL, required_count INTEGER NOT NULL,
superseded_by TEXT, superseded_by TEXT,
consumed_digest TEXT,
consumed_decision_id TEXT,
consumed_at TEXT,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL updated_at TEXT NOT NULL
); );
@ -109,12 +112,15 @@ class Approval:
expires_at: str expires_at: str
required_count: int required_count: int
superseded_by: str | None superseded_by: str | None
consumed_digest: str | None
consumed_decision_id: str | None
consumed_at: str | None
created_at: str created_at: str
updated_at: str updated_at: str
entries: list[dict[str, Any]] entries: list[dict[str, Any]]
def as_dict(self) -> dict[str, Any]: def as_dict(self) -> dict[str, Any]:
return { result = {
"id": self.id, "id": self.id,
"status": self.status, "status": self.status,
"binding": { "binding": {
@ -129,6 +135,17 @@ class Approval:
"created_at": self.created_at, "created_at": self.created_at,
"updated_at": self.updated_at, "updated_at": self.updated_at,
} }
if self.consumed_digest:
result["consumption"] = {
"request_digest": self.consumed_digest,
**(
{"decision_id": self.consumed_decision_id}
if self.consumed_decision_id
else {}
),
"consumed_at": self.consumed_at,
}
return result
class Engine: class Engine:
@ -172,6 +189,12 @@ class Engine:
conn = self._conn() conn = self._conn()
try: try:
conn.executescript(SCHEMA) conn.executescript(SCHEMA)
columns = {
row["name"] for row in conn.execute("PRAGMA table_info(approvals)").fetchall()
}
for name in ("consumed_digest", "consumed_decision_id", "consumed_at"):
if name not in columns:
conn.execute(f"ALTER TABLE approvals ADD COLUMN {name} TEXT")
conn.commit() conn.commit()
except sqlite3.Error as exc: except sqlite3.Error as exc:
raise StoreUnavailable(str(exc)) from exc raise StoreUnavailable(str(exc)) from exc
@ -295,6 +318,9 @@ class Engine:
expires_at=row["expires_at"], expires_at=row["expires_at"],
required_count=row["required_count"], required_count=row["required_count"],
superseded_by=row["superseded_by"], superseded_by=row["superseded_by"],
consumed_digest=row["consumed_digest"],
consumed_decision_id=row["consumed_decision_id"],
consumed_at=row["consumed_at"],
created_at=row["created_at"], created_at=row["created_at"],
updated_at=row["updated_at"], updated_at=row["updated_at"],
entries=entries, entries=entries,
@ -487,16 +513,51 @@ class Engine:
raise raise
return self.get(approval_id) return self.get(approval_id)
def _cas_consume(self, approval_id: str) -> Approval: def consume(
"""Unexported seam. Do not wire to HTTP. Blocked on GH-WP-0002-T06.""" self,
approval_id: str,
request_digest: str | None,
*,
decision_id: str | None = None,
) -> dict[str, Any]:
"""Atomically spend an approval for one decision-bound request.
Repeating the same request digest is an idempotent success. A different
digest against a consumed object conflicts and the caller must not act.
"""
if request_digest is None:
raise Unprocessable("request_digest is required")
if not isinstance(request_digest, str):
raise Unprocessable("request_digest must be a string")
digest = require_digest(request_digest)
if decision_id is not None and (
not isinstance(decision_id, str) or not decision_id
):
raise Unprocessable("decision_id must be a non-empty string")
conn = self._conn() conn = self._conn()
now = iso(self.now()) now = iso(self.now())
idempotent = False
try: try:
conn.execute("BEGIN IMMEDIATE") conn.execute("BEGIN IMMEDIATE")
row = conn.execute("SELECT * FROM approvals WHERE id=?", (approval_id,)).fetchone() row = conn.execute("SELECT * FROM approvals WHERE id=?", (approval_id,)).fetchone()
if row is None: if row is None:
conn.rollback() conn.rollback()
raise NotFound(approval_id) raise NotFound(approval_id)
if row["status"] == "consumed":
if row["consumed_digest"] != digest:
conn.rollback()
raise Conflict("approval already consumed for a different request digest")
idempotent = True
stored_decision_id = row["consumed_decision_id"]
conn.commit()
return {
"approval_id": approval_id,
"status": "consumed",
"request_digest": digest,
**({"decision_id": stored_decision_id} if stored_decision_id else {}),
"consumed_at": row["consumed_at"],
"idempotent": idempotent,
}
if row["status"] != "approved": if row["status"] != "approved":
conn.rollback() conn.rollback()
raise Conflict(f"cannot consume from status {row['status']}") raise Conflict(f"cannot consume from status {row['status']}")
@ -504,9 +565,10 @@ class Engine:
conn.rollback() conn.rollback()
raise Conflict("cannot consume outside validity window") raise Conflict("cannot consume outside validity window")
cur = conn.execute( cur = conn.execute(
"UPDATE approvals SET status='consumed', updated_at=? " "UPDATE approvals SET status='consumed', consumed_digest=?, "
"consumed_decision_id=?, consumed_at=?, updated_at=? "
"WHERE id=? AND status='approved'", "WHERE id=? AND status='approved'",
(now, approval_id), (digest, decision_id, now, now, approval_id),
) )
if cur.rowcount != 1: if cur.rowcount != 1:
conn.rollback() conn.rollback()
@ -516,7 +578,11 @@ class Engine:
"use", "use",
approval_id, approval_id,
actor=row["actor"], actor=row["actor"],
extra={"binding_digest": row["binding_digest"]}, extra={
"binding_digest": row["binding_digest"],
"request_digest": digest,
**({"decision_id": decision_id} if decision_id else {}),
},
) )
conn.commit() conn.commit()
except sqlite3.Error as exc: except sqlite3.Error as exc:
@ -525,7 +591,14 @@ class Engine:
except Exception: except Exception:
conn.rollback() conn.rollback()
raise raise
return self.get(approval_id) return {
"approval_id": approval_id,
"status": "consumed",
"request_digest": digest,
**({"decision_id": decision_id} if decision_id else {}),
"consumed_at": now,
"idempotent": idempotent,
}
def _outbox_insert( def _outbox_insert(
self, self,

View file

@ -29,7 +29,7 @@ classes:
action: approval.issuance action: approval.issuance
use: use:
action: approval.use action: approval.use
note: "Internal CAS only until GH-WP-0002-T06." note: "Public CAS consumption under GH-DEC-2026-003."
supersession: supersession:
action: approval.supersession action: approval.supersession
revocation: revocation:

View file

@ -0,0 +1,43 @@
# Approval consumption API
Status: implemented under Gate House decision `GH-DEC-2026-003`.
The normative protocol is
`gate-house/docs/contracts/approval-consumption.md`. This document records the
approval-engine implementation surface; it does not redefine the protocol.
## Endpoint
```text
POST /v1/approvals/{id}/consume
```
```json
{
"request_digest": "sha256:<64 lowercase hex>",
"decision_id": "decision:optional-provenance"
}
```
The caller is the PEP that is about to perform the protected side effect. It
calls consume after an ALLOW and before that side effect. `request_digest` is
the canonical digest from the PDP decision binding, not a newly serialized
request and not approval-engine's native binding digest.
## Results
- First valid consume: atomically stores the digest, changes `approved` to
`consumed`, and inserts one `approval.use` outbox row in the same transaction.
- Same digest after consumption: `200` idempotent success and no second outbox
row.
- Different digest after consumption: `409 conflict`; the PEP must not act.
- Revoked, superseded, expired, outside-window, requested, or unknown object:
conflict or not-found; the PEP must not act.
- Outbox insert/store failure: `503`; the transaction rolls back and the PEP
must not act.
There is no unconsume, release, or reserve. If the protected side effect fails
after consumption, the approval remains spent and a retry needs a new approval.
The response is mutation evidence, not a permission decision. It contains no
`effect`, `allow`, `deny`, or decision result.

View file

@ -8,6 +8,8 @@
- Claim contract: [`approval-claim.md`](approval-claim.md) - Claim contract: [`approval-claim.md`](approval-claim.md)
- Introspection: `GET /v1/approvals/{id}/claim` - Introspection: `GET /v1/approvals/{id}/claim`
- Object: `POST /v1/approvals`, entries, revoke, supersede - Object: `POST /v1/approvals`, entries, revoke, supersede
- Consumption: `POST /v1/approvals/{id}/consume`, called by the PEP before the
protected side effect under `GH-DEC-2026-003`
- Native `binding.digest` plus optional `binding.pdp_digest` for - Native `binding.digest` plus optional `binding.pdp_digest` for
`NewDecisionBinding.request_digest` `NewDecisionBinding.request_digest`
@ -15,12 +17,15 @@ T03 is unblocked on the **object**, not on a hub substitute. Validate the
claim before privileged production actions. Fail closed if this engine is claim before privileged production actions. Fail closed if this engine is
unreachable. unreachable.
## What does not exist yet ## Consumption contract
Consumption ordering (`GH-WP-0002-T06` / `APPROVAL-WP-0001-T05`). There is Gate House settled ordering in `GH-DEC-2026-003` and
no public `consume`. `FLEX-WP-0017` T05 stays blocked **only** on that `docs/contracts/approval-consumption.md`. The PEP presents the decision
contract, not on a missing object or a missing digest. binding's `request_digest` and consumes by CAS before the protected side
effect. A same-digest retry is idempotent success; a different digest conflicts;
there is no unconsume. `access-engine` remains read-only and never calls this
mutation.
Canon T-06 against this implementation: `tests/test_t06_replay.py` (wrong Canon T-06 against this implementation: `tests/test_t06_replay.py` (wrong
target, wrong action, later time, revoked, superseded). Consume-side replay target, wrong action, later time, revoked, superseded, same-digest retry, and
is out of scope until T05. different-digest consume replay).

View file

@ -26,7 +26,7 @@ object mutation.
| Class | When | `audit-core` `action` | | Class | When | `audit-core` `action` |
| --- | --- | --- | | --- | --- | --- |
| `issuance` | object becomes `approved` (threshold met) | `approval.issuance` | | `issuance` | object becomes `approved` (threshold met) | `approval.issuance` |
| `use` | object becomes `consumed` (internal CAS; not a public API until `GH-WP-0002-T06`) | `approval.use` | | `use` | public CAS accepts first `request_digest` and object becomes `consumed` | `approval.use` |
| `supersession` | object becomes `superseded` | `approval.supersession` | | `supersession` | object becomes `superseded` | `approval.supersession` |
| `revocation` | object becomes `revoked` | `approval.revocation` | | `revocation` | object becomes `revoked` | `approval.revocation` |
| `heartbeat` | signed *nothing to report* plus counts | `approval.heartbeat` | | `heartbeat` | signed *nothing to report* plus counts | `approval.heartbeat` |

26
intakes/intakes.md Normal file
View file

@ -0,0 +1,26 @@
# Intake records
## APPROVAL-IN-0001 — Publish the shared Taxonomy request-claim schema
```yaml
id: APPROVAL-IN-0001
kind: intake
title: Publish the shared Taxonomy request-claim schema
status: open
origin: residual
origin_ref: APPROVAL-WP-0001
priority: medium
owner: approval-engine
repo: approval-engine
lane: blue
tags:
- residual
created: '2026-09-01'
updated: '2026-09-01'
description: >-
NetKingdom statute §17 calls for a shared request-claim schema, but ownership
remains unassigned. approval-engine published a local approval claim contract
with explicit issuer, freshness, and binding digests and marked it as yielding
to the future Taxonomy artifact. Route this intake when the Taxonomy owner is
assigned; preserve mechanical replay and freshness semantics during adoption.
```

View file

@ -27,16 +27,86 @@ def test_create_entry_claim_roundtrip(app):
assert "decision" not in claim assert "decision" not in claim
def test_no_check_or_authorize_or_consume(app): def test_no_check_or_authorize(app):
for path in ( for path in (
"/v1/check", "/v1/check",
"/authorize", "/authorize",
"/v1/approvals/00000000-0000-0000-0000-000000000001/consume",
"/v1/approvals/abc/consume",
): ):
status, body = call(app, "POST", path, {}) status, body = call(app, "POST", path, {})
assert status == 404 assert status == 404
assert "consume is not implemented" in body.get("message", "") or body["error"] == "not_found" assert body["error"] == "not_found"
def test_consume_endpoint_is_a_mutation_not_a_decision(app):
_, created = call(
app,
"POST",
"/v1/approvals",
{"binding": binding(), "validity": validity()},
)
aid = created["id"]
call(app, "POST", f"/v1/approvals/{aid}/entries", {"subject_id": "user:alice"})
digest = "sha256:" + "12" * 32
status, result = call(
app,
"POST",
f"/v1/approvals/{aid}/consume",
{"request_digest": digest, "decision_id": "decision:123"},
)
assert status == 200
assert result == {
"approval_id": aid,
"status": "consumed",
"request_digest": digest,
"decision_id": "decision:123",
"consumed_at": "2026-08-29T12:00:00+00:00",
"idempotent": False,
}
assert not ({"effect", "decision", "allow", "deny"} & set(result))
def test_consume_requires_request_digest(app):
_, created = call(
app,
"POST",
"/v1/approvals",
{"binding": binding(), "validity": validity()},
)
aid = created["id"]
call(app, "POST", f"/v1/approvals/{aid}/entries", {"subject_id": "user:alice"})
status, body = call(app, "POST", f"/v1/approvals/{aid}/consume", {})
assert status == 422
assert body["error"] == "unprocessable"
status, body = call(
app,
"POST",
f"/v1/approvals/{aid}/consume",
{"request_digest": ["not", "a", "digest"]},
)
assert status == 422
assert body["error"] == "unprocessable"
def test_consume_endpoint_rejects_different_digest_replay(app):
_, created = call(
app,
"POST",
"/v1/approvals",
{"binding": binding(), "validity": validity()},
)
aid = created["id"]
call(app, "POST", f"/v1/approvals/{aid}/entries", {"subject_id": "user:alice"})
first = "sha256:" + "45" * 32
second = "sha256:" + "67" * 32
assert call(
app, "POST", f"/v1/approvals/{aid}/consume", {"request_digest": first}
)[0] == 200
status, body = call(
app, "POST", f"/v1/approvals/{aid}/consume", {"request_digest": second}
)
assert status == 409
assert body["error"] == "conflict"
def test_claim_after_revoke(app): def test_claim_after_revoke(app):

View file

@ -1,10 +1,11 @@
import tempfile import tempfile
import threading import threading
import sqlite3
from pathlib import Path from pathlib import Path
from approval_engine.errors import Conflict from approval_engine.errors import Conflict
from approval_engine.store import Engine from approval_engine.store import Engine
from tests.conftest import approve from tests.conftest import FROZEN, approve
def test_second_supersession_loses(engine): def test_second_supersession_loses(engine):
@ -25,7 +26,7 @@ def test_second_supersession_loses(engine):
def test_concurrent_supersessions_one_winner(): def test_concurrent_supersessions_one_winner():
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "a.sqlite" path = Path(tmp) / "a.sqlite"
setup = Engine(path) setup = Engine(path, clock=lambda: FROZEN)
obj = approve(setup) obj = approve(setup)
setup.close() setup.close()
@ -34,7 +35,7 @@ def test_concurrent_supersessions_one_winner():
barrier = threading.Barrier(2) barrier = threading.Barrier(2)
def race(): def race():
eng = Engine(path) eng = Engine(path, clock=lambda: FROZEN)
barrier.wait() barrier.wait()
try: try:
result = eng.supersede(obj.id) result = eng.supersede(obj.id)
@ -51,20 +52,87 @@ def test_concurrent_supersessions_one_winner():
t.join() t.join()
assert len(winners) == 1 assert len(winners) == 1
assert len(errors) == 1 assert len(errors) == 1
check = Engine(path) check = Engine(path, clock=lambda: FROZEN)
assert check.get(obj.id).status == "superseded" assert check.get(obj.id).status == "superseded"
check.close() check.close()
def test_internal_consume_cas_once(engine): def test_consume_same_digest_is_idempotent(engine):
obj = approve(engine) obj = approve(engine)
engine._cas_consume(obj.id) digest = "sha256:" + "ab" * 32
try: first = engine.consume(obj.id, digest, decision_id="decision:first")
engine._cas_consume(obj.id) second = engine.consume(obj.id, digest, decision_id="decision:retry")
raise AssertionError("double consume must conflict") assert first["idempotent"] is False
except Conflict: assert second["idempotent"] is True
pass assert second["decision_id"] == "decision:first"
assert [item["class"] for item in engine.undrained()].count("use") == 1
claim = engine.claim(obj.id) claim = engine.claim(obj.id)
assert claim["consumed"] is True assert claim["consumed"] is True
assert claim["valid_now"] is False assert claim["valid_now"] is False
assert claim["reason_code"] == "consumed" assert claim["reason_code"] == "consumed"
def test_consume_different_digest_conflicts(engine):
obj = approve(engine)
engine.consume(obj.id, "sha256:" + "ab" * 32)
try:
engine.consume(obj.id, "sha256:" + "cd" * 32)
raise AssertionError("different request digest must conflict")
except Conflict:
pass
def test_concurrent_same_digest_consume_is_one_use_event():
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "consume.sqlite"
setup = Engine(path, clock=lambda: FROZEN)
obj = approve(setup)
setup.close()
digest = "sha256:" + "ef" * 32
results: list[bool] = []
barrier = threading.Barrier(2)
def race():
eng = Engine(path, clock=lambda: FROZEN)
barrier.wait()
try:
results.append(eng.consume(obj.id, digest)["idempotent"])
finally:
eng.close()
threads = [threading.Thread(target=race) for _ in range(2)]
for thread in threads:
thread.start()
for thread in threads:
thread.join()
assert sorted(results) == [False, True]
check = Engine(path, clock=lambda: FROZEN)
assert [item["class"] for item in check.undrained()].count("use") == 1
check.close()
def test_existing_database_migrates_consumption_columns():
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "legacy.sqlite"
conn = sqlite3.connect(path)
conn.execute(
"""CREATE TABLE approvals (
id TEXT PRIMARY KEY, status TEXT NOT NULL,
binding_json TEXT NOT NULL, binding_digest TEXT NOT NULL,
pdp_digest TEXT, actor TEXT NOT NULL, principal TEXT NOT NULL,
action TEXT NOT NULL, purpose TEXT NOT NULL,
target_json TEXT NOT NULL, not_before TEXT NOT NULL,
expires_at TEXT NOT NULL, required_count INTEGER NOT NULL,
superseded_by TEXT, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)"""
)
conn.commit()
conn.close()
eng = Engine(path, clock=lambda: FROZEN)
columns = {
row["name"]
for row in eng._conn().execute("PRAGMA table_info(approvals)").fetchall()
}
assert {"consumed_digest", "consumed_decision_id", "consumed_at"} <= columns
eng.close()

View file

@ -1,6 +1,6 @@
from approval_engine.errors import StoreUnavailable from approval_engine.errors import StoreUnavailable
from approval_engine.store import Engine from approval_engine.store import Engine
from tests.conftest import approve, binding, validity from tests.conftest import FROZEN, approve, binding, validity
def test_issuance_queued_in_same_commit(engine): def test_issuance_queued_in_same_commit(engine):
@ -16,7 +16,7 @@ def test_issuance_queued_in_same_commit(engine):
def test_failed_outbox_rolls_back_mutation(): def test_failed_outbox_rolls_back_mutation():
eng = Engine(":memory:", fail_outbox=True) eng = Engine(":memory:", clock=lambda: FROZEN, fail_outbox=True)
obj = eng.create(binding(), validity(), required_count=1) obj = eng.create(binding(), validity(), required_count=1)
try: try:
eng.add_entry(obj.id, "user:alice") eng.add_entry(obj.id, "user:alice")
@ -70,3 +70,15 @@ def test_revocation_event_class(engine):
engine.revoke(obj.id) engine.revoke(obj.id)
classes = [p["class"] for p in engine.undrained()] classes = [p["class"] for p in engine.undrained()]
assert "revocation" in classes assert "revocation" in classes
def test_failed_outbox_rolls_back_consume(engine):
obj = approve(engine)
engine.fail_outbox = True
try:
engine.consume(obj.id, "sha256:" + "34" * 32)
raise AssertionError("must fail")
except StoreUnavailable:
pass
assert engine.get(obj.id).status == "approved"
assert all(item["class"] != "use" for item in engine.undrained())

View file

@ -4,13 +4,14 @@ Reuse a previously valid approval artifact for a different target, parameter
set, or later time. Pass: parameter binding, expiry, or replay protection set, or later time. Pass: parameter binding, expiry, or replay protection
rejects the request. rejects the request.
Consume-side replay (use twice) waits on GH-WP-0002-T06. This suite covers Consume-side replay follows GH-DEC-2026-003: same-digest retries are
the object and claim side: wrong binding, expiry, revocation, supersession. idempotent; a different digest against a consumed object conflicts.
""" """
from datetime import datetime, timezone from datetime import datetime, timezone
from approval_engine.binding import binding_digest from approval_engine.binding import binding_digest
from approval_engine.errors import Conflict
from approval_engine.store import Engine from approval_engine.store import Engine
from tests.conftest import approve, binding, validity from tests.conftest import approve, binding, validity
@ -65,3 +66,22 @@ def test_t06_superseded_rejected(engine):
obj = approve(engine) obj = approve(engine)
engine.supersede(obj.id) engine.supersede(obj.id)
assert _consumer_accepts(engine.claim(obj.id), binding()) is False assert _consumer_accepts(engine.claim(obj.id), binding()) is False
def test_t06_consumed_approval_rejects_different_request_digest(engine):
obj = approve(engine)
first = "sha256:" + "56" * 32
engine.consume(obj.id, first)
try:
engine.consume(obj.id, "sha256:" + "78" * 32)
raise AssertionError("consume-side replay must conflict")
except Conflict:
pass
def test_t06_consumed_approval_allows_same_request_retry(engine):
obj = approve(engine)
digest = "sha256:" + "9a" * 32
engine.consume(obj.id, digest)
retry = engine.consume(obj.id, digest)
assert retry["idempotent"] is True

View file

@ -4,11 +4,11 @@ type: workplan
title: "v0.7 alignment and the engine spine" title: "v0.7 alignment and the engine spine"
domain: infotech domain: infotech
repo: approval-engine repo: approval-engine
status: active status: finished
owner: grok owner: grok
topic_slug: netkingdom topic_slug: netkingdom
created: "2026-08-29" created: "2026-08-29"
updated: "2026-08-29" updated: "2026-09-01"
state_hub_workstream_id: "546f2fae-c53e-5ea5-8c63-320118d8ee1e" state_hub_workstream_id: "546f2fae-c53e-5ea5-8c63-320118d8ee1e"
--- ---
@ -133,11 +133,11 @@ Acceptance: `layer.yaml` `evidence.cadence_status` is no longer
`undeclared`; a missing heartbeat or a count divergence is specified as a `undeclared`; a missing heartbeat or a count divergence is specified as a
finding, not as a log line. finding, not as a log line.
## T05 — Wait on consumption ordering; do not implement it ## T05 — Implement the assented consumption ordering contract
```task ```task
id: APPROVAL-WP-0001-T05 id: APPROVAL-WP-0001-T05
status: wait status: done
priority: high priority: high
state_hub_task_id: "204bdfeb-a669-569e-a654-10c1a9fcacbc" state_hub_task_id: "204bdfeb-a669-569e-a654-10c1a9fcacbc"
``` ```
@ -152,6 +152,17 @@ Acceptance: a written contract both engines have assented to, recorded
here, *then* a consume path. Until then, no `consume` endpoint, no inferred here, *then* a consume path. Until then, no `consume` endpoint, no inferred
consumption from a decision record, no demo that "just consumes on allow". consumption from a decision record, no demo that "just consumes on allow".
2026-09-01: Gate House resolved the blocker in `GH-DEC-2026-003` and
`docs/contracts/approval-consumption.md`, with an explicit State Hub handoff
from gate-house (`2ade6044`). Implemented
`POST /v1/approvals/{id}/consume`: the PEP presents the PDP decision binding's
`request_digest` after ALLOW and before the protected side effect. The SQLite
CAS stores the digest and optional decision id; same-digest retries are
idempotent success, different digests conflict, and there is no unconsume.
The `approval.use` outbox row remains in the same transaction. Concurrency,
rollback, API-shape, legacy-database migration, and consume-side T-06 tests are
included.
## T06 — Durable object, closed state machine, authenticated entries ## T06 — Durable object, closed state machine, authenticated entries
```task ```task
@ -165,6 +176,9 @@ state_hub_task_id: "44c4997b-6833-5540-a554-0e24210809f2"
CAS supersession (concurrent test), distinct-approver fail-closed, revocation CAS supersession (concurrent test), distinct-approver fail-closed, revocation
without holder cooperation. `_cas_consume` is unexported. without holder cooperation. `_cas_consume` is unexported.
2026-09-01: the assented T05 contract replaced the private seam with the public
digest-bound `consume` mutation and added an in-place SQLite column migration.
Depends on T02 and T03. Implement the object and the machine in SCOPE: Depends on T02 and T03. Implement the object and the machine in SCOPE:
@ -190,7 +204,7 @@ state_hub_task_id: "ea51499d-ced5-50fa-b852-396719a8c0f4"
``` ```
2026-08-29: `GET /v1/approvals/{id}/claim`. Tests forbid decision-shaped 2026-08-29: `GET /v1/approvals/{id}/claim`. Tests forbid decision-shaped
keys and `/v1/check` / `/authorize` / `/consume`. Store unavailable → 503. keys and `/v1/check` / `/authorize`. Store unavailable → 503.
@ -240,6 +254,10 @@ wrong action, later time, revoked, superseded. Consume-side replay stays out.
Handoff: `docs/flex-auth-handoff.md`. `FLEX-WP-0017` T03 is unblocked on this Handoff: `docs/flex-auth-handoff.md`. `FLEX-WP-0017` T03 is unblocked on this
object; T05 remains blocked only on consumption ordering. object; T05 remains blocked only on consumption ordering.
2026-09-01: consume-side replay coverage added after `GH-DEC-2026-003`:
same-digest retry is idempotent and different-digest replay conflicts. The
handoff document now records the PEP-before-side-effect protocol.
Depends on T07, T08, and T05 (the last only for the consume-side replay Depends on T07, T08, and T05 (the last only for the consume-side replay
@ -251,3 +269,15 @@ Acceptance: `T-06` passes; T03 is unblocked on the object (not on a hub
substitute); T05 remains blocked only on consumption ordering if T05 of substitute); T05 remains blocked only on consumption ordering if T05 of
this workplan is still `wait`, never on a missing object or a missing this workplan is still `wait`, never on a missing object or a missing
digest. digest.
## Closeout
Finished 2026-09-01. All nine tasks are complete and the full test suite passes.
The first-cut engine now has a durable approval object, authenticated-entry
shape, atomic supersession and consumption, claim introspection, transactional
outbox, cadence declaration, and complete Canon T-06 replay coverage.
Residual production hardening and consumer adoption are carried by
`APPROVAL-WP-0002`. The unassigned shared Taxonomy request-claim schema is
carried by residual intake `APPROVAL-IN-0001`; the local claim continues to
yield rather than claiming permanent vocabulary ownership.

View file

@ -0,0 +1,85 @@
---
id: APPROVAL-WP-0002
type: workplan
title: "Production readiness and consumer adoption"
domain: infotech
repo: approval-engine
status: proposed
owner: codex
topic_slug: netkingdom
created: "2026-09-01"
updated: "2026-09-01"
origin: residual
origin_ref: APPROVAL-WP-0001
---
# APPROVAL-WP-0002 — Production readiness and consumer adoption
Move the completed first-cut engine spine into an authenticated, durable,
observable production service and prove one PEP integration end to end. This is
the residual production scope deliberately excluded from APPROVAL-WP-0001.
The workplan is proposed pending review against the deployment estate and the
current key-cape, access-engine, audit-core, and secrets-engine contracts.
## Authenticate lifecycle mutations and approver evidence
```task
id: APPROVAL-WP-0002-T01
status: todo
priority: high
```
Bind create, approval-entry, revoke, supersede, and consume callers to
authenticated identities. An API-supplied `subject_id`, `actor`, or
`decision_id` is provenance only until independently authenticated. Keep
authorization decisions in access-engine and approval doctrine in gate-house.
## Harden durable storage and migrations
```task
id: APPROVAL-WP-0002-T02
status: todo
priority: high
```
Define the production persistence, backup/restore, migration, concurrency, and
recovery posture. Prove schema upgrades preserve existing approvals and that
crash recovery cannot separate mutations from outbox evidence.
## Package and deploy the service
```task
id: APPROVAL-WP-0002-T03
status: todo
priority: high
```
Add the governed image/deployment surface, health and readiness behavior,
resource bounds, and fail-closed caller configuration. A local WSGI development
server is not production evidence.
## Wire outbox delivery and reconciliation
```task
id: APPROVAL-WP-0002-T04
status: todo
priority: high
```
Deliver the local outbox asynchronously to audit-core, preserve event-id
deduplication, publish lag/depth signals, emit the declared heartbeat, and prove
the Gate House reconciliation contract against accepted event counts.
## Prove one live PEP consumption path
```task
id: APPROVAL-WP-0002-T05
status: todo
priority: high
```
Integrate one protected-system consumer under `GH-DEC-2026-003`: claim before
decision, CAS consume after ALLOW and before side effect, same-digest retry,
different-digest conflict, spent-on-failure behavior, and no protected action
when approval-engine is unavailable.