core.foreach: a container whose body runs once per item of a
datamodel list (ADR-0004's 2026-08-29 amendment, F1 through F6).
It is the first core type whose emission is not a fixed subtree, and the amendment exists because of that: nothing in the record said where a loop counter lives or whether the list is re-read between passes, and a block type cannot decide either on its own.
The compile is a plain Appendix D loop (F1)
Nothing here reaches outside the interpreter's ordinary macrostep
semantics. There is no engine loop construct, no executable-content
<foreach> (which cannot hold a body that waits for an event), and no
unrolling: the body's states exist once however long the list is, so its
provenance entries do too.
<state id="s_blk_F" initial="s_blk_F__head">
<onentry>
<assign location="s_blk_F__items" expr="signup.invitees"/>
<assign location="s_blk_F__i" expr="0"/>
</onentry>
<transition event="done.state.s_blk_F__body" type="internal" target="s_blk_F__head">
<assign location="s_blk_F__i" expr="s_blk_F__i + 1"/>
</transition>
<state id="s_blk_F__head">
<onentry>
<assign location="invitee" expr="s_blk_F__items[s_blk_F__i]"/>
<assign location="invitee_index" expr="s_blk_F__i"/>
</onentry>
<transition cond="s_blk_F__items[s_blk_F__i] === undefined" target="s_blk_F__o_done"/>
<transition target="s_blk_F__body"/>
</state>
<state id="s_blk_F__body" initial="s_blk_INVITE">
...the body slot's children, sequenced...
<final id="s_blk_F__body_done"/>
</state>
<final id="s_blk_F__o_done">...</final>
</state>The loop-back transition is type="internal", and must be
A transition is external by default, and an external transition exits
and re-enters its own source even when its target is inside it
(Appendix D's getTransitionDomain/findLCCA). The loop-back
transition's source is this block's own state, so the external form
would re-run the <onentry> above on every pass - re-snapshotting the
list and resetting the cursor to 0 - and the loop would never end.
F4's "re-targets the head" is only true of the internal form. This is
pinned upstream by statifier-ex's st-wlrx and by a runtime test here.
The cursor and the snapshot are the compiler's roots (F2)
s_blk_<id>__i holds the cursor and s_blk_<id>__items holds a
snapshot of the list, both declared as <data> roots through
StatifierBlocks.Compiler.DeclaredRoots and both minted through
StatifierBlocks.Compiler.Context.role_id/2, so decision 3's
uniqueness keeps them out of any name an author can write.
The snapshot is assigned once, at the block's <onentry>, which is
what gives the loop SCXML 4.6.3's shallow-copy behaviour: the body walks
the list as it stood when the loop began, and a step inside the body
that writes the source path does not change what is left to iterate.
The cursor is reset in the same <onentry> as well as declared with
expr="0", which is what lets a foreach sit inside another foreach's
body and start from the top on each of the outer loop's passes.
The bindings are declared roots too (F3)
item_as and index_as are declared <data> roots rather than
anything scoped: early binding makes a root global, so they exist for
the whole session, and predicator refuses to read a root nothing
declared. They are re-assigned in the head state's <onentry> on each
pass, from snapshot[cursor].
A consequence worth stating: after the loop ends the bindings still hold
their last values - item_as holds the out-of-bounds read, undefined.
A step after the loop that reads them is reading whatever the loop left,
which is what "global" means and not something this type can narrow.
Termination, and the limit it carries (F5)
Termination is snapshot[cursor] === undefined: predicator indexes
lists and reads out of bounds as undefined, and it has no list-length
function, so there is no i < len(items) to test instead.
The operator is ===, strictly. Predicator's loose == against
undefined evaluates to :undefined rather than to a boolean, so the
loose form does not express a termination test at all.
The limit the ruling documents is that a list holding a legitimate
undefined/null item stops the loop early at that item. In the
resolved predicator the limit is narrower than that wording, and this
type is written to the narrower one: === is strict, so a nil item
does not trip === undefined - only an actual :undefined does,
which for a list read means only an out-of-bounds index. A list holding
nil items therefore iterates to its end, and a runtime test here pins
it.
Colliding bound names are refused (F6)
A nested foreach re-using its enclosing loop's item_as, or an
index_as equal to a root an enclosing block declares, would silently
overwrite that binding - early binding makes these roots global, so the
inner loop's writes are visible to the outer body after the inner loop
ends. The compiler refuses the document with a :duplicate_binding
Emit-stage finding against the inner block, carrying the config key the
name was typed into. The check is
StatifierBlocks.Compiler.DeclaredRoots', not this module's, and it is
a narrow carve-out from decision 9's delegation rather than a general
id-uniqueness check.
Sibling loops, and what F6 does not cover
F6's carve-out is nesting, and it is exactly the case where the
overwrite would otherwise be silent. Two sibling loops that both call
their item invitee are not that case - neither is inside the other -
but they are still refused, one stage later: both declare a <data>
root of the same name, and statifier's id-uniqueness check over the
whole document reports {:duplicate_id, "invitee"} against the second
one, mapped to that block and its item_as field. That is decision 9's
delegation working as written rather than a second check here, and F6
says as much when it describes itself as pre-empting exactly that
finding for the nesting case.
So a document may not have two loops binding one name anywhere, and an
author renames one of them. Whether sibling loops should instead
share one declared root is not a question this type may answer: a
shared root is safe for loops that run one after another and is a race
between the lanes of a core.parallel, and picking between them is an
ADR-level decision about the block vocabulary, not an emitter's.
Summary
Functions
The loop, emitted (F1-F5).
Everything in it is this block's except the transitions leaving the body
slot's children, which StatifierBlocks.Core.Emit.chain/2 attributes to
the child each one leaves (decision 5). The items expression, the
item_as location and the index_as location are each stamped with the
config field they came from, so an upstream finding inside one is the
author's typo rather than a bug in this type.
core.group's io/1: a step containing steps.
produces is absent rather than :unknown - a foreach has one outcome,
so there is no join to refuse, which is the distinction core.branch
and core.invoke declare :unknown for. consumes is absent too, and
it is the one place a reader might expect otherwise: a foreach does read
the datamodel through items, but that is a config path rather than a
value arriving through the type flow, exactly as core.invoke reads its
inputs through params.
One body slot, labelled for what makes this container different from
core.sequence: the steps inside it run more than once.