# `StatifierBlocks.Core.Map`
[🔗](https://github.com/riddler/statifier_blocks/blob/v0.44.1/lib/statifier_blocks/core/map.ex#L1)

`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 (an operator
ruling, 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 the
internal assign-location check, 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
12. 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 internal datamodel-path predicate since
ADR-0011 decision 13 and its widening of 2026-09-06, 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
one internal assign-location module, 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.

# `emit`

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`

`error` is failure-classed: a batch that ended on the error route is a
batch that finished badly (the 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`

```elixir
@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`

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`

`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`

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`

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.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
