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, the checks beside it that read something a block type cannot see from its own config, and the one name a root block may not declare as an outcome (ADR-0002's failure amendment of 2026-09-06, section 4 step 3, and the Note that closes it). - 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.
Config and Structure are reported together
Every other stage in decision 10's table stops the pipeline the moment it fails, and the errors a caller gets back come from that one stage. Config and Structure are the one pair that does not: when Config finds something, Structure still runs, and the refusal carries the union of what both found (RQ-SF035-2, and the dated Notes of 2026-09-06 on ADR-0004 decision 10 and on ADR-0011 decision 1).
The reason is that they are not in a consequence relation the way the later stages are. Emit reads the tree Structure has already agreed is well-formed, so its findings on a document Structure refused would be artefacts. Structure reads the document, not the config values Config checks, so a mis-typed field on one card and an unsatisfied read on another are two independent statements about the same document - and an author who can only see the first has to fix it, recompile, and discover the second, one round trip per stage. Decision 10's "every finding within a stage is reported, because they are siblings" is the same argument; this extends it across exactly the one stage boundary where it holds.
What a block whose config Config refused contributes to Structure is
nothing at all - it is skipped by id, and every other block is checked
exactly as it would have been. That is what the old sequencing bought and
what has to be bought again some other way: every source in the
Structure stage reads the refused config. A write signature comes off a
config, so an entry derived from one would make the next block's read
disagree with a type nobody declared; slots/1 takes a config too, so
slot arity and :undeclared_slot on a refused block would be counted
against a slot set that config does not really declare; and
StatifierBlocks.Shelf places a block the config named. So the refused
ids reach the environment walk as :skip_blocks - the block declares no
read and writes no entry - and the two document-shape sources drop the
findings they anchored on those same ids.
The walk itself continues past a skipped block: its siblings and its children are checked as they always were, and a child's config is its own. This is an absence of one block's answers, not a shortened stage.
Refusal semantics are unchanged. A document with a Config finding still does not compile; it now says more about why.
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.:field_candidates- the values a host offers per field, keyed{type_name, field_key}, the same map the editor's ownfield_candidatesassign takes. A closed list -[{value, label}]- turns a value it does not offer into a warning, never an error; an open list -{:open, choices}- reports nothing, and a field the map does not name is not looked at. Absent, the lint does not run.: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.The same document is read by two other checks, and it is read once: the structure stage's typed-environment read check (ADR-0011 decisions 2 and 3), and the config stage's declared-payload refusal, where a
core.on_eventthat declares itspayloadrefuses acapturepair reading a member that payload does not carry (ADR-0002's amendment of 2026-09-06). A compile with no:datamodelruns neither, and refuses nothing.: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.Beside those per-outcome finals, one shared top-level
<final>is emitted when a block below the root declares a failure-classed outcome - oneStatifierBlocks.BlockType.failure_outcomes/2names - that the document did not handle (ADR-0002's failure amendment of 2026-09-06, section 4). It is minted from the root block's id under the rolechild_failed, one transition per unhandled pair reaches it from the root block's own state, and it carries<param name="outcome" expr="'error'"/>beside the reserved<param name="statifier_persistence:run_status" expr="'failed'"/>. The outcome it reports iserrorrather than the failing block's own outcome name, becauseStatifierBlocks.Core.Subchartappendserrorto its outcomes whether or not the author listed it, so it is the one word a parent is guaranteed to have a route for. A document with no unhandled failure below its root gains nothing here, and a root block may not declare an outcome namedfailed: that would ask for this final's state id a second time, and it is refused as a Config-stage finding.: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>- except that a final for a failure-classed outcome carries the one reserved run-status<param>described below - 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.Beside those per-outcome finals, one shared top-level
<final>is emitted when a block below the root declares a failure-classed outcome the document did not handle, exactly as under:child_use(ADR-0002's failure amendment of 2026-09-06, section 4). Here it is minted from the root block's id under the roleroot_failed, one transition per unhandled pair reaches it from the root block's own state, and its<donedata>holds only the reserved<param name="statifier_persistence:run_status" expr="'failed'"/>- the keystatifier_persistence's ADR-0008 amendment of 2026-09-06 fixes, which a durable stepper reads to decide that the run failed. So a root document whose nested step fails still reaches:done, and says that it failed when it gets there. A document with no unhandled failure below its root gains nothing here, and a root block may not declare an outcome namedfailed: that would ask for this final's state id a second time, and it is refused as a Config-stage finding.: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;
:field_candidates enables the opt-in warning for a value outside a
host's own closed candidate list;
:entry_type is ADR-0003 decision 4's caller-supplied context;
:datamodel is the host's declared datamodel, read by the sensitive-path
refusal, the typed-environment read check and the declared-payload
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()} | {:field_candidates, %{optional({String.t(), String.t()}) => term()}} | {: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;
:field_candidates enables the opt-in warning for a value outside a
host's own closed candidate list;
:entry_type is ADR-0003 decision 4's caller-supplied context;
:datamodel is the host's declared datamodel, read by the sensitive-path
refusal, the typed-environment read check and the declared-payload
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), with one exception the moduledoc's "Config and Structure
are reported together" section states: those two stages run as a pair, and
a refusal carries the union of their findings. Warnings ride on the
artifact when the compile succeeds.
@spec compiler_version() :: String.t()
Decision 6's third determinism input: this package's version.