StatifierBlocks.Core.Foreach (StatifierBlocks v0.23.0)

Copy Markdown View Source

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).

core.group's io/1: a step containing steps.

One body slot, labelled for what makes this container different from core.sequence: the steps inside it run more than once.

Functions

emit(block, context)

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.

io(config)

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.

slots(config)

One body slot, labelled for what makes this container different from core.sequence: the steps inside it run more than once.