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:
tegwick 2026-09-06 08:10:21 +02:00
parent 48ffc34993
commit 87e55e2bca
4 changed files with 163 additions and 2 deletions

View file

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

View file

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

View file

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

View file

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