diff --git a/AGENTS.md b/AGENTS.md index a0d151d..e57ac5d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -185,8 +185,10 @@ frontmatter and `layer.yaml`. - Answer "is this approval valid for this exact binding, and has it been used?" Never "may this actor do X". -- Do not implement consumption until `GH-WP-0002-T06` / `APPROVAL-WP-0001-T05` - settles the contract with `access-engine`. +- Consumption follows `GH-DEC-2026-003` and + `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 mutation transaction. - Approval evidence is load-bearing. Atomicity covers crash, not compromise. diff --git a/INTENT.md b/INTENT.md index e29451e..5a516ec 100644 --- a/INTENT.md +++ b/INTENT.md @@ -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 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 -failure modes are named, and each needs an owner (standard §9.7.4, §16): +The decision precedes consumption, and consumption precedes the protected +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 approval consumed twice by racing callers; - an approval consumed after the action it authorized has already failed. -`approval-engine` performs the mutation, because `access-engine` never mutates. -But *who signals consumption, at what point relative to the decision, and what -happens on each of the three states above* is a contract between the two engines -and is **not yet settled**. It is recorded in the standard's §16, in -`GH-WP-0002-T06`, and is required before `FLEX-WP-0017` T05. Raised by -`flex-auth`. - -Nothing here may be implemented by guessing that contract. +The PEP signals consumption by presenting the decision binding's canonical +request digest to this engine. approval-engine performs the CAS before the PEP +acts; `access-engine` never mutates. Same-digest retries are idempotent success, +different digests conflict, and a failure after consume leaves the approval +spent. There is no unconsume. ## 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 as covering a compromised source, or skipping the heartbeat, is the v0.6 overclaim this engine must not reintroduce. -- **It implements consumption by guessing.** The three races are named and - unowned. Code that picks a side is a contract with `access-engine` that - `access-engine` has not assented to. +- **It diverges from the consumption contract.** PDP mutation, + action-before-consume, different-digest reuse, or unconsume violates + `GH-DEC-2026-003`. - **It cites observation or containment that has not happened.** Nothing is observed in production, and nothing can be contained automatically (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'; 7. load-bearing classes declare a heartbeat or reconciliation, and divergence 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. diff --git a/README.md b/README.md index 7aff5a5..9c0484e 100644 --- a/README.md +++ b/README.md @@ -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). 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 layer model. @@ -29,4 +30,6 @@ make test 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. diff --git a/SCOPE.md b/SCOPE.md index bf517e5..3ea6b2b 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -20,8 +20,8 @@ whether the action is permitted, does not author the policy that requires an approval, and does not archive the trail. 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 -the other side has not assented to. +and supplies the approval object and consumption mutation needed by protected +systems. Consumption follows the assented `GH-DEC-2026-003` contract. ## In Scope @@ -34,6 +34,8 @@ the other side has not assented to. `superseded / revoked / expired` as terminal exits from `valid`. - Atomic supersession and single consumption (compare-and-swap, never 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 at the next use. - 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. - PEP shape, unreachable-engine stance maps, Railiance `rail-*` / `rapp-*` / `reef-*` axes. An approval is not a workload (statute §20.1). -- Consumption signaling relative to the decision, until `GH-WP-0002-T06` - settles it with `access-engine`. Guessing that contract is out of scope - even as a prototype. +- Any consumption protocol other than `GH-DEC-2026-003`, including PDP + mutation, action-before-consume, unconsume, reserve/release, or inferring use + from a decision record. ## 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 graded readiness or a maturity ladder. - 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 @@ -97,8 +100,8 @@ the other side has not assented to. outbox, WSGI introspection API, claim contract. Not a production deploy. - Layer declaration: INTENT frontmatter + `layer.yaml`. Cadence declared in `cadence.yaml`. No Tooling contacts. -- Consumption is not a public API (`APPROVAL-WP-0001-T05` waits on - `GH-WP-0002-T06`). +- Consumption is public at `POST /v1/approvals/{id}/consume` under + `GH-DEC-2026-003`; the endpoint is a lifecycle mutation, never a decision. - Taxonomy request-claim schema is still unassigned; the local claim yields. - Work: `APPROVAL-WP-0001`. Tests: `make test`. @@ -158,8 +161,8 @@ type: api title: Approval object lifecycle description: > Create, collect authenticated entries with distinct-approver counting, - atomically supersede, and revoke without holder cooperation. No public - consume until consumption ordering is settled. + atomically supersede, consume with request-digest idempotency, and revoke + without holder cooperation. keywords: [approval, state-machine, cas, revocation, supersession] ``` diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index 2a1e464..2b368aa 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -8,13 +8,20 @@ | 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-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-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-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-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 | diff --git a/approval_engine/api.py b/approval_engine/api.py index 3fa8ca0..afcff8f 100644 --- a/approval_engine/api.py +++ b/approval_engine/api.py @@ -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 @@ -87,8 +87,6 @@ class App: if len(parts) >= 3 and parts[0] == "v1" and parts[1] == "approvals": approval_id = parts[2] 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": return 200, self.engine.get(approval_id).as_dict() if rest == ["claim"] and method == "GET": @@ -107,7 +105,14 @@ class App: if rest == ["supersede"] and method == "POST": data = _read_json(environ) 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": path} diff --git a/approval_engine/store.py b/approval_engine/store.py index 75d9b53..8beaaa5 100644 --- a/approval_engine/store.py +++ b/approval_engine/store.py @@ -1,8 +1,8 @@ """SQLite-backed approval object, closed machine, local outbox. 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 -can be tested without guessing GH-WP-0002-T06. +Consumption follows GH-DEC-2026-003: the PEP presents the decision binding's +request digest before the protected side effect. """ from __future__ import annotations @@ -50,6 +50,9 @@ CREATE TABLE IF NOT EXISTS approvals ( expires_at TEXT NOT NULL, required_count INTEGER NOT NULL, superseded_by TEXT, + consumed_digest TEXT, + consumed_decision_id TEXT, + consumed_at TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); @@ -109,12 +112,15 @@ class Approval: expires_at: str required_count: int superseded_by: str | None + consumed_digest: str | None + consumed_decision_id: str | None + consumed_at: str | None created_at: str updated_at: str entries: list[dict[str, Any]] def as_dict(self) -> dict[str, Any]: - return { + result = { "id": self.id, "status": self.status, "binding": { @@ -129,6 +135,17 @@ class Approval: "created_at": self.created_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: @@ -172,6 +189,12 @@ class Engine: conn = self._conn() try: 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() except sqlite3.Error as exc: raise StoreUnavailable(str(exc)) from exc @@ -295,6 +318,9 @@ class Engine: expires_at=row["expires_at"], required_count=row["required_count"], 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"], updated_at=row["updated_at"], entries=entries, @@ -487,16 +513,51 @@ class Engine: raise return self.get(approval_id) - def _cas_consume(self, approval_id: str) -> Approval: - """Unexported seam. Do not wire to HTTP. Blocked on GH-WP-0002-T06.""" + def consume( + 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() now = iso(self.now()) + idempotent = False try: conn.execute("BEGIN IMMEDIATE") row = conn.execute("SELECT * FROM approvals WHERE id=?", (approval_id,)).fetchone() if row is None: conn.rollback() 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": conn.rollback() raise Conflict(f"cannot consume from status {row['status']}") @@ -504,9 +565,10 @@ class Engine: conn.rollback() raise Conflict("cannot consume outside validity window") 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'", - (now, approval_id), + (digest, decision_id, now, now, approval_id), ) if cur.rowcount != 1: conn.rollback() @@ -516,7 +578,11 @@ class Engine: "use", approval_id, 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() except sqlite3.Error as exc: @@ -525,7 +591,14 @@ class Engine: except Exception: conn.rollback() 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( self, diff --git a/cadence.yaml b/cadence.yaml index 5862997..d353cd6 100644 --- a/cadence.yaml +++ b/cadence.yaml @@ -29,7 +29,7 @@ classes: action: approval.issuance use: action: approval.use - note: "Internal CAS only until GH-WP-0002-T06." + note: "Public CAS consumption under GH-DEC-2026-003." supersession: action: approval.supersession revocation: diff --git a/docs/approval-consumption.md b/docs/approval-consumption.md new file mode 100644 index 0000000..ea19c73 --- /dev/null +++ b/docs/approval-consumption.md @@ -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. diff --git a/docs/flex-auth-handoff.md b/docs/flex-auth-handoff.md index 4763a45..8d1160c 100644 --- a/docs/flex-auth-handoff.md +++ b/docs/flex-auth-handoff.md @@ -8,6 +8,8 @@ - Claim contract: [`approval-claim.md`](approval-claim.md) - Introspection: `GET /v1/approvals/{id}/claim` - 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 `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 unreachable. -## What does not exist yet +## Consumption contract -Consumption ordering (`GH-WP-0002-T06` / `APPROVAL-WP-0001-T05`). There is -no public `consume`. `FLEX-WP-0017` T05 stays blocked **only** on that -contract, not on a missing object or a missing digest. +Gate House settled ordering in `GH-DEC-2026-003` and +`docs/contracts/approval-consumption.md`. The PEP presents the decision +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 -target, wrong action, later time, revoked, superseded). Consume-side replay -is out of scope until T05. +target, wrong action, later time, revoked, superseded, same-digest retry, and +different-digest consume replay). diff --git a/docs/outbox-contract.md b/docs/outbox-contract.md index 0f63caf..e27016d 100644 --- a/docs/outbox-contract.md +++ b/docs/outbox-contract.md @@ -26,7 +26,7 @@ object mutation. | Class | When | `audit-core` `action` | | --- | --- | --- | | `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` | | `revocation` | object becomes `revoked` | `approval.revocation` | | `heartbeat` | signed *nothing to report* plus counts | `approval.heartbeat` | diff --git a/intakes/intakes.md b/intakes/intakes.md new file mode 100644 index 0000000..035085b --- /dev/null +++ b/intakes/intakes.md @@ -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. +``` diff --git a/tests/test_api.py b/tests/test_api.py index aac5015..5f1c21b 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -27,16 +27,86 @@ def test_create_entry_claim_roundtrip(app): assert "decision" not in claim -def test_no_check_or_authorize_or_consume(app): +def test_no_check_or_authorize(app): for path in ( "/v1/check", "/authorize", - "/v1/approvals/00000000-0000-0000-0000-000000000001/consume", - "/v1/approvals/abc/consume", ): status, body = call(app, "POST", path, {}) 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): diff --git a/tests/test_cas.py b/tests/test_cas.py index f2e61b0..e5c8416 100644 --- a/tests/test_cas.py +++ b/tests/test_cas.py @@ -1,10 +1,11 @@ import tempfile import threading +import sqlite3 from pathlib import Path from approval_engine.errors import Conflict from approval_engine.store import Engine -from tests.conftest import approve +from tests.conftest import FROZEN, approve def test_second_supersession_loses(engine): @@ -25,7 +26,7 @@ def test_second_supersession_loses(engine): def test_concurrent_supersessions_one_winner(): with tempfile.TemporaryDirectory() as tmp: path = Path(tmp) / "a.sqlite" - setup = Engine(path) + setup = Engine(path, clock=lambda: FROZEN) obj = approve(setup) setup.close() @@ -34,7 +35,7 @@ def test_concurrent_supersessions_one_winner(): barrier = threading.Barrier(2) def race(): - eng = Engine(path) + eng = Engine(path, clock=lambda: FROZEN) barrier.wait() try: result = eng.supersede(obj.id) @@ -51,20 +52,87 @@ def test_concurrent_supersessions_one_winner(): t.join() assert len(winners) == 1 assert len(errors) == 1 - check = Engine(path) + check = Engine(path, clock=lambda: FROZEN) assert check.get(obj.id).status == "superseded" check.close() -def test_internal_consume_cas_once(engine): +def test_consume_same_digest_is_idempotent(engine): obj = approve(engine) - engine._cas_consume(obj.id) - try: - engine._cas_consume(obj.id) - raise AssertionError("double consume must conflict") - except Conflict: - pass + digest = "sha256:" + "ab" * 32 + first = engine.consume(obj.id, digest, decision_id="decision:first") + second = engine.consume(obj.id, digest, decision_id="decision:retry") + assert first["idempotent"] is False + assert second["idempotent"] is True + assert second["decision_id"] == "decision:first" + assert [item["class"] for item in engine.undrained()].count("use") == 1 claim = engine.claim(obj.id) assert claim["consumed"] is True assert claim["valid_now"] is False 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() diff --git a/tests/test_outbox.py b/tests/test_outbox.py index 92b3c69..cc7148c 100644 --- a/tests/test_outbox.py +++ b/tests/test_outbox.py @@ -1,6 +1,6 @@ from approval_engine.errors import StoreUnavailable 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): @@ -16,7 +16,7 @@ def test_issuance_queued_in_same_commit(engine): 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) try: eng.add_entry(obj.id, "user:alice") @@ -70,3 +70,15 @@ def test_revocation_event_class(engine): engine.revoke(obj.id) classes = [p["class"] for p in engine.undrained()] 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()) diff --git a/tests/test_t06_replay.py b/tests/test_t06_replay.py index 2e58307..1d357d6 100644 --- a/tests/test_t06_replay.py +++ b/tests/test_t06_replay.py @@ -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 rejects the request. -Consume-side replay (use twice) waits on GH-WP-0002-T06. This suite covers -the object and claim side: wrong binding, expiry, revocation, supersession. +Consume-side replay follows GH-DEC-2026-003: same-digest retries are +idempotent; a different digest against a consumed object conflicts. """ from datetime import datetime, timezone from approval_engine.binding import binding_digest +from approval_engine.errors import Conflict from approval_engine.store import Engine from tests.conftest import approve, binding, validity @@ -65,3 +66,22 @@ def test_t06_superseded_rejected(engine): obj = approve(engine) engine.supersede(obj.id) 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 diff --git a/workplans/APPROVAL-WP-0001-v07-alignment-and-engine-spine.md b/workplans/APPROVAL-WP-0001-v07-alignment-and-engine-spine.md index 2bf0f34..71bd2da 100644 --- a/workplans/APPROVAL-WP-0001-v07-alignment-and-engine-spine.md +++ b/workplans/APPROVAL-WP-0001-v07-alignment-and-engine-spine.md @@ -4,11 +4,11 @@ type: workplan title: "v0.7 alignment and the engine spine" domain: infotech repo: approval-engine -status: active +status: finished owner: grok topic_slug: netkingdom created: "2026-08-29" -updated: "2026-08-29" +updated: "2026-09-01" 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 finding, not as a log line. -## T05 — Wait on consumption ordering; do not implement it +## T05 — Implement the assented consumption ordering contract ```task id: APPROVAL-WP-0001-T05 -status: wait +status: done priority: high 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 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 ```task @@ -165,6 +176,9 @@ state_hub_task_id: "44c4997b-6833-5540-a554-0e24210809f2" CAS supersession (concurrent test), distinct-approver fail-closed, revocation 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: @@ -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 -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 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 @@ -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 this workplan is still `wait`, never on a missing object or a missing 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. diff --git a/workplans/APPROVAL-WP-0002-production-readiness-and-consumer-adoption.md b/workplans/APPROVAL-WP-0002-production-readiness-and-consumer-adoption.md new file mode 100644 index 0000000..cddd181 --- /dev/null +++ b/workplans/APPROVAL-WP-0002-production-readiness-and-consumer-adoption.md @@ -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.