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
| Key | Type | What it names |
|---|---|---|
items | {:path, %{}} | the datamodel path holding the descriptor list |
chart | :string | the document id of the chart run once per item |
item_as | :string | the name a child sees its item under, default item |
index_as | :string | the 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
- 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:
| Member | Required | Type |
|---|---|---|
index | yes | integer |
status | yes | string - one of completed, failed, cancelled |
donedata | no | this block's collect_type - the name it holds, or the shape it writes inline - or unknown when there is none |
failure | no | a 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
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.
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.
@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.
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.
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).
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.
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.