The one module that serves both shapes a registered type's <send>
reaches a host in (ADR-0005, decision 5). It implements
Statifier.Send.Processor for a live Statifier.Session, and it offers
handle_effect/3 for a process-less host to call from
StatifierPersistence.Executor.execute/2. Both entry points compose the
same key, resolve the same route name, and reach the same adapter, so
there is no second implementation to keep in step.
A send is this handler's when its type is the configuration's
:send_type; an effect of any other type is ignored, because the host's
executor sees every effect the lifecycle does not consume itself. The
route name is the send's target, which the engine never parses.
The plan/perform split, on the send-processor shape
Statifier.Send.Processor.deliver/3 and
Statifier.Send.Processor.cancel/2 are pure planning callbacks,
called with no process, no clock and no I/O. They compose the key and
return one {:handler, __MODULE__, payload} instruction;
Statifier.Send.Processor.perform/2 is the impure half and calls the
adapter, or the timer queue for a delayed send and a cancel. perform/2
MAY be called more than once for the same send, so a route owes
at-most-once on the key rather than this module owing exactly-once.
A miss on that shape comes back as perform/2's {:error, reason}.
Reporting it through Statifier.Session.failed_send/3 is the host's,
which is what that function's own documentation requires: it is called
by the host, never by deliver/3 or cancel/2.
One answer is not a miss and is not reported:
{:error, {:no_config, __MODULE__}} says only that the process
perform/2 ran in holds no configuration. Nothing was attempted there,
and because perform/2 may be called more than once for one send, an
earlier call may already have delivered it, so reporting this answer
would tell the chart a send failed that may have gone. It is the host's
own configuration fault, answered by installing the configuration where
perform/2 runs (ADR-0005, as its Amendment of 2026-09-23 has it).
Where the configuration comes from on each shape
handle_effect/3 is handed the configuration, because the host builds
the executor that calls it:
executor: fn effect, context ->
StatifierRouter.SendHandler.handle_effect(config, effect, context)
endThe Statifier.Send.Processor callbacks are handed no configuration -
the session registers a bare module and the plan context carries only
session_id - so on that shape the host installs the configuration in
the process that performs the instructions, with put_config/1. That is
the process the host's own session and executor run in; this package
does not start sessions.
The scope a route is resolved in
A scope may override a route's configuration (ADR-0005, decision 2), and
the executor seam's context carries no scope, so
StatifierRouter.Delivery names the scope of the delivery it runs in the
same process. At the executor seam a send to a route that some scope in
:route_overrides overrides is resolved in that scope, and with no
scope in reach it is refused as {:no_delivery_scope, name}, which
re-enters the sender as error.communication, rather than sent to the
registered configuration, which would be the wrong one for every scope
that overrides it (ADR-0005, as its Amendment of 2026-09-23 has it). A
route no scope overrides resolves the same everywhere and needs none.
The send-processor shape is reached by no delivery, so there the host
names the scope, with the configuration's :processor_scope
(ADR-0006, as its Amendment of 2026-09-24 has it): a string is the
scope of every send perform/2 resolves, and a zero-arity fun is called
by perform/2, once for each send and delayed send whose target names a
registered route, and answers the scope or nil. A fun that answers
anything else is a miss, {:error, {:invalid_value, :processor_scope, value}}, with no route called and nothing queued. The scope is never a
send param: a chart cannot choose the scope its sends resolve in.
A configuration that names no scope there, or a fun that answers nil,
resolves as before: a send to an overridden route resolves to the
registered configuration, with no override applied and no error. The
engine discards perform/2's return, so a refusal on that shape would
tell neither the chart nor the host anything.
The idempotency key, and the cancellation key
The key handed to a route is StatifierRouter.Route.idempotency_key/0
(ADR-0005, decision 4): the scope half, where in the step the send sat,
and the ordinal. The scope half is execution_id from the seam context
at the executor seam and session_id from the plan context on the
send-processor shape.
The durable timer queue is keyed on something else and smaller -
{scope, send_id}, st-ADR-0054's cancellation key - and the composed
key rides beside the row as the dedup key. StatifierRouter.TimerQueue
says why the two are two keys.
Delayed sends
On both shapes a %Statifier.Effect.SendDelayed{} whose target
resolves to a route is recorded on the configuration's :timer_queue,
and a %Statifier.Effect.Cancel{} deletes that scope's rows for its
send id (ADR-0005, decision 5, as its Amendment of 2026-09-22 has it).
Nothing is scheduled in memory: this package resumes on every delivery,
and a live session's holds are not part of Statifier.Position.
A delayed send whose target resolves to no route is never queued. One
to the reserved execution target is refused as
{:send_refused, :delay} before the route registry is asked ("The
execution target" below); one to a name no route is registered under is
refused as "The unregistered route" below describes; and at the
executor seam, one to a route some scope overrides, with no scope in
reach, is refused as {:no_delivery_scope, name}, as a send is.
At the executor seam handle_effect/3 does both, with the execution id
as the scope. On the send-processor shape the session schedules nothing
for a delayed send - Statifier.Send.Processor gives the delay to the
processor - so perform/2 records the send on the same queue, with the
event and the composed key deliver/3 planned, and performs a planned
cancel through the same queue's StatifierRouter.TimerQueue.cancel/3,
with the session id as the scope. The row is the same one the executor
seam writes for the same scope: the same route, configuration, event,
key and delay.
perform/2 may be handed the same delayed send more than once, and each
time it carries the same key. What keeps a repeat from becoming a second
row is the queue's, not this handler's:
StatifierRouter.TimerQueue.schedule/2 adds no row for a key it
already holds. On a host that registered no queue, a delayed send whose
target resolved to a route is refused on either shape, as
{:no_timer_queue, send_id}, rather than dropped. The target is
resolved first, so a delayed send refused for its target is refused for
that whether or not a queue is registered.
What a route may not do from the executor seam
A route called at the executor seam runs inside the delivery's
transaction, under the execution's lock, so it must only hand off
durably and must never re-enter the sending execution (ADR-0005,
decision 5). sending_execution/0 names the execution a route is
running under, and StatifierRouter.Delivery.deliver/4 refuses for as
long as it is set: a route that calls StatifierRouter.route/3 is
answered {:error, {:reentrant_route, execution_id}} and nothing is
stepped. The host's timer queue is called at the same seam, inside the
same transaction, for a delayed send and for a cancel, and it is marked
the same way while it runs: a queue is not a route, but a step it took
from there would be overwritten by the sender's all the same (ADR-0005,
as its Amendment of 2026-09-23 has it). On the send-processor shape
nothing is marked, because no delivery transaction is open there. That
refusal reaches this package's own door only. A route that
calls StatifierPersistence.Executions.step/5 directly reaches past it.
The record forbids that call, and this package cannot enforce it;
statifier_persistence 0.21.0 and later refuse it themselves, answering
{:error, {:reentrant_step, execution_id}} from every door of
StatifierPersistence.Executions that takes the sending execution's id
(that package's ADR-0004 Amendment of 2026-09-26). On an earlier
statifier_persistence no reentrancy guard exists there.
The execution target
One target name is reserved, execution_target/0's (ADR-0006, section
1). A send that writes it is not handed to a route at all: it names
another durable execution by the document and key params the chart
wrote, under the sending execution's own scope, and is delivered through
StatifierRouter.Delivery.deliver_event/4 - the same transaction, the
same get-or-create, the same dedupe row and the same ledger row an
inbound delivery uses.
<send type="myapp:router" target="execution" event="pair.joined">
<param name="document" expr="'placement_counter'"/>
<param name="key" expr="placement"/>
</send>The scope is never a param: it is read from the sending execution's own
address row (StatifierRouter.Addresses.by_execution/2), so a chart can
address only inside the scope it runs in. An execution with no address
row - what :always_new produces - has no scope, and its send is
refused as unaddressed_sender, the one send_refused reason that
never writes a ledger row, because the ledger's scope is NOT NULL
(ADR-0006, section 6). For that same reason a delayed send refused as
delay below writes one only when its sender has an address row. Every
row a refusal does write is written under the savepoint bracket "The
unregistered route" below describes, so a row this package could not
write does not take the sender's step down.
At the executor seam the branch is in handle_effect/3, beside
mine?/2 and before hand_off/3, and that placement is load-bearing
rather than tidy.
hand_off/3 marks a route as running for the length of the dispatch,
and StatifierRouter.Delivery.deliver/4 refuses while that mark is set
(ADR-0005, decision 5). That refusal protects the sending execution's
own position; ADR-0006 needs a step on a different execution inside
the sender's transaction, which decision 5 never forbade, and section 6
refuses the one case it would collide with - a send to the sender's own
address - for that very lock reason. Branching before hand_off/3 keeps
the two mechanisms apart rather than narrowing the guard.
On the send-processor shape perform/2 takes the same branch (ADR-0006,
as its Note of 2026-09-24 has it): an immediate send to the reserved
name is delivered, or refused, exactly as at the executor seam, and is
never answered as an unregistered route. The sender is the composed
key's scope half, the session id, so its scope is read from the address
row that id names, and a session id that names none is refused as
unaddressed_sender. The configuration's :processor_scope is not
asked: it picks a route override and plays no part in which scope a
chart may address. Nothing is marked as running on this shape, and
StatifierRouter.Delivery.deliver_event/4 opens the transaction the
delivery runs in.
A refusal and a miss are reported to the sender the way ADR-0005,
section 7 reports an unregistered route: {:error, reason} from this
handler, which at the executor seam re-enters the sending execution as
error.communication carrying the send's sendid and does not roll its
step back. {:send_refused, reason} carries one of section 6's five
reasons, or the delay reason below, and {:send_undelivered, why} a
dropped: no_execution or a dropped: finished. Four of the five refusals also write one
send_refused ledger row, whose reason column holds the record's own
word for it: document, key, create or self_address. The row
records that the sender was told; it does not stand in for telling it.
A delayed send to the reserved name is neither delivered nor
queued: the handler answers {:error, {:send_refused, :delay}} on both
shapes, before the route registry is asked, so the reason names the
reserved target rather than a route no host could have registered
(ADR-0006's delay Amendment). It is recorded the way the unregistered
route below is, under the reason word delay: one send_refused row
with key and execution_id empty when the composed key's scope half
names an address row, and a report with no row when it names none.
The unregistered route
When the send's target names no registered route the lookup misses,
and the handler answers {:error, {:unregistered_route, name}}. At the
executor seam that return does not roll the step back, deliberately: the
executor failure is deferred, re-entered as error.communication
carrying the send's sendid, and the execution is written anyway. The
chart hears that its send did not go; the step it just took stands.
ADR-0005 section 7 also has the handler record that refusal on the
routing ledger, and it writes one. What the row's columns hold was
ruled rather than minted here, and the ruling extends ADR-0006,
section 6's send convention rather than opening a second one:
binding_id is the reserved name execution, which is what tells an
outbound send's row from an inbound delivery's; message_id is
ADR-0005, section 4's composed key, written out as every other row on
this path writes it; outcome is send_refused; and reason is one
added word, route, naming the target that resolved to no registered
route. key and execution_id stay empty, as they do for every
refusal discovered before a target is resolved. scope is the host's
partition, read from the sending execution's own address row as
ADR-0006, section 1 reads it, so a sender with no address row is
reported and not recorded, the same gap section 6 names for
unaddressed_sender and for the same reason: the ledger's scope is
NOT NULL.
The lookup is on the scope half of the composed key, whatever the shape
put there, and on nothing else: StatifierRouter.Addresses.by_execution/2
is asked for that value's address row. Which shape a refusal came in on
is not what decides whether a row is written - a scope half that names
an address row is recorded, one that names none is reported only. On
the send-processor shape the scope half is the sender's session id, and
whether that finds a row is the host's arrangement rather than this
package's guarantee: ADR-0006, section 4 holds that at this package's
seam the sender's session id is its execution id, so a host that keeps
them the same is recorded on that shape too.
The row records that the sender was told and does not stand in for
telling it: the return is {:error, {:unregistered_route, name}} on
both shapes, unchanged, and the sending step still commits.
The write is bracketed in a SQL savepoint of its own, for the reason
StatifierRouter.Delivery.deliver_event/4 documents at length: this
handler runs at the executor seam inside the sending execution's own
transaction, and a failed insert there leaves that transaction aborted,
which would take the sender's step down with it. An insert that fails
rolls back to that savepoint and nothing else, and the miss is reported
either way. The address-row read that supplies the row's scope sits
inside the same bracket, because a failed read aborts that transaction
just as a failed insert does: a read that fails rolls back to the
savepoint, no row is written, and the miss is reported either way.
The bracket is not an absolute, and the code does not pretend it is.
Only the read and the insert sit inside the guard, so a release that
raises after a successful insert cannot turn into a rollback of the row
it just wrote; that release's own failure is swallowed. What is left
uncovered is the savepoint statements themselves: a connection that has
gone away raises out of SAVEPOINT or out of the rollback, and that
raise stands and reaches the sender, because a bracket cannot settle a
transaction it can no longer speak to. What the bracket buys is that a ledger row this
package could not write is not itself the thing that takes the sender
down.
Summary
Types
Why this handler did not hand a send off.
Why an execution-to-execution send was refused: section 6 of ADR-0006
names the first five, and that record's delay Amendment adds delay, a
delayed send to the execution target. unaddressed_sender never writes
a ledger row; delay writes one only when the sender has an address row.
Functions
Removes what put_config/1 installed in the calling process.
The one target name ADR-0006, section 1 reserves for the execution
target. A host may register no route under it and give no binding this
id; StatifierRouter.Config.new/1 refuses both.
The configuration put_config/1 installed, or
{:error, {:no_config, __MODULE__}} when the host installed none. That
error says nothing about any send: the moduledoc says why a host does
not report it to the chart.
Handles one effect at StatifierPersistence.Executor.execute/2, with
that seam's context. An effect whose type is not the configuration's
:send_type, and every effect that is not a send, a delayed send or a
cancel, is ignored.
Installs the configuration the Statifier.Send.Processor callbacks
serve, in the calling process. handle_effect/3 is handed its own and
needs none.
The execution a route, or the timer queue, is running under in the
calling process at the executor seam, or nil.
StatifierRouter.Delivery.deliver/4 refuses for as long as it is set: a
route or a queue called at the executor seam may not re-enter the
sending execution (ADR-0005, decision 5, and its Amendment of
2026-09-23).
Types
@type reason() :: {:unregistered_route, String.t() | nil} | {:no_timer_queue, String.t() | nil} | {:no_config, module()} | {:no_delivery_scope, String.t()} | {:invalid_value, :processor_scope, term()} | {:send_refused, refusal()} | {:send_undelivered, :no_execution | :finished} | term()
Why this handler did not hand a send off.
@type refusal() ::
:unaddressed_sender | :document | :key | :create | :self_address | :delay
Why an execution-to-execution send was refused: section 6 of ADR-0006
names the first five, and that record's delay Amendment adds delay, a
delayed send to the execution target. unaddressed_sender never writes
a ledger row; delay writes one only when the sender has an address row.
Functions
@spec delete_config() :: :ok
Removes what put_config/1 installed in the calling process.
@spec execution_target() :: String.t()
The one target name ADR-0006, section 1 reserves for the execution
target. A host may register no route under it and give no binding this
id; StatifierRouter.Config.new/1 refuses both.
iex> StatifierRouter.SendHandler.execution_target()
"execution"
@spec fetch_config() :: {:ok, StatifierRouter.Config.t()} | {:error, reason()}
The configuration put_config/1 installed, or
{:error, {:no_config, __MODULE__}} when the host installed none. That
error says nothing about any send: the moduledoc says why a host does
not report it to the chart.
@spec handle_effect( StatifierRouter.Config.t(), Statifier.Effect.t(), StatifierPersistence.Executor.context() ) :: :ok | {:error, reason()}
Handles one effect at StatifierPersistence.Executor.execute/2, with
that seam's context. An effect whose type is not the configuration's
:send_type, and every effect that is not a send, a delayed send or a
cancel, is ignored.
@spec put_config(StatifierRouter.Config.t()) :: :ok
Installs the configuration the Statifier.Send.Processor callbacks
serve, in the calling process. handle_effect/3 is handed its own and
needs none.
@spec sending_execution() :: String.t() | nil
The execution a route, or the timer queue, is running under in the
calling process at the executor seam, or nil.
StatifierRouter.Delivery.deliver/4 refuses for as long as it is set: a
route or a queue called at the executor seam may not re-enter the
sending execution (ADR-0005, decision 5, and its Amendment of
2026-09-23).