From 87e55e2bca2457bb914b4b499803b99c339530c3 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sun, 6 Sep 2026 08:10:21 +0200 Subject: [PATCH] Carry threshold evidence on issuance and use events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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 --- approval_engine/store.py | 55 +++++++++++++++- docs/outbox-contract.md | 32 +++++++++ tests/test_outbox.py | 66 ++++++++++++++++++- ...duction-readiness-and-consumer-adoption.md | 12 ++++ 4 files changed, 163 insertions(+), 2 deletions(-) diff --git a/approval_engine/store.py b/approval_engine/store.py index bf2c63d..0d25d22 100644 --- a/approval_engine/store.py +++ b/approval_engine/store.py @@ -503,7 +503,13 @@ class Engine: "issuance", approval_id, actor=row["actor"], - extra={"binding_digest": row["binding_digest"], "required_count": row["required_count"]}, + extra={ + "binding_digest": row["binding_digest"], + "required_count": row["required_count"], + "threshold": self._threshold_evidence( + conn, approval_id, row["required_count"] + ), + }, ) conn.commit() except sqlite3.Error as exc: @@ -693,6 +699,9 @@ class Engine: "binding_digest": row["binding_digest"], "request_digest": digest, **({"decision_id": decision_id} if decision_id else {}), + "threshold": self._threshold_evidence( + conn, approval_id, row["required_count"] + ), }, ) conn.commit() @@ -711,6 +720,50 @@ class Engine: "idempotent": idempotent, } + def _threshold_evidence( + self, conn: sqlite3.Connection, approval_id: str, required_count: int + ) -> dict[str, Any]: + """Threshold evaluation as evidence, for reconstruction under §9.6. + + `GH-DEC-2026-005` moved the distinct-approver check off the PEP and onto + this engine's `valid_now`. The compensating control is detection, not + prevention: the emitted evidence must let an auditor recompute the + evaluation after the fact without reading live rows, which may since + have been superseded or expired. + + Approver identities belong here and not on the claim. The claim is + consumer-facing and discloses the least it can; the outbox is the + evidence path to audit-core, where the identities are the point. + + Distinctness is a storage invariant, not a recomputation: `entries` + has a UNIQUE constraint on (approval_id, subject_id), so a repeat + approver is refused at insert. The dedup below is belt-and-braces for + a future schema that relaxes it. + """ + rows = conn.execute( + "SELECT subject_id, approved_at, assurance, evidence_ref " + "FROM entries WHERE approval_id=? ORDER BY approved_at, subject_id", + (approval_id,), + ).fetchall() + seen: dict[str, sqlite3.Row] = {} + for r in rows: + seen.setdefault(r["subject_id"], r) + approvers = [ + { + "subject_id": r["subject_id"], + "approved_at": r["approved_at"], + **({"assurance": r["assurance"]} if r["assurance"] else {}), + **({"evidence_ref": r["evidence_ref"]} if r["evidence_ref"] else {}), + } + for r in seen.values() + ] + return { + "required_count": required_count, + "distinct_approver_count": len(approvers), + "threshold_met": len(approvers) >= required_count, + "approvers": approvers, + } + def _outbox_insert( self, conn: sqlite3.Connection, diff --git a/docs/outbox-contract.md b/docs/outbox-contract.md index 83582ef..90dce67 100644 --- a/docs/outbox-contract.md +++ b/docs/outbox-contract.md @@ -98,3 +98,35 @@ recorded anyway. - Best-effort publish after commit with no row. - A second, non-local queue as the durability mechanism. - Deduping in this engine instead of relying on `event_id` at `audit-core`. + +## Threshold evidence on `issuance` and `use` + +`GH-DEC-2026-005` moved the distinct-approver check off the PEP: a consumer +reads `valid_now` and trusts this engine's evaluation of everything folded into +it. 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. Detection, not prevention. + +So `approval.issuance` and `approval.use` both carry a `threshold` object in +`details`: + +| Field | Meaning | +| --- | --- | +| `required_count` | the threshold in force on the object at that moment | +| `distinct_approver_count` | distinct subjects who had recorded an entry | +| `threshold_met` | whether the evaluation passed | +| `approvers` | `subject_id`, `approved_at`, and `assurance` / `evidence_ref` when recorded | + +An auditor holding only the `use` row can recompute the evaluation without +reading live rows — which matters because those rows may since have been +superseded, revoked, or expired. + +**Identities are here and not on the claim.** The claim is consumer-facing and +discloses the least it can; the outbox is the evidence path to audit-core, +where the identities are the point. A consumer that wants the threshold reads +`valid_now`. + +Distinctness itself is a storage invariant rather than a recomputation: +`entries` is UNIQUE on `(approval_id, subject_id)`, so a repeat approver is +refused at insert. diff --git a/tests/test_outbox.py b/tests/test_outbox.py index de78849..29f11f0 100644 --- a/tests/test_outbox.py +++ b/tests/test_outbox.py @@ -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 diff --git a/workplans/APPROVAL-WP-0002-production-readiness-and-consumer-adoption.md b/workplans/APPROVAL-WP-0002-production-readiness-and-consumer-adoption.md index 1cc9d74..80b7278 100644 --- a/workplans/APPROVAL-WP-0002-production-readiness-and-consumer-adoption.md +++ b/workplans/APPROVAL-WP-0002-production-readiness-and-consumer-adoption.md @@ -211,3 +211,15 @@ stands as published.** `APPROVAL-IN-0002` is closed. T05 remains `wait` on T03 deployment plus the secrets-engine validator split and its `secrets-engine-approval` KeyCape registration (already requested verbatim in `docs/keycape-service-registrations.md`). + +2026-09-06 follow-on (T01): `GH-DEC-2026-005` assigned this engine a +compensating obligation. secrets-engine has implemented the split and reports +it no longer verifies the distinct-approver threshold independently; Gate House +accepted that as correct on layering and as a real reduction in defence in +depth, requiring instead that the threshold evaluation be reconstructable from +this engine's state transitions and its use outbox row under §9.6. Implemented: +`approval.issuance` and `approval.use` now carry a `threshold` object +(`required_count`, `distinct_approver_count`, `threshold_met`, and `approvers` +with `approved_at` plus assurance/evidence refs). Tests prove reconstruction +from the use row alone and that the claim still discloses no approver +identities. Documented in `docs/outbox-contract.md`.