# 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` | authenticated per-class *nothing to report* plus counts | `audit-core.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": "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`, `principal_type`, nullable verified `tenant_source`, 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. ## Exact use provenance (source refinement, 2026-10-16) New `approval.use` rows additionally retain `details.binding` (the native five-field act), its existing `binding_digest`, nullable `pdp_digest` and explicit `pdp_path`. `request_digest` remains the full decision-bound request, and `decision_id` remains caller-supplied correlation, not a verified PDP verdict. These distinct digests must never be substituted for each other. `details.consumer` records the authenticated consume caller's `subject_id`, `issuer`, `principal_type`, exact `tenant`, nullable verified `tenant_source` and token-fingerprint `evidence_ref`. It comes solely from the HTTP authenticator; body fields cannot override it. Top-level `actor` retains its existing meaning: the actor captured on the approved act. Consumer provenance distinguishes the service spending the object from that initiating actor. Bearer values, scopes, roles and assurance are not copied into this record. In-process calls without an authenticated caller record `consumer: null`. Existing outbox rows remain unchanged; missing/null consumer evidence cannot be backfilled as authenticated. Same-digest retries preserve the first use row and never replace its original consumer or decision correspondence. All fields are inserted atomically with the consumption CAS, survive restart, and pass unchanged through the existing asynchronous audit adapter. No schema migration is needed. This source evidence is not a portable signed receipt, execution grant or server-identity proof. Consumer confirmation still uses the existing trusted authenticated read/consume transport and a separate live execution journal. Audit source admission, deployed issuer/profile, lifecycle readback and actual served-PEP proof remain APPROVAL-WP-0002-T01/T04/T05 native gates.