Statifier.Interpreter (Statifier v2.0.0)

Copy Markdown View Source

Appendix D's outer loop, ported function for function (ADR-0002), with its loop state reified onto %Statifier.MachineState{} per docs/observability.md constraint 1: any %MachineState{} value is a complete, resumable interpreter position, and this module keeps nothing of that position on the call stack.

The map of the loop

Appendix D's interpret/mainEventLoop/microstep/exitInterpreter collapse onto this module's functions:

This moduleAppendix D
microstep/2microstep(enabledTransitions) verbatim
microstep/1mainEventLoop's inner while running and not macrostepDone loop body, hoisted into a value so a paused position is data, not a stack frame
macrostep/1that same inner loop, folded to quiescence
main_event_loop/1the outer-loop iteration(s) one call of initialize/2 or handle_event/2 performs - the invoke pass, its re-entry continue, and the trailing exitInterpreter()
exit_interpreter/1exitInterpreter
initialize/2, handle_event/2interpret's two entry seams

Stepping it

Constraint 1's payoff is that a step debugger is microstep/1 in iex with no support code. Fold to quiescence, then hand it an event and drive the next macrostep one round at a time, inspecting the position between calls:

# in iex, with `machine` already compiled
{machine_state, _effects} = Interpreter.initialize(machine, trace: true)
machine_state = MachineState.begin_macrostep(machine_state)

# one round at a time; a `{:quiescent, _, _}` return ends the macrostep
{machine_state, effects} = Interpreter.microstep(machine_state)
machine_state.configuration
Interpreter.microstep(machine_state)

Every binding above is an ordinary value: keep an earlier machine_state to rewind to it, or round-trip one through :erlang.term_to_binary/1 to resume in another process. macrostep/1 is the same loop run to its fixed point, so stepping and folding are the same code path, not two.

Rehydrating a position

A host driving this module directly - rather than through Statifier.Session - already has everything it needs to resume a saved position; no function in this module exists solely for that purpose (ADR-0060). Use Statifier.Position.to_binary/1 and from_binary/2 instead of the raw :erlang.term_to_binary/1 shown above: the identity check from_binary/2 runs against the supplied Statifier.Machine.t() is the entire point - a version-1 term_to_binary blob decoded against the wrong chart revision would silently rebuild a %MachineState{} that walks a document it was never measured against.

from_binary/2 restores every durable field of the position, but two fields are deliberately per-driver snapshots rather than durable position state (Statifier.Position.import/2's own docs give the same reason) and come back nil: re-stamp both before the first drive.

{:ok, machine_state} = Statifier.Position.from_binary(blob, machine)

machine_state =
  machine_state
  |> Statifier.MachineState.put_routes(routes)
  |> Statifier.MachineState.put_invoke_types(invoke_types)

{:ok, machine_state, effects} = Interpreter.handle_event(machine_state, event)

Then call any advance entry (handle_event/2, deliver_internal/5, cancel/1, microstep/1, macrostep/1) exactly as if the position had never left this process - every one of them takes a MachineState.t() and trusts it structurally, with no state of its own outside that struct.

What a resumed position must not redo is everything initialize/2 already did the one time it ran, before the position was ever saved:

  • MachineState.new/2 - the position already carries a real configuration, datamodel, and counters; calling new/2 again would discard all of it and start over.
  • Datamodel.initialize/1 - the datamodel's <data> elements have already been evaluated once; re-running that pass is <data> initialization happening twice, not resumption.
  • run_global_scripts/2 - a top-level <script> already ran during the original initialize/2; a resumed position's datamodel already carries whatever it wrote.
  • enter_states/2 on the initial transition - the position's configuration already reflects the states that macrostep entered; entering them again duplicates onentry content and history recording.

A resumed position skips all four - there is no initialize/2 call on this path at all, only the re-stamp above followed by the first advance entry.

Counters

Statifier.MachineState's counter contract is the source of truth; restated here only for where this module writes it. microstep's writer (Statifier.MachineState's docs name it) has exactly one call site in this module: the non-empty branch of run_selected/3, the private tail shared by every selection round (eventless, one dequeued internal event, and the external event handle_event/2 selects on). A selection round that finds nothing to run never reaches that call, because no exit or entry happened - the same "no exit or entry happened" rule MachineState's counter contract states for why an empty round does not advance microstep.

macrostep's writer has exactly two call sites in this module: initialize/2 (the initialization macrostep is macrostep 1) and handle_event/2, once per accepted external event (macrostep 2 onward). initialize/2 additionally advances the microstep counter once, directly, before its own enter_states/2 call - the pseudocode's enterStates([doc.initial.transition]) is not inside microstep, so the initial entry is microstep 1, not 0, and nothing the initialization macrostep emits is stamped microstep: 0.

An external event is the one case that is. begin_macrostep/1 resets the microstep counter, and handle_event/2 emits Trace.EventDequeued and (via run_selected/3) Trace.TransitionsSelected before the round's first entry has happened, so both carry microstep: 0 - the round has begun but no exit or entry has occurred yet, which is exactly what microstep: 0 means under MachineState's counter contract. The first Trace.ExitSet of that macrostep is microstep 1.

This is also why the trace effects a selection round emits (Trace.EventDequeued, Trace.TransitionsSelected) are always stamped one microstep behind the Trace.ExitSet that follows them when the round is non-empty: both are built from the machine_state selection returned, which is still at the previous microstep's count - the counter only advances afterward, inside run_selected/3, right before microstep/2 runs. The counter cannot be advanced before the selection result is known, because an empty result must not advance it.

round's writer (begin_round/1) has exactly one call site in this module: the head of microstep/1, both clauses included - so the running: false clause counts a round too, and the first round of a macrostep's fold is round 1. handle_event/2's own Trace.EventDequeued and Trace.TransitionsSelected, and everything initialize/2 emits before main_event_loop/1 begins the fold, are stamped round: 0 for the same reason they are stamped microstep: 0: no round of this macrostep's fold has begun yet.

Deviations, with their reasons (ADR-0002)

  • microstep/1 is not a pseudocode function name. It is mainEventLoop's inner loop body, hoisted so a debugger can pause between rounds without support code (constraint 1). See microstep/1's own @doc.
  • Quiescence is a tagged return, not a bare atom. microstep/1 returns {:quiescent, machine_state, effects} so the round that ends a macrostep can carry out both the machine_state its selection returned and that selection's own Trace.TransitionsSelected. Every selection this module makes emits that trace, with no exception for the terminal probe, so docs/observability.md's "includes the empty set" is literally true. See microstep/1's own @doc for why the machine_state half is load-bearing rather than tidiness.
  • The machine_state Selection returns is threaded, never discarded. Both Selection.select_eventless_transitions/1 and Selection.select_transitions/2 return {machine_state, transitions}, and this module continues with the returned machine_state rather than the one it passed in - cond evaluation lives inside Selection, where it reshapes that module's private walk without changing either entry point's signature or anything in this module.
  • The outer while running loop is driven by the caller. ADR-0003: the pure core takes one external event per call, and the session that drives it owns the waiting external events and their queue. main_event_loop/1 is the loop's tail - fold to quiescence, then exit_interpreter/1 when running went false - not the loop itself. See main_event_loop/1's own @doc for the exact port-site comment.
  • The invoke pass runs at the end of every fold, inside main_event_loop/3. run_invoke_pass/1 walks states_to_invoke sorted into entry order via Machine.document_order/2 (the same sort enter_states/2 already performs, and one the pseudocode itself specifies - statesToInvoke.sort(entryOrder) - so this is not a deviation), and within each state walks its compiled invoke list in document order. Each invocation's arguments are resolved against one threaded Evaluator.context/1; any evaluation failure raises error.execution and yields no Effect.Invoke for that invocation, siblings unaffected (ADR-0031), and otherwise the invocation is recorded in machine_state.active_invocations and emits one {:invoke, _} effect. states_to_invoke is cleared once the pass finishes, and one Trace.InvokePass is emitted last, carrying the states walked and the invoke ids started - a phase boundary Appendix D itself names (ADR-0012 item 2's parenthetical is illustrative, not closed) that predates this bead having any <invoke> to trace. When invoking left the internal queue non-empty, Appendix D's continue re-enters the outer loop - a self-call of the private main_event_loop/3 - which is why the round budget has to span it (ADR-0032; see the deviation comment above defp main_event_loop/3).
  • The finalize/autoforward pass runs once per external event, inside handle_event/2, before transition selection. apply_invoke_passes/2 walks the full configuration (ADR-0005), and for each state's compiled invoke list, in document order: when a live invocation's invokeid equals the external event's own invokeid, its <finalize> runs in the pure core (Statifier.Machine.Invoke.finalize's absent/empty/populated split, spec 6.5); when it autoforwards, one {:autoforward, %Effect.Autoforward{}} carries the event on, verbatim, for the session to deliver - not Effect.Send, since 6.4 requires an exact copy of every 5.10.1 field and ADR-0003 keeps effect construction pure. Neither if is inside the other's else: a matching, autoforwarding invocation does both, exactly as Appendix D's own two separate ifs read. The walk short-circuits to a no-op when active_invocations == %{} - but even that no-op still emits one Trace.FinalizeAutoforward with both lists empty, the same "includes the empty set" reasoning Trace.TransitionsSelected already carries into this vocabulary, so the pass's absence is never mistaken for a pass that found nothing to do.
  • returnDoneEvent becomes a returned effect, appended last. exit_interpreter/1 builds {:done, %Effect.Done{}} instead of performing an I/O call (ADR-0003), and appends it after Trace.Done rather than mid-walk - a mechanical reordering, since effects are a returned list and nothing downstream observes the difference in the pseudocode's own terms. See exit_interpreter/1's own @doc.
  • The macrostep fold is bounded. Appendix D's inner loop is unbounded; a pure core has no external entity to cancel a non-terminating macrostep, so the fold spends a round budget and stops with a :budget_exhausted effect on exhaustion (ADR-0019). See the private fold's own comment above defp macrostep/3.
  • The round budget spans an invoke re-entry. continue (Appendix D) is a self-call of main_event_loop/3 in this port, and a self-call that started a fresh budget every time would reopen the same livelock ADR-0019 closed, one loop further out - two states whose <invoke> arguments deterministically error could hand each other control forever. The budget is therefore threaded across every re-entry of one main_event_loop/1 call instead of restarted per fold (ADR-0032). See the deviation comment above defp main_event_loop/3.
  • A round ordinal counts microstep/1 invocations. Appendix D's inner loop carries macrostepDone and no round variable of any kind (ADR-0020); round is a hoisting artifact of microstep/1 itself, like the two counters before it. See the comment above microstep/1's two clauses.

Summary

Functions

mainEventLoop's cancel path (Appendix D) - running = false, then continue, which ends the while running loop and reaches exitInterpreter().

ADR-0039's re-entry seam: the sole path Statifier.Session uses to write a session-detected <send> failure (6.2.4's unsupported/invalid target, 6.2.5's unsupported type) - or a <send target="#_internal"> delivery - onto %MachineState{}'s own internal queue.

exitInterpreter (Appendix D) - every active state exited in exit order, the top-level final's <donedata> collected, then the terminal effects.

mainEventLoop's external-event tail (Appendix D) - the external-event counterpart to internal_round/1's dequeue-and-select, driven by the caller instead of a blocking queue read (ADR-0003, main_event_loop/1's own @doc). Rejects when the machine is not running - Appendix D's own running flag, so the guard reads as the pseudocode's loop condition - otherwise begins a new macrostep, runs the finalize/autoforward pass, selects on event, runs whatever it enables, and folds to quiescence.

interpret's entry seam (Appendix D) - binds opts into a fresh %MachineState{}, enters the top-level initial states, then runs the initialization macrostep to quiescence. Documents that reach a stable configuration or even terminate before any external event is ever sent are corpus-normal (enter_states/2 can already set running: false on a top-level <final> entry, and main_event_loop/1 runs exit_interpreter/1 when it does).

mainEventLoop's inner while running and not macrostepDone loop, folded to quiescence over microstep/1 (Decision 7) - not a pseudocode function name itself; docs/observability.md's vocabulary for that same loop, hoisted so a paused position is a value on %MachineState{} rather than a stack frame (constraint 1). The fold ends one of three ways

mainEventLoop's outer while running loop (Appendix D), one call's worth of iterations (ADR-0032). The loop itself is driven by the caller - one call of initialize/2 or handle_event/2 reaches here having already run its own selection round - so this function is what is left of the pseudocode's loop body after that: fold to quiescence with the private macrostep/3, run the invoke pass, clear states_to_invoke, and continue (a self-call of the private main_event_loop/3) when invoking left the internal queue non-empty; otherwise run exit_interpreter/1 when the fold left running false, or stop and emit the one terminal effect this call produces.

mainEventLoop's inner while running and not macrostepDone loop body, hoisted into a named, resumable round (Decision 1) - not a pseudocode function name itself. One call makes exactly one round of progress, and begins it: both clauses call MachineState.begin_round/1 first, so the returned position's round names which round this one was (ADR-0020).

microstep(enabledTransitions) (Appendix D) - exit the states enabled_transitions leave, run each transition's own content in document order, then enter the states they reach. The three calls run in exactly this order with nothing between them, matching the pseudocode line for line; the effect list is each block's effects concatenated in the same order.

Functions

cancel(machine_state)

@spec cancel(machine_state :: Statifier.MachineState.t()) ::
  {:ok, Statifier.MachineState.t(), [Statifier.Effect.t()]}
  | {:error, :not_running}

mainEventLoop's cancel path (Appendix D) - running = false, then continue, which ends the while running loop and reaches exitInterpreter().

ADR-0002 mechanical deviation: the outer loop is driven by the caller (handle_event/2), so there is no loop here to fall out of; the two steps the pseudocode reaches by falling out are performed directly, in the same order. Nothing about the exit itself changes - the states exited, the <onexit> blocks run, the <donedata> collected, and the {:done, _} effect produced are all exit_interpreter/1's.

Rejects an already-terminated machine_state with {:error, :not_running}, matching handle_event/2: Appendix D would not be inside the loop to check isCancelEvent at all.

deliver_internal(machine_state, kind, name, origin, opts)

@spec deliver_internal(
  machine_state :: Statifier.MachineState.t(),
  kind :: :internal | :platform,
  name :: String.t(),
  origin :: Statifier.Event.Cause.origin(),
  opts :: keyword()
) ::
  {:ok, Statifier.MachineState.t(), [Statifier.Effect.t()]}
  | {:error, :not_running}

ADR-0039's re-entry seam: the sole path Statifier.Session uses to write a session-detected <send> failure (6.2.4's unsupported/invalid target, 6.2.5's unsupported type) - or a <send target="#_internal"> delivery - onto %MachineState{}'s own internal queue.

Appendix D discovers a routing failure inside mainEventLoop and writes the internal queue in place. ADR-0003 puts routing in the effect interpreter, which is outside that loop by construction, so ADR-0039 gives the session one way back in: enqueue on the internal queue exactly as an in-loop raise would, then run to quiescence. No selection or entry/exit procedure changes.

That paragraph is ADR-0002's required citation for a deviation from the Appendix D pseudocode: the reason is mechanical (where effects run), not semantic, and the resulting queue state is what the in-loop write would have produced.

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 internal-queue writer is introduced - then folds main_event_loop/1 to quiescence, returning exactly handle_event/2's own shape.

exit_interpreter(machine_state)

@spec exit_interpreter(machine_state :: Statifier.MachineState.t()) ::
  {Statifier.MachineState.t(), [Statifier.Effect.t()]}

exitInterpreter (Appendix D) - every active state exited in exit order, the top-level final's <donedata> collected, then the terminal effects.

Body, in the pseudocode's own order, with the deviations Decision 10 records:

  1. The configuration is captured before the walk (configuration_at_exit)
    • both Trace.Done.configuration and Effect.Done.configuration document themselves as "the configuration as it stood at exit", which the walk would otherwise leave empty.
  2. states_to_exit = Machine.exit_order/2 over the configuration, and Trace.ExitSet is emitted over it, stamped against pre_exit_state (machine_state as it stood before the termination sweep) - the same phase-boundary row exit_states/2 emits, at the one other place this engine exits states. ADR-0012 item 2 binds the row to the boundaries Appendix D itself names, and exitInterpreter names one: its statesToExit is the same variable exitStates computes. Its configuration field is read from the post-sweep machine_state: the sweep provably empties the configuration, so this is always MapSet.new(), but it is read rather than hardcoded so it cannot drift from the walk. Trace.Done carries the configuration as it stood at exit (non-empty, from configuration_at_exit), but it arrives after the walk and means "the run ended here", so it is not a substitute for a marker that means "these are about to be exited" and its empty Trace.ExitSet.configuration counterpart.
  3. Each state, in exit order, runs its onexit blocks (ExitEntry.run_onexit_blocks/2 - the same per-state body exit_states/2's depart/2 runs), then each of its live invocations is cancelled (ExitEntry.cancel_invocations_for_state/2 - the same shared walk depart/2 runs, so the two exit paths cannot drift), before it leaves the configuration.
  4. No history recording. Appendix D's exitInterpreter has no history-recording loop at all - unlike exitStates, which has two consecutive for s in statesToExit loops for exactly that reason. "Port as written" therefore means recording nothing here; this walk never touches machine_state.history_values.
  5. returnDoneEvent(s.donedata) fires for the one state, if any, that is Machine.final?/2 with parent == 0 - isFinalState(s) and isSCXMLElement(s.parent), the same test ExitEntry.raise_completion_events/2 already makes. Since the root is compound, at most one child is active, so at most one top-level final is ever in the exit set. Its donedata (ExitEntry.donedata/2) becomes both Trace.Done's and Effect.Done's donedata. A failed <content expr> raises error.execution onto the returned machine_state's internal queue - nothing ever dequeues it, since the event loop has already stopped by the time this function runs, but that is Appendix D's own consequence (5.6/5.7's error rule is unqualified, and exitInterpreter runs after the loop) rather than a deviation this port introduces. It is still observable: MachineState.internal_events/1 on the returned terminal state shows it.
  6. The terminal effects are appended last, {:done, %Effect.Done{}} last of all - returnDoneEvent becomes a returned effect rather than an I/O call (ADR-0003), and moving its emission to the end of the list is a mechanical reordering: effects are a returned list, not an I/O call, so nothing the pseudocode's own terms observe changes order. Both Trace.Done and Effect.Done are populated from the same configuration_at_exit and donedata locals, so the trace row and the core effect always agree on the terminal position.

status: :done is set only here, at the very end - the window MachineState's moduledoc describes between running: false (from top-level final entry) and status: :done (once this walk finishes).

handle_event(machine_state, event)

@spec handle_event(
  machine_state :: Statifier.MachineState.t(),
  event :: Statifier.Event.t()
) ::
  {:ok, Statifier.MachineState.t(), [Statifier.Effect.t()]}
  | {:error, :not_running}

mainEventLoop's external-event tail (Appendix D) - the external-event counterpart to internal_round/1's dequeue-and-select, driven by the caller instead of a blocking queue read (ADR-0003, main_event_loop/1's own @doc). Rejects when the machine is not running - Appendix D's own running flag, so the guard reads as the pseudocode's loop condition - otherwise begins a new macrostep, runs the finalize/autoforward pass, selects on event, runs whatever it enables, and folds to quiescence.

initialize(machine, opts \\ [])

@spec initialize(machine :: Statifier.Machine.t(), opts :: keyword()) ::
  {Statifier.MachineState.t(), [Statifier.Effect.t()]}

interpret's entry seam (Appendix D) - binds opts into a fresh %MachineState{}, enters the top-level initial states, then runs the initialization macrostep to quiescence. Documents that reach a stable configuration or even terminate before any external event is ever sent are corpus-normal (enter_states/2 can already set running: false on a top-level <final> entry, and main_event_loop/1 runs exit_interpreter/1 when it does).

opts passes straight to MachineState.new/2 - no option is interpreted here, so a new one is a MachineState change, not an entry-point change.

Cannot fail: a %Machine{} is valid by construction (docs/architecture.md), so this returns the same untagged {machine_state, [effect]} pair every other loop function in this module returns, rather than an {:ok, _, _} wrapper with one possible value.

macrostep(machine_state)

@spec macrostep(machine_state :: Statifier.MachineState.t()) ::
  {Statifier.MachineState.t(), [Statifier.Effect.t()]}

mainEventLoop's inner while running and not macrostepDone loop, folded to quiescence over microstep/1 (Decision 7) - not a pseudocode function name itself; docs/observability.md's vocabulary for that same loop, hoisted so a paused position is a value on %MachineState{} rather than a stack frame (constraint 1). The fold ends one of three ways:

  • Quiescence - microstep/1 returns {:quiescent, machine_state, effects}: no eventless transition is enabled and the internal queue is empty. That round's own effects (the terminal eventless probe's Trace.TransitionsSelected) are appended like any other round's, and its machine_state is the one this fold returns - never the one it passed in, which is what keeps a write made by the final selection from being dropped. The returned machine_state is still running, so this function then appends Trace.MacrostepStable with the configuration and counters as they stand.
  • Termination - a microstep entered a top-level <final>, setting running: false mid-fold. This is not quiescence - Trace.MacrostepStable's own moduledoc reserves that trace for reaching a stable configuration - so no Trace.MacrostepStable is emitted; Trace.Done is the vocabulary row for this case, emitted by exit_interpreter/1.
  • Budget exhaustion (ADR-0019) - the private fold spent machine_state.max_macrostep_rounds without reaching quiescence. The position comes back exactly as the last round left it: running stays true and status stays :running (no exit_interpreter/1 runs, no Effect.Done is built - faking termination would be a semantic lie). Trace.MacrostepStable is not emitted, and a {:budget_exhausted, %Effect.BudgetExhausted{}} core effect is appended last.

The three outcomes are therefore mutually exclusive per macrostep. macrostep/1 is public rather than starting private, because it is exactly microstep/1 driven to a fixed point and a stepper and the fold should be the same code path (docs/observability.md:41-42).

main_event_loop(machine_state)

@spec main_event_loop(machine_state :: Statifier.MachineState.t()) ::
  {Statifier.MachineState.t(), [Statifier.Effect.t()]}

mainEventLoop's outer while running loop (Appendix D), one call's worth of iterations (ADR-0032). The loop itself is driven by the caller - one call of initialize/2 or handle_event/2 reaches here having already run its own selection round - so this function is what is left of the pseudocode's loop body after that: fold to quiescence with the private macrostep/3, run the invoke pass, clear states_to_invoke, and continue (a self-call of the private main_event_loop/3) when invoking left the internal queue non-empty; otherwise run exit_interpreter/1 when the fold left running false, or stop and emit the one terminal effect this call produces.

Delegates immediately to the private main_event_loop/3, which carries the round budget across every re-entry (ADR-0032) exactly as the private macrostep/3 already carries it across rounds of one fold.

microstep(machine_state)

@spec microstep(machine_state :: Statifier.MachineState.t()) ::
  {Statifier.MachineState.t(), [Statifier.Effect.t()]}
  | {:quiescent, Statifier.MachineState.t(), [Statifier.Effect.t()]}

mainEventLoop's inner while running and not macrostepDone loop body, hoisted into a named, resumable round (Decision 1) - not a pseudocode function name itself. One call makes exactly one round of progress, and begins it: both clauses call MachineState.begin_round/1 first, so the returned position's round names which round this one was (ADR-0020).

  • Not running - returns {:quiescent, machine_state, []}, with round advanced and nothing else changed.
  • An eventless transition is enabled - the round runs it, exactly as microstep/2 above.
  • No eventless transition is enabled - falls to internal_round/1, which dequeues one internal event (if any) and selects on it.

Never runs two rounds and never inspects the call stack for a paused position: the returned machine_state is the position, on both the progressing and the quiescent return.

Quiescence carries a machine_state and an effect list of its own (Decision 2, revised). The round that ends a macrostep still ran a selection - Selection.select_eventless_transitions/1 - and that call returns a machine_state. A bare :quiescent atom has nowhere to put it, so the fold would fall back to the machine_state it passed in and drop whatever the final selection wrote, on the one round every macrostep ends with.

Be precise about which writes that loses, because the obvious candidate is not one of them. The two Selection entry points enqueue error.execution on a failed cond - condition_match/2 itself stays a pure query and never enqueues, so the write happens in select_transitions/2 and select_eventless_transitions/1 on the way out - and that enqueue is self-rescuing: it leaves the internal queue non-empty, so internal_round/1 takes its dequeue branch instead of the terminal one and the write survives even under the old shape - traced end to end and pinned by InterpreterAcceptanceTest's "a failed cond becomes a catchable error.execution" tests. What the old shape lost was any selection-side write that does not touch the internal queue - a datamodel write, a memo, a diagnostic - since only a queue write changes which branch runs. Carrying the machine_state out closes that gap without having to predict which kind of write a future selection-side change lands on.

The effect slot is the other half: it is what the terminal eventless probe's own Trace.TransitionsSelected rides out on, which is why docs/observability.md's "includes the empty set" holds with no exception.

microstep(machine_state, enabled_transitions)

@spec microstep(
  machine_state :: Statifier.MachineState.t(),
  enabled_transitions :: [Statifier.Machine.Transition.t()]
) :: {Statifier.MachineState.t(), [Statifier.Effect.t()]}

microstep(enabledTransitions) (Appendix D) - exit the states enabled_transitions leave, run each transition's own content in document order, then enter the states they reach. The three calls run in exactly this order with nothing between them, matching the pseudocode line for line; the effect list is each block's effects concatenated in the same order.