approval-engine/docs/outbox-contract.md
tegwick 9c9528f5b2 Implement the engine spine: claim, outbox, machine, API
Contracts first (T02–T04): approval claim schema with issuer, freshness,
and binding digest; local transactional outbox wire; load-bearing cadence
as heartbeat or reconciliation (layer.yaml declared).

Then the object (T06–T08): SQLite closed state machine, CAS supersession,
distinct-approver fail-closed, revocation without holder cooperation,
outbox insert in the same transaction. Tests fail the mutation when
emission fails, and revoke while the drain sink is down.

Introspection GET /v1/approvals/{id}/claim is a PIP fact, not a decision.
No public consume (T05 waits on GH-WP-0002-T06). Canon T-06 coverage for
wrong binding, expiry, revoke, and supersede.

FLEX-WP-0017 T03 is unblocked on this object; T05 remains blocked only on
consumption ordering.

Assistant: grok
Assistant-Session: 01a04ceb-2057-7e20-b0f9-c282964d5dd9
2026-08-29 12:52:49 +02:00

3.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 object becomes consumed (internal CAS; not a public API until GH-WP-0002-T06) 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 audit-core.event.v1alpha1 record, ready to POST.
created_at RFC 3339 UTC
drained_at RFC 3339 UTC or null Set after a successful audit-core ack.

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. POST each payload to audit-core.
  3. On success, set drained_at.
  4. On failure, leave the row; 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.