# Architecture

Statifier is a ground-up rewrite of [statifier](https://github.com/riddler/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

1. **The W3C algorithm is ported literally, not re-derived** ([ADR-0002](https://github.com/riddler/statifier-ex/blob/main/docs/adr/0002-literal-w3c-appendix-d-port.md)).
   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.txt` and the full
   `scxml-rec.html` sit in `$(git rev-parse --path-format=absolute --git-common-dir)/spec-cache/`,
   populated by `mise run spec:fetch` (see [`tools/spec/README.md`](https://github.com/riddler/statifier-ex/blob/main/tools/spec/README.md)).
   Internal, external, and targetless transitions share one code path via the
   transition domain.

2. **Pure functional core, effects at the edge** ([ADR-0003](https://github.com/riddler/statifier-ex/blob/main/docs/adr/0003-pure-core-with-effects.md)).
   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.

3. **Errors are events.** SCXML defines an error model: evaluation failures raise
   `error.execution` on the internal queue. Every evaluation in the core returns
   `{:ok, value} | {:error, reason}` and the interpreter decides what an error means.
   No `rescue`-to-`false` at the leaves (a dozen places in v1 swallowed errors this way).

4. **Make invalid states unrepresentable.** Parsing produces a `Document`; validation
   produces a distinct `Machine` type (interned, optimized, guaranteed valid). The
   interpreter only accepts a `Machine`, 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 the `Machine`
   instead and never blocks compilation ([ADR-0033](https://github.com/riddler/statifier-ex/blob/main/docs/adr/0033-validator-warning-tier.md)).

## 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 application
```

### Parser: 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](https://github.com/riddler/statifier-ex/blob/main/docs/adr/0005-full-configuration-and-interned-state-indexes.md)).
  "Leaf states" is a view derived on demand, not the storage model.
- A `running` flag 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.*` matching `foo`).
- The transition-selection block lives in `Statifier.Interpreter.Selection`;
  `Statifier.Interpreter.NameMatch` is the 3.13 matcher above.
- The exit and entry blocks - history recording/restoration and
  `done.state.*` generation included - live in `Statifier.Interpreter.ExitEntry`.
- A `MachineState`'s configuration is interned integer indexes, stable only
  within one `Machine` build - persisting and reloading one safely, across a
  chart revision or not, is [docs/persistence.md](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](durable-timers.md) and
[ADR-0054](https://github.com/riddler/statifier-ex/blob/main/docs/adr/0054-durable-timers-consume-the-effect-vocabulary.md). The pure
core lowers, validates, and compiles `<invoke>`
([ADR-0031](https://github.com/riddler/statifier-ex/blob/main/docs/adr/0031-invoke-argument-failure-aborts-the-invocation.md)),
runs Appendix D's `statesToInvoke` and cancel-invoke passes
([ADR-0032](https://github.com/riddler/statifier-ex/blob/main/docs/adr/0032-round-budget-spans-the-invoke-re-entry.md) 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](https://github.com/riddler/statifier-ex/blob/main/docs/adr/0027-embedder-placed-session-runtime.md) 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](https://github.com/riddler/statifier-ex/blob/main/docs/adr/0038-invoke-source-resolves-at-the-session-boundary.md) -
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](https://github.com/riddler/statifier-ex/blob/main/docs/adr/0050-invoked-children-inherit-observation-by-opt-in.md)), 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](https://github.com/riddler/statifier-ex/blob/main/docs/adr/0051-invoke-handlers-are-registered-per-session.md)). See
[docs/extending.md](extending.md) for how to write and register one.

Generated identifiers split on the pure core's boundary
([ADR-0008](https://github.com/riddler/statifier-ex/blob/main/docs/adr/0008-uxid-for-identifiers.md)). 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](https://github.com/riddler/statifier-ex/blob/main/docs/adr/0003-pure-core-with-effects.md)) 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](https://github.com/riddler/statifier-ex/blob/main/docs/adr/0004-predicator-as-the-datamodel.md)). 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.
