The delivery module StatifierRouter.Config names by default: one
binding's delivery of one event, as one transaction on the host's repo
(ADR-0003, section 1).
It has two doors onto one transaction. deliver/4 is the seam
StatifierRouter.route/3 calls for a binding, and deliver_event/4 the
one StatifierRouter.SendHandler calls for an execution-to-execution
send (ADR-0006, section 2), which brings its own event and its own
idempotency key and is otherwise settled here exactly as a binding's
delivery is.
deliver/4 reads five options of the configuration, four of them
required:
:store- the%StatifierPersistence.Storage{}executions are kept in. It must be built over the configuration's own:repo, so thatStatifierPersistence.Executions.create/4andStatifierPersistence.Executions.step/5write through the delivery's transaction rather than opening their own (statifier_persistence's README, "Writing inside a caller's transaction").:executor- theStatifierPersistence.Executorboth doors hand their effects to.:resolver- theStatifierRouter.Resolver, a module or an arity-2 fun, answering{content_hash, machine}or{:error, reason}for(scope, document): the chart a new execution ofdocumentstarts on (ADR-0002, section 4). It is called only when the delivery is about to create an execution. Thecontent_hashit answers is not carried anywhere: the machine is handed on, and statifier_persistence records the hashStatifier.Machine.identity/1derives from that machine, which is the hash:chart_resolveris later asked for (ADR-0002, the 2026-09-20 Note).:chart_resolver-(content_hash) -> {:ok, machine}or:error: the compiled chart an existing execution started on, whichstep/5is handed. An existing execution keeps the chart it started on, so this is looked up by the content hash its record carries, never by its document.:persistence_options- the per-call snapshot options both doors carry,:routes,:invoke_typesand:send_types. They reach astep/5beside the event and acreate/4inside itsinitialize::Statifier.MachineState.new/2is the one writer of the fields they set, and a create has no stored position to stamp (statifier_persistence'screate/4option docs, at statifier_persistence 9cd192b; its own driver placesinvoke_types:the same way).
What one delivery does
The transaction first claims (binding_id, message_id) with
StatifierRouter.Dedupe.claim/4, its first write under every create
mode. When the pair's row is present and unexpired, the delivery is a
duplicate, and the transaction writes the ledger row and nothing else:
no address row is read or written (ADR-0003, section 6). Past the claim,
the binding's create decides the rest (ADR-0003, section 4):
:if_absentreads the address row for(scope, document, key). When there is none, it mints an execution id and inserts the row with it, an insert that inserts nothing on a conflict with the unique index and does not fail the transaction; when it inserted nothing, another delivery's row won the race, and a following statement reads that row (ADR-0003, section 3). For the row it inserted, it asks the resolver for the chart and callscreate/4under the minted id; for a row it read, it reads the execution's status.:neverreads the address row and never inserts one. When there is none, the outcome is{:dropped, binding_id, :no_execution}: nothing is created or stepped, and the ledger row and the claim's dedupe row are the delivery's only writes. For a row it read, it reads the execution's status, as:if_absentdoes.:always_newneither reads nor writes an address row (ADR-0002, section 7). It mints an execution id, asks the resolver for the chart and callscreate/4under that id, for every delivery; the minted id is the only handle on the execution.
Then it calls step/5 with the event, unless the execution is
terminal, writes the ledger row (ADR-0004, section 4) and commits.
The outcomes are {:created_and_delivered, binding_id, execution_id}
when this delivery created the execution, {:delivered, binding_id, execution_id} when it stepped one that existed, {:duplicate, binding_id} when the claim found the pair already handled,
{:dropped, binding_id, :no_execution} for a :never binding whose
address has no row, and {:dropped, binding_id, :finished} when the
execution was terminal: read terminal before the step, created already
terminal, or answered {:discarded, execution} by step/5 (ADR-0004,
section 3). A binding's delivery whose step/5 took the event and
selected no transition for it - the state step/5 answers carries
last_selection: :none - is {:dropped, binding_id, :unmatched_event},
on the delivered path and on the created path alike, and its ledger
row names the execution (ADR-0004, the Note of 2026-09-25). The
execution's input log holds the event all the same, because step/5
appended it. deliver_event/4 does not take this outcome: a send the
receiving execution does not take is delivered or created_and_delivered
as before. A terminal sighting through an address row stamps the row's
terminal_seen_at when it is still empty (ADR-0002, section 5); the
row itself is left in place, and removing it is
StatifierRouter.Addresses.reap/2's. Every outcome but the duplicate
commits the dedupe row the claim wrote, a drop included, and a
duplicate's ledger row carries the key and no execution id (ADR-0004,
section 4).
Nothing here writes the input log: step/5 appends the event it steps
(ADR-0003, section 1).
The create and step hooks
StatifierRouter.Config's :on_create and :on_step stand in for the
two persistence calls above (ADR-0003, the Amendment of 2026-09-25).
When one is set, the delivery calls it where it would have called
create/4 or step/5, with the same arguments, inside the same
transaction and savepoint, and reads its answer as it reads
persistence's: the execution's status decides a finish, the state's
last_selection decides an unmatched event, a {:discarded, execution}
from :on_step is a finish, and an {:error, reason} from either
rolls the delivery back to its savepoint and is returned. An answer
outside the contract raises ArgumentError. When neither is set, the
delivery calls statifier_persistence itself.
The completion hook
StatifierRouter.Config's :on_complete names a registered route an
execution's donedata is handed to on the delivery that finishes it. It
fires from the call that produced the termination - the create/4 or
the step/5 whose answer carries a terminal execution - and from no
other, because that answer is the only place the donedata exists:
StatifierPersistence.Execution.from_record/1's own documentation says
a stored record carries none (its ADR-0008 decision 3), so it sets the
field to nil on every struct built from a row. A delivery to an
execution that was already terminal answers
{:dropped, binding_id, :finished} and never reaches the hook.
The route is handed a done.execution event whose data is the
donedata verbatim - :undefined, statifier's no-value marker, for a
<final> that carries none - and whose origin is the execution id, under an idempotency key of that execution
id, the counters the answering state reports, and no ordinal. It runs
inside this delivery's transaction like any other route, and an
{:error, reason} from it settles the delivery as
{:error, {:on_complete, route_name, reason}}.
What a route may not do while a delivery runs
A route called at the executor seam runs inside this transaction, under
the execution's lock, and ADR-0005 decision 5 forbids it to call back
into the sending execution: a nested step would run from the position
the outer step has not written yet and would then be overwritten by it.
deliver/4 refuses while StatifierRouter.SendHandler.sending_execution/0
names an execution, answering
{:error, {:reentrant_route, execution_id}} before it opens anything,
so a route that calls StatifierRouter.route/3 steps nothing and writes
nothing. A route that calls
StatifierPersistence.Executions.step/5 directly reaches past this
door; the record forbids that call and this package has no guard for it.
statifier_persistence 0.21.0 and later refuse it themselves: every door
of StatifierPersistence.Executions that takes an execution id answers
{:error, {:reentrant_step, execution_id}} when called for an
execution whose executor is running in the calling process, as the
sending execution's is here, before it reads or writes anything (that
package's ADR-0004 Amendment of 2026-09-26). On an earlier
statifier_persistence nothing refuses the call.
The scope a delivery runs under is also set for the length of the call, because the executor seam's context carries an execution id and a content hash and no scope, and a per-scope route override needs one. The seam runs in this same process, inside this transaction.
An {:error, reason} from create/4, step/5 or
StatifierPersistence.Storage.fetch_execution/2,
{:error, {:unresolved_document, document, reason}} when the resolver
answers {:error, reason}, and
{:error, {:chart_not_resolved, content_hash}} when the chart resolver
answers :error, roll this delivery's writes back and are returned. Both
doors do that the same way: the delivery runs inside an SQL savepoint of
its own, an error rolls back to that savepoint, and the reason is
returned as an ordinary value, with no Ecto.Repo.rollback/1 anywhere
(ADR-0003, the Amendment of 2026-09-23). When the door opened the
transaction itself, that leaves it holding nothing of the delivery's,
which is ADR-0003, section 1's guarantee; when it was called inside a
caller's transaction, it undoes the delivery's writes and nothing of the
caller's. deliver_event/4's documentation says why a rollback cannot
do this. A raise propagates; nothing here rescues it, and it takes an
enclosing transaction with it. Effects the executor was handed before a
rollback stay fired (ADR-0003, section 2).
The BasicHTTP location
On a configuration that sets :basichttp, the address row an
:if_absent insert writes gets a location: a token
StatifierRouter.BasicHTTP mints, inserted into the location table in
the same transaction and savepoint, before create/4, and handed to
that create in the snapshot's StatifierRouter.BasicHTTP registration,
so the new execution's _ioprocessors names it (ADR-0002, the
Amendment of 2026-09-30, decisions 1 and 4). Both doors do it, so an
execution-to-execution send's create gets one too. A read address row,
an :always_new create and a :never miss mint nothing. Without the
key the delivery writes exactly what it wrote before.
The execution id
By default the execution id is a UXID with the prefix ex, minted by
UXID.generate!/1; nothing is derived from the address (ADR-0002,
section 3). When StatifierRouter.Config's :execution_id is set, the
delivery mints through it instead, at the same two places - the
address row :if_absent inserts and the create :always_new makes -
handing it the delivery's scope, the document and the key, and the
answer is the id the address row, create/4 and the ledger row carry
(ADR-0002, the Amendment of 2026-09-25). An answer that is not a
non-empty string raises ArgumentError; a raise from the callback
propagates. A duplicate, a read address row and a :never drop mint
nothing, so they never call it.
Summary
Types
The message one delivery carries: the event stepped into the execution, the id its dedupe row and its ledger row are written under, the scope it runs in and the time its rows carry.
What one delivery settles, past the event itself: the name its ledger row
and its dedupe claim are written under, the document its address names,
the create mode a miss follows, and the dedupe horizon that claim uses.
Types
@type envelope() :: %{ :event => Statifier.Event.t(), :message_id => String.t(), :scope => String.t(), :now => DateTime.t(), optional(atom()) => term() }
The message one delivery carries: the event stepped into the execution, the id its dedupe row and its ledger row are written under, the scope it runs in and the time its rows carry.
@type plan() :: %{ :id => String.t(), :document => String.t(), :create => :if_absent | :never | :always_new, :dedupe => %{by: :message_id, horizon_ms: pos_integer()}, optional(atom()) => term() }
What one delivery settles, past the event itself: the name its ledger row
and its dedupe claim are written under, the document its address names,
the create mode a miss follows, and the dedupe horizon that claim uses.
A StatifierRouter.Binding.t/0 is one of these, and it is the one
deliver/4 builds. The other is ADR-0006's execution target: the
reserved name execution, the document param the chart wrote, the
create param it wrote, and ADR-0001, section 1's default horizon, since
no binding supplies one there.
Functions
@spec deliver( StatifierRouter.Config.t(), StatifierRouter.Binding.t(), String.t(), StatifierRouter.delivery() ) :: StatifierRouter.outcome() | {:error, term()}
Delivers one event for one binding under key, as the module
documentation describes, and answers with the outcome or
{:error, reason}.
@spec deliver_event(StatifierRouter.Config.t(), plan(), String.t(), envelope()) :: StatifierRouter.outcome() | {:error, term()}
Delivers one prebuilt event under plan, in the same transaction and the
same order deliver/4 uses, and answers with the same outcomes.
This is the door ADR-0006, section 2 names: an execution-to-execution
send resolves its address, gets or creates its execution and writes its
dedupe and ledger rows through this one function, so neither the unique
index race nor the ledger has a second implementation here. Its caller
owns three things deliver/4 owns for a binding: it builds the event
(ADR-0006, section 4 gives an execution-to-execution send the engine
builder rather than Statifier.Event.external/2), it composes the
message_id, and it decides whether a scope override is in force.
StatifierRouter.SendHandler is that caller.
The reentrancy refusal deliver/4 opens with is not repeated here. It
guards the route door, and an execution-to-execution send never enters a
route: StatifierRouter.SendHandler branches on the reserved target name
before it marks a route as running.
This door takes a savepoint, and it does not roll back
deliver/4 settles the same way, for the same reason: route/3 is
usually the outermost transaction on its path, but nothing stops a host
from calling it inside its own. This door is never outermost at the
seam: it is called inside the sending execution's own step/5, which
statifier_persistence has already opened a transaction for. A
Ecto.Repo.rollback/1 there would take the sender's transaction
with it, answer a bare :rollback in place of the reason, and raise
DBConnection.ConnectionError out of the sending step's persist tail. And
mode: :savepoint does not prevent it: DBConnection.transaction/3
ignores its options once the connection is already in a transaction
(db_connection 2.10.2, the conn_mode: :transaction clause), so a nested
Ecto.Repo.transaction/2 gets no savepoint to roll back to.
That outcome is the opposite of what the records require: ADR-0005,
section 7 has the handler's {:error, reason} leave the sending step
standing, and ADR-0006, section 3 rests on it. So this door rolls nothing
back. It brackets the delivery in an explicit SQL savepoint of its own and
answers the reason as an ordinary return:
- on an outcome, the savepoint is released and the delivery's writes stand with the rest of the enclosing transaction;
- on a delivery-level error - an unresolved document, a chart that does
not resolve, a refusal from
create/4,step/5orStatifierPersistence.Storage.fetch_execution/2- the transaction rolls back to that savepoint, which undoes every write this delivery made on the target side (the dedupe row it claimed, the address row it inserted, the execution it created) and nothing else, and{:error, reason}carrying the real reason is returned.
The savepoint's name is minted from System.unique_integer/1, never from
anything a caller supplies, and a nested delivery gets its own. The host's
:repo therefore has to answer query!/1 as well, which every
Ecto.Adapters.SQL repo does; this package's tables are Postgres already.
A raise is the one case that still reaches the enclosing transaction, exactly as it does on the binding path: nothing here rescues, and the record leaves a raise as a raise.