Routes external events to durable statifier executions, creating them when absent.
The front is Broadway: the host starts StatifierRouter.Broadway in its own
supervision tree with any producer, and partition_by keeps every message
for one key on one processor. Behind it sits a binding, addressing and
delivery layer over statifier_persistence.
What this package owns
- Bindings: source -> match -> key -> document -> event, with
matchandkeywritten as predicator programs over the normalized event. - The address table:
(scope, document, key)->execution_id. - Atomic get-or-create-and-deliver: the execution an address names is created when absent and handed the event in the same step.
- Dedupe on
(binding, message_id)with a horizon. - The recorded outcome vocabulary: every delivery attempt ends in one named outcome.
- Execution-to-execution sends: a
<send>whosetargetis the reserved nameStatifierRouter.SendHandler.execution_target/0resolves through the address table and is delivered by the same transaction a binding's delivery uses (ADR-0006). - The webhook front,
StatifierRouter.Webhook: a Plug-shaped helper a host calls from its own controller or plug. - The source invoke: an
<invoke>whose lifetime is a subscription's, throughsubscribe/3,cancel/2and the delegate a host's invoke handler calls,StatifierRouter.SourceInvoke(ADR-0007).
What it does not own
- Sinks and the route registry.
- Any queue adapter.
- Timers: those are
statifier_oban's. - A publish store: a host callback resolves a document to its active chart.
- Any process or supervisor: the host schedules the reapers and starts the pipeline.
scope is an opaque host string; the package gives it no meaning.
Of the pieces named above, this release builds the Broadway front, as
StatifierRouter.Broadway, and the binding, as
StatifierRouter.Binding. It builds the tables behind the rest,
created by StatifierRouter.Migrations and read through the schemas in
StatifierRouter.Schema. It builds route/3, which evaluates the
bindings for one event and hands each delivery to the configuration's
delivery module, and that module's default, StatifierRouter.Delivery,
which claims the message for the binding with StatifierRouter.Dedupe
and then, in the same transaction, gets or creates the execution an
address names and steps the event into it, under each of the three
create modes. It builds StatifierRouter.Resolver, the host's answer
to the chart a new execution starts on, with
StatifierRouter.Resolver.Static over charts compiled at boot. The
host schedules the two reapers,
StatifierRouter.Dedupe.reap/2 and StatifierRouter.Addresses.reap/2.
It builds the source invoke's two calls, subscribe/3 and cancel/2,
over the subscription table StatifierRouter.Migrations.V02 adds.
Each piece lands behind the decision record that fixes it, in
docs/adr/.
Routing an event
route/3 takes a StatifierRouter.Config, the event and options. The
event carries the host's scope beside its message id, its source and
the adapter-normalized event (ADR-0003, section 8). route/3 returns one
outcome per enabled binding whose source is the event's source, in the
order the configuration lists them, or its :bindings_resolver answers
them for the event's scope (ADR-0004, section 6). Bindings are
chosen by source alone: a binding's selector is the source adapter's to
read, and the router never reads it (ADR-0001, section 1). For each
binding, the outcome is the first of these that applies (ADR-0004,
section 2):
- A
matchthat does not hold is{:no_match, binding_id}. It writes nothing durable (ADR-0004, section 5); it is reported as the telemetry event[:statifier_router, :route, :no_match], with the measurement%{count: 1}and the metadatabinding_id,source,scopeandmessage_id. - A
matchthat refuses, or akeythat refuses, is{:key_refused, binding_id, reason}, withreasonone of{:match, {:error, error}},{:match, {:value, value}},{:key, {:error, error}}and{:key, {:value, value}}(ADR-0004, section 1). One row is written to the routing ledger for it, on its own (ADR-0004, section 4), and the delivery module is not called. - Otherwise the delivery module is called, and its answer is the binding's outcome.
The ledger's reason column holds the reason term as inspect/1
renders it with its default options, so {:key, {:value, :undefined}}
is stored as that text. The encoding is for a person reading the
ledger; nothing parses it back.
An {:error, reason} from the delivery module ends the attempt: the
bindings after it are not evaluated, route/3 returns that error, and
what was already written for the bindings before it stays written
(ADR-0004, section 7). A raise inside a delivery is not rescued: it
propagates out of route/3 (ADR-0003, section 1). Neither is a raise
from writing a key_refused row: a Repo failure there propagates too,
rather than becoming {:error, reason}.
The delivery seam
The configuration's :delivery module is called once for each binding
whose key produced one, as deliver(config, binding, key, delivery),
where delivery is a map of:
:name- the chart event, the binding'sevent;:data- the event's data projected through the binding'sdatapaths (StatifierRouter.Binding.project/2);:message_idand:scope- the event's own;:now- the time this attempt uses for the rows it writes.
It answers with one of {:delivered, binding_id, execution_id},
{:created_and_delivered, binding_id, execution_id},
{:duplicate, binding_id}, {:dropped, binding_id, :no_execution},
{:dropped, binding_id, :finished} and
{:dropped, binding_id, :unmatched_event} for the binding it was handed, or
with {:error, reason}, and it writes that outcome's rows inside the
delivery's own transaction (ADR-0003, section 1). Any other answer
raises ArgumentError. The default module is StatifierRouter.Delivery.
Summary
Types
What the delivery module is handed besides the configuration, binding and key.
One invocation of one execution: the execution's id and the invoke_id
the engine minted for the <invoke> (Statifier.Effect.Invoke,
statifier 2.6.0). invoke_id is a deterministic %MachineState{}
counter, so it is stable across a replay of the same drive and unique
within its execution, not across executions.
One binding's outcome of one routing attempt (ADR-0004, section 1).
Which program refused an event for a binding, and how (ADR-0004, section 1).
The event a host hands route/3: the scope it routes under, the message
id its source adapter derived (a non-empty string, taken as given), the
source it came from, and the adapter-normalized event as a string-keyed
map. Other keys are ignored.
The identity of one subscription: its binding, its execution and the invocation it belongs to (ADR-0007, section 6).
Functions
Cancels the subscription named by {binding_id, execution_id, invoke_id},
deleting the row the matching subscribe/3 created and nothing else
(ADR-0007, section 3).
Routes one event through the configuration's bindings.
Subscribes execution_id's invocation invoke_id to the binding
binding_id, for as long as the invoking state is entered (ADR-0007,
sections 2 and 6).
Returns this package's version, as mix.exs declares it.
Types
@type delivery() :: %{ name: String.t(), data: map(), message_id: String.t(), scope: String.t(), now: DateTime.t() }
What the delivery module is handed besides the configuration, binding and key.
One invocation of one execution: the execution's id and the invoke_id
the engine minted for the <invoke> (Statifier.Effect.Invoke,
statifier 2.6.0). invoke_id is a deterministic %MachineState{}
counter, so it is stable across a replay of the same drive and unique
within its execution, not across executions.
@type outcome() :: {:delivered, String.t(), String.t()} | {:created_and_delivered, String.t(), String.t()} | {:duplicate, String.t()} | {:no_match, String.t()} | {:key_refused, String.t(), refusal_reason()} | {:dropped, String.t(), :no_execution | :finished | :unmatched_event}
One binding's outcome of one routing attempt (ADR-0004, section 1).
@type refusal_reason() :: {:match, {:error, term()}} | {:match, {:value, term()}} | {:key, {:error, term()}} | {:key, {:value, term()}}
Which program refused an event for a binding, and how (ADR-0004, section 1).
@type source_event() :: %{ :scope => String.t(), :message_id => String.t(), :source => String.t(), :data => map(), optional(atom()) => term() }
The event a host hands route/3: the scope it routes under, the message
id its source adapter derived (a non-empty string, taken as given), the
source it came from, and the adapter-normalized event as a string-keyed
map. Other keys are ignored.
@type subscription() :: {binding_id :: String.t(), execution_id :: String.t(), invoke_id :: String.t()}
The identity of one subscription: its binding, its execution and the invocation it belongs to (ADR-0007, section 6).
Functions
@spec cancel(StatifierRouter.Config.t(), subscription()) :: {:ok, :cancelled | :not_subscribed}
Cancels the subscription named by {binding_id, execution_id, invoke_id},
deleting the row the matching subscribe/3 created and nothing else
(ADR-0007, section 3).
Returns {:ok, :cancelled}, or {:ok, :not_subscribed} when no such row
is there - which is not an error: the engine may plan a cancel for an
invocation that is already over, and a handler must tolerate cancelling
an invoke_id it no longer knows (Statifier.Invoke.Handler.cancel/2,
statifier 2.6.0). Calling it twice is therefore harmless.
It touches nothing else: not the execution, not its address row, not its input log, not its ledger rows, not a delayed send the chart armed, and not another binding's subscription for the same execution.
Which cancel a host calls
This package carries three, and they undo three different things:
StatifierRouter.cancel/2, this one - the source invoke's. A host calls it when the engine cancels an invocation, which it does itself on state exit;StatifierRouter.SourceInvoke.cancel/3is the delegate that turns a%Statifier.Effect.CancelInvoke{}into this call, and a host with no source invokes never calls it.StatifierRouter.SendHandler's implementation ofStatifier.Send.Processor.cancel/2- a delayed send's. It takes a%Statifier.Effect.Cancel{}and a scope map, and it is reached from the executor seam for spec 6.3's<cancel sendid>, which the chart author writes. It is not called on state exit: the engine cancels no delayed send there (ADR-0007, section 4).StatifierRouter.TimerQueue.cancel/3- a queued timer's. It is a callback on the host's queue adapter, not a function a host calls on this package;SendHandlercalls it.
@spec route(StatifierRouter.Config.t(), source_event(), keyword()) :: {:ok, [outcome()]} | {:error, term()}
Routes one event through the configuration's bindings.
Returns {:ok, outcomes}, one outcome per enabled binding whose source
is the event's source, in configuration order, and {:ok, []} when there
is no such binding. Returns {:error, reason} before any binding is
evaluated for an event whose message_id is nil or empty
(:no_message_id), for any other malformed event
({:invalid_event, event}) and for a malformed option
({:invalid_opts, opts}, {:unknown_key, name},
{:invalid_value, :now, value}), and for the first error the delivery
module answers with. The module documentation says what each outcome
writes.
The bindings are the configuration's :bindings, or, when it gives a
:bindings_resolver, that resolver's answer for the event's scope,
asked once per call after the event and the options are checked
(ADR-0001, the Amendment of 2026-09-25). An answer carrying a binding
under the reserved id returns {:error, {:reserved_binding_id, name}},
and one carrying a duplicated id returns
{:error, {:duplicate_binding_id, id}}, before any binding is
evaluated.
opts:
:now- aDateTimein UTC, the time the attempt uses for the rows it and the delivery module write. Defaults toDateTime.utc_now/0.
@spec subscribe(StatifierRouter.Config.t(), String.t(), invocation()) :: {:ok, :subscribed | :already_subscribed} | {:error, {:unknown_binding | :unaddressed_execution, String.t()}}
Subscribes execution_id's invocation invoke_id to the binding
binding_id, for as long as the invoking state is entered (ADR-0007,
sections 2 and 6).
The subscription reads its scope and key from the execution's own
address row, by execution id alone: the invoke names the binding and
nothing else, because an execution that could name its own key could
name another execution's (ADR-0007, section 1).
Returns {:ok, :subscribed}, or {:ok, :already_subscribed} when a row
for this (execution_id, binding_id, invoke_id) is already there, so a
handler whose perform/2 runs twice for one invoke_id - which
Statifier.Invoke.Handler says it must tolerate (statifier 2.6.0) -
writes one row.
Refuses with:
{:error, {:unknown_binding, binding_id}}- the configuration has no binding under that id, so there is no document to read events for. Under a:bindings_resolverthe binding is looked for in the resolver's answer for the scope of the execution's address row, so that row is read first and:unaddressed_executionis the refusal when both apply (ADR-0001, the Amendment of 2026-09-25); an answer carrying the reserved or a duplicated bindingidraisesArgumentError, because this function's return names no such refusal.{:error, {:unaddressed_execution, execution_id}}- the execution has no address row, which is what analways_newcreate leaves (ADR-0002, section 7). It has no key to subscribe under, and ADR-0007, section 6 refuses the invocation rather than subscribing it under an invented one.
@spec version() :: String.t()
Returns this package's version, as mix.exs declares it.
iex> is_binary(StatifierRouter.version())
true