Statifier is a ground-up rewrite of statifier
(the original, kept as a read-only reference in ../statifier). The rewrite exists
to fix structural problems the original could only patch around; the reasoning for
each major decision lives in docs/adr/.
Design principles
The W3C algorithm is ported literally, not re-derived (ADR-0002). The interpreter implements the SCXML Appendix D pseudocode function-for-function, keeping the spec's names:
select_transitions,remove_conflicting_transitions,get_transition_domain,compute_exit_set,compute_entry_set,add_descendant_states_to_enter,microstep,enter_states,exit_states,main_event_loop,exit_interpreter. When a conformance test fails, the debugging move is "diff the function against the pseudocode", never "tune the heuristic". Diff against the cached text, not against memory:appendix-d.txtand the fullscxml-rec.htmlsit in$(git rev-parse --path-format=absolute --git-common-dir)/spec-cache/, populated bymise run spec:fetch(seetools/spec/README.md). Internal, external, and targetless transitions share one code path via the transition domain.Pure functional core, effects at the edge (ADR-0003). The core engine is a pure function:
(machine_state, event) -> {machine_state, [effect]}. Delayed sends, external sends, invocations, cancellations, and log/trace entries are returned as effect data. Interpreters of effects (a GenServer session, a test harness, an iex user) live outside the core. The same document has the same semantics through every API - v1's "delayed send silently becomes immediate in the sync API" class of bug is unrepresentable.Errors are events. SCXML defines an error model: evaluation failures raise
error.executionon the internal queue. Every evaluation in the core returns{:ok, value} | {:error, reason}and the interpreter decides what an error means. Norescue-to-falseat the leaves (a dozen places in v1 swallowed errors this way).Make invalid states unrepresentable. Parsing produces a
Document; validation produces a distinctMachinetype (interned, optimized, guaranteed valid). The interpreter only accepts aMachine, so "validate if not already validated" fallback branches do not exist. A finding that gates this boundary is an error; a finding that does not is a warning, which rides on theMachineinstead and never blocks compilation (ADR-0033).
Layers
XML string
| Parser (Saxy SAX -> generic DOM with source locations)
v
DOM (element name, attrs, children, location)
| Lowering (typed per-element builders)
v
Document (typed structs, source locations, uncompiled expressions)
| Validator (structural + semantic checks; two channels - errors gate
| compilation, warnings ride on the Machine instead, ADR-0033)
| Compiler (intern IDs, index hierarchy, compile expressions)
v
Machine (valid by construction, integer state indexes, compiled Expr values)
| Interpreter (pure Appendix D core)
v
{state, [effect]}
| Effect interpreters
v
Statifier.Session (GenServer) | test harness | embedding applicationParser: DOM first, then lowering
v1's SAX handler lowered elements directly into typed structs, which required a
handler clause per (element type x parent context) pair - 73 clauses in one 903-line
module. v2 parses into a generic DOM node (name, attributes, children,
location), then lowers the DOM to typed structs in a separate pass. Adding an
element touches one builder, not a clause matrix. Source locations are a property of
every DOM node, not bolted-on bookkeeping.
Machine: interned and indexed
The compiler pass interns state IDs to integers and stores states in a flat array with parent pointers and descendant index ranges in document order. Ancestor checks, descendant checks, LCCA, and exit-set computation become integer comparisons - no precomputed cache module, no four separate ways to fetch ancestors.
Expressions are compiled once, here, into a single sum type:
@type expr :: {:static, term()} | {:compiled, Predicator.Compiled.t(), source :: String.t()}Every attribute that accepts foo / fooexpr pairs stores one expr value,
evaluated by one function. (v1 carried x / x_expr / compiled_x_expr triples on
every action struct.)
Interpreter: the Appendix D core
Statifier.MachineState is the state in the {state, [effect]} pipeline
above, and Statifier.Effect is the effect.
- The full active configuration (ancestors included) is stored, as the spec's algorithm assumes (ADR-0005). "Leaf states" is a view derived on demand, not the storage model.
- A
runningflag with real termination: top-level<final>entry stops the machine, runs<donedata>, and emits the terminal effect. done.state.<id>events are generated for compound and parallel completion.- Event descriptor matching follows spec 3.13 exactly (including
foo.*matchingfoo). - The transition-selection block lives in
Statifier.Interpreter.Selection;Statifier.Interpreter.NameMatchis the 3.13 matcher above. - The exit and entry blocks - history recording/restoration and
done.state.*generation included - live inStatifier.Interpreter.ExitEntry. - A
MachineState's configuration is interned integer indexes, stable only within oneMachinebuild - persisting and reloading one safely, across a chart revision or not, is docs/persistence.md's concern, not this document's.
Executable content
One Statifier.ExecutableContent node protocol with
execute(node, context) -> {:ok, context, [effect]} | {:error, reason} | {:error, context, reason},
where context is a Statifier.ExecutableContent.Context. No central
dispatcher with per-struct clauses and a parallel summary function to keep in
sync. Statifier.Interpreter.Content is the block runner: it walks a block's
nodes in document order, stopping at the first error, and is the only place
that converts a node's error - fatal or the non-fatal spec-5.9.1 channel
carried in context.pending_errors - into error.execution. A new element is
a new Statifier.Machine.Content.* struct plus a defimpl in the same file -
never a change to the runner or the interpreter (the error model itself -
the shape of result() and what the runner drains - is a separate,
ADR-governed thing from an element's own code; see
docs/plans/260813-st-af3.5-if-elseif-else-conditional-executable-content.md,
Decision 5, for why it was widened once).
Sessions and invoke
Statifier.Session is the GenServer effect interpreter (ADR-0003): it owns the
outer while running loop, the waiting external events, the delayed-send
timers, <cancel>'s effect, and the fan-out of the effect stream to
subscribers. That ownership of the delayed-send timers is replaceable, not
load-bearing - a host can take over scheduling itself by consuming the
SendDelayed/Cancel effect pair instead, per
docs/durable-timers.md and
ADR-0054. The pure
core lowers, validates, and compiles <invoke>
(ADR-0031),
runs Appendix D's statesToInvoke and cancel-invoke passes
(ADR-0032 covers the
round budget across a post-invoke re-entry), runs <finalize> before
transition selection, and emits {:invoke, _}, {:cancel_invoke, _}, and
{:autoforward, _} effects for the session to act on.
Statifier.Session performs all three, under the embedder-placed runtime
ADR-0027 decided:
Statifier.Supervisor holds a Statifier.Registry and a flat
Statifier.SessionSupervisor, :rest_for_one. {:invoke, _} resolves the
child's source through Statifier.Invoke.Source
(ADR-0038 -
the library never fetches src itself; an embedder-supplied invoke_source
resolver does, or the invocation raises error.communication), seeds the
child's datamodel per 6.4.3's name-matched <param>/namelist rule, and
starts it on Statifier.SessionSupervisor with invoked_by: {parent_pid, invoke_id}, monitored in both directions; Statifier.Session.start_link/2's
:inherit_observers opt-in carries the parent's :trace and subscribers down
onto that same child start
(ADR-0050), and
Statifier.Session.invocations/1 names a session's live children for an
observer attaching after the fact. #_parent/_parent and a live
invocation's done.invoke.<invokeid> (carrying its donedata) both resolve
through that invoked_by link directly, with no registry lookup needed;
#_<invokeid> resolves through that same invocation table
(Statifier.Session.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 ordinary error.communication path (st-xcgr).
{:autoforward, _} forwards every external event the
parent removes from its queue to each autoforwarding invocation, unmodified,
at the point the core's finalize/autoforward pass runs. {:cancel_invoke, _}
stops the child via Session.cancel/1 (not stop/2, so its <onexit>
handlers still run) and pops its table entry before the stop, so the
drain-time discard - a queued entry delivered by an invocation that no
longer names a live one is dropped, per 6.4.3's "MUST NOT insert them into
the external event queue" - is correct against events queued either before
or after the cancel. The discard reads the queue entry's own origin
(Statifier.Session.Inbox's {:invoked_event, _, _}, set only on the
child-to-parent direction) rather than the event's invokeid field, because
6.4.2 requires an autoforwarded copy to preserve every 5.10.1 field: an
event forwarded down to a child carries the sibling invocation's id into a
session that has never had one, and keying on the field would discard
exactly the copy 6.4.2 requires be delivered. Inline <content><scxml>...</scxml></content> does not lower yet
(a parser-layer, layer-boundary decision deferred to st-53ys); a <content>
holding markup as a text/CDATA binary compiles and runs today. v2 now has the
seam v1's handler-registry invoke gestured at: a host registers a
Statifier.Invoke.Handler per session for any <invoke type="..."> beyond
the built-in scxml/bare-URI set (Statifier.Invoke.Handler.Scxml), which
itself sits behind the same interface rather than being special-cased
(ADR-0051). See
docs/extending.md for how to write and register one.
Generated identifiers split on the pure core's boundary
(ADR-0008). Minted outside the core, the
session id is sortable, prefixed (sess_), and stable per session (v1
regenerated _sessionid on every expression evaluation). Minted inside it, an
id is a deterministic value derived from %MachineState{} alone - an
entropy-based id reads the wall clock and a CSPRNG, and the core's contract
(ADR-0003) admits neither. The invoke id is
the only one minted inside the core today, and spec 6.4.1 fixes its shape: the
invoking state's id, a dot, then inv_ and a session-global counter - or bare
inv_<counter> when the state has no id. A future <send idlocation> generator
sits inside the core too, so the same no-entropy rule governs it.
What is deliberately out of scope
- ECMAScript datamodel. The datamodel is predicator
(ADR-0004). No JS engine, no
eval, and no raw Elixir code in documents - safety is the point;<invoke>is the escape hatch for real computation. - v1 API compatibility. The conformance corpus is the compatibility contract, not v1's module surface.