Status: accepted (2026-08-15) - amended in part by ADR-0047 (2026-08-17:
the rejected "move target routing into the core" alternative is scoped to
liveness; static target/type classification runs in the core) - amended in
part by ADR-0048 (2026-08-18: the liveness foreclosure narrows again - the
core never consults the registry, but it may judge reachability against a
caller-declared route snapshot; deliver_internal/5 stays the single
write-back door for session-detected failures)
Context
Spec 6.2.4 and C.1 both put <send>'s two runtime failures - an unreachable
or nonexistent target, and an unsupported type - on the internal event
queue of the sending session, not the receiving one:
If the SCXML session specified in the target field is unable to accept an event, e.g. due to erroneous configuration information such as an unreachable or otherwise invalid target URI, the Processor MUST place the error error.communication on the internal event queue of the sending session.
If a Processor implementing the SCXML Event I/O Processor receives a
<send>element with atypeattribute value that it does not support ... the Processor MUST place the error error.execution on the internal event queue of the sending session.
Under ADR-0003 the interpreter's core is pure and produces Effect.Send for
the caller to perform; under ADR-0027 the caller that performs it, and the
one place that can see whether a #_scxml_<sessionid> target resolves, is
Statifier.Session - it holds the registry lookup, and only it knows what a
type this deployment does not support means. Both failures are therefore
discovered after the core has already handed the effect off, in
Session's process, not the core's.
Session has no path back into the queue it needs to write to. Its only
inputs into a running %MachineState{} are Statifier.Interpreter.handle_event/2
and Statifier.Interpreter.cancel/1 (lib/statifier/interpreter.ex:430-453
and its neighbor), and handle_event/2 hardcodes from: :external in its
dequeue trace and treats its argument as an Statifier.Event.t() bound for
the external queue - there is no argument that says "this is already an
internal event, skip the external queue and go straight to the round." The
existing internal-queue writers, MachineState.raise_internal/4 and
MachineState.raise_platform/4 (lib/statifier/machine_state.ex:587-636),
are pure functions on %MachineState{} with no caller outside the core
itself - <raise>'s executable content and done.state.* generation. Appendix
D has no equivalent split to begin with: its mainEventLoop never returns,
so a failure discovered while sending is just another loop iteration, not a
boundary a host process has to cross back over.
Decision
One new pure entry point, Statifier.Interpreter.deliver_internal/5,
taking (machine_state, kind, name, origin, opts) with kind :: :internal | :platform, is the sole path Statifier.Session uses to write a
session-detected failure onto %MachineState{}'s internal queue. It
delegates to MachineState.raise_internal/4 or raise_platform/4 by kind -
the same two functions the core's own executable content already uses, so no
third writer is introduced - and then runs main_event_loop/1 to quiescence,
returning exactly handle_event/2's own shape: {:ok, machine_state, effects} | {:error, :not_running}. Session never touches
%MachineState{}'s queues directly; every write to the machine, whether the
event originated outside the session or was discovered by the session itself,
crosses this or handle_event/2.
Two alternatives were considered and rejected:
SessioncallsMachineState.raise_platform/4(orraise_internal/4) itself and re-drivesmain_event_loop/1inline. This works today, but it puts the queue write outside any core entry point: the two raise functions become reachable from two different kinds of caller with no shared chokepoint between them, so a future invariant this project wants to attach to "something was enqueued" (a trace effect, a recording hook, a counter assertion) has nowhere to attach without touching every call site separately. Naming the seam as one function is cheaper now than discovering the missing chokepoint later.- Move target routing into the core, so the core itself decides whether a
target resolves and raises the failure inline during
handle_event/2. This is foreclosed outright: ADR-0003's pure core knows nothing about which sessions are live, and ADR-0027 places the registry - the only source of truth for "does this session id resolve" - on the embedder side of the session boundary. Teaching the core to consult it would make the core aware of live sessions, which both records already forbid.
Consequences
deliver_internal/5is a recordable session-side input under ADR-0029:Statifier.Session.Recordinggains an entry for it, anddocs/observability.mdconstraint 6's four-input replay tuple accounts for it the same way it already accounts forinterpret/2batches - a call the recorder must capture at its position in the session's serialized input order, not something replay can re-derive from the external event log alone, because the failure that triggers it happened inSession, not in the core.- The same seam is the delivery path for
<send target="#_internal">, not only for the two spec-6.2.4 failures: anything the session needs to place directly on the internal queue - self-targeted send included - goes throughdeliver_internal/5withkind: :internal, anddone.state.*-shaped platform deliveries go through it withkind: :platform.:internaland:platformshare one seam rather than each growing its own. - The deviation from Appendix D is mechanical, not semantic, per ADR-0002:
Appendix D's loop never exits mid-send, so nothing in the pseudocode
dictates a re-entry shape, and
deliver_internal/5's definition site gets an inline comment citing this record so the "diff against the pseudocode" habit still finds an explanation instead of a gap. - Whoever implements the target router (this bead, later phases) writes it
as the thing that decides whether to call
deliver_internal/5and with whichname/opts; this record settles only that the write-back, once a failure is decided, has exactly one door.