The router's resolved configuration: the host's repo, where this package's tables live in it, the bindings events are routed by, the module each delivery is handed to, and what that module needs to reach statifier_persistence.
new/1 takes a keyword list and returns {:ok, config} or
{:error, reason}:
| Option | Value | Default |
|---|---|---|
:repo | the host's Ecto.Repo module | required |
:delivery | the module StatifierRouter.route/3 hands each delivery to | StatifierRouter.Delivery |
:store | a %StatifierPersistence.Storage{} built over the same repo | required by StatifierRouter.Delivery |
:persistence_options | the per-call statifier_persistence snapshot options every create and every step carries: :routes, :invoke_types, :send_types | [] |
:executor | the StatifierPersistence.Executor effects are handed to: a module or an arity-2 fun | required by StatifierRouter.Delivery |
:resolver | the StatifierRouter.Resolver naming the chart a new execution starts on: a module implementing it or an arity-2 fun | required by StatifierRouter.Delivery |
:chart_resolver | a fun of (content_hash) compiling the chart an existing execution started on | required by StatifierRouter.Delivery |
:on_create | what StatifierRouter.Delivery calls in place of StatifierPersistence.Executions.create/4, with its arguments and its return contract: a module exporting create/4 or an arity-4 fun (see below) | nil (the delivery calls create/4 itself) |
:on_step | what StatifierRouter.Delivery calls in place of StatifierPersistence.Executions.step/5, with its arguments and its return contract: a module exporting step/5 or an arity-5 fun (see below) | nil (the delivery calls step/5 itself) |
:execution_id | what StatifierRouter.Delivery mints a new execution's id with, from (scope, document, key): a module exporting execution_id/3 or an arity-3 fun answering a non-empty string (see below) | nil (a UXID with the prefix ex) |
:bindings | a list of bindings, each a map or keyword list StatifierRouter.Binding.new/1 accepts, or a %StatifierRouter.Binding{} it built | [] |
:bindings_resolver | the StatifierRouter.BindingsResolver answering the bindings of one scope, in place of :bindings: a module implementing it or an arity-1 fun (see below) | nil (the bindings are :bindings) |
:route_adapters | the route registry, a map from route name to {module, config} where the module implements StatifierRouter.Route | %{} |
:route_overrides | a map from scope to a map from route name to a configuration, merged over that route's registered configuration in that scope | %{} |
:send_type | the one <send> type string StatifierRouter.SendHandler answers to | nil |
:send_handlers | the host's own <send> processors served beside the router's handler, a map from a non-empty type string to the processor module, merged with :send_type into the one send_types: snapshot (see below) | %{} |
:basichttp | the W3C Basic HTTP Event I/O Processor for durable executions: a keyword list with a :base_url, a non-empty string, required, and a :transport module, optional, registering StatifierRouter.BasicHTTP in the send_types: snapshot and giving each execution created under a new address row a location (see below) | nil |
:processor_scope | the scope a live session's sends resolve their routes in on the send-processor shape: a non-empty string, or a zero-arity fun answering one or nil (see below) | nil |
:on_complete | the name of a registered route an execution's donedata is handed to on the delivery that finishes it; an {:error, _} from that route rolls the delivery back, finishing step included, so a route that never succeeds keeps the execution from finishing (see below) | nil |
:timer_queue | {module, config} where the module implements StatifierRouter.TimerQueue | nil |
:table_prefix | a string prefixed to every table name | "statifier_router_" |
:prefix | the Postgres schema the tables live in, as a string | nil (the repo's default) |
StatifierRouter documents what a delivery module must answer, and
StatifierRouter.Delivery what the four options it requires must be.
A configuration that names another delivery module may leave those four
out; one it gives is checked all the same. :persistence_options is
never required: a configuration that omits it carries no snapshot, which
is what statifier_persistence reads as "the built-in set only".
The route registry, and the two routes on this struct
:route_adapters is ADR-0005 decision 2's registry: the named outbound
destinations a chart reaches with <send target="...">, each mapped to
the module that serves it. It is deliberately not spelled :routes.
:routes already means something else here - it is one of
:persistence_options, where it is Statifier.Send.Routes, the
engine's point-in-time claim about which <send> routes are live - and
ADR-0005 decision 6 closed that ambiguity by vocabulary rather than by
renaming the engine's. route/3 resolves a name in a scope.
:send_type is the one type string StatifierRouter.SendHandler is
registered under. Giving it is what puts send_types: into
:persistence_options, as the Statifier.Send.Types snapshot built
from that type string and that handler (ADR-0005, decision 6); the
registry cannot supply it, because a registry maps a route name to an
adapter and holds no type string. A configuration that gives both
:send_type and a :send_types of its own is refused with
{:declared_send_types, send_type} rather than one silently winning.
:send_handlers is for a host that serves send types of its own beside
the router's handler: a map from each type string to the module that
processes it, the shape Statifier.Send.Types.from_send_types/1 takes.
new/1 merges it with %{send_type => StatifierRouter.SendHandler} and
builds the one snapshot from the merged map (ADR-0005, the Amendment of
2026-09-26), so every create and every step of every delivery carries
the host's types beside the router's, and
StatifierRouter.Routes.unsupported_types/2 judges a chart against that
same set. Left out, or given as nil or %{}, the snapshot is built from
:send_type alone, exactly as before. A map that is not one of
non-empty type strings to module names, or that names a built-in
spelling ("scxml" or the SCXML processor's URI), is refused with
{:invalid_value, :send_handlers, value}; an entry under the
:send_type itself is refused with {:declared_send_types, send_type},
as a :send_types of the host's own is; and a non-empty map beside a
:persistence_options that carries :send_types, on a configuration
with no :send_type, is refused with
{:exclusive_keys, :send_handlers, :send_types}. Only the shape is
checked: the module is the engine's to call, and new/1 does not load
it.
:processor_scope names the scope :route_overrides is read in on the
send-processor shape, where a live Statifier.Session registers
StatifierRouter.SendHandler itself and no delivery is in reach to name
one (ADR-0006, as its Amendment of 2026-09-24 has it). A string is that
scope for every send; a zero-arity fun is called by
Statifier.Send.Processor.perform/2, in the process that performs the
send, once for each send and delayed send whose target names a registered
route, and answers the scope or nil for none. It is the host's to name,
never the chart's: no send param reaches it. handle_effect/3 does not
read it, because a delivery names its own scope at the executor seam.
:on_complete names one route in that same registry, and a name absent
from :route_adapters is refused with {:unregistered_on_complete, name} rather than missed on the one delivery that would have used it.
It is what StatifierRouter.Delivery hands an execution's donedata to
on the delivery that finishes the execution, inside that delivery's
transaction; the README's "A finished execution reaches a sink" sets it
beside the chart's own way of telling a sink.
An {:error, reason} from that route settles the delivery as
{:error, {:on_complete, route_name, reason}} and rolls it back, the
finishing step with it (ADR-0003, section 1). That is deliberate: a
terminal execution has no error.communication transition left to
take, so rolling back is the only way the hand-off is not lost. Its
consequence is that a route that never succeeds is a poison pill.
The delivery that would finish the execution never commits, so the
execution stays at the position it held before that step; each time the
source hands the same message over again, the step runs again, its
effects reach the executor again, the route is called again and the
delivery fails again, and the front sees a message that fails every
time. An execution that would finish on create/4 is rolled back whole
instead, and each new attempt creates it under a new execution id
(ADR-0003, section 2). The route must therefore be safe to call again
under the same idempotency key - the at-most-once obligation
StatifierRouter.Route states - and must eventually succeed. Nothing in
this package retries or holds a failed message, so the number of
attempts is bounded only by the source's own redelivery policy (the
producer's contract, StatifierRouter.Broadway's "Redelivery is the
producer's contract").
BasicHTTP locations
:basichttp is for a host that serves the W3C Basic HTTP Event I/O
Processor for its durable executions (ADR-0002, the Amendment of
2026-09-30). Given, it is a keyword list with :base_url, a non-empty
string, and optionally :transport, a module; any other shape is
refused with {:invalid_value, :basichttp, value}. The snapshot this
configuration builds then registers StatifierRouter.BasicHTTP under
the processor's URI and its short form basichttp, with those options,
beside :send_type's handler and :send_handlers; a :send_handlers
entry or a :send_type under either string is refused with
{:declared_send_types, type}, and the key beside a :send_types of
the host's own in :persistence_options with
{:exclusive_keys, :basichttp, :send_types}, because the location
token can be added only to a snapshot this configuration builds.
On such a configuration the plan name the front delivers under,
basichttp, is reserved: a binding whose id is basichttp, given or
answered by a :bindings_resolver, is refused with
{:reserved_binding_id, "basichttp"}, as execution is on every
configuration. Without the key nothing is reserved beyond execution.
StatifierRouter.BasicHTTP says what a location is, and
StatifierRouter.BasicHTTP.Front how a POST at one is delivered. The
key needs the location table StatifierRouter.Migrations.V04 creates.
Wrapping the create and step calls
:on_create and :on_step are for a host whose own engine wraps
statifier_persistence's two doors - its own context around them, its own
rows beside them - and that wants the router's transaction, savepoint,
dedupe and ledger to stay the router's (ADR-0003, the Amendment of
2026-09-25). Each takes exactly the arguments StatifierRouter.Delivery
would hand the direct call and answers with that call's own return
contract:
:on_create-(store, execution_id, machine, opts), answering{:ok, execution, state}or{:error, reason}, asStatifierPersistence.Executions.create/4does;:on_step-(store, execution_id, machine, event, opts), answering{:ok, execution, state},{:discarded, execution}or{:error, reason}, asStatifierPersistence.Executions.step/5does.
A module is called as module.create/4 or module.step/5, so
StatifierPersistence.Executions itself is a valid value of either,
and behaves exactly as leaving the key out does. The delivery calls the
hook where it would have called persistence, inside the delivery's
transaction and its savepoint, and reads the answer as it reads
persistence's. new/1 checks the shape only: a module that is loadable
and exports the function, or a fun of the right arity. Anything else is
refused with {:error, {:invalid_value, name, value}}.
Minting the execution id
:execution_id is for a host that names its executions itself: its own
id shape, its own prefix, an id its other tables already carry
(ADR-0002, the Amendment of 2026-09-25). StatifierRouter.Delivery
calls it each time it is about to create an execution, with the
delivery's scope, the document and the key, and writes the answer
where it would have written its own mint: onto the address row, into
StatifierPersistence.Executions.create/4 (or :on_create) and onto
the ledger row. A module is called as module.execution_id/3. new/1
checks the shape only, as it does for the two hooks above, and refuses
anything else with {:error, {:invalid_value, :execution_id, value}}.
The answer must be a non-empty string; any other answer raises
ArgumentError from the delivery, as a malformed hook answer does, and
a raise from the callback itself propagates unrescued. The id must also
be new: an id statifier_persistence already holds is refused by
create/4 with {:error, :execution_exists}, which rolls the delivery
back and is returned. Left out, the delivery mints a UXID with the
prefix ex, as it always has.
Bindings answered per scope
:bindings is one list for every scope. :bindings_resolver is for a
host whose bindings differ by scope: a StatifierRouter.BindingsResolver
called with a scope, answering the %StatifierRouter.Binding{} structs
that scope routes by (ADR-0001, the Amendment of 2026-09-25). The two
keys are exclusive, and a configuration that gives both - :bindings
given at all, whatever its value - is refused with
{:error, {:exclusive_keys, :bindings, :bindings_resolver}} rather than
one silently winning. A resolver that is not one of the behaviour's
shapes is refused with {:error, {:invalid_value, :bindings_resolver, value}}. A configuration with a resolver keeps bindings: [].
The resolver is called where a scope is in hand: StatifierRouter.route/3
calls it once with the event's scope, before any binding is evaluated;
StatifierRouter.Broadway's partitioner once per message; and
StatifierRouter.subscribe/3 once, with the scope of the subscribing
execution's address row. Each answer is checked as :bindings is here:
the reserved id and the duplicated id are refused as
{:reserved_binding_id, name} and {:duplicate_binding_id, id}, and an
answer that is not a list of built bindings raises ArgumentError.
StatifierRouter.BindingsResolver says what each caller does with a
refusal. The publish-time checks of StatifierRouter.Contracts.check/3
and the reaper StatifierRouter.Addresses.reap/3 read no scope, so they
do not call it: the first reads the configuration's bindings: [] and
reports the bindings unchecked with the entry
StatifierRouter.Contracts.bindings_unchecked/0, and the second has
always taken the host's bindings as its own argument.
What the checks here do and do not catch
:store must be built over this configuration's own :repo, so that
StatifierPersistence.Executions.create/4 and
StatifierPersistence.Executions.step/5 write through the delivery's
transaction rather than opening their own (ADR-0003, section 1). A store
over another repo writes outside that transaction, and the delivery's
rollback then leaves the execution behind. new/1 catches the case it
can see: a store whose resolved adapter options carry a :repo that is
not this configuration's is refused with
{:error, {:invalid_value, :store, store}}. Those options are
statifier_persistence's own, and only its Ecto storage resolves a
:repo into them, so a store built on any other adapter passes this
check without being checked. On such a store the rule is the host's to
keep, and keeping it is not optional.
:resolver and :executor are checked to different depths, on purpose.
StatifierRouter.Resolver is this package's own behaviour, so a resolver
module is held to it: loadable and exporting resolve/2, by that
module's internal validity check. StatifierPersistence.Executor is the
dependency's, normalized per effect by an internal function of
statifier_persistence, so an executor is checked for the shape that
option takes - a module name or an arity-2 fun - and the
dependency's own dispatch rule is left to it. A host that wants the
deeper check on its executor gets it from statifier_persistence, not from
here.
Each binding given as a map or keyword list is built with
StatifierRouter.Binding.new/1. The resolved configuration keeps the
bindings in the order given: it is the order
StatifierRouter.route/3 returns its outcomes in. A duplicate binding
id is a fault of the list rather than of any one binding, so it is
refused here, naming the duplicated id, before any event is routed
(ADR-0001, section 1).
A %StatifierRouter.Binding{} in the list is trusted as
StatifierRouter.Binding.new/1 built it and kept as given: it is not
passed through StatifierRouter.Binding.new/1 again, so its programs
are not recompiled and its fields are not re-checked. A struct built or
altered any other way is the host's to keep valid. The two checks on
the list as a whole, the reserved id and the duplicated id, apply to
it all the same.
The five tables are the address table of ADR-0002 (addresses), the
dedupe table of ADR-0003 (dedupe), the ledger of ADR-0004
(routing_ledger), the subscription table of ADR-0007
(subscriptions) and the location table of ADR-0002's Amendment of
2026-09-30 (locations); table/2 names each one under a
configuration.
StatifierRouter.Migrations creates them from the same two storage
options, and put_meta/2 and queryable/2 point the schemas in
StatifierRouter.Schema at them, so the DDL and the rows cannot
disagree on a name.
iex> {:ok, config} =
...> StatifierRouter.Config.new(
...> repo: MyApp.Repo,
...> delivery: MyApp.Delivery,
...> prefix: "routing"
...> )
iex> StatifierRouter.Config.table(config, :addresses)
"statifier_router_addresses"
iex> config.prefix
"routing"
Summary
Types
The host's compiled chart for a content hash an existing execution
records, or :error when it has none: the shape
StatifierPersistence.Driver's chart_resolver: takes.
What the default delivery mints a new execution's id with, from the
delivery's scope, the document and the key: a module exporting
execution_id/3, or an arity-3 fun, answering a non-empty string.
Why new/1 refused a configuration.
What the default delivery calls in place of
StatifierPersistence.Executions.create/4: a module exporting create/4,
or an arity-4 fun with that function's arguments and return contract.
What the default delivery calls in place of
StatifierPersistence.Executions.step/5: a module exporting step/5, or
an arity-5 fun with that function's arguments and return contract.
The scope the send-processor shape resolves a route in: a non-empty
string, or a zero-arity fun StatifierRouter.SendHandler calls per send,
answering a non-empty string or nil for no scope.
The host's answer to which chart a new execution of document starts on,
under scope (ADR-0002, section 4): a module implementing
StatifierRouter.Resolver, or an arity-2 fun with its callback's
signature.
One of the five tables this package owns.
Functions
Validates the options in the table above and resolves them into a configuration.
Points a row of one of the StatifierRouter.Schema modules at this
configuration's table and Postgres schema, so that Repo.insert/2
writes it there.
A query over one of the StatifierRouter.Schema modules that reads this
configuration's table in its Postgres schema.
The adapter serving the route name under scope, with that scope's
override applied over the adapter's registered configuration, or
:error when the host registered no such route (ADR-0005, decision 2).
The name of table under this configuration: the table prefix followed
by the table's own name.
Types
@type chart_resolver() :: (content_hash :: String.t() -> {:ok, Statifier.Machine.t()} | :error)
The host's compiled chart for a content hash an existing execution
records, or :error when it has none: the shape
StatifierPersistence.Driver's chart_resolver: takes.
@type execution_id() :: module() | (scope :: String.t(), document :: String.t(), key :: String.t() -> String.t())
What the default delivery mints a new execution's id with, from the
delivery's scope, the document and the key: a module exporting
execution_id/3, or an arity-3 fun, answering a non-empty string.
@type new_error() :: {:unknown_key, term()} | {:missing_key, :repo | :store | :executor | :resolver | :chart_resolver} | {:invalid_value, atom(), term()} | {:invalid_config, term()} | {:binding, non_neg_integer(), StatifierRouter.Binding.new_error()} | {:duplicate_binding_id, String.t()} | {:unregistered_route, String.t(), String.t()} | {:declared_send_types, String.t()} | {:unregistered_on_complete, String.t()} | {:reserved_route, String.t()} | {:reserved_binding_id, String.t()} | {:exclusive_keys, :bindings, :bindings_resolver} | {:exclusive_keys, :send_handlers, :send_types} | {:exclusive_keys, :basichttp, :send_types}
Why new/1 refused a configuration.
@type on_create() :: module() | (StatifierPersistence.Storage.t(), String.t(), Statifier.Machine.t(), keyword() -> {:ok, StatifierPersistence.Execution.t(), Statifier.MachineState.t()} | {:error, term()})
What the default delivery calls in place of
StatifierPersistence.Executions.create/4: a module exporting create/4,
or an arity-4 fun with that function's arguments and return contract.
@type on_step() :: module() | (StatifierPersistence.Storage.t(), String.t(), Statifier.Machine.t(), Statifier.Event.t(), keyword() -> {:ok, StatifierPersistence.Execution.t(), Statifier.MachineState.t()} | {:discarded, StatifierPersistence.Execution.t()} | {:error, term()})
What the default delivery calls in place of
StatifierPersistence.Executions.step/5: a module exporting step/5, or
an arity-5 fun with that function's arguments and return contract.
The scope the send-processor shape resolves a route in: a non-empty
string, or a zero-arity fun StatifierRouter.SendHandler calls per send,
answering a non-empty string or nil for no scope.
@type resolver() :: StatifierRouter.Resolver.t()
The host's answer to which chart a new execution of document starts on,
under scope (ADR-0002, section 4): a module implementing
StatifierRouter.Resolver, or an arity-2 fun with its callback's
signature.
@type t() :: %StatifierRouter.Config{ basichttp: keyword() | nil, bindings: [StatifierRouter.Binding.t()], bindings_resolver: StatifierRouter.BindingsResolver.t() | nil, chart_resolver: chart_resolver() | nil, delivery: module(), execution_id: execution_id() | nil, executor: StatifierPersistence.Executor.t() | nil, on_complete: String.t() | nil, on_create: on_create() | nil, on_step: on_step() | nil, persistence_options: keyword(), prefix: String.t() | nil, processor_scope: processor_scope() | nil, repo: module(), resolver: resolver() | nil, route_adapters: %{optional(String.t()) => StatifierRouter.Route.t()}, route_overrides: %{optional(String.t()) => %{optional(String.t()) => map()}}, send_handlers: %{optional(String.t()) => module()}, send_type: String.t() | nil, store: StatifierPersistence.Storage.t() | nil, table_prefix: String.t(), timer_queue: StatifierRouter.TimerQueue.t() | nil }
@type table() :: :addresses | :dedupe | :routing_ledger | :subscriptions | :locations
One of the five tables this package owns.
Functions
Validates the options in the table above and resolves them into a configuration.
Returns {:ok, config}, or {:error, reason} naming the first fault:
an unknown option, then a missing or malformed :repo, then a malformed
:delivery, then a missing or malformed :store, :executor,
:resolver or :chart_resolver, in that order, then a :store whose
adapter options name another repo, then a malformed :send_handlers,
then a malformed :persistence_options, then a malformed :on_create
or :on_step, then a malformed :execution_id, then a storage value the
table does not allow, then :bindings and :bindings_resolver both
given, then a malformed :bindings_resolver, then the first binding
StatifierRouter.Binding.new/1 refuses, as {:binding, index, reason}
with index counted from zero, then a binding whose id is the
reserved name, then the first duplicated binding id.
An :on_complete naming a route the host did not register is refused
with {:unregistered_on_complete, name}, after the registry itself is
resolved.
ADR-0006, section 1 reserves one name for the execution target,
StatifierRouter.SendHandler.execution_target/0. A :route_adapters
entry under it is refused with {:reserved_route, name}, after the
registry's own shape is checked, and a binding whose id is it with
{:reserved_binding_id, name}.
iex> StatifierRouter.Config.new(repo: MyApp.Repo, delivery: MyApp.Delivery, table_prefix: 7)
{:error, {:invalid_value, :table_prefix, 7}}
iex> StatifierRouter.Config.new(repo: MyApp.Repo)
{:error, {:missing_key, :store}}
Points a row of one of the StatifierRouter.Schema modules at this
configuration's table and Postgres schema, so that Repo.insert/2
writes it there.
@spec queryable(t(), module()) :: Ecto.Query.t()
A query over one of the StatifierRouter.Schema modules that reads this
configuration's table in its Postgres schema.
@spec route(t(), String.t() | nil, String.t() | nil) :: {:ok, StatifierRouter.Route.t()} | :error
The adapter serving the route name under scope, with that scope's
override applied over the adapter's registered configuration, or
:error when the host registered no such route (ADR-0005, decision 2).
A scope overrides a route's configuration and never its existence, so a
name absent from :route_adapters misses in every scope, and a scope
of nil - a caller with no scope in reach - resolves the registered
configuration unchanged.
The name of table under this configuration: the table prefix followed
by the table's own name.