core.branch: one slot per condition arm, plus otherwise (ADR-0002
decision 10).
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:
config = %{"arms" => [%{"slot" => "arm_approved", "cond" => "budget_remaining > amount"}]}
slots(config)
#=> [{"arm_approved", :at_least_one, ~s(When "approved")},
#=> {"otherwise", :any, "Otherwise"}]Three details worth naming, because each is a place a reader would reasonably guess the other way:
- 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.
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.
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.
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.