Implement the engine spine: claim, outbox, machine, API

Contracts first (T02–T04): approval claim schema with issuer, freshness,
and binding digest; local transactional outbox wire; load-bearing cadence
as heartbeat or reconciliation (layer.yaml declared).

Then the object (T06–T08): SQLite closed state machine, CAS supersession,
distinct-approver fail-closed, revocation without holder cooperation,
outbox insert in the same transaction. Tests fail the mutation when
emission fails, and revoke while the drain sink is down.

Introspection GET /v1/approvals/{id}/claim is a PIP fact, not a decision.
No public consume (T05 waits on GH-WP-0002-T06). Canon T-06 coverage for
wrong binding, expiry, revoke, and supersede.

FLEX-WP-0017 T03 is unblocked on this object; T05 remains blocked only on
consumption ordering.

Assistant: grok
Assistant-Session: 01a04ceb-2057-7e20-b0f9-c282964d5dd9
This commit is contained in:
tegwick 2026-08-29 12:52:49 +02:00
parent 624e43f554
commit 9c9528f5b2
29 changed files with 2121 additions and 26 deletions

56
tests/conftest.py Normal file
View file

@ -0,0 +1,56 @@
from datetime import datetime, timezone
import pytest
from approval_engine.api import App
from approval_engine.store import Engine
FROZEN = datetime(2026, 8, 29, 12, 0, tzinfo=timezone.utc)
@pytest.fixture
def now():
return FROZEN
@pytest.fixture
def engine(now):
eng = Engine(":memory:", clock=lambda: now)
yield eng
eng.close()
@pytest.fixture
def app(engine):
return App(engine)
def binding(**overrides):
base = {
"action": "secrets.kv.destroy",
"target": {"id": "lane-openbao-root", "stage": "prod"},
"actor": "agt-secrets-engine",
"principal": "bernd",
"purpose": "rotate-exposed-key",
}
base.update(overrides)
return base
def validity():
return {
"not_before": "2026-08-29T11:00:00+00:00",
"expires_at": "2026-08-29T15:00:00+00:00",
}
def approve(engine, required=1, extra_binding=None, pdp_digest=None):
obj = engine.create(
extra_binding or binding(),
validity(),
required_count=required,
pdp_digest=pdp_digest,
)
for i in range(required):
obj = engine.add_entry(obj.id, f"user:approver-{i}")
return obj

69
tests/test_api.py Normal file
View file

@ -0,0 +1,69 @@
from approval_engine.api import call
from tests.conftest import binding, validity
def test_readyz(app):
status, body = call(app, "GET", "/v1/readyz")
assert status == 200
assert body["status"] == "ok"
def test_create_entry_claim_roundtrip(app):
status, created = call(
app,
"POST",
"/v1/approvals",
{"binding": binding(), "validity": validity(), "required_count": 1},
)
assert status == 201
aid = created["id"]
status, _ = call(app, "POST", f"/v1/approvals/{aid}/entries", {"subject_id": "user:alice"})
assert status == 200
status, claim = call(app, "GET", f"/v1/approvals/{aid}/claim")
assert status == 200
assert claim["kind"] == "approval-claim"
assert claim["valid_now"] is True
assert "effect" not in claim
assert "decision" not in claim
def test_no_check_or_authorize_or_consume(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"
def test_claim_after_revoke(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"})
call(app, "POST", f"/v1/approvals/{aid}/revoke", {})
status, claim = call(app, "GET", f"/v1/approvals/{aid}/claim")
assert status == 200
assert claim["valid_now"] is False
assert claim["reason_code"] == "revoked"
def test_store_unavailable_is_503():
from approval_engine.api import App
from approval_engine.errors import StoreUnavailable
from approval_engine.store import Engine
class Dead(Engine):
def outbox_stats(self):
raise StoreUnavailable("down")
status, body = call(App(Dead.__new__(Dead)), "GET", "/v1/readyz")
assert status == 503
assert body["error"] == "store_unavailable"

22
tests/test_cadence.py Normal file
View file

@ -0,0 +1,22 @@
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
def test_layer_yaml_cadence_declared():
text = (ROOT / "layer.yaml").read_text()
assert "cadence_status: declared" in text
assert "cadence_form: heartbeat-or-reconciliation" in text
assert "cadence: cadence.yaml" in text
assert "cadence_status: undeclared" not in text
def test_cadence_yaml_forbids_rate_monitoring():
text = (ROOT / "cadence.yaml").read_text()
assert "kind: load-bearing" in text
assert "form: heartbeat-or-reconciliation" in text
assert "rate_monitoring: forbidden" in text
assert "missing: finding" in text
assert "divergence: finding" in text
for cls in ("issuance", "use", "supersession", "revocation", "heartbeat"):
assert f"{cls}:" in text

70
tests/test_cas.py Normal file
View file

@ -0,0 +1,70 @@
import tempfile
import threading
from pathlib import Path
from approval_engine.errors import Conflict
from approval_engine.store import Engine
from tests.conftest import approve
def test_second_supersession_loses(engine):
obj = approve(engine)
first = engine.supersede(obj.id)
assert engine.get(obj.id).status == "superseded"
assert first["successor_id"]
try:
engine.supersede(obj.id)
raise AssertionError("second supersession must conflict")
except Conflict:
pass
claim = engine.claim(obj.id)
assert claim["valid_now"] is False
assert claim["reason_code"] == "superseded"
def test_concurrent_supersessions_one_winner():
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "a.sqlite"
setup = Engine(path)
obj = approve(setup)
setup.close()
winners: list[str] = []
errors: list[str] = []
barrier = threading.Barrier(2)
def race():
eng = Engine(path)
barrier.wait()
try:
result = eng.supersede(obj.id)
winners.append(result["successor_id"])
except Conflict as exc:
errors.append(str(exc))
finally:
eng.close()
threads = [threading.Thread(target=race) for _ in range(2)]
for t in threads:
t.start()
for t in threads:
t.join()
assert len(winners) == 1
assert len(errors) == 1
check = Engine(path)
assert check.get(obj.id).status == "superseded"
check.close()
def test_internal_consume_cas_once(engine):
obj = approve(engine)
engine._cas_consume(obj.id)
try:
engine._cas_consume(obj.id)
raise AssertionError("double consume must conflict")
except Conflict:
pass
claim = engine.claim(obj.id)
assert claim["consumed"] is True
assert claim["valid_now"] is False
assert claim["reason_code"] == "consumed"

View file

@ -0,0 +1,58 @@
import json
from pathlib import Path
from approval_engine.binding import binding_digest
from tests.conftest import approve, binding
ROOT = Path(__file__).resolve().parents[1]
def test_schema_forbids_decision_keys():
schema = json.loads((ROOT / "schemas/approval_claim.schema.json").read_text())
assert "not" in schema
kinds = [item["required"][0] for item in schema["not"]["anyOf"]]
assert set(kinds) >= {"effect", "decision", "allow", "deny"}
def test_claim_has_issuer_digest_freshness(engine):
obj = approve(engine)
claim = engine.claim(obj.id)
assert claim["kind"] == "approval-claim"
assert claim["issuer"] == "approval-engine"
assert claim["approval_id"] == obj.id
assert claim["valid_now"] is True
assert claim["reason_code"] == "ok"
assert claim["binding"]["digest"] == binding_digest(binding())
assert claim["freshness"]["ttl_seconds"] == 30
assert claim["freshness"]["not_after"] == "2026-08-29T12:00:30+00:00"
assert "effect" not in claim
assert "decision" not in claim
assert "yields_to" in claim
def test_wrong_target_changes_digest():
a = binding()
b = binding(target={"id": "other-lane", "stage": "prod"})
assert binding_digest(a) != binding_digest(b)
def test_wrong_action_changes_digest():
assert binding_digest(binding()) != binding_digest(binding(action="secrets.kv.read"))
def test_pdp_digest_is_recorded_not_recomputed(engine):
pdp = "sha256:" + "ab" * 32
obj = approve(engine, pdp_digest=pdp)
claim = engine.claim(obj.id)
assert claim["binding"]["pdp_digest"] == pdp
assert claim["binding"]["digest"] == binding_digest(binding())
assert claim["binding"]["digest"] != pdp
def test_examples_are_claim_shaped():
for name in ("claim.valid.json", "claim.revoked.json"):
data = json.loads((ROOT / "examples" / name).read_text())
assert data["kind"] == "approval-claim"
assert data["issuer"] == "approval-engine"
for forbidden in ("effect", "decision", "allow", "deny"):
assert forbidden not in data

80
tests/test_machine.py Normal file
View file

@ -0,0 +1,80 @@
from datetime import datetime, timezone
from approval_engine.errors import Conflict, DuplicateApprover
from approval_engine.store import Engine
from tests.conftest import approve, binding, validity
def test_requested_until_threshold(engine):
obj = engine.create(binding(), validity(), required_count=2)
assert obj.status == "requested"
obj = engine.add_entry(obj.id, "user:alice")
assert obj.status == "requested"
claim = engine.claim(obj.id)
assert claim["valid_now"] is False
assert claim["reason_code"] == "requested"
def test_threshold_approves_and_valid_now(engine):
obj = approve(engine, required=2)
assert obj.status == "approved"
assert len(obj.entries) == 2
claim = engine.claim(obj.id)
assert claim["state"] == "valid"
assert claim["valid_now"] is True
def test_duplicate_approver_fails_closed(engine):
obj = engine.create(binding(), validity(), required_count=2)
engine.add_entry(obj.id, "user:alice")
try:
engine.add_entry(obj.id, "user:alice")
raise AssertionError("duplicate must fail")
except DuplicateApprover:
pass
obj = engine.get(obj.id)
assert len(obj.entries) == 1
assert obj.status == "requested"
def test_revoke_without_holder_and_next_claim(engine):
obj = approve(engine)
engine.revoke(obj.id)
claim = engine.claim(obj.id)
assert claim["valid_now"] is False
assert claim["state"] == "revoked"
assert claim["reason_code"] == "revoked"
def test_expiry_on_observation():
jumping = {"t": datetime(2026, 8, 29, 12, 0, tzinfo=timezone.utc)}
eng = Engine(":memory:", clock=lambda: jumping["t"])
obj = eng.create(binding(), validity(), required_count=1)
obj = eng.add_entry(obj.id, "user:alice")
assert eng.claim(obj.id)["valid_now"] is True
jumping["t"] = datetime(2026, 8, 29, 16, 0, tzinfo=timezone.utc)
claim = eng.claim(obj.id)
assert claim["state"] == "expired"
assert claim["valid_now"] is False
eng.close()
def test_not_yet_valid():
future = datetime(2026, 8, 29, 10, 0, tzinfo=timezone.utc)
eng = Engine(":memory:", clock=lambda: future)
obj = eng.create(binding(), validity(), required_count=1)
obj = eng.add_entry(obj.id, "user:alice")
claim = eng.claim(obj.id)
assert claim["valid_now"] is False
assert claim["reason_code"] == "not_yet_valid"
eng.close()
def test_cannot_revoke_twice(engine):
obj = approve(engine)
engine.revoke(obj.id)
try:
engine.revoke(obj.id)
raise AssertionError("second revoke must conflict")
except Conflict:
pass

72
tests/test_outbox.py Normal file
View file

@ -0,0 +1,72 @@
from approval_engine.errors import StoreUnavailable
from approval_engine.store import Engine
from tests.conftest import approve, binding, validity
def test_issuance_queued_in_same_commit(engine):
obj = approve(engine)
pending = engine.undrained()
classes = [p["class"] for p in pending]
assert classes == ["issuance"]
assert pending[0]["approval_id"] == obj.id
payload = pending[0]["payload"]
assert payload["source"] == "approval-engine"
assert payload["action"] == "approval.issuance"
assert payload["event_id"] == pending[0]["event_id"]
def test_failed_outbox_rolls_back_mutation():
eng = Engine(":memory:", fail_outbox=True)
obj = eng.create(binding(), validity(), required_count=1)
try:
eng.add_entry(obj.id, "user:alice")
raise AssertionError("must fail")
except StoreUnavailable:
pass
obj = eng.get(obj.id)
assert obj.status == "requested"
assert obj.entries == []
assert eng.undrained() == []
eng.close()
def test_revoke_succeeds_when_drain_sink_is_down(engine):
obj = approve(engine)
engine.revoke(obj.id)
assert engine.get(obj.id).status == "revoked"
def down(_payload):
raise ConnectionError("audit-core unreachable")
result = engine.drain(down)
assert result["failed"] >= 1
assert engine.get(obj.id).status == "revoked"
assert any(p["class"] == "revocation" for p in engine.undrained())
def test_drain_marks_delivered(engine):
approve(engine)
sink: list[dict] = []
result = engine.drain(sink.append)
assert result["delivered"] == 1
assert result["failed"] == 0
assert engine.undrained() == []
assert sink[0]["action"] == "approval.issuance"
def test_heartbeat_is_positive_claim(engine):
approve(engine)
beat = engine.emit_heartbeat()
assert beat["assertion"] == "nothing-to-report"
assert beat["counts"]["issuance"] == 1
assert beat["counts"]["heartbeat"] == 1
pending = [p for p in engine.undrained() if p["class"] == "heartbeat"]
assert len(pending) == 1
assert pending[0]["payload"]["details"]["assertion"] == "nothing-to-report"
def test_revocation_event_class(engine):
obj = approve(engine)
engine.revoke(obj.id)
classes = [p["class"] for p in engine.undrained()]
assert "revocation" in classes

67
tests/test_t06_replay.py Normal file
View file

@ -0,0 +1,67 @@
"""Canon T-06 — Approval Replay.
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.
"""
from datetime import datetime, timezone
from approval_engine.binding import binding_digest
from approval_engine.store import Engine
from tests.conftest import approve, binding, validity
def _consumer_accepts(claim, proposed_binding) -> bool:
if claim["issuer"] != "approval-engine":
return False
if not claim["valid_now"]:
return False
if claim["consumed"]:
return False
if claim["reason_code"] != "ok":
return False
return claim["binding"]["digest"] == binding_digest(proposed_binding)
def test_t06_wrong_target_rejected(engine):
obj = approve(engine)
claim = engine.claim(obj.id)
assert _consumer_accepts(claim, binding()) is True
other = binding(target={"id": "some-other-lane", "stage": "prod"})
assert _consumer_accepts(claim, other) is False
def test_t06_wrong_action_rejected(engine):
obj = approve(engine)
claim = engine.claim(obj.id)
other = binding(action="secrets.kv.read")
assert _consumer_accepts(claim, other) is False
def test_t06_later_time_expired():
jumping = {"t": datetime(2026, 8, 29, 12, 0, tzinfo=timezone.utc)}
eng = Engine(":memory:", clock=lambda: jumping["t"])
obj = approve(eng)
claim = eng.claim(obj.id)
assert _consumer_accepts(claim, binding()) is True
jumping["t"] = datetime(2026, 8, 29, 16, 0, tzinfo=timezone.utc)
claim = eng.claim(obj.id)
assert claim["state"] == "expired"
assert _consumer_accepts(claim, binding()) is False
eng.close()
def test_t06_revoked_rejected(engine):
obj = approve(engine)
engine.revoke(obj.id)
assert _consumer_accepts(engine.claim(obj.id), binding()) is False
def test_t06_superseded_rejected(engine):
obj = approve(engine)
engine.supersede(obj.id)
assert _consumer_accepts(engine.claim(obj.id), binding()) is False