Status: accepted (2026-08-14) - amended 2026-08-17 (st-hgyu: the test suite places one run-scoped runtime in test_helper.exs; start_supervised! could not be shared by async corpus files)
Context
st-cmq.4 shipped Statifier.Session with start_link/2 and a generated
child_spec/1, restart: :transient written explicitly, no application
callback, no supervision tree, and no registry. That was Decision 1 of its
plan (docs/plans/260814-st-cmq.4-session-genserver-effect-interpreter.md),
made deliberately for a bead whose only caller was its own test suite, with
this record's bead named as the revisit trigger. The trigger has fired: the
next two beads both need process infrastructure that does not exist, and
whichever was planned first would otherwise invent it as a side effect.
st-cmq.5 must resolve
#_scxml_<sessionid>send targets "through the registry, not raw pids". Spec 6.2 (The SCXML Event I/O Processor's target rules, restated in C.1) is precise about both the routing and the failure mode:If the target is the special term '#_scxml_sessionid', where sessionid is the id of an SCXML session that is accessible to the Processor, the Processor MUST add the event to the external queue of that session.
If the sending SCXML session specifies a session that does not exist or is inaccessible, the SCXML Processor MUST place the error error.communication on the internal event queue of the sending session.
So an id that names nothing live is an event on the sender, never a crash. C.1 also leaves this platform room to stand in:
The set of SCXML sessions that are accessible to a given SCXML Processor is platform-dependent.
st-cmq.7 must start child sessions "under the parent supervision umbrella" and, on cancellation, stop the child and discard its queued events. Spec 6.4:
If the invoking session takes a transition out of the state containing the <invoke> before it receives the 'done.invoke.id' event, the SCXML Processor MUST automatically cancel the invoked component and stop its processing. The cancel operation MUST act as if it were the final <onexit> handler in the invoking state.
Once it cancels the invoked session, the Processor MUST ignore any events it receives from that session. In particular it MUST NOT not insert them into the external event queue of the invoking session.
Appendix D reaches the same cancellation from
exitStatesandexitInterpreter, each of which runscancelInvoke(inv)for the exiting state's invocations after its<onexit>handlers.
The constraint that shapes the cost: Mix.Statifier.AdrGuard exempts
exactly one path from the ADR-0003 side-effect ban -
@effect_interpreter_paths ["lib/statifier/session.ex"], with the comment
"Excluding it is the design, not a hole in the check". Any new supervisor or
application module is a new exempt path, which amends the guard, which is a
gate-relevant change and therefore ADR-0011 ledger territory
(docs/quality-gate-changes.md).
Decision
The library ships no application callback. It ships one embedder-placed
supervisor, Statifier.Supervisor, holding a named Registry and a flat
DynamicSupervisor for sessions. Parent/child lifetime is an ownership
protocol in the session, not supervision-tree nesting. Sessions under the
supervisor are restart: :temporary. Four numbered decisions:
No
mod:inmix.exs; the embedder placesStatifier.Supervisor. A library that starts processes on load taxes every host that only ever compiles a document, and ADR-0003's consequence that "Embedders can supply their own effect interpreter" already commits the lifecycle to the embedder's side of the line.Statifier.Supervisoris a module-based supervisor whose children are, in order,{Registry, keys: :unique, name: Statifier.Registry}and{DynamicSupervisor, name: Statifier.SessionSupervisor}, under:rest_for_one- a registry crash loses every registration it held, so the sessions behind it restart-fresh rather than continuing as unreachable orphans (and per decision 4 they do not come back as amnesiacs; the embedder observes the loss through monitors). One default-named instance; multiple named runtimes are mechanism with no caller and stay out, per the same standing rule that keptstates_to_invokeoffMachineStateuntil st-cmq.6. The test suite is itself an embedder under this decision: it places one runtime for the whole run intest/test_helper.exs, beforeExUnit.start/1. (Amended 2026-08-17, st-hgyu: this sentence originally read "Testsstart_supervised!the same supervisor." st-cmq.9's corpus harness made that mechanism impossible to keep:start_supervised!binds the runtime's lifetime to one test process, the children are fixed module-qualified names so only one instance can exist per node, and the generated corpus isasync: trueunconditionally, so no two corpus files could share a runtime placed that way. The principle is untouched - the embedder places the runtime, the library never does, and nothing inlib/starts a process; only the stated test mechanism moved.)The registry is
Statifier.Registry: a named, library-ownedRegistrywith unique keys, keyed by the session id string. Registration happens insideSession.init/1, because that is the only place it can: thesess_UXID is generated byMachineState.new/2during init, so no{:via, ...}tuple can name the session before it starts. Sessions started through the runtime (DynamicSupervisor.start_childonStatifier.SessionSupervisor, wrapped by a public helper st-cmq.5 names) register; a bareSession.start_link/2stays exactly as st-cmq.4 shipped it - legal, supervised by whoever placed it, and unregistered, which C.1's "accessible to a given SCXML Processor is platform-dependent" sanctions outright: an unregistered session is an inaccessible one. Resolution of#_scxml_<sessionid>isRegistry.lookup(Statifier.Registry, sessionid); an empty result - whether the id never existed, named a bare session, or named a session that died (Registry drops entries on death) - takes 6.2's mandated path:error.communicationon the sending session's internal queue, never a crash and never a raise. The:name/{:via, Registry, _}seam Decision 1 of the st-cmq.4 plan left open remains open for embedder-owned registries; the library's own routing consults onlyStatifier.Registry.Parents own their children through monitors and an invocation table, on the flat
Statifier.SessionSupervisor- no links, no supervisor per parent. A child session starts on the same DynamicSupervisor as its parent; the "umbrella" st-cmq.7 asks for is satisfied by ownership, not nesting. The parent session holdsinvokeid -> {child_session_id, pid, monitor_ref}and monitors each child; the child monitors its parent and stops on the parent's:DOWN, which is what makes a brutally-killed parent (whereterminate/2never runs) still take its children down. Cancellation is an explicit act of the parent's own code - interpreting a cancel-invoke effect, or its own termination - because 6.4 puts it in the parent's execution order ("as if it were the final <onexit> handler in the invoking state"), an ordering a supervisor's shutdown sequence cannot express. Discarding queued events is inbox work, not process work: killing the child does nothing for events it already delivered, so the parent drops, at drain time, every queued entry originating from a cancelled invokeid, per 6.4's "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 candidates rejected:- Links. Symmetric by construction: a child crash would kill the
parent, a coupling no clause of 6.4 asks for, and avoiding it means
trap_exitandhandle_info({:EXIT, ...})bookkeeping that monitors provide without the blast radius. - A supervisor per parent. One extra process per session whose only job is a shutdown ordering the parent must sequence itself anyway (finalize, then cancel, then discard), and which cannot touch the queued-events half of the requirement at all.
- Links. Symmetric by construction: a child crash would kill the
parent, a coupling no clause of 6.4 asks for, and avoiding it means
restart: :temporaryreplacesrestart: :transient. A supervisor restart re-runsstart_link(machine, opts):MachineState.new/2generates a freshsess_UXID, and the configuration, datamodel, external queue, delayed-send timers, subscriber set, registry key, and every parent's invokeid mapping of the old process are all gone. What comes back is not the session that crashed but a new session wearing its supervisor slot - the registry cannot even re-associate it, since its id is new. Restarting is therefore actively wrong, not merely useless, and:transient's one remaining behavior over:temporary(restart on abnormal exit) is exactly the wrong one. A crashed session is observed through monitors and the subscriber stream; recovery that preserves identity is replay - ADR-0003's "(machine, initial data, event log)" tuple - and belongs to the embedder or a later replay bead, not to a restart flag. Theuse GenServer, restart: :transientline insession.exchanges to:temporaryin the first bead that implements this record; the st-cmq.4 plan's stated reason for writing the option explicitly (never:permanent) still stands.
The guard amendment this costs, and the ledger entry it owes. One new
exempt path is expected: lib/statifier/supervisor.ex. Registration,
lookup, monitor, and start_child calls are made from session.ex, which
is already exempt, so the supervisor module should stay the only addition;
a second new path is a smell to be argued, not defaulted. Whether or not
each new line happens to match @effect_call_pattern, the exemption list
is the design statement, and riding a pattern gap instead of amending it
would be the hole the guard's own comment disclaims. The amendment lands in
Mix.Statifier.AdrGuard.@effect_interpreter_paths on the implementing
branch, together with a docs/quality-gate-changes.md entry that needs: a
## <date> - <issue> heading, an Approved-by: line naming the human who
made the call, a - lib/mix/statifier/adr_guard.ex: ... bullet naming the
widened exemption, and a reason - that this record decided the session
runtime's shape, that the exemption widens from one path to the named
session-runtime set, and that it loosens no check, skips no test, and
lowers no threshold. Writing that entry is the human's call at
implementation time, per ADR-0011; this record specifies its contents and
does not write it.
Consequences
- st-cmq.5 plans against a concrete shape: resolve
#_scxml_<sessionid>viaRegistry.lookup(Statifier.Registry, _), empty lookup =>error.communicationon the sender's internal queue;#_parentreaches the parent through the invocation wiring of decision 3. The public start-through-the-runtime helper's name and signature are st-cmq.5's planning detail, not re-argument. - st-cmq.7 plans against decision 3's protocol: children on the flat
Statifier.SessionSupervisor, parent-held invocation table and monitors, child-side parent monitor, cancel sequenced by the parent, queued-event discard by invokeid at the parent's inbox. What error an<invoke>raises when the runtime supervisor is not running is st-cmq.7's detail to settle against spec 6.4's error clauses. - Decision 1 of the st-cmq.4 plan is discharged on its own revisit trigger.
session.exchanges two things when implementation lands: therestart:option and registration ininit/1. Barestart_link/2embedders keep working unchanged, minus the restart-on-crash nobody should have wanted. - Hosts that only compile documents keep paying nothing: no application
callback exists, and nothing starts unless
Statifier.Supervisoris placed. - The implementing branch owes the AdrGuard amendment and the ADR-0011
ledger entry described above,
Approved-bya human. - Open question, deferred with its trigger named: multiple named runtime
instances (a
:nameoption onStatifier.Supervisorfanning out to registry and DynamicSupervisor names) are excluded as mechanism without a caller. The first embedder who needs two isolated session populations in one VM is the trigger, and amending this record there is expected to be additive. #_scxml_<sessionid>resolution has one case that never reaches the registry: a session addressing itself. st-cmq.5 resolvessid == state.session_idto the session's own inbox ahead ofRegistry.lookup/2, so a bare, unregisteredSession.start_link/2session can still<send>to its own id. Decision 2's "an unregistered session is an inaccessible one" is about reachability by other sessions; the sending session is by construction neither nonexistent nor inaccessible to itself.