StatifierBlocks.Core.Branch (StatifierBlocks v0.1.0)

Copy Markdown View Source

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, otherwise is :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 empty otherwise is the ordinary "and if not, carry on".
  • produces is :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

config_schema(config)

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.

emit(block, context)

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 &gt; 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.

fixtures()

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.

slots(config)

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.