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 four 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 |
collect | {:path, %{writes: {:list, :unknown}}} | where the assembled answer is written |
on | {:select, ...} | the aggregation policy, all or first_error |
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 accepts what core.subchart's
assign_to accepts, refused with the same wording: a bare lowercase
identifier. Two spellings of the same complaint would suggest an author
had met two fields.
collect carries the writes key ADR-0002's Note of 2026-09-06
records, and what it writes is {:list, :unknown} (ADR-0011 decision
12): the assembled answer is a list, dense and in item-index order per
ADR-0009 decision 5, and this block says nothing about what one element of
it holds, because the shipped child recipe emits the outcome name and
nothing else. A block after a core.map therefore knows it is looking
at a list - which is more than it knew before - and knows nothing about
an element, which is exactly true. 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="'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 the three fields whose values reach a quoted expression refuse a
single quote in validate_config/1 - a value that closed the literal
early would compile to something the author did not write.
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.
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 four fields' 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 emits no failure transition and no error
<final>, exactly as core.invoke has it: outcome wiring is an event
rather than a target, so a parent may transition on an outcome whose
final was never emitted and the transition simply never fires (ADR-0004
2c). 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.
@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 four fields' findings, and nothing about N.
on is read through its default, so a config that never carried the
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).