core.branch: one slot per condition arm, plus otherwise (ADR-0002
decision 10) and undecided (ADR-0012 decision 2).
The config-parameterized case ADR-0001 decision 5 exists for. config
carries an ordered "arms" list, each arm a %{"slot" => name, "cond" => expression} pair, and slots/1 returns one slot per arm in that order
followed by otherwise and undecided:
config = %{"arms" => [%{"slot" => "arm_approved", "cond" => "budget_remaining > amount"}]}
slots(config)
#=> [{"arm_approved", :at_least_one, ~s(When "approved")},
#=> {"otherwise", :any, "Otherwise"},
#=> {"undecided", :any, "Cannot be decided"}]The third slot is the reading, not a third destination
A predicator condition has three readings and the compiled chart used to have two destinations. A comparison against an operand it cannot compare
- an unbound datamodel path being the ordinary case - produces neither
truenorfalsebut predicator's:undefinedsentinel, which the engine turns into "the transition is not taken, plus oneerror.execution". So an undecided condition behaved exactly as a false one. ADR-0012 gives that reading a slot of its own, and the whole of the compiled difference is one synthesized guard transition - seeemit/2.
Wiring is what turns the difference on: a branch that leaves undecided
empty compiles to the bytes it compiled to at 0.20.0, error.execution
included (ADR-0012 decision 3).
Three details worth naming, because each is a place a reader would reasonably guess the other way:
undecidedis a slot, not an outcome.outcomes/1is untouched:slots/1says where children live andoutcomes/1says how finishing can differ (ADR-0002 amendment A2). An undecided condition changes which children run; it does not change how the branch finishes.- An arm stores its whole slot name,
"arm_approved", not the suffix. ADR-0002 decision 10's table says "slot suffix"; the ADR-0001 worked example stores the full name, and the stored bytes are what this type has to read. - Arms are
:at_least_one,otherwiseis:any. ADR-0002 decision 6's arity table names "a branch arm that must do something" as:at_least_one's motivating case, and an empty arm compiles to a condition guarding nothing. An emptyotherwiseis the ordinary "and if not, carry on". producesis:unknown, not a join of the arms. ADR-0003 decision 4 is explicit: combining the arms' outputs is a type lattice, and this package does not build one.
The schema is one :expression field per arm
config_schema/1 returns a condition field per arm, keyed by that arm's
slot name - which is unique within the block, and is the same key
validate_config/1 reports findings against, so a finding routes to the
field it is about (ADR-0005 decision 11's {:config, block_id, key}
anchor).
The condition itself is not stored under that key, though: it lives at
config["arms"][i]["cond"], so each field also declares the value_path
ADR-0002 decision 7 was amended to carry (2026-08-27) -
["arms", i, "cond"], with i the arm's index in the stored list.
Key and value path are two different questions about one field, and this
is the core type that has to answer them differently: the editor reads
and writes the condition through the path while findings and the form
control keep addressing the arm by name.
The "arms" list itself is deliberately not a field. Adding and removing
an arm changes the block's slot set, which makes it an editor command
over the document rather than a value typed into a form.
Summary
Functions
One :expression condition field per arm, keyed by the arm's slot name
and reading through value_path: ["arms", index, "cond"].
A compound state whose initial is a transient pick state carrying one
conditional transition per arm, in config order, then an unconditional
one for otherwise (ADR-0004 decision 2 names this shape).
Two datasets and one condition evaluated against both, so a palette panel can show what an arm's expression does before the author commits to it.
One slot per well-formed arm, in config order, then otherwise, then
undecided.
N arms + otherwise, counting the well-formed arms (ADR-0002 amendment
H6).
Functions
One :expression condition field per arm, keyed by the arm's slot name
and reading through value_path: ["arms", index, "cond"].
index is the arm's position in the stored list rather than among
the well-formed ones, so an arm below a malformed one still addresses
its own condition while an author is mid-edit. See the moduledoc for why
the key and the path answer two different questions.
A compound state whose initial is a transient pick state carrying one
conditional transition per arm, in config order, then an unconditional
one for otherwise (ADR-0004 decision 2 names this shape).
<state id="s_BR" initial="s_BR__pick">
<state id="s_BR__pick">
<transition cond="budget_remaining > amount" target="s_blk_A"/>
<transition target="s_blk_B"/>
</state>
<transition event="done.state.s_blk_A" target="s_BR__done"/>
...
<final id="s_BR__done"/>
</state>An arm's cond is the author's :expression config passed through
verbatim into predicator's datamodel - the compiler ships no expression
checking of its own (ADR-0004 decision 9), so a typo there surfaces as an
upstream compile error routed back through provenance by sb-qz0.
Each arm's steps are sequenced the same way a core.sequence's are, and
every arm's last step transitions to the block's own <final>, so a
branch is done when whichever arm it took is done. An empty otherwise
transitions there directly.
The undecided guard, when the slot is wired
A branch whose undecided slot holds at least one child emits one
extra transition, and it sits after every arm and before otherwise
(ADR-0012 decision 4). Its condition is composed from the arms' own
sources:
not ((c1) === false and (c2) === false and ... and (cn) === false)Strict equality is the one comparison predicator answers with a boolean
when handed its :undefined sentinel, so each conjunct is true
exactly when that arm decided false, and the negation is true
exactly when at least one arm did not decide (ADR-0012 decision 5).
Ordering is what keeps the guard that simple: by the time it is reached
no arm decided true, so "some arm was undecided" is all that is left
to test, and an earlier undecided arm never shadows a later decided one
(decision 6).
Two things the guard deliberately does not do. It does not rewrite the
arms' own transitions into (ci) === true, because a prefix on the
author's source would offset every provenance span composed inside it;
the arms keep the author's bytes. And it carries no cond_key,
because it is the type's composition rather than an author's
:expression field - a typo in ci surfaces on that arm's own
transition, which does carry the key.
An arm that errors rather than going undecided makes the guard error
the same way, so the guard is not taken and the block falls to
otherwise - which is where that arm already sent it. Wiring the slot
therefore adds a second error.execution for such an arm and routes
nothing differently (ADR-0012 decisions 5 and 7). A branch with no
usable condition emits no guard at all.
Two datasets and one condition evaluated against both, so a palette panel can show what an arm's expression does before the author commits to it.
Provisional: the accepted spellings are not settled
PROVISIONAL - see ADR-0002 decision 9. The atom-keyed spelling below
comes from an amendment to that decision which has not been accepted.
Until it is, treat the shape as the intended target rather than a
settled contract. That this callback exists, and returns term(), is
settled either way.
This is the one core type whose examples earn a bundle on their own:
every other structural type arranges blocks and has nothing to evaluate,
and statifier-ui's own docs/fixture-bundles.md names core.sequence as
its example of a fragment that ships no examples.
One slot per well-formed arm, in config order, then otherwise, then
undecided.
Total for any config, including config validate_config/1 rejects:
malformed arms are skipped rather than raised on, which is what keeps
ADR-0002 decision 6's stability rule true while an author is mid-edit.
undecided is ADR-0012 decision 2: a slot rather than an outcome,
:any for the same reason otherwise is, and labelled
"Cannot be decided" because that label is the whole of the
explanation an author gets in the editor. It cannot collide with an
arm, whose slot name must match arm_[a-z][a-z0-9_]*.
N arms + otherwise, counting the well-formed arms (ADR-0002 amendment
H6).
A count rather than a list of conditions: an arm's condition is an
expression, and an expression is not a chip. otherwise is named
rather than counted because it is always there - slots/1 appends it
to every branch, including one with no arms at all, and a card reading
1 arm would be under-reporting the paths out of the block by one.
Counted through the same filter slots/1 and config_schema/1 read
arms through, so a malformed arm an author is mid-edit on is not
counted and the card agrees with the slot list.
undecided is not named here, in either direction. ADR-0012 decision 9
asks for "1 arm + otherwise + undecided" on a branch that wires
the slot, and this callback cannot answer that: @callback summary(Block.config()) is handed the config alone
(lib/statifier_blocks/block_type.ex:609), and whether a slot holds
children is a fact about the block's slots map rather than its
config. Widening the callback to see the block is a contract change no
record has asked for, so the card reads exactly as it did at 0.20.0 -
which is decision 9's own answer for every unwired branch, and an
under-report by one for a wired one.
iex> StatifierBlocks.Core.Branch.summary(%{"arms" => [%{"slot" => "arm_approved", "cond" => "x"}]})
"1 arm + otherwise"
iex> StatifierBlocks.Core.Branch.summary(%{})
nil