The selected block's config form (ADR-0005 decisions 9 and 13).
The form is a projection of current config, never a stateful mirror of
it: StatifierBlocks.ViewModel re-derives config_schema/1 against the
block's current config on every build, so a branch that gains an arm gains
a field on the very next render with nothing here to invalidate.
Two things this component does not do, both deliberate:
- It does not decide what reaches the document. Decision 9's gate -
an
:update_configis applied only whenvalidate_config/1returns:ok- lives inStatifierBlocks.Edit.check_config/3, whichStatifierBlocks.Edit.History.commit/4calls. This component posts params;StatifierBlocks.Editordecodes them and offers the command. - It does not render an unresolvable block's config. There is no
config_schema/1for one, and inventing a form would be guessing (decision 12), soStatifierBlocks.Editor.BlockNodeshows canonical JSON read-only instead.
form.unrouted renders at the head. That bucket exists because
Core.Branch.config_schema/1 keys one field per arm by the arm's own
slot name while validate_config/1 also emits findings keyed "arms" -
a key matching no field, because adding or removing an arm is a document
edit, not a form value. Rendering it here is what stops such a finding
from silently having nowhere to go.
Summary
Functions
The candidate list a host offered for one field, or [].
The capture pairs, one two-control row each (ADR-0011 decision 10).
One block's form: unrouted findings, then a control per schema field.
Decodes a phx-change payload into a config map, one field at a time
through StatifierBlocks.Editor.Field.decode/2.
Functions
@spec candidates_for( map(), StatifierBlocks.Block.type_name(), StatifierBlocks.ViewModel.Field.t() ) :: term()
The candidate list a host offered for one field, or [].
Keyed {type_name, field_key}: the values belong to a field of a
block TYPE rather than to a block, because which values exist is a
property of the deployment and the same field on two blocks of one
type offers the same ones.
The capture pairs, one two-control row each (ADR-0011 decision 10).
A row is a datamodel path written and a path inside the firing event's
_event.data read, and it is a repeated row rather than a field
because ADR-0002 decision 7's closed field-type set has no member that
describes a map and this record declined to add one. So the pairs have
no config_schema/1 declaration to render from, and they are drawn
here from the config the editor already holds.
There is always one blank row at the end, and it is what adds a pair: filling it writes a pair and the next blank row appears beneath it. Clearing both controls of a row is what removes one. That is two gestures rather than an add button and a remove button, and it is the shape the pairs already have: a map with a blank key is not a pair, so a row that says nothing is a row that is not there.
A stored map has no order of its own, so the rows before the blank one are in their targets' sorted order - the order the emission already fixes, for the same reason.
Attributes
rows(:list) (required)sources(:list) (required)target(:any) (required)block_id(:string) (required)
One block's form: unrouted findings, then a control per schema field.
Attributes
node(StatifierBlocks.ViewModel.Node) (required)target(:any) (required)class(:string) - Defaults tonil.expression_component(:any) - Defaults tonil.invoke_types(:list) - Passed through toStatifierBlocks.Editor.Field; see its moduledoc. Defaults to[].path_candidates(:list) - Passed through toStatifierBlocks.Editor.Field; see its moduledoc. Defaults to[].value_candidates(:map) - Passed through toStatifierBlocks.Editor.Field; see its moduledoc. Defaults to%{}.path_types(:map) - Passed through toStatifierBlocks.Editor.Field; see its moduledoc. Defaults to%{}.event_candidates(:list) - Passed through toStatifierBlocks.Editor.Field; see its moduledoc. Defaults to[].outcome_candidates(:list) - Passed through toStatifierBlocks.Editor.Field; see its moduledoc. Defaults to[].fixtures(:any) - Thefixturesthe editor holds,%{block_id => [TruthTable.t()]}ornil. Read here throughStatifierBlocks.Shell.tables_for/2for the selected block's rows and passed toStatifierBlocks.Editor.Fieldas the fixture hint; nothing else is derived from it.Defaults to
nil.field_candidates(:map) - The values a host offers, keyed{type_name, field_key}. Looked up here for the selected node's own type and handed toStatifierBlocks.Editor.Fieldone field at a time; see its moduledoc for the two spellings.Defaults to
%{}.capture_pairs(:any) - The block's capture pairs as ordered{target, source}rows, ornilfor a block that takes no capture map.[]is a block that takes one and has none yet, which still draws the row.Defaults to
nil.capture_sources(:list) - The source keys a captured event's example payload carries, drawn as the source control's<datalist>. Empty renders a plain input.Defaults to
[].pending(:list) - The fields whose typed value is not in the document, in schema order. Empty when the block has no outstanding draft.Defaults to
[].
@spec decode( [StatifierBlocks.ViewModel.Field.t()], map(), StatifierBlocks.Block.config() ) :: StatifierBlocks.Block.config()
Decodes a phx-change payload into a config map, one field at a time
through StatifierBlocks.Editor.Field.decode/2.
Three properties, each of which is a bug if it is missing:
- It starts from
base, the config the block already carries. An:update_configcommand replaces a block's whole config, so a decode that built a fresh map from the schema alone would delete every key the schema does not name. ADR-0002 decision 7 makesconfig_schema/1a rendering hint rather than the authority, and ADR-0001 decision 9's principle - leave alone what you do not understand - applies to config keys as much as to block types. - It is keyed off the schema, not off the params. A param naming something the schema does not is ignored, so a crafted payload cannot inject config keys.
- A field whose control did not post keeps the value it had. A partially rendered form does not blank out the fields it did not show.
Where a decoded value is written
A field's key names its control; where the value goes is the field's
value_path (ADR-0002 decision 7, amended 2026-08-27), which is [key]
unless the block type said otherwise. Core.Branch is the one core type
that says otherwise: its per-arm condition fields keep their slot-name
keys and declare ["arms", i, "cond"], so a branch's conditions are
editable here without this component ever inferring anything from the
shape of a key. Writing through the path is also what stops a top-level
config["arm_approved"] from accumulating beside the arm the author
actually edited.
The one value that is not written at all
An empty :duration omits its key rather than storing ""
(ADR-0005 decision 9, amended 2026-08-29). A cleared field and a
never-set field are the same value - there is no zero-duration
stand-in and no third
state for "the author touched this and then did not finish" - so the
two have to produce the same config, and the only config an absent key
can produce is one without the key.
This is the only place that can perform it. Field.decode/2 returns a
value and every value it could return is a value the key would then
hold; whether a key exists is a property of the map, which is this
function's to write. The omission is still the ordinary path in every
other respect: the resulting config goes to the same whole-config gate
through StatifierBlocks.Edit.check_config/3, and a required duration
left empty is refused there rather than here.