Statifier.Session (Statifier v2.0.0)

Copy Markdown View Source

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.

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. One asymmetry: because the interpreter has not exited under :budget_exhausted, its already-scheduled delayed sends stay armed and still deliver; they are discarded only when the eventual cancel/1 halts it :cancelled.

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 an interpret/2 caller) hands this session, trace effects included, in non-decreasing (macrostep, round) order - the same order Statifier.Replay produces 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, not one a subscriber can re-derive from the structs: round is carried only by the Statifier.Effect.Trace.* payloads and by Statifier.Effect.BudgetExhausted today, so a mixed stream cannot be sorted back into this order after the fact (ADR-0044 decision 4 leaves stamping round onto the rest as follow-on work). Take the order as it arrives.

    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 with Trace.Done instead, either after its final Trace.MacrostepStable or - when the halting drive is the only one

    • with no Trace.MacrostepStable of its own at all.
  • {:unroutable, effect} - reserved for an effect this session cannot route; every :send/:send_delayed/:invoke/:cancel_invoke/ :autoforward effect now routes (see Statifier.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).

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 suffix

ADR-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 = false followed by exitInterpreter() - 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.

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.

A GenServer server reference - a pid, or whatever :name was started with.

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.

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/1 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.

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

invocation()

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.

resume()

@type resume() :: :fresh | {:resumed, Statifier.MachineState.t()}

What resolve_resume/2 decided: an ordinary start, or a resumed position.

server()

@type server() :: GenServer.server()

A GenServer server reference - a pid, or whatever :name was started with.

status()

@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{}.

subscribe_opts()

@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

cancel(server)

@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.

child_spec(init_arg)

Returns a specification to start this module under a supervisor.

See Supervisor.

done_invocation(server, invoke_id, donedata \\ nil)

@spec done_invocation(server :: server(), invoke_id :: String.t(), donedata :: term()) ::
  :ok

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.

interpret(server, effects)

@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/1 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.

invocations(server)

@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).

recording(server)

@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).

send_event(server, event)

@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.

send_invoked_event(server, invoke_id, event)

@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.

session_id(server)

@spec session_id(server :: server()) :: String.t()

This session's sess_ id - datamodel["_sessionid"], held apart for routing.

snapshot(server)

@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.

start_link(machine, opts \\ [])

@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 to GenServer.start_link/3 unchanged, 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 the sess_ id itself (ADR-0008); it reads it back off machine_state.datamodel["_sessionid"] once new/2 has written it, since there is no session_id field on %MachineState{}.

  • :subscribers - pids to monitor and forward the effect stream to from the start (default []); subscribe/2 adds more afterward.

  • :record - when true (default false), builds a Statifier.Session.Recording.t() that captures every delivered event, timer firing, cancel marker, and interpret/2 batch this session handles, in input order (ADR-0029). Read it back with recording/1.

  • :invoke_source - a (src :: String.t() -> {:ok, Machine.t()} | {:error, term()}) function this session hands to Statifier.Invoke.Source.resolve/2 for every <invoke src="..."> it starts (ADR-0038). nil (the default) leaves every src-only invocation unresolved, per Statifier.Invoke.Source's own contract.

  • :invoke_handlers - a %{type_string => module} map of Statifier.Invoke.Handler-implementing modules this session dispatches a registered <invoke type="..."> to (ADR-0051). Default %{}, which registers no type beyond the built-in scxml/bare-URI set Statifier.Invoke.Handler.Scxml already serves as the default handler for. The %MachineState{} invoke_types snapshot 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 by Statifier.Session itself when it starts a child for an <invoke> (never set by an ordinary caller). Makes this session monitor parent_pid, so a dead parent stops it in turn ("Starting an invocation's child session" above).

  • :inherit_observers - when true, every child session this session starts for an <invoke> is started with this session's :trace setting, this session's subscriber pids as of the moment the child starts, and inherit_observers: true of its own, so one opt-in at the root traces the whole invoke tree (ADR-0050). Default false, which starts children exactly as before. Each inherited subscriber receives the child's messages under the child's own session_id in 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/2 and unsubscribe/2 after a child has started do not reach that child, and invocations/1 plus subscribe/2 on the child is how an already-running child is attached to.

  • :resume - boots this session at a persisted position (ADR-0060) instead of running Statifier.Interpreter.initialize/2. Accepts either a Statifier.Position.to_binary/1 blob (decoded via Statifier.Position.from_binary/2 against machine, which inherits the full ADR-0052 identity gate) or an already-decoded %Statifier.MachineState{} (the Statifier.Position.import/2 migration path - checked instead against machine's own identity via Statifier.Machine.Identity.matches?/2, one identity rule for both shapes). machine stays the positional %Machine{} either way; the position's own machine field is rebound to it, so one compiled Machine term 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 - no Interpreter.initialize/2 call, no re-entry of the chart's initial states, no <script> or <onentry> block run a second time. :session_id resolves to the caller's own option if supplied, otherwise to machine_state.datamodel["_sessionid"]; supplying it explicitly rewrites datamodel["_sessionid"] to agree, so the session_id == datamodel["_sessionid"] invariant always holds. record: true on a resumed session anchors the new Statifier.Session.Recording.t() at the resumed position rather than at the chart's initial configuration (ADR-0060 decision 6), so catch-up (subscribe/3 with catch_up: true) and Statifier.Replay still 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 empty timer_refs map; a durable host re-arms them itself from the SendDelayed/Cancel effect 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_invocations carries forward verbatim as the record of what was invoked, Statifier.Session.Invocations starts 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.Inbox lives 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, for reason in:

    • {:conflicting_options, opts} - :resume was 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_chart
    • :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 instruction Statifier.Position.export/1 already gives.
    • :position_not_running - the position has running: false (status: :done); booting a GenServer that is already terminated, has notified nobody, and has halted: nil is the surprising outcome. A host that wants to inspect a finished position uses Statifier.Position.from_binary/2 and Statifier.active_leaf_states/1 directly, no session required.

    See ADR-0060 for the full decision record.

status(server)

@spec status(server :: server()) :: status()

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.

stop(server, reason \\ :normal)

@spec stop(server :: server(), reason :: term()) :: :ok

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.

subscribe(server, pid)

@spec subscribe(server :: server(), pid :: pid()) :: :ok

Adds pid to this session's monitored subscriber set. Idempotent - a pid already subscribed is not monitored twice.

subscribe(server, pid, opts)

@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.

unsubscribe(server, pid)

@spec unsubscribe(server :: server(), pid :: pid()) :: :ok

Removes pid from this session's subscriber set. A no-op if it was never in it.