# 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": "", "observed_at": "", "tenant": "platform", "scope": "netkingdom-approvals", "source": "approval-engine", "actor": "", "action": "approval.revocation", "resource": "approval:", "outcome": "success", "reason": null, "details": { "class": "revocation", "approval_id": "", "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.