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
5.4 KiB
Local transactional outbox contract
Coordinates with GH-WP-0002-T02. Boundary and locality are in INTENT.md
and statute §9.4; this is the wire.
An implementer cannot satisfy this contract by emitting synchronously to
audit-core inside the state-change transaction. That path is atomic and is
forbidden: an audit outage would become an inability to revoke.
Rule
Every issuance, use, supersession, and revocation inserts an outbox row in
the same transaction that mutates the approval object. The durable queue
lives in this engine's own store. Drain is asynchronous, at-least-once.
audit-core dedupes on event_id; a replay does not fork the chain.
If the outbox insert cannot be committed, the mutation does not commit. Emit-after-commit is a defect.
Heartbeat rows (see emission-cadence.md) use the
same table and the same at-least-once drain. They are not coupled to an
object mutation.
Event classes
| Class | When | audit-core action |
|---|---|---|
issuance |
object becomes approved (threshold met) |
approval.issuance |
use |
public CAS accepts first request_digest and object becomes consumed |
approval.use |
supersession |
object becomes superseded |
approval.supersession |
revocation |
object becomes revoked |
approval.revocation |
heartbeat |
signed nothing to report plus counts | approval.heartbeat |
Expiry is a clock crossing, persisted on observation, and is not an emitted class. The validity window is already on the object.
Outbox row
| Field | Type | Notes |
|---|---|---|
event_id |
UUID | Stable. Drain retries reuse it. |
class |
enum above | |
approval_id |
UUID or null | Null only for heartbeat. |
payload |
object | The source record adapted to audit-core's HTTP envelope at drain time. |
created_at |
RFC 3339 UTC | |
drained_at |
RFC 3339 UTC or null | Set after a successful audit-core ack. |
attempts |
integer | Delivery attempts, including the successful attempt. |
last_attempt_at |
RFC 3339 UTC or null | Most recent delivery attempt. |
last_error |
class name or null | Bounded failure category; never exception text. |
Payload (audit-core v1alpha1)
{
"schema_version": "audit-core.event.v1alpha1",
"event_id": "<same as outbox.event_id>",
"observed_at": "<created_at>",
"tenant": "platform",
"scope": "netkingdom-approvals",
"source": "approval-engine",
"actor": "<actor or null for heartbeat>",
"action": "approval.revocation",
"resource": "approval:<approval_id>",
"outcome": "success",
"reason": null,
"details": {
"class": "revocation",
"approval_id": "<uuid>",
"binding_digest": "sha256:…",
"superseded_by": null
}
}
No secret values. details may add non-secret identifiers; it must not add a
validity verdict for consumers to branch on. audit-core MUST NOT expose an
approval-validity query; this payload does not invite one.
Drain
- Select undrained rows, oldest first.
- Adapt it to audit-core's
{id,type,source,subject,tenant,correlation_id, occurred_at,data}envelope, usingevent_idas bothidand theIdempotency-Keyheader. - POST with the mounted sender credential, reread on every attempt.
- On
202 acceptedor200 duplicate, setdrained_at. - On failure, leave the row and record only a bounded failure class; retry later. Do not roll back the object mutation — it already committed with the row.
An audit-core outage therefore cannot block a revocation. This engine's
own store being unavailable can, and should: the change could not have been
recorded anyway.
Forbidden shapes
audit-coreHTTP (or any client) insideBEGIN…COMMITof a mutation.- 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_idataudit-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.