approval-engine/docs/outbox-contract.md
tegwick 87e55e2bca 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
2026-09-06 08:10:21 +02:00

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

  1. Select undrained rows, oldest first.
  2. Adapt it to audit-core's {id,type,source,subject,tenant,correlation_id, occurred_at,data} envelope, using event_id as both id and the Idempotency-Key header.
  3. POST with the mounted sender credential, reread on every attempt.
  4. On 202 accepted or 200 duplicate, set drained_at.
  5. 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-core HTTP (or any client) inside BEGINCOMMIT of 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_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.