core.parallel: one slot per lane, with no ordering between the lanes
(ADR-0002 decision 10).
The second config-parameterized type. config carries an ordered
"lanes" list of bare lane names, and each lane name becomes the slot
"lane_" <> name:
config = %{"lanes" => ["capture", "receipt"]}
slots(config)
#=> [{"lane_capture", :any, "capture"}, {"lane_receipt", :any, "receipt"}]A lane stores its bare name and the slot prefixes it, where
StatifierBlocks.Core.Branch's arms store the whole slot name. The
asymmetry is not this type's choice: it is what the ADR-0001 worked
example stores, and the stored bytes win over any tidier scheme.
Lane slots are :any - a declared-but-empty lane is an ordinary
intermediate state, not a finding - and the order of the lanes in
config is presentation order only. Nothing about a parallel block gives
its lanes a sequence; that is the whole point of it, and it is why
palette_entry/0 declares layout: :columns so the lanes render side by
side (ADR-0005 decision 10).
produces is :unknown for the same reason core.branch's is: joining
the lanes' outputs is a type lattice, and ADR-0003 decision 4 refuses to
build one.
complete: when the block is done
A second config key, "complete", picks the completion rule
(ADR-0004's 2026-08-29 amendment, "core.parallel complete: first"):
"all"(the default, and what an absent key reads as) is the statifier-native rule - a<parallel>is done when every region is, so one transition ondone.state.<run>needs no join logic."first"is the racing rule - the block is done at the first lane's completion, which is one transition per lane on the<parallel>element itself, each taken on that lane's owndone.state.<region id>.
The key is read through its default everywhere, so every
core.parallel stored before it existed decodes, validates, and
compiles to the byte it did before (ADR-0001 decision 6: a stored
null is not an absent key and is still refused).
Summary
Functions
A compound state wrapping one <parallel> whose regions are the lanes
(ADR-0004 decision 2), each region sequencing its own steps and finishing
at its own <final>.
What the join marker under the lanes reads, given the block's config.
One :any slot per well-formed lane, in config order.
The lane names, as a chip list (ADR-0002 amendment H6).
The lane findings, then the complete one.
Functions
A compound state wrapping one <parallel> whose regions are the lanes
(ADR-0004 decision 2), each region sequencing its own steps and finishing
at its own <final>.
<state id="s_PAR" initial="s_PAR__run">
<transition event="done.state.s_PAR__run" target="s_PAR__done"/>
<parallel id="s_PAR__run">
<state id="s_PAR__lane_capture" initial="...">...<final id="s_PAR__done_lane_capture"/></state>
<state id="s_PAR__lane_receipt" ...>
</parallel>
<final id="s_PAR__done"/>
</state>A <parallel> is done when every region is, so the outer state needs one
transition and no join logic of its own.
The two role families are lane_<name> and done_lane_<name>. They
cannot collide with each other whatever a lane is called, because they
differ in their first token rather than their last - a lane named
capture_done mints lane_capture_done and done_lane_capture_done, and neither is
any other lane's id.
A parallel with no lanes emits no <parallel> at all: an empty one is
not valid SCXML, and "run nothing concurrently" is done the moment it
starts, so the block compiles to a state whose initial is its <final>.
That is true of both completion rules: there is no first lane to win
and no last lane to wait for, so complete moves no byte of it.
complete: "first": one transition per lane, on the <parallel>
The racing rule replaces the single done.state.<run> transition with
one transition per lane, placed on the <parallel> element itself
(ADR-0004's 2026-08-29 amendment, P1):
<state id="s_PAR" initial="s_PAR__run">
<parallel id="s_PAR__run">
<transition event="done.state.s_PAR__lane_authorize" target="s_PAR__done"/>
<transition event="done.state.s_PAR__lane_fraud_check" target="s_PAR__done"/>
<state id="s_PAR__lane_authorize" ...>
<state id="s_PAR__lane_fraud_check" ...>
</parallel>
<final id="s_PAR__done"/>
</state>Three properties of that shape are decisions rather than accidents.
The transitions come before the regions. Document order among the children of one element is the emitter's to fix, and this is the fixed position: the joins ahead of the lanes they join, which is the order the upstream ruling's worked chart is written in and the order the wrapper already uses for its own transition. One position, chosen once, is what ADR-0004 decision 6's determinism asks for; the transition set stays a pure function of the ordered lane list either way.
They are external. The block's done <final> is a sibling of the
<parallel>, not a descendant of it, so there is nothing for
type="internal" to preserve - and exiting the <parallel>, with every
region still in it, is precisely the effect wanted. Appendix D then runs
each losing region's <onexit> and raises one CancelInvoke per live
invocation it owns (P2). None of that is this package's to emit.
The done.state.<run> transition is dropped, not kept. It could
never be taken: done.state.<run> is raised only once every region is
final, and the first region to reach its own <final> raises
done.state.<region> first and exits the <parallel> on it. Keeping it
would write bytes that cannot fire, which a reader of the chart - or of
the provenance map - would have to explain to themselves. P1 says the
transition set is per lane and that nothing joins, so it is per lane and
nothing joins.
@spec join_label(StatifierBlocks.Block.config()) :: String.t()
What the join marker under the lanes reads, given the block's config.
A paletteEntry callback rather than a case in a renderer: the type
owns its own completion rule and therefore its own words, and nothing
on the layout path learns the string "core.parallel" (ADR-0005
decision 10). A host type that fans into lanes with a rule of its own
declares its own callback and gets its own.
iex> StatifierBlocks.Core.Parallel.join_label(%{"complete" => "first"})
"continue at first"
iex> StatifierBlocks.Core.Parallel.join_label(%{})
"continue when all"
One :any slot per well-formed lane, in config order.
Total for any config: a lane name that could not make a usable slot name is skipped rather than raised on, keeping ADR-0002 decision 6's stability rule true mid-edit.
The lane names, as a chip list (ADR-0002 amendment H6).
Read through the same filter slots/1 reads them through, so a
malformed lane is absent from the card exactly as it is absent from the
slot list and the two cannot disagree about which lanes exist.
Chips, not one string: a lane name longer than the presentation cap
loses its own chip and no other, since
StatifierBlocks.BlockType.summary/2 refuses each one on its own. That
is the whole reason this is the shape it is - the authoring spike wrote
the same line as fraud_review, balance_chec... and clipped it.
iex> StatifierBlocks.Core.Parallel.summary(%{"lanes" => ["capture", "receipt"]})
["capture", "receipt"]
iex> StatifierBlocks.Core.Parallel.summary(%{})
[]
The lane findings, then the complete one.
complete is read through its default, so an absent key validates
exactly as it did before the key existed and a config that carries only
lanes still answers with only lane findings. A stored null is not an
absent key: Map.get/3 hands the nil straight to one_of/2, which
refuses it (ADR-0001 decision 6).