# `Statifier.Session`
[🔗](https://github.com/riddler/statifier-ex/blob/v2.0.0/lib/statifier/session.ex#L1)

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

```elixir
{: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/2`s 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/2`s 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.

# `invocation`

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

# `resume`

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

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

# `server`

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

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

# `status`

```elixir
@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`

```elixir
@type subscribe_opts() :: [{:catch_up, boolean()}]
```

`subscribe/3` options. `catch_up: true` asks for the recording snapshot
alongside the subscription; the default is `false`.

# `cancel`

```elixir
@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`

Returns a specification to start this module under a supervisor.

See `Supervisor`.

# `done_invocation`

```elixir
@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`

```elixir
@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`

```elixir
@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`

```elixir
@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`

```elixir
@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`

```elixir
@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`

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

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

# `snapshot`

```elixir
@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`

```elixir
@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`
        - `Statifier.Position.from_binary/2`'s own errors (blob form), or
        the equivalent checks against `machine_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 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`

```elixir
@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`

```elixir
@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`

```elixir
@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`

```elixir
@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`

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

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

---

*Consult [api-reference.md](api-reference.md) for complete listing*
