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:
parent
624e43f554
commit
9c9528f5b2
29 changed files with 2121 additions and 26 deletions
56
tests/conftest.py
Normal file
56
tests/conftest.py
Normal 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
69
tests/test_api.py
Normal 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
22
tests/test_cadence.py
Normal 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
70
tests/test_cas.py
Normal 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"
|
||||
58
tests/test_claim_contract.py
Normal file
58
tests/test_claim_contract.py
Normal 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
80
tests/test_machine.py
Normal 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
72
tests/test_outbox.py
Normal 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
67
tests/test_t06_replay.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue