How a block type contributes a compiler-declared <data> root to the
chart's one top-level <datamodel> (ADR-0004's 2026-08-29 foreach
amendment, F2 and F3), and the collision refusal F6 records.
Until core.foreach there was no such thing: every core type wrote into
the datamodel the host had already declared, and the compiler emitted no
<datamodel> at all. A loop cannot. Its cursor and its snapshot of the
list are the compiler's own state, its item_as/index_as bindings are
roots an author named, and predicator refuses to read a root nothing
declared - so the four names have to be declared somewhere, and early
binding means "somewhere" is the top of the document, before any state
is entered.
The mechanism: declare in place, hoist once
A block type emits declare/2 elements among its own state's
children, where everything else it emits already is, and the compiler
lifts every one of them out of the assembled emission into a single
top-level <datamodel> just before serialization (hoist/1).
Declaring in place rather than through a new StatifierBlocks.BlockType
callback is what keeps this small. A callback would be a change to
ADR-0002's declaration surface for one block type's benefit; a <data>
element is something emit/2 can already return, it goes through
StatifierBlocks.Compiler.Attribution on the ordinary path, and so it
arrives here already carrying the owner and the config key a finding
against it needs. Nothing about the block type's contract changes.
Determinism (decision 6)
hoist/1 walks the assembled tree in document order and emits the roots
in the order it meets them, outermost first. The walk is over lists, in
the order emit/2 built them, so the same document produces the same
bytes - the property decision 6 requires and the reason no sort happens
here. A document declaring no roots gets no <datamodel> element at
all, so every chart this package compiled before this mechanism
existed still compiles to the same bytes.
Provenance (decision 5)
A hoisted element is moved, never rebuilt: it carries the owner
Attribution stamped on it, so the provenance map stays total over the
bytes it becomes. The <datamodel> wrapper belongs to no block and is
attributed to the root block, exactly as <scxml> is.
F6: :duplicate_binding
Early binding makes a declared root global, so a nested loop re-using an
enclosing loop's item_as would overwrite the outer binding and the
outer body would carry on with the inner loop's last item. The compiler
refuses the document instead: a root declared inside the subtree of
a block that already declares the same name is a :duplicate_binding
finding, reported by StatifierBlocks.Compiler as an Emit-stage error
against the block whose binding collides, carrying the config_key the
name was typed into (item_as or index_as) and therefore the author's
fault.
The rule this module applies is nesting, not the whole document -
"collide across nesting", as F6 words it - and that is deliberately the
narrow carve-out from decision 9's delegation rather than a general
id-uniqueness check. Two roots of one name that are not nested are
still refused, one stage later and by the delegated check: statifier's
own id-uniqueness pass reports {:duplicate_id, name} over the two
<data> elements, and StatifierBlocks.Compiler.Chart maps it back to
the declaring block and its config field. F6 describes itself as
pre-empting exactly that finding for the case where the overwrite would
otherwise be silent, and pre-empting it anywhere else would be a
widening of decision 9 this module has no mandate for.
Author-declared <data> ids, F6's other half, are covered by the same
walk the day they exist: no config field declares one and no block type
emits one for a name an author typed as an id. Nothing here
special-cases core.foreach, so a declaration arriving as a <data>
element is checked against the loops beneath it without this module
being touched.
Host-declared roots (the :declare compile option)
That day arrived for the host rather than for the author.
declarations/1 turns StatifierBlocks.Compiler.compile/3's
:declare option - a list of {id, expr} pairs in declaration order -
into the same declare/2 emissions a block type contributes, and the
compiler prepends them to the root block's own children before
hoist/1 runs. Three things follow from that placement rather than
from any new code here:
- Order.
hoist/1lifts an element's own declarations before it descends, so the host's roots lead the single<datamodel>in the order the option lists them and block-declared roots follow in document order. - Collision. A host root is in scope for everything beneath it,
so a block declaring the same name is F6's
:duplicate_binding, reported against that block and its config key exactly as a nested loop's collision is. A name repeated within the option list never reaches the walk:declarations/1refuses it, because there is no block to name in a finding about a list the host wrote. - Nothing else. No
<datamodel>is emitted for a document that declares nothing, option absent or[]alike, so every chart compiled before the option existed still compiles to the same bytes.
An id must be a bare lowercase identifier, the rule core.invoke
applies to assign_to: the host is declaring a location the chart will
assign to and read in a guard, and predicator's grammar is what both
ends have to agree on. An expr is either nil, for a root that reads
as undefined until something assigns it, or a non-empty expression
written verbatim into the attribute. Run creation still wins over
expr - a run seeded with a value for the id starts from that value
(SCXML 5.3.2), which is upstream's behaviour and not this module's.
Document-declared roots (ADR-0001 decision 11)
The document itself carries a second declaration surface: a top-level
datamodel key of StatifierBlocks.Document.DatamodelEntry structs,
each already checked against ADR-0001's structural rules by
StatifierBlocks.Validation before a document ever reaches the
compiler. document_declarations/1 turns them into the same
declare/2 emissions declarations/1 builds from the compile call's
:declare option, and StatifierBlocks.Compiler prepends them to the
root block's own children after the host's roots and before any
block-declared root, which is what puts them second in the single
<datamodel>:
- the compile call's
:declareroots lead, - the document's own
datamodelroots follow, - block-declared roots follow those, in document order.
Host wins. A document root whose id a host root already declares is
dropped by shadowed/2 before the hoist ever runs, and the compiler
turns the dropped id into one warning on the compiled artifact
rather than a refusal - the compile call leads, so the host's
declaration is the one that survives and the document's is silently
fine to have, not silently fine to lose track of. This is different
from F6: F6 is a refusal because an uncaught collision would silently
overwrite a binding a chart still reads from two places, while a
host-shadowed document root is never emitted at all, so there is
nothing left in the chart to overwrite.
A document root colliding with a block-declared root is unchanged
F6 :duplicate_binding, an error, through the same hoist/1 walk
every other collision goes through: document roots are prepended among
the root block's own children before the hoist runs, so every block in
the document sits inside their scope exactly as it sits inside the
host's. Nothing about hoist/1 needs to know a root came from the
document rather than the host - the walk does not distinguish the two,
and shadowed/2 is what keeps a host/document collision from ever
reaching it as a false F6.
Summary
Types
One entry of the :declare compile option: a root id and either an
initial expression or nil.
A refusal of the :declare option itself, before any walk: an entry
that is not a well-formed declaration, or an id the list declares
twice.
One collision: the block whose binding collides, the config key it was
typed into (nil for a root no author named), and the offending name.
Functions
The top-level <datamodel> holding roots, or nothing at all when
there are none.
The :declare compile option as declare/2 emissions, in the order
the option lists them.
A <data> declaration for the root id, optionally with an initial
expr.
The document's own datamodel entries as declare/2 emissions, in
list order.
Lifts every <data> element out of emission, returning the stripped
tree and the roots in document order.
Splits the document's own root emissions into the ones that survive and
the ids a host root already declares.
Types
One entry of the :declare compile option: a root id and either an
initial expression or nil.
@type declaration_finding() :: {:invalid_declaration, term()} | {:duplicate_declaration, String.t()}
A refusal of the :declare option itself, before any walk: an entry
that is not a well-formed declaration, or an id the list declares
twice.
@type finding() :: {:duplicate_binding, StatifierBlocks.Block.id() | nil, String.t() | nil, String.t()}
One collision: the block whose binding collides, the config key it was
typed into (nil for a root no author named), and the offending name.
Functions
@spec datamodel([StatifierBlocks.Emission.t()]) :: [StatifierBlocks.Emission.t()]
The top-level <datamodel> holding roots, or nothing at all when
there are none.
A list rather than a value, so the caller splices it into <scxml>'s
children without a conditional - and so "no roots, no element" is this
module's decision rather than every caller's.
@spec declarations(term()) :: {:ok, [StatifierBlocks.Emission.t()]} | {:error, [declaration_finding()]}
The :declare compile option as declare/2 emissions, in the order
the option lists them.
nil and [] are both "the host declares nothing" and produce no
emissions, which is what keeps a document compiled without the option
byte-identical to what it was before the option existed.
{:error, findings} when an entry is not a {id, expr} pair whose id
is a bare lowercase identifier and whose expr is nil or a non-empty
string, or when the list declares one id twice. Every entry is checked,
so a host fixing its call sees all of them at once.
@spec declare(String.t(), String.t() | nil) :: StatifierBlocks.Emission.t()
A <data> declaration for the root id, optionally with an initial
expr.
A root declared with no expr reads as undefined until something
assigns it, which is what the loop's bindings want: item_as means
nothing until the head state's first pass binds it.
@spec document_declarations([StatifierBlocks.Document.DatamodelEntry.t()]) :: [ StatifierBlocks.Emission.t() ]
The document's own datamodel entries as declare/2 emissions, in
list order.
Unlike declarations/1, this takes already-validated entries: the
Document stage (StatifierBlocks.Document.validate/1, run before the
compiler's Emit stage) refuses a document whose datamodel is
malformed, so a %DatamodelEntry{} reaching here always has a bare
identifier for an id and either nil or a non-empty expression for
expr. That is why this returns a plain list rather than a tagged
tuple: the shape is a schema rule, checked exactly once by
StatifierBlocks.Validation, and re-checking it here would be a second
place for that rule to drift from the one the document was actually
validated against.
description is dropped - it is prose for a human reading the
document, never compiled and never written into the emitted SCXML.
@spec hoist(StatifierBlocks.Emission.t()) :: {:ok, {StatifierBlocks.Emission.t(), [StatifierBlocks.Emission.t()]}} | {:error, [finding()]}
Lifts every <data> element out of emission, returning the stripped
tree and the roots in document order.
{:error, findings} when a root is declared inside the subtree of a
block that already declares the same name (F6).
@spec shadowed([StatifierBlocks.Emission.t()], [StatifierBlocks.Emission.t()]) :: {[StatifierBlocks.Emission.t()], [String.t()]}
Splits the document's own root emissions into the ones that survive and
the ids a host root already declares.
Host wins: a document root whose id host also declares is dropped
from kept_document_roots and its id collected into shadowed_ids, in
document order. Both lists are otherwise document's own order,
unchanged - this makes no claim about the host's own order, and does
not need to, since the caller prepends host ahead of whatever this
returns.