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,