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
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue