Carry threshold evidence on issuance and use events
GH-DEC-2026-005 moved the distinct-approver check off the PEP onto this engine's valid_now. secrets-engine has implemented the split and reports it no longer verifies the threshold independently. Gate House accepted that as correct on layering AND as a genuine reduction in defence in depth, and named the compensating control: not a second check at the PEP, which is the duplication the split removes, but reconstructability at the issuer under §9.6. The emitted events could not support that. approval.issuance carried required_count but never who satisfied it, and approval.use carried no threshold evidence at all, so an auditor replaying the stream could not recompute the evaluation without reading live rows -- rows that may since have been superseded, revoked, or expired. Both events now carry a threshold object: required_count, distinct_approver_count, threshold_met, and approvers with approved_at plus assurance and evidence_ref when recorded. Tests prove reconstruction from the use row alone, and that the claim still discloses no approver identities -- they are evidence for audit-core, not consumer-facing, and the claim keeps disclosing the least it can. Writing the tests showed distinctness is already a storage invariant: entries is UNIQUE on (approval_id, subject_id), so a repeat approver is refused at insert and a separate entry_count could never differ from the distinct count. Dropped that field rather than ship a number that cannot vary, and the test now asserts the refusal instead. 88 tests pass (4 new). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TvyJPAaVCGsVheVhcCwNND Assistant: claude-code Assistant-Model: opus Assistant-Process: 411227@bnt-lap001 Assistant-Session: d566f6d3-bcaf-43c3-bc5e-3ddd0f64b535
This commit is contained in:
parent
48ffc34993
commit
87e55e2bca
4 changed files with 163 additions and 2 deletions
|
|
@ -1,4 +1,8 @@
|
|||
from approval_engine.errors import StoreUnavailable
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from approval_engine.errors import DuplicateApprover, StoreUnavailable
|
||||
from approval_engine.store import Engine
|
||||
from tests.conftest import FROZEN, approve, binding, validity
|
||||
|
||||
|
|
@ -98,3 +102,63 @@ def test_drain_failure_records_bounded_attempt_state(engine):
|
|||
stats = engine.outbox_stats()
|
||||
assert stats["failed_pending"] == 1
|
||||
assert stats["attempts"] == 1
|
||||
|
||||
|
||||
def _event(engine, cls):
|
||||
return next(p["payload"] for p in engine.undrained() if p["class"] == cls)
|
||||
|
||||
|
||||
def test_issuance_carries_the_threshold_evaluation(engine):
|
||||
"""GH-DEC-2026-005 §9.6: the PEP no longer counts approvers, so the
|
||||
evaluation must be recoverable from what this engine emitted."""
|
||||
approve(engine, required=2)
|
||||
threshold = _event(engine, "issuance")["details"]["threshold"]
|
||||
assert threshold["required_count"] == 2
|
||||
assert threshold["distinct_approver_count"] == 2
|
||||
assert threshold["threshold_met"] is True
|
||||
assert [a["subject_id"] for a in threshold["approvers"]] == [
|
||||
"user:approver-0",
|
||||
"user:approver-1",
|
||||
]
|
||||
assert all(a["approved_at"] for a in threshold["approvers"])
|
||||
|
||||
|
||||
def test_use_event_reconstructs_the_threshold_without_live_rows(engine):
|
||||
"""An auditor holding only the use row must be able to recompute it."""
|
||||
obj = approve(engine, required=2)
|
||||
engine.consume(obj.id, obj.binding_digest)
|
||||
threshold = _event(engine, "use")["details"]["threshold"]
|
||||
assert threshold["required_count"] == 2
|
||||
assert threshold["distinct_approver_count"] == 2
|
||||
assert threshold["threshold_met"] is True
|
||||
approvers = [a["subject_id"] for a in threshold["approvers"]]
|
||||
assert approvers == ["user:approver-0", "user:approver-1"]
|
||||
# the recomputation an auditor performs
|
||||
assert len(set(approvers)) >= threshold["required_count"]
|
||||
|
||||
|
||||
def test_duplicate_approver_is_refused_so_distinctness_is_an_invariant(engine):
|
||||
"""Dual control is enforced at storage, not recomputed from evidence."""
|
||||
obj = engine.create(binding(), validity(), required_count=2)
|
||||
engine.add_entry(obj.id, "user:alice")
|
||||
with pytest.raises(DuplicateApprover):
|
||||
engine.add_entry(obj.id, "user:alice")
|
||||
assert engine.get(obj.id).status == "requested"
|
||||
engine.add_entry(obj.id, "user:bob")
|
||||
threshold = _event(engine, "issuance")["details"]["threshold"]
|
||||
assert threshold["distinct_approver_count"] == 2
|
||||
assert threshold["threshold_met"] is True
|
||||
assert [a["subject_id"] for a in threshold["approvers"]] == [
|
||||
"user:alice",
|
||||
"user:bob",
|
||||
]
|
||||
|
||||
|
||||
def test_claim_still_discloses_no_approver_identities(engine):
|
||||
"""Identities are evidence for audit-core, never consumer-facing."""
|
||||
obj = approve(engine, required=2)
|
||||
claim = engine.claim(obj.id)
|
||||
assert "threshold" not in claim
|
||||
assert "approvers" not in claim
|
||||
assert "user:approver-0" not in json.dumps(claim)
|
||||
assert claim["valid_now"] is True
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue