hecks

A transactional outbox for domain events and external effects

Status: Shipped. future-features.md item 8 (“OutboundEvent, a durable outbox, pending → claimed → delivered | failed, idempotent delivery ids”) was the last named durability gap left open by the saga-durability review (docs/decisions/ has no ADR for that review; its findings live in saga_pending_dispatch.rb’s header and the guide’s “Nobody had to notice” caveat). This ADR closes it — for the Ruby runtime, on Memory, Sqlite, Postgres and PostgresEra — and records what it deliberately does not do.

The gap

A dispatch was three separately-committed things: the aggregate save (step_save, inside the adapter’s own transaction where it has one), the event record (step_emit → record_event, a second statement after that transaction closed), and the reactions (Dispatcher#dispatch’s two announced.each loops — policies, then sagas — after the interpreter returned and the aggregate lock was released). Nothing tied them together. A process that died after the save and before the reactions lost every reaction silently: the store said the command happened, nothing said what was still owed because it happened. Sagas had a partial answer (SAGA_PENDING_DISPATCH_KEY, visibility of a stalled leg, no redrive); policies and the external effects they trigger through outbound port operations had none.

What shipped

One row per (event, consumer), committed with the save. Runtime::Outbox::Fanout.rows_for resolves, at enqueue time, exactly the consumers PolicyInterpreter#policies_for and SagaInterpreter#advance would select at delivery time — every matching policy across every loaded bluebook, every process manager of the emitting domain that starts on, ends on, or handles the event — and writes one Outbox::Row each: delivery_id (<event uid>/<consumer>, UNIQUE per store), the serialized event, kind (reaction, or effect when the policy’s trigger resolves to an outbound asks port operation), status.

One commit boundary. Interpreting#run_dispatch_order now splits the vocabulary’s dispatch order at save and runs save, emit, and the enqueue inside repository.transaction { }. No step was added — AggregateDispatchOrder/EntityDispatchOrder are a pinned contract (spec/vocabulary_conformance_spec.rb); the enqueue is a consequence of emit, not a step. AppendOnly#transaction forwards to the adapter’s own when it has one (Sqlite, Postgres, PostgresEra — each made re-entrant so their existing atomic_put/save/append transactions join the outer one instead of raising “BEGIN inside BEGIN”) and just yields otherwise. A side effect worth naming: Sqlite’s plain save (append + project, previously two un-transacted statements) is now atomic too.

Inline delivery, unchanged order. Dispatcher#react drains the rows the interpreter handed back: for each, outbox_claim (a compare-and-set UPDATE … WHERE status = 'pending'), run the consumer through the same PolicyInterpreter#react/SagaInterpreter#advance as before (each grew an only: to run one named consumer), outbox_settle to delivered — or failed, with the defect, when something escapes the interpreters’ own refusal/defect handling (a consumer no bluebook declares any more, say). Policy rows before saga rows, event order within each: the order dispatch always ran reactions in, which is why no existing reaction/saga spec changed. Port operations, which save nothing, enqueue after the fact through the same relay — durable, not transactional, because there is nothing for them to be transactional with.

Boot-time reconciliation. Loader.boot calls Relay#redrive! once the dispatcher exists (a row’s consumer runs through its interpreters, so this cannot be a plain registry gate like saga rehydration) and after sagas are rehydrated (a redriven row may advance one). pending rows are redriven — their consumer provably never started, claiming being the first thing delivery does, so this is exactly-once by construction. claimed rows are surfaced (warn, plus a stalled: true entry in Relay#log) and left standing for redrive!(claimed: true), an explicit operator call.

Adapters. Memory (in-process rows, so a spec can watch them move — gone with the process like everything else Memory holds), Sqlite (hecks_outbox table in the aggregate’s own database file), Postgres and PostgresEra (shared Adapters::PostgresOutbox module — the outbox is not lineage-specific, so it is copied between them exactly the way their events/hecks_saga_instances DDL already is). The contract is four optional methods probed together by AppendOnly#outbox? — outbox_enqueue, outbox_claim, outbox_settle, outbox_rows — plus transaction. Heki, LocalStorage, D1, Lambda and Folder have none; a domain bound to one of them that declares a policy or process manager gets the pre-outbox behaviour (reactions run directly) and a boot warning from Registry::Verification#warn_undurable_outbox!, a warning rather than a refusal for the reason warn_undurable_sagas! already gives.

Inspection. runtime.outbox.rows, .rows(status: "claimed"), .redrive!, .redrive!(claimed: true), .log. The log is a new, Ruby-only array — never a key in saga_log/reaction_log, which the Rust kernel ports byte-for-byte (registry.rb’s own note on saga_dispatch_log).

What it deliberately does not do

Verification