The GenServer effect interpreter (ADR-0003): the outer while running
loop, the waiting external events, the delayed-send timers, and the
fan-out of the effect stream to subscribers. The pure core decides; this
module performs.
This is the one module under lib/statifier/ allowed to do I/O
(lib/mix/statifier/adr_guard.ex's @effect_interpreter_paths), which is
why the deciding half lives in Statifier.Session.Inbox,
Statifier.Session.Timers, and Statifier.Session.Effects - pure values
and a pure function, testable with no process at all - and only the
performing half lives here. A different path or module name fails the
gate.
A bare start_link/2 stays legal; the runtime is opt-in
start_link/2 and the use GenServer-generated child_spec/1 still work
exactly as before: an embedder can place a session directly
in their own supervision tree, and :name is passed to
GenServer.start_link/3 unchanged, so an embedder-owned
{:via, Registry, _} works with no code here. A session started this way
is legal and unregistered - ADR-0027 decision 2 sanctions this outright,
since C.1 leaves "accessible to a given SCXML Processor" platform-defined,
and an unregistered session is an inaccessible one to every other
session (decision 10 below is the one exception, and it is about
self-addressing, not registration).
Statifier.start_session/2 is the alternative that registers: it starts
the session on Statifier.SessionSupervisor under Statifier.Supervisor
(ADR-0027), and init/1 below registers under Statifier.Registry when
that registry is running - never fatal to the session when it is not, so
a bare start_link/2 embedder pays nothing for a runtime it never placed.
use GenServer, restart: :temporary (ADR-0027 decision 4) is written
explicitly, both because the generated child_spec/1 otherwise defaults
to restart: :permanent - which would have a supervisor restart a
session that halted normally, the opposite of the next section - and
because :temporary itself is a decision: a supervisor restart re-runs
start_link/2, which generates a fresh sess_ id and loses every
bit of the crashed session's state, so restarting is actively wrong, not
merely useless. Recovery that preserves identity is replay, not a
restart flag.
:done idles the session; it does not stop it
The process stays alive with the terminal %MachineState{} and the
retained %Effect.Done{} in hand, so snapshot/1 and status/1 still
answer after termination. Stopping is the caller's move (stop/2), or the
supervisor's. Idle means inspectable, not still acting: further events are
queued but never drained, and every pending delayed-send timer is
cancelled at the halt itself (spec 6.2's discard-on-termination -
discard_pending_timers/2 - not deferred to terminate/2, which an
idled process may never reach). The same is
true, with a narrower meaning, for :cancelled (cancel/1 reached the
same exit_interpreter/1 path) and for :budget_exhausted (ADR-0019):
the session neither retries with a larger budget nor stops itself, and
cancel/1 still works from a budget-halted session, since the
underlying %MachineState{} is still running and the inbox's cancel
entry is checked before any drive of the core - a queued ordinary event
is not. While the session is halted :budget_exhausted, the drain takes
a cancel entry ahead of the ordinary events queued before it, and the
cancel's exit discards them as a :cancelled halt does. One asymmetry:
because the interpreter has not exited under :budget_exhausted, its
already-scheduled delayed sends stay armed and still fire; they are
discarded only when the eventual cancel/1 halts it :cancelled. A
fired send that targets another session is delivered to it, while one
that targets this session itself is enqueued on its inbox, like any
event sent to it, and is not drained while the session stays halted.
One that targets the internal queue, or a route unreachable when it
fires, is dropped without driving the core and is not recorded, as a
budget-halted sender's miss reported through failed_send/3 is.
One subscriber stream
There is no :owner concept. Subscribers are a monitored set;
start_link/2 takes :subscribers (default []), and
subscribe/2/unsubscribe/2 manage it afterward. Every message a
subscriber receives is {:statifier, session_id, message}, where
message is one of:
{:effect, effect}- every effect the core (or aninterpret/2caller) hands this session, trace effects included, in non-decreasing(macrostep, round)order - the same orderStatifier.Replayproduces for the same recording (ADR-0044 decision 1). An ADR-0039 re-entry crosses the seam at its own instruction's position but its effects are queued and drained after the batch that triggered it, so a subscriber never sees a later round ahead of an earlier one. Trace effects are ordinary list members here too, never a side channel.This is a guarantee about delivery order, and it is stronger than what a subscriber can re-derive from the structs - but the structs do carry the key. Every effect in the vocabulary, core and trace alike, is stamped with
macrostep/microstep/round(ADR-0046, which settled the follow-on ADR-0044 decision 4 left open), so a mixed stream whose arrival order was lost can still be sorted back into(macrostep, round)order after the fact. What that key does not recover is the interleave within one round, which is precisely what arrival order gives you here - so take the order as it arrives rather than re-sorting a live stream.A macrostep may carry more than one
Trace.MacrostepStable- one per core drive that reached quiescence - and there is exactly one per(macrostep, round)(ADR-0044 decision 3). Within a macrostep the last one is that macrostep's last quiescent point, which is not always where the macrostep ends: a macrostep that halts ends withTrace.Doneinstead, either after its finalTrace.MacrostepStableor - when the halting drive is the only one- with no
Trace.MacrostepStableof its own at all.
- with no
{:unroutable, effect}- reserved for an effect this session cannot route; every:send/:send_delayed/:invoke/:cancel_invoke/:autoforwardeffect now routes (seeStatifier.Session.Effects's own moduledoc), so no message currently reaches a subscriber this way.{:halted, :done | :cancelled | :budget_exhausted}- one lifecycle message, following the effects that caused it, and the last message this session sends its subscribers for the run (ADR-0044 decision 2), with one exception: a latercancel/1reaches a budget-halted session and halts it:cancelled, so a{:halted, :budget_exhausted}is followed by that exit's effects and the{:halted, :cancelled}closing them. Short of that cancel, a budget-halted session keeps its halted message the last one in two ways: a further miss handed it throughfailed_send/3is dropped without stepping, and so is a fired delayed send that targets the internal queue or a route unreachable when it fires.
A late subscriber catches up by replaying the recording, not from any
buffer this session retains (ADR-0049). subscribe/3 with
catch_up: true returns {:ok, recording} - the session's current
Statifier.Session.Recording.t(), snapshotted in the same handle_call
that adds the pid - or {:error, :not_recorded} without adding it, for a
session not started with record: true.
The invariant that makes the split exact: between GenServer callbacks,
Statifier.Replay.run/1 over this session's current recording produces
exactly the messages this session has notified so far. Each callback is
atomic in the effects' terms - it records its input and notifies every
resulting effect, including ADR-0044's deferred re-entry batches drained
before the callback returns, which is why deferred is documented always
[] between callbacks - so a subscribe call, serialized between
callbacks, observes a recording whose replay is precisely the notified
prefix.
The consumption recipe, as it will actually be written:
{:ok, recording} = Statifier.Session.subscribe(session, self(), catch_up: true)
{:ok, %{stream: prefix}} = Statifier.Replay.run(recording)
# every subsequent {:statifier, session_id, message} is the suffixADR-0044 decision 1's monotone-arrival contract holds across the seam: the
prefix is replay's own order and the suffix continues it. A halted session
composes for free - the recording is complete, the replayed stream ends
{:halted, reason}, the live suffix is empty, and Replay.run/1's
status says so, so post-mortem attachment is the same call as late
attachment. A subscriber wanting only the current picture keeps using
snapshot/1/status/1 with a plain subscribe/2; nothing here replaces
them.
A subscriber that dies is dropped on its own :DOWN.
A cancel is a queue entry, not an out-of-band call
cancel/1 enqueues the cancel marker onto this session's own external
inbox (Statifier.Session.Inbox.enqueue_cancel/1), so a cancellation is
recorded in the same ordered queue as every event
(docs/observability.md constraint 6) rather than racing ahead of it
through a side channel. Draining it runs Statifier.Interpreter.cancel/1
- Appendix D's
running = falsefollowed byexitInterpreter()- and halts the session:cancelled, even though the{:done, _}effect that walk produces is still forwarded to subscribers exactly as natural termination's is.
<send> routing
A <send>/<send_delayed> with no target lands on this session's own
external queue. #_internal and a self-addressed #_scxml_<sessionid>
(decision 10: a session is always accessible to itself, registry or not)
both resolve with no registry at all - the former through
Statifier.Interpreter.deliver_internal/5 (ADR-0039), the latter onto
this session's own inbox. Every other #_scxml_<sessionid> resolves
through Registry.lookup(Statifier.Registry, sid) (ADR-0027 decision 2):
a hit casts the event onto that session's inbox via send_event/2; an
empty lookup - the id never existed, named a bare unregistered session,
or named a session that has since died - takes C.1's mandated
error.communication path on the sending session's own internal queue,
through the private communication_error/4 resolver. #_parent/_parent
(6.4.4/C.1's spelling disagreement, decision 8) resolves through
state.invoked_by: a live invocation's child stamps invokeid (5.10.1)
and delivers straight to the parent's external queue; a session that was
never invoked has no parent and takes the same communication_error/4
path as any other unreachable route. #_<invokeid> resolves through the
same invocation table this module holds (Statifier.Session.Invocations,
Invocations.fetch/2) rather than the registry: a live entry's pid gets
the event delivered to its external queue directly, and an invokeid
naming no live invocation - never one, or since cancelled or exited -
takes the same communication_error/4 path as any other unreachable
route. An
unsupported type or an unparseable target raises error.execution the
same way, at plan time. :cancel_invoke plans {:stop_child, invoke_id}
("Cancelling an invocation" below). A child reaching a top-level final
returns done.invoke.<invokeid> to its parent's external queue the same
direct way ("Starting an invocation's child session" below names the
reciprocal obligation this halt-time delivery completes). See
Statifier.Session.Effects and Statifier.Send.Target.
Autoforward delivery
{:autoforward, %Effect.Autoforward{}} plans {:forward, invoke_id, event} unconditionally (Statifier.Session.Effects) - every external
event the parent removes from its queue, forwarded verbatim to each
autoforwarding invocation, at the point 6.4.2 puts it: inside
apply_invoke_passes/2's own turn, before the next Inbox.next/1.
Performing it looks invoke_id up in state.invocations and
send_event/2s the event unmodified; a miss - the invocation was
cancelled or the child died between the core's pass and this instruction -
is a silent no-op, not an error.
Starting an invocation's child session
{:invoke, %Effect.Invoke{}} with a supported type plans
{:start_child, invoke, effect} (Statifier.Session.Effects). This is
never performed from init/1: an initial configuration's own <invoke>
would otherwise start the child through Statifier.start_session/2 while
this session's own start is still being served by the same
Statifier.SessionSupervisor, and that supervisor process cannot answer
the inner call until it has answered the outer one. Every
planned instruction - this one included, at whatever position
Statifier.Session.Effects.plan/2 gave it - performs from
handle_continue/2, one message-loop turn after init/1 returns.
Performing it resolves invoke.content/invoke.src through Statifier.Invoke.Source
(state.invoke_source, the embedder-supplied resolver from start_link/2
ADR-0038 hands off to), seeds the resolved child Machine.t()'s datamodel
through Statifier.Session.Invocations.seed_datamodel/2 (spec 6.4.3's
name-matched <param>/namelist seeding), and starts it on
Statifier.SessionSupervisor via Statifier.start_session/2 with
invoked_by: {self(), invoke.invoke_id}. Success monitors the child and
records {pid, session_id, monitor_ref, autoforward} in
state.invocations; either a resolve failure or a
Statifier.start_session/2 failure raises error.communication on this
session's own internal queue instead (Decision 4: 3.12.2 names <invoke>
outright as a communication-error source), through the private
invoke_error/4 resolver, and writes no table entry - "terminate the
processing of the element without further action."
A session started with invoked_by: {parent_pid, invoke_id} (an invoked
child) monitors parent_pid in turn: the parent's :DOWN stops this
session (a child whose parent is gone has nobody to report to), and a
monitored child's own :DOWN pops its entry from state.invocations -
both checked ahead of the ordinary subscriber :DOWN clause.
start_link/2's :inherit_observers starts a child with this session's
:trace and subscriber pids at the moment it starts (ADR-0050), rather than
leaving observation to a post-hoc attach, because an attach cannot
substitute here: start_link/2 runs Interpreter.initialize/2 to
quiescence, and the child is started from inside the parent's own invoke
pass, so a subscriber added after the child's pid is knowable has already
missed that child's Trace.EntrySet, Trace.ContentExecuted,
Trace.InvokePass, and Trace.MacrostepStable.
A child is otherwise started with an empty :invoke_handlers registry, so a
nested chart that itself invokes a registered type cannot resolve a handler
the root registered - it plans nothing and parks at that state.
start_link/2's :inherit_invoke_handlers hands the child this session's
own handler map instead, transitively; see that option for why it is an
opt-in rather than the default.
Cancelling an invocation
{:cancel_invoke, %Effect.CancelInvoke{}} - the core's own reaction to a
state exiting while one of its <invoke>s is still live - plans
{:stop_child, invoke_id} unconditionally (Statifier.Session.Effects).
Performing it pops invoke_id's table entry before touching the child,
Process.demonitor/2s the ref with [:flush], and calls this module's own
cancel/1 on the child - never stop/2: 6.4.3 requires the cancelled
session to "exit at the end of the next microstep" having "execute[d] the
<onexit> handlers for all active states", which is Interpreter.cancel/1's
own exit_interpreter/1 walk, and GenServer.stop/2 would skip every one
of them. The child is left to idle :cancelled exactly as any cancelled
session does; this session is no longer monitoring it and no longer holds
its id, so it is silent by construction. A miss - the invocation already
popped by its own :DOWN, or a second cancel of the same id - is a silent
no-op.
Popping the entry first is what makes the drain-time discard below correct: any event this child already delivered is un-keyed the instant the pop happens, not only once the cancelled child actually halts.
The discard of a cancelled invocation's queued events
6.4.3: after cancelling, this session "MUST ignore any events it receives
from that session. In particular it MUST NOT ... insert them into the
external event queue of the invoking session" (the doubled "not" is
verbatim in the REC). handle_continue(:drain, _) applies this at the one
point every queued entry passes through it: an {:invoked_event, invoke_id, _} entry whose invoke_id is no longer a key of
state.invocations is dropped and the drain continues, with no separate
retired-id bookkeeping - the live table is already the predicate (the
invoke child-session plan's own Decision 6), and cancelling removes the
key, so every event from that invocation - queued before the cancel or
arriving after it - is dropped this way.
The predicate reads the entry kind, which send_invoked_event/3 sets
only on the child-to-parent direction, and never event.invokeid. 6.4.2
requires an autoforwarded copy to preserve every 5.10.1 field, so an event
this session forwards to one of its children arrives there still carrying
a sibling invocation's invokeid - an id that names nothing in the
receiving session's own table. Keying the discard on that field would drop
exactly the copy 6.4.2 requires be delivered; keying it on where the entry
came from is also what ADR-0027 already says ("every queued entry
originating from a cancelled invokeid").
Statifier.Session.Inbox still needs no keyed discard of its own: the
predicate is applied to Inbox.next/1's result, not inside the queue,
which is what keeps Inbox ignorant of the invocation table even while
it carries the entry's origin.
terminate/2 cancels every entry still in state.invocations alongside
its existing timer cancellation, so an orderly stop of this session leaves
no orphaned children; the child-side parent monitor above already covers
the disorderly case.
Two snapshot shapes
snapshot/1 returns the whole %MachineState{} - the complete, resumable
position tooling and replay need. status/1 returns a small projection
for a caller polling in a loop, since snapshot/1 copies the entire
compiled machine on every call. For a halted session, the projection's
configuration is read from the retained %Effect.Done{} rather than
%MachineState{}.configuration, which exit_interpreter/1 empties by
construction - mirroring the restore test/support/case.ex performs for
the same reason.
interpret/2 is a public seam, not a test hook
Decided by ADR-0029; see its own @doc for the recording contract it
carries.
Recording taps the input clauses, never the inbox
:record (start_link/2) builds a Statifier.Session.Recording.t() that
each of the five input-handling clauses appends one entry to, before that
entry's effects are ever planned or performed - the same "one recordable
input path" the converging-paths comment above handle_info/2's fired-timer
clause already describes. ADR-0029 named the
four inputs a sound recording needs; ADR-0034 decided replay re-derives core
effects and re-injects interpret/2 batches rather than replaying against a
live session, which is why the tap sits on the input side and not on the
effect stream notify/2 fans out. recording/1 reads the value back; see
its own @doc for the ordering caveat on when it is safe to call.
Summary
Types
One live invocation, as invocations/1 reports it: the author-or-core
invoke_id this session knows the invocation by, the child's own sess_
id, and its pid.
What resolve_resume/2 decided: an ordinary start, or a resumed position.
The small status projection status/1 returns, as the counterpart to
snapshot/1's whole %MachineState{}.
subscribe/3 options. catch_up: true asks for the recording snapshot
alongside the subscription; the default is false.
Functions
Enqueues the cancel marker onto this session's external inbox - a queue
entry, not an out-of-band call, so a cancellation is recorded in the same
ordered queue as every event: one queued behind several events is
processed in that order, not ahead of them. Draining it runs
Statifier.Interpreter.cancel/1 - Appendix D's running = false
followed by exitInterpreter() - and halts this session :cancelled.
Returns a specification to start this module under a supervisor.
The door a non-scxml <invoke> handler's host uses when its externally
run service has finished (ADR-0051 decision 5) - the generalization of
what a child Statifier.Session does for itself through
return_done_event/2 when it halts :done. Constructs
done.invoke.<invoke_id> from donedata (spec 6.4's own shape: the
service's <donedata>, or whatever a process-less host's own equivalent
is - 6.4's MUST here is on the service, not this engine, which only
provides the door and documents what arrives through it), stamps
invokeid, and delivers it exactly as send_invoked_event/3 would -
through the same invocation-tagged entry, subject to the same 6.4.3
drain-time discard if invoke_id is no longer live by the time it is
dequeued (a late arrival for an invocation already cancelled). server
is invoke_id's own owning session - the one whose <invoke> started
it, not a child of it, since a handler-backed invocation has no child
session at all; the built-in scxml handler's own completion
(return_done_event/2) calls this the same way, on its own parent.
done_invocation/3's failing counterpart (ADR-0068): the door a
non-scxml <invoke> handler's host uses when its externally run service
has failed permanently - the host's retry policy is exhausted and no
done.invoke.<invoke_id> will ever follow. Constructs
error.communication.invoke.<invoke_id> and delivers it exactly as
done_invocation/3 delivers its own event: through the same
invocation-tagged entry, subject to the same 6.4.3 drain-time discard if
invoke_id is no longer live when it is dequeued, and popping the
invocation's table entry the same deferred way, because a permanently
failed invocation is over in exactly the sense a completed one is.
The door a host uses when the Event I/O Processor it registered for a
<send> type (ADR-0069) cannot deliver a send while the sender still
exists - failed_invocation/3's shape, for <send> (ADR-0069 decision
5). server is the sending session, send is the
%Statifier.Effect.Send{} or %Statifier.Effect.SendDelayed{} the
processor was handed (Statifier.Send.Processor.deliver/3), and
failure is a keyword list in failed_invocation/3's position. The
library does not read failure: the event carries no payload, so the
process-less write below delivers the same event this door does.
Hands effects - any list of Statifier.Effect.t() values, from any
driver of the pure core - to this session's own effect-interpretation
path: planned through Statifier.Session.Effects.plan and performed
exactly as the effects this session's own drive of the core produces.
ADR-0003's "Embedders can supply their own effect interpreter", read the
other way: an embedder that drives the core itself can still lean on this
session for timer and routing service. It is also what makes
:send_delayed/:cancel testable end to end before any document can
produce them, and it funnels through the same internal cast path
send_event/2 uses, so it opens no side door around the inbox.
This session's live invocations - one entry per <invoke> whose child
session is still running under it, sorted by invoke_id, and [] for a
session with none. The counterpart to status/1 for the invoke tree: an
observer holding a parent can name each child and subscribe/2 to it, or
recurse with invocations/1 again for a grandchild.
The Statifier.Session.Recording.t() this session has captured so far, or
{:error, :not_recording} if it was not started with record: true.
Note the neighbouring atom: subscribe/3 answers the same condition
with {:error, :not_recorded}. The two are one letter apart and are
deliberately not unified here - each reads correctly in its own sentence
("this session is not recording"; "that material was not recorded") - so
match on the one belonging to the function you called.
Recomputes this session's registered _ioprocessors entries, the live
counterpart of Statifier.MachineState.refresh_ioprocessors/1
(ADR-0075's Amendment of 2026-10-02): every registered type whose
processor exports Statifier.Send.Processor.ioprocessors_entry/2 is
asked for its entry again, from the registration this session holds, so
a host whose front rotated a location can tell the chart. The SCXML
entry, an entry from a processor exporting only
Statifier.Send.Processor.ioprocessors_entry/1, and the set of keys
are left as they are. A session that registers nothing answers :ok
and changes nothing, whether it is running, halted or recording: there
is nothing to recompute, so :ok is true of it in every case.
Replaces this session's registration for one registered send type and
recomputes that type's _ioprocessors entry from it, in one call
(ADR-0069's Amendment of 2026-10-09). registration is a
Statifier.Send.Processor module or {module, opts}, as a
:send_types value of start_link/2 is. A host whose front moved a
registration's options while the session runs (a new :base_url for
Statifier.Send.BasicHTTP, or a rotated location) hands the session the
new registration here, and the chart reads the new entry from the next
event it processes.
Delivers event to this session's external inbox (asynchronously - :ok
is returned before the event is necessarily processed). A plain string is
a convenience over Statifier.Event.external/2 carrying no data; a
caller that needs event data builds the %Statifier.Event{} directly.
Queued behind whatever else is already waiting, and processed in that
order.
Delivers event to server's external queue as an entry originating from
server's own invocation invoke_id - the child-to-parent direction, and
the only direction 6.4.3's "MUST ignore any events it receives from that
[cancelled] session" applies to. send_event/2 is what every other caller
wants, autoforwarded copies included; see Statifier.Session.Inbox's
entry typedoc for why the two are distinct entries rather than one entry
read two ways.
This session's sess_ id - datamodel["_sessionid"], held apart for routing.
The whole %Statifier.MachineState{} this session currently holds - a
complete, resumable position (docs/observability.md constraint 1). A
term copy and nothing more: MachineState carries no pid, ref, port, or
fun.
Starts a session over machine (already compiled - Statifier.compile/1),
running Statifier.Interpreter.initialize/2 to quiescence before
returning. A document that reaches a stable configuration, or even
terminates, before any external event is ever sent is corpus-normal; the
session comes up already :running, :done, or :budget_exhausted
accordingly.
A small status projection - session_id, status, configuration (as
string ids), the three step counters, and the queued-event and
pending-timer counts - for a caller polling in a loop, since snapshot/1
copies the entire compiled machine on every call. For a halted session,
configuration is read from the retained %Statifier.Effect.Done{}
rather than the (by-then-empty) %MachineState{}.configuration.
Stops the session. terminate/2 cancels every outstanding delayed-send
timer before the process exits (spec 6.2's discard-on-termination), so
nothing scheduled is ever delivered after this call returns.
Adds pid to this session's monitored subscriber set. Idempotent - a
pid already subscribed is not monitored twice.
Adds pid to this session's monitored subscriber set, optionally handing
back the material the pid needs to reconstruct what it missed (ADR-0049).
Removes pid from this session's subscriber set. A no-op if it was never in it.
Types
@type invocation() :: Statifier.Session.Invocations.public_entry()
One live invocation, as invocations/1 reports it: the author-or-core
invoke_id this session knows the invocation by, the child's own sess_
id, and its pid.
@type resume() :: :fresh | {:resumed, Statifier.MachineState.t()}
What resolve_resume/2 decided: an ordinary start, or a resumed position.
@type server() :: GenServer.server()
A GenServer server reference - a pid, or whatever :name was started with.
@type status() :: %{ session_id: String.t(), status: :running | :done | :cancelled | :budget_exhausted, configuration: MapSet.t(String.t()), macrostep: non_neg_integer(), microstep: non_neg_integer(), round: non_neg_integer(), queued_events: non_neg_integer(), pending_timers: non_neg_integer() }
The small status projection status/1 returns, as the counterpart to
snapshot/1's whole %MachineState{}.
@type subscribe_opts() :: [{:catch_up, boolean()}]
subscribe/3 options. catch_up: true asks for the recording snapshot
alongside the subscription; the default is false.
Functions
@spec cancel(server :: server()) :: :ok
Enqueues the cancel marker onto this session's external inbox - a queue
entry, not an out-of-band call, so a cancellation is recorded in the same
ordered queue as every event: one queued behind several events is
processed in that order, not ahead of them. Draining it runs
Statifier.Interpreter.cancel/1 - Appendix D's running = false
followed by exitInterpreter() - and halts this session :cancelled.
Returns a specification to start this module under a supervisor.
See Supervisor.
The door a non-scxml <invoke> handler's host uses when its externally
run service has finished (ADR-0051 decision 5) - the generalization of
what a child Statifier.Session does for itself through
return_done_event/2 when it halts :done. Constructs
done.invoke.<invoke_id> from donedata (spec 6.4's own shape: the
service's <donedata>, or whatever a process-less host's own equivalent
is - 6.4's MUST here is on the service, not this engine, which only
provides the door and documents what arrives through it), stamps
invokeid, and delivers it exactly as send_invoked_event/3 would -
through the same invocation-tagged entry, subject to the same 6.4.3
drain-time discard if invoke_id is no longer live by the time it is
dequeued (a late arrival for an invocation already cancelled). server
is invoke_id's own owning session - the one whose <invoke> started
it, not a child of it, since a handler-backed invocation has no child
session at all; the built-in scxml handler's own completion
(return_done_event/2) calls this the same way, on its own parent.
invoke_id's table entry is popped once the delivered event has cleared
the drain that decides whether to discard it - not synchronously here,
which would make the very entry this call is reporting on already look
gone to that same drain-time check (handle_info/2's {:pop_invocation, _} clause below carries the full reasoning). A handler-backed
invocation's entry (ADR-0051 decision 6) has no pid for a :DOWN to pop
on its own, so this call is the only place it is ever removed; calling
this for an invocation this session already popped (a prior cancel, or a
second done_invocation/3 call for the same id) is a harmless no-op both
times - the discard drops the event, and the pop finds nothing.
The delivered event inherits invoke_id's own caller_context - ADR-0063's
opaque slot as the invoking event carried it into the <invoke>, kept on
this session's invocation-table entry and copied onto the answer unread
(ADR-0063's 2026-09-02 decision note). There is nothing to pass: the caller
supplies no context and cannot override the invocation's. An invocation
started with no context, or answered on a resumed session whose table was
rebuilt, yields nil.
done_invocation/3's failing counterpart (ADR-0068): the door a
non-scxml <invoke> handler's host uses when its externally run service
has failed permanently - the host's retry policy is exhausted and no
done.invoke.<invoke_id> will ever follow. Constructs
error.communication.invoke.<invoke_id> and delivers it exactly as
done_invocation/3 delivers its own event: through the same
invocation-tagged entry, subject to the same 6.4.3 drain-time discard if
invoke_id is no longer live when it is dequeued, and popping the
invocation's table entry the same deferred way, because a permanently
failed invocation is over in exactly the sense a completed one is.
The name is spec 3.12.1's blessed suffix extension of the
error.communication ADR-0051 decision 1's table already assigns to "a
registered handler fails to reach its service", so a chart transitioning
on error.communication (or on error) catches this by the descriptor
prefix rule with no edit, while a chart naming
error.communication.invoke.<invoke_id> parks that one invocation alone.
failure is read for three optional keys, none of which this library
interprets:
:reason- a host-chosen string naming the failure class, read from a chart as_event.data.reason. Defaults to"unknown".:attempts- how many attempts the host made before giving up, read as_event.data.attempts. Absent readsundefined(ADR-0037), which is distinct from a host that counted zero.:detail- any further host term, read as_event.data.detail. Absent readsundefined.
server is invoke_id's own owning session, on done_invocation/3's
own terms. This is the host's call to make, never a handler callback's:
start/2, cancel/2, and forward/3 are pure, and perform/2 returning
{:error, term()} is a transient signal belonging to whatever retry policy
wraps it - permanent exhaustion is a judgement only that policy can make.
The delivered event inherits invoke_id's caller_context exactly as
done_invocation/3's does, and for the same reason: the two answers are
one vocabulary, and a host that could correlate a completion but not a
failure would have ADR-0068's asymmetry back one layer out.
@spec failed_send( server :: server(), send :: Statifier.Effect.Send.t() | Statifier.Effect.SendDelayed.t(), failure :: keyword() ) :: :ok
The door a host uses when the Event I/O Processor it registered for a
<send> type (ADR-0069) cannot deliver a send while the sender still
exists - failed_invocation/3's shape, for <send> (ADR-0069 decision
5). server is the sending session, send is the
%Statifier.Effect.Send{} or %Statifier.Effect.SendDelayed{} the
processor was handed (Statifier.Send.Processor.deliver/3), and
failure is a keyword list in failed_invocation/3's position. The
library does not read failure: the event carries no payload, so the
process-less write below delivers the same event this door does.
The session writes C.1's error.communication onto its own internal
queue through Statifier.Interpreter.deliver_internal/5, ADR-0039's
single write-back door, with the send's content position as the event's
origin, and runs to quiescence. The event's sendid is the send's
send_id whether or not the author named the send: 5.10.1 sets sendid
on "error events triggered by a failed attempt to send an event" to the
send id of the triggering <send>, as the library's own
error.execution for a refused send does. A chart reads the miss as
<transition event="error.communication" cond="_event.sendid == 'joined'" target="retry"/>This is the host's call, never a processor's pure planning half:
deliver/3 and cancel/2 perform nothing, and perform/2 returning
{:error, term()} is a transient signal for whatever retry policy wraps
it. Only the layer that owns that policy knows a send has missed for
good.
The dead-letter rule. When the sender has reached a final state, was
cancelled, or no longer exists, C.1's queue does not exist, and this
call writes nothing: a finished session ignores it, and a cast to a
process that is gone is dropped. The library absorbs nothing, so the
host MUST record the miss itself as a dead letter keyed by the send's
dedup key (ADR-0054 decision 3's components read off send, with the
host's session scope), with its reason, and never drop it silently. Two
halts finish a sender, :done and :cancelled, and a host learns of
either from the {:halted, :done} or {:halted, :cancelled} a
subscriber receives, or from status/1. A :budget_exhausted halt does
not finish a sender, but since 2.13.0 its failed-send miss is dropped
all the same (below). A processor whose route creates its target on a
miss (get-or-create) has no miss to report.
A budget-halted sender. A sender halted :budget_exhausted is still
running, but it does not step on a miss either: the call writes nothing,
queues nothing and records nothing, no further message reaches a
subscriber, and the miss is the host's dead letter under the rule above.
No session process. Statifier.Interpreter.deliver_internal/5 takes
the %Statifier.MachineState{} this session holds privately, which is
why a live session needs this door. A host driving
Statifier.Interpreter itself holds its own %MachineState{} and makes
the same write directly, which is the whole of what this door does:
{:ok, machine_state, effects} =
Statifier.Interpreter.deliver_internal(
machine_state,
:platform,
"error.communication",
{:content, send.c_index, send.owner},
sendid: send.send_id
){:error, :not_running} from that call is the finished-sender case, and
the dead-letter rule above is then the host's, the same way.
@spec interpret(server :: server(), effects :: [Statifier.Effect.t()]) :: :ok
Hands effects - any list of Statifier.Effect.t() values, from any
driver of the pure core - to this session's own effect-interpretation
path: planned through Statifier.Session.Effects.plan and performed
exactly as the effects this session's own drive of the core produces.
ADR-0003's "Embedders can supply their own effect interpreter", read the
other way: an embedder that drives the core itself can still lean on this
session for timer and routing service. It is also what makes
:send_delayed/:cancel testable end to end before any document can
produce them, and it funnels through the same internal cast path
send_event/2 uses, so it opens no side door around the inbox.
This widens what a recording has to contain (ADR-0029). The
three-input replay tuple - (machine, initial data, external event log) - reconstructs a run only when every effect this session
interpreted came from Statifier.Interpreter.initialize/2 or
handle_event/2. An interpret/2 call hands the session effects that no
such call produced, so replaying a run that used it needs a fourth
input: each interpret/2 batch, recorded at its position in this
session's serialized input order alongside the event log
(docs/observability.md constraint 6). Calling this function does not
void the replay guarantee - it obligates the recording. That is a
statement about the recording's contents, not a leak in the boundary:
the calls are still ordered, still observable, still on the one input
path.
@spec invocations(server :: server()) :: [invocation()]
This session's live invocations - one entry per <invoke> whose child
session is still running under it, sorted by invoke_id, and [] for a
session with none. The counterpart to status/1 for the invoke tree: an
observer holding a parent can name each child and subscribe/2 to it, or
recurse with invocations/1 again for a grandchild.
An entry is present from the moment the child is started until the
invocation is cancelled or the child exits - the same liveness
#_<invokeid> routing is judged against. A caller reading this against a
running session is reading a value that may already have changed; it is a
snapshot, not a subscription.
A child started before this session opted into :inherit_observers (or
one under a session that never did) has its own subscriber set, so
attaching to it here observes it only from the moment of the
subscribe/2. Catch-up does not close that gap for a child: children
are not started with record: true, so subscribe/3 with
catch_up: true on one answers {:error, :not_recorded} - see
start_link/2's :inherit_observers for why that is not equivalent to
inheriting from the start (ADR-0050 decisions 3 and 6).
@spec recording(server :: server()) :: {:ok, Statifier.Session.Recording.t()} | {:error, :not_recording}
The Statifier.Session.Recording.t() this session has captured so far, or
{:error, :not_recording} if it was not started with record: true.
Note the neighbouring atom: subscribe/3 answers the same condition
with {:error, :not_recorded}. The two are one letter apart and are
deliberately not unified here - each reads correctly in its own sentence
("this session is not recording"; "that material was not recorded") - so
match on the one belonging to the function you called.
Call this only after the run has quiesced relative to whatever this caller
is waiting on - a timer firing arrives as a message with no ordering
guarantee against this call, so a recording/1 issued before an
assert_receive on the effect it produced (or a wait_for_status/3-style
poll) can race the entry it is meant to observe.
A caller that also wants to subscribe should use subscribe/3 with
catch_up: true instead: recording/1 followed by subscribe/2 has a
window between the two calls, while subscribe/3 snapshots and adds the
pid atomically, in the same handle_call (ADR-0049).
@spec refresh_ioprocessors(server :: server()) :: :ok | {:error, :not_running | :recorded_session | {:ioprocessors_entry, String.t(), Exception.t()} | term()}
Recomputes this session's registered _ioprocessors entries, the live
counterpart of Statifier.MachineState.refresh_ioprocessors/1
(ADR-0075's Amendment of 2026-10-02): every registered type whose
processor exports Statifier.Send.Processor.ioprocessors_entry/2 is
asked for its entry again, from the registration this session holds, so
a host whose front rotated a location can tell the chart. The SCXML
entry, an entry from a processor exporting only
Statifier.Send.Processor.ioprocessors_entry/1, and the set of keys
are left as they are. A session that registers nothing answers :ok
and changes nothing, whether it is running, halted or recording: there
is nothing to recompute, so :ok is true of it in every case.
For a session that registers something, answers :ok, or, changing
nothing:
{:error, reason}- the first{:error, reason}a registered processor'sStatifier.Send.Processor.check_registration/2answered, in type order; forStatifier.Send.BasicHTTPwithout:base_url,{:error, {:missing_option, :base_url}}.{:error, :not_running}- the session has halted (:done,:cancelledor:budget_exhausted), so no chart is left to read the entries.{:error, :recorded_session}- the session was started withrecord: true. A recording (Statifier.Session.Recording) is a persisted, versioned format with no entry for a refresh, so a replay could not reproduce one. A host that records refreshes before it starts the session instead: it re-stamps the position withStatifier.MachineState.put_send_types/2, callsStatifier.MachineState.refresh_ioprocessors/1, and passes the result as:resume, and the recording's anchor then carries the refreshed entries.{:error, {:ioprocessors_entry, type, exception}}- the processor registered astyperaisedexceptionwhile its entry was asked for again (or answered something other than a string-keyed map, raised as anArgumentError). The session keeps running, at the position it held before the call: every entry is computed before any is stored, so none is. An Erlang-level error is answered as the exception struct Elixir normalises it to: an entry that calls:erlang.error(:jam)answers%ErlangError{original: :jam}. Only an exception is answered; a throw or an exit out of an entry is outside the callback's contract and is not. An exit exits the session. A throw is taken byGenServeras the session's return from this call: a thrown term that is not a valid reply exits the session with{:bad_return_value, term}, and a thrown{:reply, reply, new_state}is answered withreplyand replaces the session's state withnew_state.Statifier.MachineState.refresh_ioprocessors/1raises the same exception instead.
This is a call, not a cast: a host learns whether the entries moved.
It and replace_send_type/3 are the only two of this module's calls
that change the session's position. Like every other message, on a running session it is served
only once the session has drained its queue: every event already
queued, and every event those macrosteps queue to the session itself,
is processed first, and a chart reads the refreshed entries from the
next event it processes after that. The call waits GenServer.call/2's default 5 seconds, so
behind a long enough backlog it exits the caller with a timeout. A
timeout does not withdraw the request: the session still serves it
when it reaches it, so a refresh that succeeds is stored though its
answer is lost. An entry that raises an exception is answered, not
raised, so a processor's raise never exits a running session. A
running, unrecorded session resumed from a state built by hand without
an _ioprocessors map is outside this call's input, and the call
exits it (Statifier.MachineState.refresh_ioprocessors/1 says why;
ADR-0075's Note of 2026-10-09 records the boundary and that the exit
stays).
@spec replace_send_type( server :: server(), type :: String.t(), registration :: Statifier.Send.Types.registration() ) :: :ok | {:error, :not_running | :recorded_session | {:unknown_send_type, String.t()} | {:send_types, {:invalid_registration, String.t(), term()}} | {:ioprocessors_entry, String.t(), Exception.t()}}
Replaces this session's registration for one registered send type and
recomputes that type's _ioprocessors entry from it, in one call
(ADR-0069's Amendment of 2026-10-09). registration is a
Statifier.Send.Processor module or {module, opts}, as a
:send_types value of start_link/2 is. A host whose front moved a
registration's options while the session runs (a new :base_url for
Statifier.Send.BasicHTTP, or a rotated location) hands the session the
new registration here, and the chart reads the new entry from the next
event it processes.
The set of registered types does not change: type must be one the
session was started with, and no type is added or dropped. The entry is
recomputed as the session computed it at start: asked of a processor
that exports Statifier.Send.Processor.ioprocessors_entry/2 with the
type, the session id and the new registration's options, else the
processor's Statifier.Send.Processor.ioprocessors_entry/1 value. The
SCXML entry and every other type's entry are left as they are; a host
that also wants those asked again calls refresh_ioprocessors/1. Every
later <send> of type is delivered through the new registration, and a
child this session starts afterwards under :inherit_send_types is
started with it. A child already running keeps the registration it
started with.
Only a key _ioprocessors already has is rewritten. A session whose
_ioprocessors holds no key for type (one resumed with type among
its :send_types from a position whose datamodel was written before
type was registered) answers :ok and stores the new registration,
so later <send>s of type go through it, but no entry is added: the
chart still reads no _ioprocessors entry for type.
A <cancel> reaches the processor holding a delayed send through the
registration the session holds when the cancel runs, not the one the
send was handed to: a delayed send of type handed over before this call
is cancelled through the new registration's
Statifier.Send.Processor.cancel/2, with the new options. A host that
replaces the module, not only the options, cancels the old processor's
held sends itself, or has the new module cancel them.
Answers :ok, or, changing nothing (the registration, the entries and
the position stay as they were):
{:error, :not_running}- the session has halted (:done,:cancelledor:budget_exhausted), asrefresh_ioprocessors/1answers it.{:error, :recorded_session}- the session was started withrecord: true, asrefresh_ioprocessors/1answers it: a recording has no entry for a registration that moved.{:error, {:unknown_send_type, type}}- the session does not registertype, including a session that registers nothing.{:error, {:send_types, {:invalid_registration, type, reason}}}- the new registration's processor exportsStatifier.Send.Processor.check_registration/2and answered{:error, reason}, spelled asstart_link/2spells the same refusal at a fresh start. This differs fromrefresh_ioprocessors/1, which answers a processor's refusal as the bare{:error, reason}. A check that raises, throws, exits or answers outside its contract refuses nothing, as at a fresh start.{:error, {:ioprocessors_entry, type, exception}}- the new registration's processor raisedexceptionwhile its entry was computed (or answered something other than a string-keyed map, raised as anArgumentError), asrefresh_ioprocessors/1answers it. The session keeps running at the position it held before the call. Only an exception is answered: a throw or an exit out of the processor'sStatifier.Send.Processor.ioprocessors_entry/2(orStatifier.Send.Processor.ioprocessors_entry/1) is not, and does whatrefresh_ioprocessors/1says it does: an exit exits the session, and a throw is taken byGenServeras the session's return from this call.
The checks run in that order. A type that is not a string, or a
registration that is neither an atom nor a two-element tuple of an
atom and a list, is outside the call's input and raises
FunctionClauseError in the caller, before the session is asked. The
guard checks that shape only, as start_link/2 accepts the same shapes:
an atom that names no module is accepted and stored, with an empty
_ioprocessors entry, and the next <send> of the type then exits the
session.
Like refresh_ioprocessors/1, this is a call served once the session has
drained its queue, and it waits GenServer.call/2's default 5 seconds:
a timeout does not withdraw the request, so a replacement that succeeds
is stored though its answer is lost. A running, unrecorded session
resumed from a state built by hand without an _ioprocessors map is
outside this call's input, and the call exits it, as
refresh_ioprocessors/1 does.
@spec send_event(server :: server(), event :: Statifier.Event.t() | String.t()) :: :ok
Delivers event to this session's external inbox (asynchronously - :ok
is returned before the event is necessarily processed). A plain string is
a convenience over Statifier.Event.external/2 carrying no data; a
caller that needs event data builds the %Statifier.Event{} directly.
Queued behind whatever else is already waiting, and processed in that
order.
@spec send_invoked_event( server :: server(), invoke_id :: String.t(), event :: Statifier.Event.t() ) :: :ok
Delivers event to server's external queue as an entry originating from
server's own invocation invoke_id - the child-to-parent direction, and
the only direction 6.4.3's "MUST ignore any events it receives from that
[cancelled] session" applies to. send_event/2 is what every other caller
wants, autoforwarded copies included; see Statifier.Session.Inbox's
entry typedoc for why the two are distinct entries rather than one entry
read two ways.
This session's sess_ id - datamodel["_sessionid"], held apart for routing.
@spec snapshot(server :: server()) :: Statifier.MachineState.t()
The whole %Statifier.MachineState{} this session currently holds - a
complete, resumable position (docs/observability.md constraint 1). A
term copy and nothing more: MachineState carries no pid, ref, port, or
fun.
@spec start_link(machine :: Statifier.Machine.t(), opts :: keyword()) :: GenServer.on_start()
Starts a session over machine (already compiled - Statifier.compile/1),
running Statifier.Interpreter.initialize/2 to quiescence before
returning. A document that reaches a stable configuration, or even
terminates, before any external event is ever sent is corpus-normal; the
session comes up already :running, :done, or :budget_exhausted
accordingly.
opts:
:name- passed toGenServer.start_link/3unchanged, so{:via, Registry, _}works today with no code here.:session_id,:trace,:datamodel,:max_macrostep_rounds-Statifier.MachineState.new/2's own options, passed straight through. The session never generates thesess_id itself (ADR-0008); it reads it back offmachine_state.datamodel["_sessionid"]oncenew/2has written it, since there is nosession_idfield on%MachineState{}.:subscribers- pids to monitor and forward the effect stream to from the start (default[]);subscribe/2adds more afterward.:record- whentrue(defaultfalse), builds aStatifier.Session.Recording.t()that captures every delivered event, timer firing, cancel marker, andinterpret/2batch this session handles, in input order (ADR-0029). Read it back withrecording/1.:invoke_source- a(src :: String.t() -> {:ok, Machine.t()} | {:error, term()})function this session hands toStatifier.Invoke.Source.resolve/2for every<invoke src="...">it starts (ADR-0038).nil(the default) leaves everysrc-only invocation unresolved, perStatifier.Invoke.Source's own contract.:invoke_handlers- a%{type_string => module}map ofStatifier.Invoke.Handler-implementing modules this session dispatches a registered<invoke type="...">to (ADR-0051). Default%{}, which registers no type beyond the built-inscxml/bare-URI setStatifier.Invoke.Handler.Scxmlalready serves as the default handler for. The%MachineState{}invoke_typessnapshot this session's core is stamped with is derived from this same map's keys, so the registered-type set and the dispatch map cannot diverge (decision 3).:invoked_by-{parent_pid, invoke_id}, set byStatifier.Sessionitself when it starts a child for an<invoke>(never set by an ordinary caller). Makes this session monitorparent_pid, so a dead parent stops it in turn ("Starting an invocation's child session" above).:inherit_observers- whentrue, every child session this session starts for an<invoke>is started with this session's:tracesetting, this session's subscriber pids as of the moment the child starts, andinherit_observers: trueof its own, so one opt-in at the root traces the whole invoke tree (ADR-0050). Defaultfalse, which starts children exactly as before. Each inherited subscriber receives the child's messages under the child's ownsession_idin the{:statifier, session_id, message}envelope, so a mixed stream demultiplexes on that field;{:halted, _}is still end-of-stream per session id (ADR-0044 decision 2), not for the mailbox as a whole. It is a snapshot:subscribe/2andunsubscribe/2after a child has started do not reach that child, andinvocations/1plussubscribe/2on the child is how an already-running child is attached to.:inherit_invoke_handlers- whentrue, every child session this session starts for an<invoke>is started with this session's:invoke_handlersmap andinherit_invoke_handlers: trueof its own, so one opt-in at the root registers the same handler palette down the whole invoke tree. Defaultfalse, which starts children with an empty registry exactly as before - and is why a nested chart whose own<invoke type="myapp:authorize">needs a registered handler raiseserror.executionthere unless this is set. Inheritance is a start-time hand-off, not a shared registry: the child's dispatch map and its%MachineState{}invoke_typesstamp are fixed at the child's own boot from the map it was handed, so ADR-0051 decision 2's per-session cadence is unchanged and two roots with different palettes still hand their own subtrees their own. Handlers descend independently of:invoke_source, which ADR-0038 leaves to its own option, and independently of:inherit_observers, which is an observation knob.:send_types- a%{type_string => registration}map of the Event I/O Processor types this session registers for<send type="...">(ADR-0069 decision 2), each registration aStatifier.Send.Processormodule or{module, opts}(ADR-0075 decision 8, point b; seeStatifier.Send.Processor's "Registration options"). Default%{}, which registers nothing: only the built-in types (the attribute absent,"scxml", and the SCXML Event I/O Processor URI) are supported, exactly as before. The%MachineState{}send_typessnapshot this session's core is stamped with, at both a fresh start and a resume, is derived from this same map's keys byStatifier.Send.Types.from_send_types/1. The core then accepts a<send>of a registered type without reading itstarget, and refuses any other non-built-in type witherror.execution, as before. A map naming a built-in spelling is refused before the session boots, with{:error, {:send_types, {:built_in_types, types}}}, so a built-in send can never be redirected to a host processor. At a fresh start, a registration whose processor exports the optionalStatifier.Send.Processor.check_registration/2and rejects it is refused by this function, before any session process is spawned and so with no crash report, with{:error, {:send_types, {:invalid_registration, type, reason}}}for the first such type in the order of the type strings; a resume does not ask the processor, and a map naming a built-in spelling gets the built-in refusal above.:inherit_send_types- whentrue, every child session this session starts for an<invoke>is started with this session's:send_typesmap andinherit_send_types: trueof its own. Defaultfalse, which starts children registering no send type. The<send>counterpart of:inherit_invoke_handlers, on the same start-time terms.:function_providers- a list ofPredicator.FunctionProvidermodules whose functions everycondandexprof this session's chart may call besideIn/1and the builtins (ADR-0078). Defaultnil, which registers none. The session stamps them onto its%MachineState{}at a fresh start throughStatifier.MachineState.new/2and at a resume throughStatifier.MachineState.put_function_providers/2, since a persisted position carries none, andrecord: truerecords the list soStatifier.Replayevaluates with the same functions. A list that does not resolve, or that names a functionIn/1or a builtin answers to, is refused before the session boots with{:error, {:function_providers, reason}}(Statifier.Evaluator.Functions.from_providers/1names eachreason). Each function must be pure: it reads its arguments and the context'shostterm only, and does no I/O and reads no clock, so a replay or a resume evaluates it to the same value. A child session started for an<invoke>does not inherit the list.:resume- boots this session at a persisted position (ADR-0060) instead of runningStatifier.Interpreter.initialize/2. Accepts either aStatifier.Position.to_binary/1blob (decoded viaStatifier.Position.from_binary/2againstmachine, which inherits the full ADR-0052 identity gate) or an already-decoded%Statifier.MachineState{}(theStatifier.Position.import/2migration path - checked instead againstmachine's own identity viaStatifier.Machine.Identity.matches?/2, one identity rule for both shapes).machinestays the positional%Machine{}either way; the position's ownmachinefield is rebound to it, so one compiledMachineterm is shared by the resumed state.A resumed session comes up with
machine_state's configuration, datamodel, history values,entered_states,states_to_invoke,active_invocations, and all six counters exactly as persisted - noInterpreter.initialize/2call, no re-entry of the chart's initial states, no<script>or<onentry>block run a second time.:session_idresolves to the caller's own option if supplied, otherwise tomachine_state.datamodel["_sessionid"]; supplying it explicitly rewritesdatamodel["_sessionid"]to agree, so thesession_id == datamodel["_sessionid"]invariant always holds._ioprocessorsis not rewritten: an entry built from the session id, such as a Basic HTTP location, still names the position's id until the host callsrefresh_ioprocessors/1on the running session (a session started withrecord: trueanswers that call with{:error, :recorded_session}).record: trueon a resumed session anchors the newStatifier.Session.Recording.t()at the resumed position rather than at the chart's initial configuration (ADR-0060 decision 6), so catch-up (subscribe/3withcatch_up: true) andStatifier.Replaystill reproduce exactly the notified prefix.What a resume does not restore, each for a reason ADR-0060 decision 7 records: in-flight delayed-send timers (no scheduling deadline is ever stored - a resumed session starts with
Statifier.Session.Timers.new()and an emptytimer_refsmap; a durable host re-arms them itself from theSendDelayed/Canceleffect vocabulary, ADR-0054/0055/0059); live invoked children (pids, monitor refs, and child session ids are process-local and were never part of a position -active_invocationscarries forward verbatim as the record of what was invoked,Statifier.Session.Invocationsstarts empty, and re-establishing the processes behind those ids is the host's job through the invoke handler registry, ADR-0051); and the external inbox (Statifier.Session.Inboxlives outside%MachineState{}by ADR-0002's core/session split - anything queued but not yet dequeued at persist time is lost with the process that held it).Refuses with
{:error, {:resume, reason}}rather than booting a silently-wrong session, forreasonin:{:conflicting_options, opts}-:resumewas passed alongside:trace,:datamodel, or:max_macrostep_rounds(Statifier.MachineState.new/2's own options, not read on this path) or:invoked_by(a child session is always library-started, never resumed).:not_a_statifier_blob,{:unsupported_format_version, v},{:identity_mismatch, expected, actual}, or:unidentified_chartStatifier.Position.from_binary/2's own errors (blob form), or the equivalent checks againstmachine_state.machine's identity (struct form).
:position_not_quiescent- the position's internal event queue is non-empty (Statifier.MachineState.internal_queue_empty?/1); booting mid-macrostep would produce effects with no ADR-0048 input boundary behind them. A host drains to quiescence before persisting, the same instructionStatifier.Position.export/1already gives.:position_not_running- the position hasrunning: false(status: :done); booting aGenServerthat is already terminated, has notified nobody, and hashalted: nilis the surprising outcome. A host that wants to inspect a finished position usesStatifier.Position.from_binary/2andStatifier.active_leaf_states/1directly, no session required.
See ADR-0060 for the full decision record.
A small status projection - session_id, status, configuration (as
string ids), the three step counters, and the queued-event and
pending-timer counts - for a caller polling in a loop, since snapshot/1
copies the entire compiled machine on every call. For a halted session,
configuration is read from the retained %Statifier.Effect.Done{}
rather than the (by-then-empty) %MachineState{}.configuration.
Stops the session. terminate/2 cancels every outstanding delayed-send
timer before the process exits (spec 6.2's discard-on-termination), so
nothing scheduled is ever delivered after this call returns.
Adds pid to this session's monitored subscriber set. Idempotent - a
pid already subscribed is not monitored twice.
@spec subscribe(server :: server(), pid :: pid(), opts :: subscribe_opts()) :: :ok | {:ok, Statifier.Session.Recording.t()} | {:error, :not_recorded}
Adds pid to this session's monitored subscriber set, optionally handing
back the material the pid needs to reconstruct what it missed (ADR-0049).
With catch_up: false (the default) this is subscribe/2: :ok, delivery
from now on.
With catch_up: true on a session started with record: true, returns
{:ok, recording} - the session's current
Statifier.Session.Recording.t(), snapshotted in the same handle_call
that adds pid. The missed prefix is
Statifier.Replay.run(recording)'s result.stream, computed by the
caller; the suffix is everything pid receives from now on. There is no
overlap and no gap between them, and no dedup key is needed - see the
moduledoc's "One subscriber stream" section for why, and for the
prefix ++ suffix consumption recipe.
With catch_up: true on a session started without record: true,
returns {:error, :not_recorded} and does not add pid - there is
nothing to re-derive from, and this record adds no second retention
mechanism (ADR-0049 decision 2). A caller for whom live-only delivery is
acceptable falls back to subscribe/2. Note the neighbouring atom:
recording/1 answers the same condition with {:error, :not_recording},
one letter apart and deliberately not unified - match on the one belonging
to the function you called.
Removes pid from this session's subscriber set. A no-op if it was never in it.