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?"
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.

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
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.

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).
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.

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.
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]
```

View file

@ -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 |

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
@ -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}

View file

@ -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,

View file

@ -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:

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)
- 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).

View file

@ -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` |

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
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):

View file

@ -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()

View file

@ -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())

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
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

View file

@ -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.

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.