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

132 lines
5.4 KiB
Markdown

# 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`](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)
```json
{
"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 `BEGIN` … `COMMIT` 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.