The run lifecycle: create and step durable runs with no live Session process, the loop this package exists to package.
A step runs in ADR-0004 decision 3's order, and the order is the
contract: liveness check on the run record -> load (guarded) -> re-stamp
routes/invoke_types unconditionally (with the nil tripwire from
st-ADR-0064: the fields are pattern-matched nil before stamping, so an
upstream regression fails loudly here, not silently downstream) -> step
via Interpreter.handle_event/2 -> execute effects via the executor
seam -> consume :done and :budget_exhausted into run status -> assert
MachineState.internal_queue_empty?/1 -> persist.
Effect execution is at-least-once: a crash between step and persist
re-drives the same event and re-emits the same effects with identical
deterministic keys (st-ADR-0054 decision 3, st-ADR-0059), and this loop
never dedupes - idempotency is the consumer's. :done is the only path
to :completed (ADR-0004 decision 6); an event delivered to a terminal
run is discarded with a typed {:discarded, run} result, never an
exception and never a silent step.
A chart says its own run failed
Two routes reach :failed, and both are the chart's own word rather than
the host's - fail/4 is the host-driven one (ADR-0004 decision 6).
The first is macrostep-budget exhaustion, which also returns
{:error, {:budget_exhausted, payload}} after the record is durable.
The second (ADR-0008's 2026-09-06 amendment) is a failure-classed
final: a top-level <final> whose <donedata> carries the reserved
key statifier_persistence:run_status with the value "failed".
<final id="ended_badly">
<donedata>
<param name="statifier_persistence:run_status" expr="'failed'"/>
</donedata>
</final>Settling there is an ordinary successful step - it returns
{:ok, %StatifierPersistence.Run{status: :failed}, machine_state}, not
the budget route's error tuple, because a chart that says it failed has
not malfunctioned, it has finished. The run's failure string is
"failed_final", the same string the
[:statifier_persistence, :run, :terminated] event reports as reason
and StatifierPersistence.Driver sends a durable parent as
{:failed, reason: ...}, so a :first_error fan-out cancels the failed
child's siblings through the cascade ADR-0008 decision 5 already built.
The resolved <donedata> reaches the parent verbatim, tag included -
nothing is stripped.
The value set is closed at "failed": any other value is ignored and the
run takes the status it would have taken with no key at all, so a chart
cannot claim a :completed it did not reach or a :cancelled that is
the parent's word. An unhandled error.communication or
error.execution is not a route: a chart that raises an error it
does not catch stays :active, which is a chart bug its author fixes
with a transition to a failure-classed final, not a status this package
infers on the author's behalf (amendment decision 4).
Executor failures on actionable effects re-enter the chart as
error.communication events through Statifier.Interpreter.deliver_internal/5
(st-ADR-0039's seam), per st-ADR-0051's failed-communication row: the core
alone mints the planning-time execution-error events, before any effect is
emitted, so every failure an executor can report re-enters uniformly as
error.communication (ADR-0004
decision 4). Failures on observational effects are discarded. Re-entry is
single-wave per step: effects the re-entries emit are executed too, but
their failures are not re-entered again, so a deterministically failing
executor cannot loop this library.
Concurrent deliveries to one run are ordered by a pluggable per-run
serialization strategy (ADR-0004 decision 5): every entry point runs its
fetch-to-persist tail inside the strategy's
StatifierPersistence.Serialization.with_run/3, selected per call with
serialization: {module, config} and defaulting to
{StatifierPersistence.Serialization.AdapterLock, store} - the adapter's
own optional lock_run/3. A strategy refusal surfaces unchanged as
{:error, {:serialization, reason}}.
Summary
Types
The fixed vocabulary of public doors entry names on this package's own
telemetry (docs/telemetry.md). It is the dimension an operator slices
step latency by first, because a :done_invocation step and a :step
step have different expected shapes.
This module's error vocabulary: the facade's arms, unflattened, plus the
{:budget_exhausted, payload} arm returned after a budget-exhausted step
or create has persisted its :failed run record, plus the serialization
strategy's own refusal, surfaced unchanged
({:serialization, :not_supported} from the default strategy over an
adapter with no lock_run/3).
An event step/5 can only build once the run's position is loaded.
A run's caller-supplied opaque key (ADR-0004 decision 2).
Functions
Cancels a run: the second host-driven terminal transition (ADR-0004 decision 6 as extended by ADR-0008 decision 5), and the one a cascading cancel writes through.
Cancels every run linked to parent_run_id - for one invocation, or for
all of them - and every run linked to those, recursively (ADR-0008
decision 5).
Creates a run: Statifier.Interpreter.initialize/2 (which cannot fail),
then the shared persist tail - effects through the executor seam,
:done/:budget_exhausted consumed into run status, quiescence
asserted, the record inserted with its encoded position.
Abandons a run: the only host-driven terminal transition (ADR-0004 decision 6). No interpreter is involved - abandonment is a host decision about the run, not a chart transition - so the stored position is left untouched and only the record's status and failure reason change.
Lists run_id's input log, in the order the run's interpreter saw it
(ADR-0010 decision 2).
Delivers one external event to a run, in ADR-0004 decision 3's order (the moduledoc quotes it).
Types
@type entry() ::
:create
| :step
| :done_invocation
| :failed_invocation
| :answer_parent
| :fail
| :cancel
The fixed vocabulary of public doors entry names on this package's own
telemetry (docs/telemetry.md). It is the dimension an operator slices
step latency by first, because a :done_invocation step and a :step
step have different expected shapes.
It is also ADR-0010's door vocabulary: the same seven atoms, stored as
strings on an input log entry, and the record adds no second one. Of the
seven, only :step, :done_invocation and :failed_invocation -
:answer_parent among them, since it re-enters the parent through one
of the two invocation doors - ever carry an event into an interpreter,
so those are the doors that append (decision 5's table).
@type error() :: StatifierPersistence.Storage.error() | {:budget_exhausted, Statifier.Effect.BudgetExhausted.t()} | {:serialization, term()}
This module's error vocabulary: the facade's arms, unflattened, plus the
{:budget_exhausted, payload} arm returned after a budget-exhausted step
or create has persisted its :failed run record, plus the serialization
strategy's own refusal, surfaced unchanged
({:serialization, :not_supported} from the default strategy over an
adapter with no lock_run/3).
@type event_builder() :: (Statifier.MachineState.t() -> {:ok, Statifier.Event.t()} | :discard)
An event step/5 can only build once the run's position is loaded.
Called with the loaded, re-stamped Statifier.MachineState.t/0, inside
the serialization strategy's with_run/3 and before
Statifier.Interpreter.handle_event/2 - so what it reads and what the
step acts on are the same position under the same exclusion. {:ok, event} steps that event; :discard steps nothing and returns
{:discarded, run}.
It exists for events whose right to be delivered at all is a property
of the position: an invocation's late answer, which spec 6.4.3 discards
when the invocation is no longer live (StatifierPersistence.Driver's
done_invocation/5). This adds no step to ADR-0004 decision 3's order -
the event argument is late-bound, the loop is not re-ordered.
@type opt() :: {:executor, StatifierPersistence.Executor.t()} | {:routes, Statifier.MachineState.routes()} | {:invoke_types, Statifier.MachineState.invoke_types()} | {:initialize, keyword()} | {:serialization, {module(), term()}} | {:metadata, StatifierPersistence.Storage.Adapter.metadata()} | {:linkage, StatifierPersistence.Run.Linkage.t()} | {:entry, entry()} | {:invoke_id, String.t()} | {:child_count, pos_integer()}
Options create/4 and step/5 accept:
executor:(required) - theStatifierPersistence.Executor.t/0every non-lifecycle effect is handed to, in list order.routes:- theStatifier.Send.Routes.t/0snapshot stamped onto the loaded position before the step; host-supplied per call, never read back from storage (st-ADR-0048). Defaults tonil, "no determination made".invoke_types:- theStatifier.Invoke.Types.t/0snapshot, stamped the same way (st-ADR-0051). Defaults tonil, "the built-in set only".initialize:(create/4only) - passed toStatifier.Interpreter.initialize/2unchanged.metadata:(create/4only) - the optional opaque map of host identities stored beside the run record (ADR-0006 decision 1), defaulting to%{}. Identities only, never personal data (decision 2); an adapter that cannot store a non-empty map refuses the create with{:error, :metadata_unsupported}(decision 3).serialization:- the{module, config}per-run serialization strategy the fetch-to-persist tail runs inside (ADR-0004 decision 5;fail/4accepts it too). Defaults to{StatifierPersistence.Serialization.AdapterLock, store}.entry:- this package's own, never a host's: the public door this drive came through, carried on[:statifier_persistence, :run, :step, :start | :stop]and[:statifier_persistence, :run, :discarded]asentry(ADR-0009,docs/telemetry.md).StatifierPersistence.Driversets it to:done_invocation,:failed_invocationor:answer_parenton the doors that reachstep/5rather than being one of its own; every other entry point derives its own (:create,:step,:fail,:cancel). It stopped being telemetry-only with ADR-0010: on an adapter that keeps an input log,entry:also stamps the stored entry'sdoor(decision 5). It changes nothing else.linkage:(create/4only) - this package's own, never a host's. Set by the durable subchartstart_childclause (Phase 3) to record a child's parent under the reserved metadata namespace (StatifierPersistence.Run.Linkage, ADR-0008 decision 2). A host suppliesmetadata:for its own identities; supplyinglinkage:from outside this package is a caller bug the same way a malformedmetadata:is.invoke_id:andchild_count:- this package's own, never a host's, and telemetry only. Set byStatifierPersistence.Driverbesideentry: :answer_parent, they name the invocation the step is answering and its width on[:statifier_persistence, :run, :step, :stop](the ADR-0009 sp-8wv amendment).child_countisnilfor a single-child subchart. They change nothing else about the step.
@type run_id() :: StatifierPersistence.Storage.Adapter.run_id()
A run's caller-supplied opaque key (ADR-0004 decision 2).
Functions
@spec cancel( store :: StatifierPersistence.Storage.t(), run_id :: run_id(), opts :: keyword() ) :: {:ok, StatifierPersistence.Run.t()} | {:discarded, StatifierPersistence.Run.t()} | {:error, error()}
Cancels a run: the second host-driven terminal transition (ADR-0004 decision 6 as extended by ADR-0008 decision 5), and the one a cascading cancel writes through.
Cancellation retains: no record and no position is deleted, no
interpreter is involved, and the stored position is left untouched - only
the record's status changes, to :cancelled. A run that is already
terminal - cancelled by an earlier, interrupted cascade included - is
discarded with {:discarded, run}, which is what makes re-running a
cascade over an already-cancelled subtree a no-op.
opts accepts serialization: only, exactly as fail/4 does.
@spec cascade_cancel( store :: StatifierPersistence.Storage.t(), metadata_match :: StatifierPersistence.Storage.Adapter.metadata(), opts :: keyword() ) :: {:ok, non_neg_integer()} | {:error, error()}
Cancels every run linked to parent_run_id - for one invocation, or for
all of them - and every run linked to those, recursively (ADR-0008
decision 5).
Retains: nothing is deleted and every position is left byte-identical;
each run simply takes the :cancelled terminal status through cancel/3.
Idempotent, and idempotent in the strong sense a crash needs. The walk
descends into every child it finds, whatever that child's own status, and
cancel/3 discards a run that is already terminal - so a cascade
interrupted halfway through a deep tree is completed correctly by
re-running it, and a cascade over a subtree that is already fully
cancelled writes nothing at all.
There is no global transaction and there deliberately is none: each run's cancel is its own serialized write under its own run's exclusion (ADR-0004 decision 5), so a deep tree is O(subtree) writes. Cross-run locking is the only way to make it atomic, and this package does not have it and does not want it.
Termination rests on the run tree being acyclic, which it is by
construction: a child's run id strictly extends its parent's
(StatifierPersistence.Run.Linkage.child_run_id/3), so no run can be its
own descendant. This is why no depth ceiling is needed (ADR-0008
decision 6).
That same fact is what makes the lock order safe, which is worth stating
because this walk is the one place a cycle would be conceivable. It runs
from inside the caller's own exclusion on every path that has one - the
{:cancel_invoke, _} effect fires inside the exiting run's, and
first_error's settlement fires it inside the PARENT's - and it only
ever takes an exclusion on a run further down that same subtree. Nothing
here holds a descendant's exclusion and then asks for an ancestor's: a
child releases its own before answering its parent
(Driver.maybe_answer_parent/3 runs after the drive returns), and the
parent's door is stepped after the settlement's exclusion closes rather
than inside it. So the wait-for relation between two connections embeds
in the run tree, and an acyclic tree has no cycle to deadlock on.
test/statifier_persistence/driver_fanout_test.exs pins the direction;
its Ecto variant runs it against real Postgres advisory locks.
metadata_match is a StatifierPersistence.Run.Linkage containment map -
Linkage.invocation_match/2 to cancel one invocation's subtree,
Linkage.parent_match/1 for every child a parent has ever started. opts
accepts serialization: only, threaded to every cancel/3 call the walk
makes, exactly as cancel/3 itself accepts it.
@spec create( store :: StatifierPersistence.Storage.t(), run_id :: run_id(), machine :: Statifier.Machine.t(), opts :: [opt()] ) :: {:ok, StatifierPersistence.Run.t(), Statifier.MachineState.t()} | {:error, error()}
Creates a run: Statifier.Interpreter.initialize/2 (which cannot fail),
then the shared persist tail - effects through the executor seam,
:done/:budget_exhausted consumed into run status, quiescence
asserted, the record inserted with its encoded position.
Create-exactly-once rests on the adapter's atomic :run_exists refusal
(ADR-0004 decision 2), not on a pre-check here: creating an existing
run_id returns {:error, :run_exists}.
A create whose initialize/2 exhausts its macrostep budget persists a
:failed run with no position blob (there is no quiescent position to
store - ADR-0004 decision 1) and then returns
{:error, {:budget_exhausted, payload}}, so the caller sees both the
durable state and the reason.
metadata: rides through to the inserted run record unchanged (ADR-0006
decision 1). Create is the only place it is set - step/5 and fail/4
carry the stored map forward and take no metadata: of their own - and
an adapter that cannot store a non-empty map refuses here, before any
effect is executed: {:error, :metadata_unsupported}.
@spec fail( store :: StatifierPersistence.Storage.t(), run_id :: run_id(), reason :: String.t(), opts :: keyword() ) :: {:ok, StatifierPersistence.Run.t()} | {:discarded, StatifierPersistence.Run.t()} | {:error, error()}
Abandons a run: the only host-driven terminal transition (ADR-0004 decision 6). No interpreter is involved - abandonment is a host decision about the run, not a chart transition - so the stored position is left untouched and only the record's status and failure reason change.
A terminal run is discarded, same as step/5: {:discarded, run}.
reason is the short string stored as the run's failure - keep it a
prefixed, console-readable reason, not an inspect dump.
opts accepts serialization: only - the same {module, config}
strategy create/4 and step/5 take, with the same default.
@spec inputs(store :: StatifierPersistence.Storage.t(), run_id :: run_id()) :: {:ok, [StatifierPersistence.Storage.input()]} | :not_supported | {:error, error()}
Lists run_id's input log, in the order the run's interpreter saw it
(ADR-0010 decision 2).
Each entry carries its ordinal (seq, dense from zero), the public
door it entered by, and the %Statifier.Event{} itself - equal to the
one that was delivered, caller_context and all. An entry whose
event is nil is the closed marker a host-declared cap wrote
(decision 6); a reader mapping this log onto a replay refuses on it
rather than replaying a run that never happened.
:not_supported for a store whose adapter keeps no log - which is not
a failure, since nothing in this package refuses a run over it
(decision 1). {:error, :run_not_found} for a run that does not exist,
and {:ok, []} for one that has taken no input yet.
Read-only and outside the run's exclusion by design: this is a
diagnostic read, and nothing in this package consumes it. The replay
itself is StatifierUI.Trace.Replay.from_events/4's, under the mapping
ADR-0010 decision 8 names and no code here builds.
@spec step( store :: StatifierPersistence.Storage.t(), run_id :: run_id(), machine :: Statifier.Machine.t(), event :: Statifier.Event.t() | event_builder(), opts :: [opt()] ) :: {:ok, StatifierPersistence.Run.t(), Statifier.MachineState.t()} | {:discarded, StatifierPersistence.Run.t()} | {:error, error()}
Delivers one external event to a run, in ADR-0004 decision 3's order (the moduledoc quotes it).
An event delivered to a terminal run returns {:discarded, run} from the
run record alone, before any position decode. handle_event/2's
{:error, :not_running} arm is the structural backstop for a run record
whose :active status lies about a terminal stored position: it discards
too, and repairs the record's status to :completed on the way out.
event may also be a event_builder/0 - a fun the loaded position is
handed, for an event only the position can build or decline. A builder
that declines discards the delivery through the same {:discarded, run}
arm.