StatifierBlocks.Core.Map (StatifierBlocks v0.27.0)

Copy Markdown View Source

core.map: a step that runs another chart once per item of a datamodel list, all of them at once, and waits for the whole batch (ADR-0009, accepted 2026-09-01).

It is a sibling of core.subchart, not a mode of it, and it is not core.foreach with a flag. ADR-0009 decision 1 is why: core.foreach is SCXML's synchronous loop, compiled into the parent chart, and a fan-out is N concurrent runs that may outlive the process that started them. One type meaning both would make the execution model conditional on a config value.

One <invoke>, whatever N turns out to be

Decision 3: a compiled core.map carries exactly one <invoke> - one entry in active_invocations, one done.invoke.<block id>, one error.communication.invoke.<block id> route - and the handler is what reads the list and starts the children. The compiled bytes do not scale with N and cannot: N is a runtime value, and a compile of one document never sees it. That is what keeps ADR-0004 decision 6's byte determinism intact - the same document compiles to the same bytes over three items and over three thousand.

It follows that this type validates nothing about N (campaign-031 ruling D31-9, recorded as ADR-0009's 2026-09-05 Tier A note). A bound on the batch, if measurement forces one, is a configuration key of the fan-out runtime with a runtime refusal on the ordinary error route - never a compile finding here, because the value a bound would apply to does not exist at compile time. Nothing in this package imports or knows about the runtime that enforces it.

items are descriptors, and the handler resolves them

The items field names a datamodel path, and the path is carried into the invocation as a path - a quoted literal in the <param> - so the handler evaluates it at the point it fans out. ADR-0009's second 2026-09-05 note is the discipline that goes with it: the list holds descriptors - ids, ranges, chunk handles - never row payloads. The parent's datamodel is serialized on every persisted step for the rest of the run, so a fan-out over ten thousand order ids costs what ids cost, and one over ten thousand order records charges the parent for those records forever.

The invoke type is a constant, and a different one from a subchart's

invoke_type/0 returns "statifier_blocks:map", in the shape core.subchart's constant has: one definition site, never a config field, because which handler starts children is deployment state rather than authoring state (st-ADR-0051). It is deliberately a different string from "statifier_blocks:subchart" (decision 3): a host that wired a single-child subchart handler has not thereby wired a fan-out handler, and a document that reached such a host should fail to find a handler rather than quietly start one child. StatifierBlocks.Compiler.InvokeTypes reports that gap at the one moment a caller holds both registries; nothing has to be registered there for it to, because that pass reads emitted <invoke type> strings rather than a list of known types.

Two outcomes, fixed

done and error, declared here rather than derived from the child chart. core.subchart takes its outcomes from the chart it names, because one child reports one outcome and an author can branch on it. N children report N outcomes, and joining them into one branch target has no meaning - "seven approved and one declined" is data, not control flow (decision 4). So the per-child answers go where data goes, into collect, and the block's own outcome says only whether the fan-out as a whole succeeded. An author who wants to branch on the answers reads the collected list with a core.branch after the block.

The seven fields

KeyTypeWhat it names
items{:path, %{}}the datamodel path holding the descriptor list
chart:stringthe document id of the chart run once per item
item_as:stringthe name a child sees its item under, default item
index_as:stringthe name a child sees its position under, when the author wants one
collect{:path, %{writes: {:list, envelope}}}where the assembled answer is written
collect_type{:type_expr, opts}what a collected answer is, as a declared type name or an inline shape
on{:select, ...}the aggregation policy, all or first_error

collect_type, and what it is not

ADR-0013 decision 1: the parent declares what one child's answer holds. It declares it in two arms - the name of a type the parent document's datamodel declares, read through StatifierDatamodel.Types.parse/2, or an inline shape written where the field is - and the field type is {:type_expr, opts}, decision 1's spelling as the ADR-0002 amendment of 2026-09-06 grew it. It is meaningful only beside a collect, it is optional, and an absent or empty one is what every document stored before it existed carries. A stored string is the name arm, so no stored document is rewritten and none reads differently.

It is a type, never a path and never an expression: it does not carry datamodel_path?, it is not read through StatifierBlocks.Core.AssignLocation, and it is offered no path candidates. It has no findings of this module's either: what the shared check StatifierBlocks.BlockType.type_expr_findings/2 refuses is bytes that are no arm at all, and it refuses them for every {:type_expr, opts} field in one place rather than here. parse/2 is total and answers {:opaque, s} for a name it does not recognize, which is the permissiveness ADR-0006 and ADR-0011 already chose: what the document does not say is not thereby wrong.

The declaration is the parent's because the parent is the document being compiled. A core.map names its child chart by document id and cannot resolve it, so the parent's is the only declaration always in hand where collect's environment entry is computed - and where both are in hand, StatifierBlocks.BlockType.agrees?/3 is the dormant agreement check of ADR-0013 decision 4.

It produces no bytes. emit/2 gains nothing from it. What it types is the "donedata" member of collect's element, which is ADR-0013 decision 5's entry and is spelled below.

The names a child sees, and why they bind nothing here

item_as and index_as are ADR-0009 decision 4's declared names for the item and its position, kept with the defaults item and index by ADR-0011 decision 11. They are the child's vocabulary: decision 3's <param> list carries them beside items, and the handler is what binds one item and one position per child run. Nothing in this document reads them, and this module declares no <data> root for either.

That is the one place a reader coming from core.foreach has to slow down. A foreach's item_as and index_as are declared roots in this chart, written by the loop's own <assign>, so a block in its body reads them and the walk binds them there. A map has no body to bind into: ADR-0009 decision 3 is "a per-item chart, not a per-item body", and an inline body slot was considered there and deliberately not built. So StatifierBlocks.Environment's fan-out binding - which fires for a block declaring a datamodel-path items field and carrying a slot called body - does not reach a core.map, and this block's only contribution to the environment stays the collect write of decision

  1. A name a child sees is bound in the child's own run, one document away from anything this walk can check.

Two smaller consequences follow from the same fact. Neither name is a datamodel path, so neither draws ADR-0005 clause 11e's declared-path advisory and neither is offered path candidates. And neither can collide with an enclosing loop's binding the way core.foreach's can (that check is DeclaredRoots', and there is no root here to collide), so the only cross-field rule this type carries is the foreach one that still means something: the item and its position cannot share one name, because the handler would bind the second over the first.

items and collect are declared {:path, opts} - ADR-0002 decision 7's eighth field type - so the editor offers the host's declared datamodel paths as candidates on both and gives a value the datamodel does not declare ADR-0005 clause 11e's :info advisory, which is a remark and not a refusal. collect is refused unless it is a datamodel path, the grammar ADR-0009 decision 4's Amendment of 2026-09-06 widened it to. The other three fields this package writes an <assign location="..."> from - core.invoke's and StatifierBlocks.InvokeStep's assign_to, and core.subchart's - read the same StatifierBlocks.Core.Config.datamodel_path?/1 since ADR-0011 decision 13 and sb-r313, so all four now agree: the same <assign> element writes the same datamodel, so there is one location rule to have. The shape of all four refusals is shared in StatifierBlocks.Core.AssignLocation, and now the rule is shared too. A bare lowercase identifier is still a valid collect - every one of them is already a datamodel path - so the widening refuses nothing the field accepted before.

collect carries the writes key ADR-0002's Note of 2026-09-06 records, and what it writes is {:list, <the envelope>} (ADR-0011 decision 12 as amended 2026-09-06, over ADR-0013 decision 5): the assembled answer is a list, dense and in item-index order per ADR-0009 decision 5, and one element of it is the envelope the shipped handler writes rather than the child's answer itself.

The envelope is an inline shape, and the declaration sits one level inside it:

MemberRequiredType
indexyesinteger
statusyesstring - one of completed, failed, cancelled
donedatanothis block's collect_type - the name it holds, or the shape it writes inline - or unknown when there is none
failurenoa shape of reason, attempts and detail

donedata and failure are optional because no element carries both and a cancelled element carries neither; index and status are required because every element carries both, on all three arms. Both rows of decision 5's table are richer than what shipped before, and the undeclared row is richer with no declaration at all: a block after a core.map learns that an element has an index and a status and how a failure is shaped whether or not the author declared anything. No compiled bytes move with it.

items carries neither key, so ADR-0011 decision 2 reads it as writing :unknown at the path it names: known without becoming typed.

on is read through its default, in core.parallel's G7a shape: an absent key reads as "all" everywhere, so a block an author never opened the field on compiles identically. A stored null is not an absent key and is refused (ADR-0001 decision 6). Decision 6 reserves the word quorum by refusing everything outside the two permitted values, so no host can establish a private meaning for it before its own walk happens.

all waits for every child to settle and a child failing is data at its index rather than a route to error; first_error cancels the live siblings and routes error. The runtime is what implements either, and it reads the policy off the on param verbatim.

What it compiles to

<state id="s_blk_INV" initial="s_blk_INV__running">
  <state id="s_blk_INV__running">
    <invoke id="blk_INV" src="bdoc_CHILD" type="statifier_blocks:map">
      <param expr="'signup.invitees'" name="items"/>
      <param expr="'bdoc_CHILD'" name="chart"/>
      <param expr="'invitee'" name="item_as"/>
      <param expr="'position'" name="index_as"/>
      <param expr="'answers'" name="collect"/>
      <param expr="'all'" name="on"/>
    </invoke>
    <transition event="done.invoke" target="s_blk_INV__o_done">
      <assign expr="_event.data" location="answers"/>
    </transition>
    <transition event="error.communication.invoke" target="s_blk_PARK"/>
  </state>
  ...
</state>

Three things about those bytes are worth saying out loud.

src carries the document id verbatim, as ADR-0004's subchart-src amendment has it, which is also what puts a core.map under StatifierBlocks.Compiler.SelfReference: that pass classifies by SCXML's own semantics rather than by block type, so a map naming the document it sits in is refused with no edit there.

Every <param> carries a literal, not an expression. The handler evaluates items; the parent does not. So each value is emitted quoted, and every field whose value reaches a quoted expression is refused a single quote in validate_config/1 - a value that closed the literal early would compile to something the author did not write. item_as and index_as are identifiers, so their own rule already excludes it.

item_as is emitted through its default and index_as only when the author named one. on's G7a shape, for on's reason: a stored config from before either key existed reads as item and no position name, so it validates exactly as it did and nothing has an older shape to migrate from (current_version stays 1). What it does not do is stay byte-identical: a document compiled before this type carried the names gains the item_as param, because ADR-0009 decision 3 says the param list carries them and until now it did not.

chart is emitted twice, as src and as a param. src is decision 3's requirement and is what the self-reference pass and a reading host see; the param is what the handler reads beside the other three, so a handler needs one place to look rather than two. They are the same verbatim string, stamped with the same provenance.

Where the answer is written

Decision 5: the whole result is one list at one author-named location, one element per item, in item index order, dense - errors sit at their own index, and under first_error cancelled siblings sit at theirs. Ordering by index rather than by completion is what makes the result a function of the input: completion order is not reproducible across a restart or a change of concurrency bound.

The write happens once, at the invocation's completion: the <assign> sits on the success transition, in the shape ADR-0007 decision 2 describes for a leaf step and core.subchart already emits. collect is optional and omitting it is supported (decision 7 clause 3) - a fan-out whose answers the parent does not need accumulates nothing, and nothing else about the block changes.

What this type does not do

It runs nothing. ADR-0002 decision 2's two-registry seam holds here as it does for core.invoke and core.subchart: this type names an invoke type, and a handler the host registers per session is what fans out. It imports nothing from the durable runtime packages, it mints no effect and no event name, and it takes no position on how child starts are batched or bounded - that is the fan-out runtime's record, cited by ADR-0009 decision 9 and not restated here.

Summary

Functions

A compound state that runs the whole batch as one invocation in an inner state and finishes at the <final> of whichever outcome it reached.

error is failure-classed: a batch that ended on the error route is a batch that finished badly (the campaign-033 failure seam, 2026-09-06).

The invoke type every core.map emits: the host-registered fan-out type.

A step with two outcomes, so produces is :unknown for core.invoke's reason: joining what the call produces with what the on_error subtree produces is the lattice ADR-0003 decision 4 refuses to build.

done and error, fixed rather than config-derived (ADR-0009 decision 4). The moduledoc says why N children cannot hand a parent one outcome to branch on.

Two zero_or_one slots, on_done then on_error, one per outcome (ADR-0009 decision 4).

The six fields with findings, and nothing about N.

Functions

emit(block, context)

A compound state that runs the whole batch as one invocation in an inner state and finishes at the <final> of whichever outcome it reached.

The two transitions match done.invoke and error.communication.invoke by SCXML's descriptor prefix rule and name no invocation, which is safe for core.invoke's reason: both sit on the inner state, active only while this block's own call is outstanding. The <invoke> still carries an explicit id of the block's own id (ADR-0004 C3), so a parent running two fan-outs at once can tell their completions apart by a value it knows at compile time.

An absent on_error slot still ends the block on error, exactly as core.invoke has it: ADR-0002's amendment of 2026-09-06 section 2 emits a failure-classed outcome's <final> whether or not its slot is occupied, because the class is read off the final, and with the slot empty the failure transition targets that final directly. An absent on_done slot is the ordinary case and routes straight to the done final.

Who owns what

Everything here is this block's except one transition per occupied slot: the one leaving a slot's child for that outcome's final is attributed to that child, because what happens after the parking step finishes is a fact about the child (ADR-0004 decision 5). The src attribute's value is stamped as coming from chart and the location's from collect, and each <param> from the field it carries, so an upstream finding inside one is the author's typo rather than a bug in this type.

failure_outcomes(config)

error is failure-classed: a batch that ended on the error route is a batch that finished badly (the campaign-033 failure seam, 2026-09-06).

ADR-0009 decision 4's outcome set is untouched by this - there are still exactly two outcomes, done and error, and the class is a second axis on error rather than a third outcome. Nor does it change what decision 5's collect holds: the per-child answers are still data, one element per item in index order, and an author still branches on them with a core.branch after the block. What the class changes is the top-level <final> for error: it carries the reserved <donedata> <param> that tells a durable stepper the run failed, so a fan-out that ended badly settles its parent's invocation instead of completing quietly. For a document whose root block is a core.map that is the root's own outcome final; from ADR-0002's amendment of 2026-09-06 (sections 2 and 4) a core.map below the root whose on_error slot is empty reaches the document's shared failed final too, and one whose slot is occupied is handled and reaches nothing above the block.

invoke_type()

@spec invoke_type() :: String.t()

The invoke type every core.map emits: the host-registered fan-out type.

A constant rather than a config field, for core.subchart's reason - which handler starts children is deployment state (st-ADR-0051) - and a different constant from that one, for ADR-0009 decision 3's: wiring a single-child handler is not wiring a fan-out handler.

io(config)

A step with two outcomes, so produces is :unknown for core.invoke's reason: joining what the call produces with what the on_error subtree produces is the lattice ADR-0003 decision 4 refuses to build.

consumes is absent - a fan-out reads its input out of the datamodel through items, not through the type flow.

outcomes(config)

done and error, fixed rather than config-derived (ADR-0009 decision 4). The moduledoc says why N children cannot hand a parent one outcome to branch on.

slots(config)

Two zero_or_one slots, on_done then on_error, one per outcome (ADR-0009 decision 4).

Arity is zero_or_one for core.invoke's reason: an outcome path is one continuation, not a list of them, and an author who wants several steps there puts a core.sequence in it.

validate_config(config)

The six fields with findings, and nothing about N.

collect_type is the seventh field and has no check here: it carries a type rather than a path or an expression, parse/2 and StatifierBlocks.Environment.inline_shape/1 are each total over any spelling, and what is left - bytes that are neither arm - is the shared StatifierBlocks.BlockType.type_expr_findings/2's to refuse for every {:type_expr, opts} field at once rather than this module's to re-implement (ADR-0013 decision 1, and ADR-0002's amendment of 2026-09-06 clause 2).

on and item_as are read through their defaults, so a config that never carried either key validates exactly as it did before the key existed; a stored null is not an absent key and is refused (ADR-0001 decision 6).

The one cross-field check is core.foreach's, and it earns its place here for the same reason it does there: two bindings that share a name read fine and mean nothing, since the handler binding the position would overwrite the item.