StatifierBlocks.Core.Parallel (StatifierBlocks v0.18.0)

Copy Markdown View Source

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 on done.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 own done.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

emit(block, context)

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.

join_label(config)

@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"

slots(config)

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.

summary(config)

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(%{})
[]

validate_config(config)

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).