The one-way compile: a block document plus a palette in, one artifact out (ADR-0004 decisions 1-4, 6-7).
compile/3 is a total function of {document, palette}. No process
state, no global registry, no IO, no clock - the same purity ADR-0002
decision 4 imposes on the callbacks, imposed on the pipeline that calls
them. It returns {:ok, %StatifierBlocks.Compiled{}} or
{:error, [%StatifierBlocks.Compiler.Finding{}]}, never raises, and never
partially succeeds.
The pipeline
Each stage runs over the whole document; the first stage that produces
errors stops the compile and reports every error it found. Stopping
rather than accumulating across stages is deliberate - see
StatifierBlocks.Compiler.Finding.
- Document -
StatifierBlocks.Document.validate/1. Structural only; no palette is consulted. - Resolve - every block through
StatifierBlocks.Palette.resolve/2, which also applies an in-memory config migration (ADR-0002 decision 8). Nothing is written back. - Config - every block's
validate_config/1. - Structure -
StatifierBlocks.SlotValidation.validate/2(slot arity,:undeclared_slot) andStatifierBlocks.Assignability.validate/3(may this block land in this slot, by kind tag and by data-flow type - ADR-0003), reported together. - Emit - bottom-up. Each block's
emit/2is called with its children already compiled and summarized, the scope-shaped cancel for any delayed send a direct child armed is added to its own state (StatifierBlocks.Compiler.Cancels), its emission is attributed (StatifierBlocks.Compiler.Attribution), and its child placeholders are spliced with those children's own emissions. - Chart - serialize once through
StatifierBlocks.Compiler.Serializer, which writes the bytes and the provenance map together, then run those bytes throughStatifier.compile/2and map every finding back through provenance (StatifierBlocks.Compiler.Chart).
Findings from every stage are reported in document order over blocks -
StatifierBlocks.Document.blocks/1's pre-order - which is how upstream's
own document-order sort survives the trip.
The Structure stage is whole
Decision 10's table names three things in this stage: slot arity,
:undeclared_slot, and assignability. ADR-0004's amendment of
2026-08-31, section D3, adds two more to the same row under campaign-024
ruling R-b - :drafts_block_misplaced and :duplicate_drafts_block,
the two placement facts a block type's io/1 cannot carry, owned by
StatifierBlocks.Shelf. All of them run here, and their
findings are reported together rather than either short-circuiting the
other: an undeclared slot key does not induce an assignability finding
(a slot with no declaration gets slot_accepts :any, which admits
everything), and an arity violation is a count, which no assignability
rule reads. Neither is a consequence of the other - they are siblings,
which is decision 10's own rule for what one stage reports - so
StatifierBlocks.SlotValidation.validate/2 and
StatifierBlocks.Assignability.validate/3 are both always run and their
findings concatenated. Before this, the pipeline never visited a slot
slots/1 did not declare, so an undeclared slot's blocks were absent
from the emission rather than misplaced in it - a silent drop.
Options
:known_invoke_types- decision 8's opt-in lint. A set (or list) of invoke types the caller believes will be registered; every emitted type absent from it becomes a warning, never an error. SeeStatifierBlocks.Compiler.InvokeTypes.:entry_type- ADR-0003 decision 4's caller-supplied context: the type flowing into the document's root. Defaults to absent, whichStatifierBlocks.Assignabilityreads as:unknown. ADR-0004's own typespec lists only the first option, because it delegated assignability wholesale to ADR-0003 (decision 11) without noticing that ADR-0003's context is caller-supplied and therefore has to arrive through this function. This is that arrival, not a second decision about what assignability means.:datamodel- the host's declared datamodel, whichStatifierBlocks.Compiler.SensitivePathsreads to refuse a document that carries a declared-sensitive path into a position the chart evaluates against the datamodel (ADR-0002 decision 7'ssensitive?key and the secrets rule behind it). Absent, or declaring nothing sensitive, the check does not run and produces nothing - absence is not unknown-ness (ADR-000511f). SeeStatifierBlocks.Compiler.SensitivePaths.datamodel/1for the shapes it accepts, and that module for the criterion the refusal applies.:child_use- compile this document for use as a child of another chart (ADR-0004's 2026-08-29 amendment, C1). The emission gains one top-level<final>per outcome the root block declares, reached fromdone.outcome.<root state id>.<outcome>and carrying<donedata><param name="outcome" expr="'<outcome>'"/></donedata>, which is how a child's outcome crosses the invoke boundary: raised events are internal to the session that raises them, so the only thing a parent observes is the completion event and the data the child sent with it (SCXML 3.7 and 5.5). Defaults tofalse, and a document compiled without it is byte-identical to what it was before the option existed. The parent half isStatifierBlocks.Core.Subchart.:terminate- compile this document as a root document that finishes (ADR-0004's 2026-08-29 root-termination note). The emission gains one top-level<final>per outcome the root block declares, reached fromdone.outcome.<root state id>.<outcome>and carrying no<donedata>, so the session reaches:donewhen the root block completes. Without it a compiled root document never terminates: the root block's own outcome finals are children of the root compound state, so completing the root block raisesdone.outcomeinternally and the session stays active forever, which is what leaves a durable run uncompleted. Defaults tofalse, and a document compiled without it is byte-identical to what it was before the option existed.:terminateand:child_useare the same emission shaped for two different uses, and a document is compiled for one or the other: passing both is refused with an:emitfinding rather than resolved silently, because both would put a transition on the samedone.outcomeevent on the root block's own state and document order would quietly decide which top-level<final>a run reaches.:declare- the<data>roots the host declares for this document (ADR-0004's 2026-08-29 host-declared-roots note): a list of{id, expr}pairs, in declaration order, whereexpris either an expression written verbatim into the attribute ornilfor a root that reads asundefineduntil something assigns it.Compiler.compile(document, palette, declare: [{"targets", nil}, {"parked", "false"}])Each pair becomes one
StatifierBlocks.Compiler.DeclaredRootsdeclaration, prepended to the root block's own children before the hoist, so the host's roots lead the single<datamodel>in the order given and block-declared roots follow in document order. An id must be a bare lowercase identifier -core.invoke'sassign_torule - and an id the option repeats, or an entry that is not a well-formed pair, is refused as an Emit-stage finding against the root block. An id a block also declares is F6's:duplicate_bindingagainst that block, through the same walk a nested loop's collision goes through.This is the compile call's declaration surface, and it leads: the document has a second one, ADR-0001 decision 11's top-level
datamodelkey (added 2026-08-31), whose roots follow the host's in the single<datamodel>-:declareroots, then the document's own, then block-declared roots, all in document order. A root both declare is host-wins: the compile call's declaration is the one emitted, the document's is dropped, and the artifact carries a warning (:shadowed_document_root) rather than a refusal, since the compile call is what a host controls and the document edit that would silence the warning is not the one that fixes anything. No block type declares a root of its own - that surface is still untaken, ADR-0002's to take. Absent or[]emits no<datamodel>unless the document or a block declares a root, so a document compiled without the option is byte-identical to what it was before the option existed. Run creation still wins overexpr(SCXML 5.3.2) - a run seeded with a value for the id starts from that value, which is the engine's behaviour and not this package's.See
StatifierBlocks.Compiler.DeclaredRoots's "Document-declared roots" section for the full precedence rule and for why a document root colliding with a block-declared root stays F6's:duplicate_bindingerror rather than becoming a second kind of warning.
Determinism (decision 6)
For a fixed
{document canonical bytes, palette, compiler version}, the generated SCXML is byte-identical on every machine and every run, forever.
All three inputs are real, and StatifierBlocks.CompilationRecord
records all three. What this module contributes to the guarantee is that
it never iterates a bare map: slots are visited in slots/1 declaration
order (ADR-0002 decision 6 made that order meaningful), children in
document order, and attributes are sorted at construction by
StatifierBlocks.Emission.element/3.
The guarantee is not reversible and must not be read as one. Equal
output does not imply equal input: a metadata-only edit changes the
document hash and produces identical SCXML, because metadata is not
compiled. A host may use "same triple" to skip a recompile, and may
not use "same SCXML" to conclude the document is unchanged.
The document's datamodel key (ADR-0001 decision 11) is part of the
document's canonical bytes and therefore of the triple's first input,
so the guarantee is unweakened by its existence - moving an entry's
id or expr moves the document hash and, because those are compiled,
moves the generated SCXML too. But it is a second instance of the same
non-reversibility clause above: an entry's description is prose, not
compiled, so two documents differing only in a description hash
differently and still produce byte-identical SCXML.
Summary
Types
:known_invoke_types enables decision 8's optional two-registry lint;
:entry_type is ADR-0003 decision 4's caller-supplied context;
:datamodel is the host's declared datamodel, read only by the
sensitive-path refusal; :declare is the <data> roots the host
declares for this document. See the moduledoc.
Functions
Compiles document against palette.
Decision 6's third determinism input: this package's version.
Types
@type option() :: {:known_invoke_types, Enumerable.t()} | {:entry_type, StatifierBlocks.Assignability.type_expr() | :unknown} | {:datamodel, term()} | {:child_use, boolean()} | {:terminate, boolean()} | {:declare, [StatifierBlocks.Compiler.DeclaredRoots.declaration()]}
:known_invoke_types enables decision 8's optional two-registry lint;
:entry_type is ADR-0003 decision 4's caller-supplied context;
:datamodel is the host's declared datamodel, read only by the
sensitive-path refusal; :declare is the <data> roots the host
declares for this document. See the moduledoc.
Functions
@spec compile(StatifierBlocks.Document.t(), StatifierBlocks.Palette.t(), [option()]) :: {:ok, StatifierBlocks.Compiled.t()} | {:error, [StatifierBlocks.Compiler.Finding.t()]}
Compiles document against palette.
Total: {:ok, %StatifierBlocks.Compiled{}} or
{:error, [%StatifierBlocks.Compiler.Finding{}]}, never a raise and
never a partial success. Errors come from the first failing stage only
(decision 10); warnings ride on the artifact when the compile succeeds.
@spec compiler_version() :: String.t()
Decision 6's third determinism input: this package's version.