Everything the editor renders, derived from {document, palette, findings} (ADR-0005 decisions 9, 10, 11, 12, 13).
This is the load-bearing module decision 13 names: it is where
resolution, migration, validation, and palette_entry/0 lookup happen,
and it produces a structure in which every block already carries its
declared slots, its form fields, its presentation metadata, and its
findings routed to the position that renders them. It lives outside
StatifierBlocks.Editor.* despite being an editor concern, and it names
no LiveView module - the components downstream of it are close to
mechanical, reading a view model and emitting markup with no palette
lookups and no callback invocations of their own.
The derived finding sources, and the compiler adapter
build/3's third argument is a caller-supplied [StatifierBlocks.Finding.t()].
This module derives sources of its own, because decision 13
puts resolution, migration and validation inside ViewModel rather than
upstream of it. There are five of them, listed below. The count grew
with ADR-0005 clause 11o, which superseded the "exactly two" this
moduledoc used to say, and again with clauses 11p to 11t, which gave a
host a whole-document rule of its own - so the number is stated here
rather than left to a reader to count, and the list beneath it is what it
counts:
:resolution-Palette.resolve/2failing on a block (:unknown_block_type,:block_type_too_new,:migration_failed), anchored{:block, id}, severity:error.:config-validate_config/1on a resolved block, one per{key, message}pair, anchored{:config, id, key}, severity:error.:configagain, this time about the whole document - a palette entry declaringsingleton(ADR-0005 clause 10z) that the document does not satisfy, anchored{:block, root_id}, severity:error. See "d10's cardinality declaration" below.:lint- a summary chip the presentation cap refused or clipped, anchored{:block, id}, severity:warning. It is read in the drawer's Findings tab and never on the card face (ADR-0005's Note of 2026-09-08, item 2).:lintagain, this time from a host's own whole-document rule - aStatifierBlocks.DocumentValidatorin the palette'svalidators(ADR-0005 clauses 11p to 11t) - anchored wherever the rule says, severity:warningunless the rule says otherwise. The rule says where and what; this module stamps the source, which is what keeps a host's rule distinguishable from a declared shape's:config.
:assignability findings are never produced here, and the two :lint
producers above are the only ones that are; the rest live elsewhere -
StatifierBlocks.SlotValidation (palette-aware slot arity and
undeclared-slot checks; landed under sb-da9, was described here as "not
yet built") and Assignability.validate/3 for :assignability, the
compiler's invoke-type lint for a :lint this module has never derived -
and this module does not
adapt StatifierBlocks.Compiler.Finding into StatifierBlocks.Finding
to manufacture them. That adapter is a real, mechanical possibility
(Compiler.Finding carries block_id and config_key, which map onto
{:config, id, key} / {:block, id} cleanly), and it landed instead as
StatifierBlocks.Finding.from_compiler/2 (sb-kmk) - ViewModel still
derives no findings from it; callers adapt compiler findings themselves
and pass the result to build/3 as caller-supplied findings. Derived
and caller-supplied findings are concatenated - derived first - into one
list, which is both t().findings and the document-level panel's source.
Routing, and the case that must not vanish
Each finding in the concatenated list is placed by its anchor:
| Anchor | Position in the view model |
|---|---|
| any anchor naming a block id not in the document | t().orphan_findings |
{:block, id} | that node's findings |
{:slot, id, name} | that slot's findings (a slot name the node does not carry falls back to the node's findings) |
{:config, id, key}, key matches a config_schema/1 field | that field's findings |
{:config, id, key}, key matches no field | that node's form.unrouted |
The fourth row exists because Core.Branch.config_schema/1 keys one
field per arm by the arm's own slot name, but validate_config/1 also
emits findings keyed "arms" - a key that matches no field, because
adding or removing an arm is a document edit, not a form value
(core/branch.ex). form.unrouted is rendered at the head of the
config form for exactly this case, and an unresolvable node - which has
no form at all - folds the same case into its own findings instead of
discarding it.
No route drops a finding: every arm of the table above lands somewhere,
and t().findings_count accumulates the same placements. A node's
findings_count covers its whole subtree - its own findings, its slots'
findings, its form's field and unrouted findings, plus every child's own
findings_count - so a collapsed subtree can carry a count badge
(decision 11's last sentence) without walking back down into it.
d10's defaults
palette_entry/0 is optional, and every one of its keys has a
default (decision 10) so a block type that implements none of it still
renders: label defaults to the type name, group to "Other",
description to "", icon to nil, keywords to [], order to
0, layout to :stack, slot_style to %{}, and slot_outcome_key
(decision 10's proposed 10f) to %{}.
d10's outcome declaration
A container whose statically-named slot holds blocks that finish it in
more than one way may declare, per slot, the config key those blocks
carry the answer under - core.group says
slot_outcome_key: %{"interrupts" => "outcome"}. Two things come out of
it here, both read through BlockType's total normalizers: the slot
carries the declared key as Slot.outcome_key, and every child in that
slot carries the resolved value as Node.outcome. A malformed
declaration, or a child whose config holds no well-formed outcome name,
is nil in both places - the uniform rendering, never a broken one
(ADR-0002 amendment B3).
d10's cardinality declaration
A palette entry may declare singleton: :head | :anywhere (ADR-0005
clause 10z), and build/3 answers it with one :config finding per
violating type - never with a repair. Both values mean "the document
holds exactly one block of this type"; :head additionally means it is
the first child of the root's first slot.
What "the root's first slot" is, because a root type declaring more
than one slot leaves the phrase undefined otherwise: it is the first
slot the root's type declares - slots/1's list is ordered and that
order is load-bearing everywhere else here, so the head is index 0 of
that slot's children. A root whose type declares no slots, or does not
resolve at all, falls back to the alphabetically first slot name the root
block actually carries, which is the order build_resolved_node/4
already puts undeclared slots in. Every :head finding names the slot it
measured against, so an author with a multi-slot root is never left
guessing which one the rule meant.
The count is over the document's blocks by type and the declarations
come from the palette, which is what lets the zero case exist at all:
a type nothing in the document uses still draws its finding, because the
palette is where the host said the document needs one. One finding per
violating type, not one per surplus block. The anchor is {:block, root_id} - decision 11's anchor enum has no document member, this clause
does not widen it, and the root is the one block every document has.
d12: unresolvable nodes
A block whose type does not resolve renders with its type name, a
status: {:unresolvable, reason} carrying Palette.resolve/2's own
error term, form: nil, its config as canonical-JSON text in
raw_config_json (there is no config_schema/1 to drive a form and
inventing one would be guessing), a :resolution finding, and its
existing children rendered normally, recursively with raw slot names -
the document's slots map preserved every one of them, decoding never
having consulted a registry.
d9: the form is a projection, never cached
A resolved node's form.fields come from module.config_schema/1
called against the block's current config, every time build/3
runs. Nothing here memoizes a schema across an edit: a branch that gains
an arm gains a field the very next time build/3 is called, because the
schema is a function of config (ADR-0002 decision 7), not a cache of one.
A field declared hidden?: true (ADR-0002 decision 7, amended
2026-09-07) is still listed in form.fields, carrying the flag. The
projection is the whole schema, and hiding is a rendering claim rather
than a filter: a host reads the list and filters by the flag to draw its
own surface, and StatifierBlocks.Editor.ConfigForm - the package's own
form - is the surface that skips them. Keeping them in the list is also
what lets ConfigForm.decode/3 preserve a hidden value, since that
function is keyed off the fields it is handed.
The readers a host asks the view model
find_node/2, parent_of/2, positions/1, sentence/1,
shown_fields/1, fields_for/2, overlay_draft/2 and
drafted_field/2 answer questions about a built view model and derive
nothing new. They are public because the reference embedder's Plan view -
statifier_examples' read-and-edit page over these same documents, and
the first consumer of every one of them - had written each of them out
privately in order to draw a list of blocks at all. A fragment that
answers a question about a document promotes; a fragment that decides how
a page is arranged does not, which is why there is no layout mode here to
go with them. The package's own editor calls these too, so the two
surfaces cannot answer the same question differently.
Summary
Types
What a host says each of its stored documents finishes with: the finals a
compile with StatifierBlocks.Compiler's :child_use option emits for
that document, keyed by the document id an author types into a
core.subchart's chart field.
Threaded through the recursive walk instead of two positional arguments.
How a block in outline/1's list is reached from the block above it
(ADR-0005's 2026-09-07 amendment).
What the parent's palette_entry/0 says about ONE slot: how it is placed
(decision 10's slot_style) and where its children carry their outcome
(decision 10's slot_outcome_key, proposed as 10f). Passed as a pair
rather than as two arguments because build_slot/8 is already at the
arity the style guide allows, and because the two are read together.
Functions
The custom-property NAME a palette entry declares as its block type's
accent, or nil when it declared none or declared one this package will
not put in a style attribute (ADR-0005 decision 14's accent_token).
How a container arranges its body slots: :lanes, :fan or :stack
(ADR-0005 amendment 10b, and the campaign-012 spike's arrangementOf).
Every slot placed in the body flow, in order: the arrangement's columns.
Whether a container draws as a boundary box: true when ANY of its slots declares a rail style (ADR-0005 amendment 10c, as amended by 10h).
Builds the view model. Derives :resolution, :config and :lint
findings from {document, palette}, concatenates findings after them,
routes every one of the combined list per the moduledoc's table, and
groups palette's types into palette_groups.
The three core containers a host most often flattens in an outline of its
own: core.sequence, core.group and core.resumable_group.
One field, showing draft's value where the draft has one.
Where the block carrying id sits once the transparent containers above
it are drawn through: parent_of/2's tuple, climbed past every
transparent ancestor.
Where an "add to the end of this list" lands for the block carrying id,
once the transparent containers above it are drawn through: the target
after the last flow child of effective_parent/3's slot.
Which edge vocabulary a slot's exit is drawn in (amendment 10h's exit-edge
row, as ruled on sb-67s, 2026-08-29).
The words on the pill drawn on the edge below an arranged container, or
nil when nothing is arranged (ADR-0005 amendment 10b, campaign 016).
The config fields of the block carrying id, or [] when there is no
such block or it has no form.
The node carrying id, or nil when no block in the tree does.
A slot's children that are in the flow: everything but a shelf.
groups in the reading order order names: the groups it names first, in
its order, then every group it did not name, by name.
The disagreements between what a core.subchart declares in outcomes
and what the host says the chart it names actually finishes with
(sb-r4w7).
The document in reading order: one {node, depth, kind} per block,
pre-order (ADR-0005's 2026-09-07 amendment).
node with draft's values over its form's fields, or the node
unchanged when it has no form.
node with findings over its form's fields, or the node unchanged
when it has no form.
Where the block carrying id sits: the {parent block id, slot name, index} its parent holds it at, or nil.
Where every block sits, as one map from block id to
parent_of/2's tuple.
Whether one slot is placed as an attached rail rather than in the body
flow: :secondary and :failure, and nothing else (amendment 10h's
placement row).
A node's line of prose: its own sentence, else title/1.
Whether this node is the drafts shelf itself.
A slot's children that are shelves - at most one, by ADR-0002 G12b, and drawn after the flow children so the shelf sits at the foot of the canvas.
A node's fields with the hidden ones rejected, or [] for a node with
no form.
The block type's own label, drawn under the title when the title is the
author's - the one thing a renamed card says nowhere else, and nil
when the author's name and the type's label are the same word.
The raw text behind each chip summary_chips/1 draws, nil where the
chip is drawn as its type declared it, and always exactly as long as
summary_chips/1.
The chips on the card's second line: the type's summary of this block's
config, one element per chip, and [] when there is no row to draw.
The name on the face of a card: the author's own, when this block carries one, and the block type's palette label otherwise.
Whether this node is one a caller draws through rather than draws.
Whether one slot is a detached shelf rather than either a body slot or an attached rail (ADR-0005's amendment of 2026-08-31, section 10s).
Types
What a host says each of its stored documents finishes with: the finals a
compile with StatifierBlocks.Compiler's :child_use option emits for
that document, keyed by the document id an author types into a
core.subchart's chart field.
@type ctx() :: {StatifierBlocks.Palette.t(), %{optional(StatifierBlocks.Block.id()) => [StatifierBlocks.Finding.t()]}, StatifierBlocks.BlockType.chip_labels()}
Threaded through the recursive walk instead of two positional arguments.
@type kind() :: :step | :arm | :rail | :tray
How a block in outline/1's list is reached from the block above it
(ADR-0005's 2026-09-07 amendment).
A partition of how a block is reached, never a filter on which blocks are listed: arms, rails and trays are kinds, not omissions.
@type slot_presentation() :: {:primary | :secondary | :failure | :tray, String.t() | nil}
What the parent's palette_entry/0 says about ONE slot: how it is placed
(decision 10's slot_style) and where its children carry their outcome
(decision 10's slot_outcome_key, proposed as 10f). Passed as a pair
rather than as two arguments because build_slot/8 is already at the
arity the style guide allows, and because the two are read together.
@type t() :: %StatifierBlocks.ViewModel{ document_id: StatifierBlocks.Document.id(), findings: [StatifierBlocks.Finding.t()], orphan_findings: [StatifierBlocks.Finding.t()], palette_groups: [StatifierBlocks.ViewModel.PaletteGroup.t()], revision: non_neg_integer(), root: StatifierBlocks.ViewModel.Node.t() }
Functions
The custom-property NAME a palette entry declares as its block type's
accent, or nil when it declared none or declared one this package will
not put in a style attribute (ADR-0005 decision 14's accent_token).
This is the consumption half of that seam. The editor stamps the name
on the block's card and its palette row and rebinds --sb-block-accent
there; two rules in the stylesheet read that property - an icon tile and
a card stripe - and they are the only two, which is what keeps a block
type's identity from becoming a rule per type. The editor never learns a
type name at any point in that path.
A descriptor carries a name and never a colour, on the same discipline
icon is under: a block type naming a hex value would be deciding what
it looks like in themes it has never seen. The value is the theme's.
Total, and validating for the reason the spike's theme.js was: the
return value is interpolated into a style attribute, so anything but an
anchored --sb-* name resolves to nil and the card falls back to the
editor's accent. A typo in a host's registry degrades to the default
rather than producing a broken card or an injection point - ADR-0002
amendment B3's discipline, arriving at one more key.
iex> StatifierBlocks.ViewModel.accent_token(%{accent_token: "--sb-accent-invoke"})
"--sb-accent-invoke"
iex> StatifierBlocks.ViewModel.accent_token(%{accent_token: "red; background: url(x)"})
nil
iex> StatifierBlocks.ViewModel.accent_token(%{})
nil
@spec arrangement(StatifierBlocks.ViewModel.Node.t()) :: :lanes | :fan | :stack
How a container arranges its body slots: :lanes, :fan or :stack
(ADR-0005 amendment 10b, and the campaign-012 spike's arrangementOf).
One derivation, read by three consumers that would otherwise each grow
their own: the class BlockNode puts on the slot box, the words the
ONE OF / ALL OF pill reads, and Connectors' decision between a fan
and a single entry edge. Three answers derived separately from the same
two facts is how they drift, and the pill disagreeing with the layout is
the drift a reader would see first.
The two facts, and neither of them a type name:
layout: :columns- decision 10's own metadata - is:lanes, the concurrent arrangement.core.paralleldeclares it.- More than one body slot is
:fan, the exclusive one.core.branchreaches it by declaring one slot per arm, and a host type of the same shape reaches it the same way.
Rails are excluded from the count for the same reason Connectors excludes
them: a rail is attached beside the body, so it is not one of the things
the body fans into.
A container with no body slot at all arranges nothing and is :stack,
whatever it declares - there is no second column for a marker to sit over.
@spec body_slots(StatifierBlocks.ViewModel.Node.t()) :: [ StatifierBlocks.ViewModel.Slot.t() ]
Every slot placed in the body flow, in order: the arrangement's columns.
@spec boundary?(StatifierBlocks.ViewModel.Node.t()) :: boolean()
Whether a container draws as a boundary box: true when ANY of its slots declares a rail style (ADR-0005 amendment 10c, as amended by 10h).
The partition is the rail partition, :secondary and :failure
alike, not the :secondary partition. 10c's stated reason - an attached
rule is about a region, so the region needs a visible edge - is as true of
a failure path as of an interrupt, and deriving both the rail placement
and the boundary from one partition is what kept decision 13's recursion
from acquiring a branch.
Drawing a box around every container instead turns a deeply nested document into nested rectangles that read as noise, which is why this reads metadata rather than depth.
@spec build(StatifierBlocks.Document.t(), StatifierBlocks.Palette.t(), [ StatifierBlocks.Finding.t() ]) :: t()
Builds the view model. Derives :resolution, :config and :lint
findings from {document, palette}, concatenates findings after them,
routes every one of the combined list per the moduledoc's table, and
groups palette's types into palette_groups.
The :lint half is the summary chips the presentation cap refused or
clipped (StatifierBlocks.BlockType.summary_refusals/2), one :warning
per entry, and whatever the palette's validators return - the derived
findings that are not errors.
The document-level rules run after the per-block ones and before the
findings argument (ADR-0005 clause 11t): the palette's own singleton
declarations first, then each StatifierBlocks.DocumentValidator in the
palette's list order. A palette declaring neither has nothing to run and
builds exactly the view model it built before either existed.
@spec core_containers() :: [StatifierBlocks.Block.type_name()]
The three core containers a host most often flattens in an outline of its
own: core.sequence, core.group and core.resumable_group.
The documented default for the types argument the three readers below
take, and nothing more than that - it is a list a host passes, extends
with its own container types, or replaces outright. The package does not
decide which types a host draws through; it only knows which of its own
are the obvious candidates, and saying so once here is what keeps three
hosts from each writing the same three strings slightly differently.
Mechanism upstream, policy host: transparent?/2, effective_parent/3
and end_of_list_target/3 never read this function. A caller passes the
list it means.
iex> StatifierBlocks.ViewModel.core_containers()
["core.sequence", "core.group", "core.resumable_group"]
@spec drafted_field( StatifierBlocks.ViewModel.Field.t(), StatifierBlocks.Block.config() ) :: StatifierBlocks.ViewModel.Field.t()
One field, showing draft's value where the draft has one.
A field the draft says nothing about keeps the value the document holds,
which is what makes a partially typed form show one changed row rather
than a blank set. The draft is read at the field's
StatifierBlocks.ViewModel.Field.value_path/1, so a field whose value
lives inside a nested member is drafted the same way a flat one is.
@spec effective_parent(t(), StatifierBlocks.Block.id(), [ StatifierBlocks.Block.type_name() ]) :: {StatifierBlocks.Block.id(), StatifierBlocks.Block.slot_name(), non_neg_integer()} | nil
Where the block carrying id sits once the transparent containers above
it are drawn through: parent_of/2's tuple, climbed past every
transparent ancestor.
parent_of/2 answers where a block sits in the document. A host that
flattens core.group out of its outline is drawing a different tree, and
the row it drew is held, as far as its reader can tell, by whatever
ancestor it did draw. This answers that: the {parent block id, slot name, index} of the highest unbroken run of transparent ancestors above
id, which is a position inside the nearest ancestor the host still
draws.
The climb stops at the root whether or not the root's own type is
transparent. The root sits in no slot, so there is no tuple above it to
climb to, and a flattened outline of an ordinary core.sequence document
is exactly the case where every ancestor is transparent - answering nil
there would make this reader useless on the documents it exists for. The
root's own answer is parent_of/2's: nil.
An id no block carries is nil, for parent_of/2's reason.
iex> alias StatifierBlocks.{Block, Document, Palette, ViewModel}
iex> wait = Block.new("core.wait", id: "wait", config: %{"duration" => "30s"})
iex> group = Block.new("core.group", id: "grp", slots: %{"body" => [wait]})
iex> vm =
...> Block.new("core.sequence", id: "root", slots: %{"body" => [group]})
...> |> Document.new()
...> |> ViewModel.build(Palette.core(), [])
iex> ViewModel.parent_of(vm, "wait")
{"grp", "body", 0}
iex> ViewModel.effective_parent(vm, "wait", ViewModel.core_containers())
{"root", "body", 0}
@spec end_of_list_target(t(), StatifierBlocks.Block.id(), [ StatifierBlocks.Block.type_name() ]) :: {StatifierBlocks.Block.id(), StatifierBlocks.Block.slot_name(), non_neg_integer()} | nil
Where an "add to the end of this list" lands for the block carrying id,
once the transparent containers above it are drawn through: the target
after the last flow child of effective_parent/3's slot.
The append a flattened outline offers at the foot of a list, as against
effective_parent/3's "where this row sits". The index counts
flow_children/1, not children - a shelf is drawn at the foot of the
canvas and is in no chain, so appending after it would put the new block
after something the reader was never shown in the list.
nil where effective_parent/3 is nil - the root offers no place
after itself, which is the same refusal parent_of/2 makes.
iex> alias StatifierBlocks.{Block, Document, Palette, ViewModel}
iex> wait = Block.new("core.wait", id: "wait", config: %{"duration" => "30s"})
iex> group = Block.new("core.group", id: "grp", slots: %{"body" => [wait]})
iex> after_group = Block.new("core.wait", id: "after", config: %{"duration" => "1s"})
iex> vm =
...> Block.new("core.sequence", id: "root", slots: %{"body" => [group, after_group]})
...> |> Document.new()
...> |> ViewModel.build(Palette.core(), [])
iex> ViewModel.effective_parent(vm, "wait", ViewModel.core_containers())
{"root", "body", 0}
iex> ViewModel.end_of_list_target(vm, "wait", ViewModel.core_containers())
{"root", "body", 2}
@spec exit_edge(StatifierBlocks.ViewModel.Slot.t()) :: :flow | :interrupt
Which edge vocabulary a slot's exit is drawn in (amendment 10h's exit-edge
row, as ruled on sb-67s, 2026-08-29).
:interrupt for the interrupt rail alone. A :failure rail's exit is
:flow, the same edge an ordinary body slot leaves by: ADR-0004's
amendment makes a failure path end in an error-outcome final whose
completion event the PARENT continues on, so it leaves in-band, and the
dashed exit channel with the interrupt arrowhead stays exclusively
interrupt vocabulary. Before the ruling the spike drew a failure rail
in-band and drew it leaving out-of-band in the same picture.
It is total over the three styles rather than defined on rails only: the
answer for a body slot is the same :flow its children already leave by,
and a partial function here would make every caller re-derive the rail
test this module already owns.
@spec fan_label(StatifierBlocks.ViewModel.Node.t()) :: String.t() | nil
The words on the pill drawn on the edge below an arranged container, or
nil when nothing is arranged (ADR-0005 amendment 10b, campaign 016).
The distinction the pill states is the one the arrangement already makes and nothing else in the picture does: a fan's columns are alternatives and a parallel's lanes are concurrent, and side-by-side columns look identical either way. The spike drew the same two words off the same derivation, and its note is the reason this is here rather than in the renderer: the words are the only place the exclusive/concurrent distinction is stated.
It is the editor's own vocabulary rather than a type's, which is what
separates it from join_label - a type phrases what its columns come back
together as, because only the type knows its completion rule, but whether
its columns are alternatives is a fact about the arrangement this module
already derived.
@spec fields_for(t() | StatifierBlocks.ViewModel.Node.t(), StatifierBlocks.Block.id()) :: [ StatifierBlocks.ViewModel.Field.t() ]
The config fields of the block carrying id, or [] when there is no
such block or it has no form.
find_node/2 and shown_fields/1's subject in one call: the list a
form decoder needs in order to read a submitted form back into a config.
@spec find_node(t() | StatifierBlocks.ViewModel.Node.t(), StatifierBlocks.Block.id()) :: StatifierBlocks.ViewModel.Node.t() | nil
The node carrying id, or nil when no block in the tree does.
The first consumer is the reference embedder's Plan view, which wrote
this walk out privately in order to answer "which node is selected" -
the question a view model that already holds the tree should answer
itself. It takes either the view model or a node, so a caller holding
a subtree can search inside it without reaching for root first.
iex> alias StatifierBlocks.{Block, Document, Palette, ViewModel}
iex> root =
...> Block.new("core.sequence",
...> id: "root",
...> slots: %{"body" => [Block.new("core.wait", id: "wait", config: %{"duration" => "30s"})]}
...> )
iex> vm = root |> Document.new() |> ViewModel.build(Palette.core(), [])
iex> ViewModel.find_node(vm, "wait").type
"core.wait"
iex> ViewModel.find_node(vm, "absent")
nil
@spec flow_children(StatifierBlocks.ViewModel.Slot.t()) :: [ StatifierBlocks.ViewModel.Node.t() ]
A slot's children that are in the flow: everything but a shelf.
@spec order_palette_groups( [StatifierBlocks.ViewModel.PaletteGroup.t()], [String.t()] | :all ) :: [ StatifierBlocks.ViewModel.PaletteGroup.t() ]
groups in the reading order order names: the groups it names first, in
its order, then every group it did not name, by name.
build/3 orders the palette's groups by name (decision 10's grouping
rule), which is the only order a package that does not know what its own
groups mean can produce. A host that regroups its palette by intent -
"Authorization" ahead of "Structure" because that is the order its staff
work in - does know, and the palette_groups list of a profile is already
that knowledge written down. This is how a caller spends it.
order is the caller's list, not the package's, on the footing
core_containers/0 sets: :all - the profile default - is a host that
named no order, and the groups come back untouched, by name, as they
always were. A name no group carries orders nothing. A group the list does
not name is not dropped here; it sorts after the named ones, by name.
Dropping is the profile's own rule and it runs where the profile is read
(docs/profiles.md), so a host calling this directly keeps every group it
built and only says which come first.
iex> alias StatifierBlocks.ViewModel
iex> alias StatifierBlocks.ViewModel.PaletteGroup
iex> groups = [
...> %PaletteGroup{name: "Authorization"},
...> %PaletteGroup{name: "Structure"},
...> %PaletteGroup{name: "Timing"}
...> ]
iex> groups |> ViewModel.order_palette_groups(["Structure", "Authorization"]) |> Enum.map(& &1.name)
["Structure", "Authorization", "Timing"]
iex> groups |> ViewModel.order_palette_groups(:all) |> Enum.map(& &1.name)
["Authorization", "Structure", "Timing"]
@spec outcome_findings( StatifierBlocks.Document.t(), StatifierBlocks.Palette.t(), chart_outcomes() ) :: [ StatifierBlocks.Finding.t() ]
The disagreements between what a core.subchart declares in outcomes
and what the host says the chart it names actually finishes with
(sb-r4w7).
It is a separate pass rather than part of build/3 for the reason
StatifierBlocks.Datamodel.findings/4 is: chart_outcomes is the
host's input, not the document's, and the projection stays a function of
the document plus the palette. The result goes in through the same
caller-findings seam every other supplied finding uses, so there is one
routing path and one place it is tested.
Three rules, and the first is the one that keeps it quiet:
- Unknown is not disagreement. A
chartthe map says nothing about produces nothing, and so does an entry holding an empty list - an empty list is the absence of knowledge about a chart's finals, not the claim that it has none. ADR-0005 amendment11ftakes the same posture for anildatamodel, for the same reason: a host that has not answered has not disagreed. - The author's own list is what is compared.
child_outcomes/1, notoutcome_names/1: the appendederroris ADR-0068's failure event rather than a<final>the child reports. - It is a
:warning, never an error. The document compiles either way. What a mismatch costs is a conditioneddone.invoketransition that can never match - a dead routing arm - which is exactly the "compiles, and may not behave as intended":warningnames. The source is:lintfor the reasonsummary_findings/4above is one: the rule is this package's reading of a host value, not the block type'svalidate_config/1, which cannot read the chart at all.
Anchored {:config, block_id, "outcomes"}, so it renders under the
field the author would fix it in.
@spec outline(t()) :: [ {StatifierBlocks.ViewModel.Node.t(), non_neg_integer(), kind()} ]
The document in reading order: one {node, depth, kind} per block,
pre-order (ADR-0005's 2026-09-07 amendment).
Pure, and a pure function of the view model alone - it reads root and
walks the tree build/3 already put in the struct, resolving nothing,
calling no callback, consulting no palette and reading no findings.
Calling it twice on one view model returns two identical lists.
It exists so that the outline pane this package may grow, a host's own
list view and a test asserting what a document says read one walk rather
than three re-derivations of it. Node.sentence is the line each entry
draws; depth is how far it is indented.
Every block appears exactly once. The first entry is always
{root, 0, :step}. A consumer wanting only the flow filters the list it
was given: the walk hides nothing, because a walk that hides a failure
rail is a walk a reviewer cannot trust to be the document.
kind | The slot the node's parent holds it in |
|---|---|
:step | the parent's body, where arrangement/1 is :stack |
:arm | one of the parent's body slots, where arrangement/1 is :fan or :lanes |
:rail | a slot rail?/1 accepts |
:tray | a slot tray?/1 accepts |
Depth is block nesting depth and nothing else. Every child is
exactly one deeper than the node whose slot holds it, in all four rows:
a slot is not an entry in the list and never consumes a level, so an
arm's blocks are one deeper than their container and a rail's blocks sit
at the same depth as that container's body blocks. The slot's identity
is carried by kind instead, which is why kind exists rather than a
second numeric column.
:step versus :arm is arrangement/1's question, asked once here
rather than re-derived from a slot count, so a type declaring
layout: :columns reads as arms for the same reason its slots sit side
by side on the canvas and the two surfaces cannot drift apart.
Slot order is the canvas's order: body_slots/1 first, then the
rails, then the trays, each group in node.slots order. Within a slot
the order is flow_children/1 then shelf_children/1, so a drafts
shelf sits at the foot of its slot and takes that slot's kind like any
other child - it is visited rather than skipped, because
flow_children/1 exists so a renderer can draw connectors past the
shelf, not so a reader can be told the shelf is not in the document.
shelf?/1 and this list are what a consumer wanting the flow alone
reads.
iex> alias StatifierBlocks.{Block, Document, Palette, ViewModel}
iex> root =
...> Block.new("core.sequence",
...> id: "root",
...> slots: %{
...> "body" => [
...> Block.new("core.wait", id: "wait", config: %{"duration" => "30s"}),
...> Block.new("core.send", id: "send", config: %{"event" => "order.paid"})
...> ]
...> }
...> )
iex> root |> Document.new() |> ViewModel.build(Palette.core(), []) |> ViewModel.outline()
...> |> Enum.map(fn {node, depth, kind} -> {node.block_id, depth, kind, node.sentence} end)
[
{"root", 0, :step, "Sequence"},
{"wait", 1, :step, "Wait 30s"},
{"send", 1, :step, "Send order.paid"}
]
@spec overlay_draft( StatifierBlocks.ViewModel.Node.t() | nil, StatifierBlocks.Block.config() ) :: StatifierBlocks.ViewModel.Node.t() | nil
node with draft's values over its form's fields, or the node
unchanged when it has no form.
This is the effective config a form shows beside a refused draft
(ADR-0002 decision 9): a config the document never accepted, made
visible without letting it near the document. Values only - only the
form is touched, and slots/1 is never called on a draft, which is the
promise decision 6 is owed. This function states no opinion about
whether the draft validates; the findings half is overlay_findings/2,
and a refused StatifierBlocks.Edit.Session.change_config/3 has already
put the findings to hand it in the session's draft_findings.
nil in, nil out, so a caller that has not resolved a selection yet
can pipe through it.
@spec overlay_findings(StatifierBlocks.ViewModel.Node.t() | nil, [ StatifierBlocks.BlockType.finding() ]) :: StatifierBlocks.ViewModel.Node.t() | nil
node with findings over its form's fields, or the node unchanged
when it has no form.
The findings half of the pair overlay_draft/2 opens, and the reason a
surface no longer re-runs StatifierBlocks.BlockType.validate_config/1
on a refused draft: findings are the
StatifierBlocks.BlockType.finding/0 pairs the refusal itself carried,
which a refused StatifierBlocks.Edit.Session.change_config/3 keeps in
the session's draft_findings under the block's id.
Routing is decision 11's, asked of the form rather than of the document:
a finding whose key names a field on this form is that field's, and one
whose key names no field lands in form.unrouted, where the form draws
it at the head. The unrouted ones are ordered by key so a redraw does
not move them. Every field is written, so a field the findings say
nothing about is left with none rather than with the document's - the
findings shown beside a draft are about the draft.
nil in, nil out, so a caller that has not resolved a selection yet
can pipe through it.
This is also where validate_config/1's declared findings shape is
enforced. findings must be a list of {key, message} string pairs -
StatifierBlocks.BlockType.finding/0, which ADR-0002 decision 7 states
as each finding naming a config key and a message. Anything else raises an
ArgumentError naming the block type that answered with it: the map
spelling some type could once slip past every Enum.group_by/3 consumer is
refused rather than normalized, and the message points at the type rather
than at this function.
Examples
iex> alias StatifierBlocks.ViewModel
iex> alias StatifierBlocks.ViewModel.{Field, Form, Node}
iex> field = %Field{key: "duration", type: :duration, label: "Wait for", required?: true, default: nil, value: "nope"}
iex> node = %Node{block_id: "blk_ONE", type: "core.wait", type_version: 1, status: :ok, form: %Form{fields: [field]}}
iex> overlaid = ViewModel.overlay_findings(node, [{"duration", "must be a duration"}, {"gone", "no such field"}])
iex> Enum.map(hd(overlaid.form.fields).findings, & &1.message)
["must be a duration"]
iex> Enum.map(overlaid.form.unrouted, & &1.message)
["no such field"]
@spec parent_of(t() | StatifierBlocks.ViewModel.Node.t(), StatifierBlocks.Block.id()) :: {StatifierBlocks.Block.id(), StatifierBlocks.Block.slot_name(), non_neg_integer()} | nil
Where the block carrying id sits: the {parent block id, slot name, index} its parent holds it at, or nil.
The root has no position, which is what makes moving and deleting it
refuse rather than raise - StatifierBlocks.Edit.apply/2 refuses to
remove the root too, and this is that refusal one step earlier, so a
surface can draw no button that cannot work. An id no block carries is
nil for the same reason.
The tuple is StatifierBlocks.Edit.target/0: what this answers is
directly what a command takes.
iex> alias StatifierBlocks.{Block, Document, Palette, ViewModel}
iex> root =
...> Block.new("core.sequence",
...> id: "root",
...> slots: %{"body" => [Block.new("core.wait", id: "wait", config: %{"duration" => "30s"})]}
...> )
iex> vm = root |> Document.new() |> ViewModel.build(Palette.core(), [])
iex> ViewModel.parent_of(vm, "wait")
{"root", "body", 0}
iex> ViewModel.parent_of(vm, "root")
nil
@spec positions(t() | StatifierBlocks.ViewModel.Node.t()) :: %{ required(StatifierBlocks.Block.id()) => {StatifierBlocks.Block.id(), StatifierBlocks.Block.slot_name(), non_neg_integer()} }
Where every block sits, as one map from block id to
parent_of/2's tuple.
parent_of/2 asked of one block; this is the same answer for the whole
document in one walk, which is what a surface drawing a row per block
wants rather than a lookup per row. The root is absent from the map for
the reason parent_of/2 answers nil for it.
iex> alias StatifierBlocks.{Block, Document, Palette, ViewModel}
iex> root =
...> Block.new("core.sequence",
...> id: "root",
...> slots: %{"body" => [Block.new("core.wait", id: "wait", config: %{"duration" => "30s"})]}
...> )
iex> root |> Document.new() |> ViewModel.build(Palette.core(), []) |> ViewModel.positions()
%{"wait" => {"root", "body", 0}}
@spec rail?(StatifierBlocks.ViewModel.Slot.t()) :: boolean()
Whether one slot is placed as an attached rail rather than in the body
flow: :secondary and :failure, and nothing else (amendment 10h's
placement row).
10i's posture applies above this: a slot_style value this editor does
not know resolves to :primary before it ever reaches here, so a host
declaring against a newer record gets an ordinary body slot rather than a
raise or a dropped slot.
@spec sentence(StatifierBlocks.ViewModel.Node.t()) :: String.t()
A node's line of prose: its own sentence, else title/1.
Node.sentence is the block type's own line where the type declares
sentence/1, and title/1 is the fallback this module already uses for
a type that declares none - so the answer is never blank for a block the
document holds. Every surface drawing a row writes this same two-clause
fallback, and writing it once is what keeps two surfaces from naming one
block differently.
It is the arity that separates it from the two other sentences in the
package, and the three are deliberately distinct: this one takes a node
and answers what to draw, StatifierBlocks.BlockType.sentence/2 asks a
block type for its own line, and this module's private sentence/5 is
where a built node's sentence field came from in the first place.
iex> alias StatifierBlocks.{Block, Document, Palette, ViewModel}
iex> root =
...> Block.new("core.sequence",
...> id: "root",
...> slots: %{"body" => [Block.new("core.wait", id: "wait", config: %{"duration" => "30s"})]}
...> )
iex> vm = root |> Document.new() |> ViewModel.build(Palette.core(), [])
iex> vm |> ViewModel.find_node("wait") |> ViewModel.sentence()
"Wait 30s"
@spec shelf?(StatifierBlocks.ViewModel.Node.t()) :: boolean()
Whether this node is the drafts shelf itself.
A shelf is a child of the root's body like any other block, so the slot
holding it is an ordinary :primary one and tray?/1 says nothing about
it. What has to be true of the node is 10u's other half: no connector
enters the shelf and none leaves it, so it is not in its own parent's
chain either. flow_children/1 and shelf_children/1 are that partition,
and they are the rendering counterpart of ADR-0002's G9a - the sibling
before the shelf is adjacent to the sibling after it, on the canvas for
the same reason it is in the compiler.
@spec shelf_children(StatifierBlocks.ViewModel.Slot.t()) :: [ StatifierBlocks.ViewModel.Node.t() ]
A slot's children that are shelves - at most one, by ADR-0002 G12b, and drawn after the flow children so the shelf sits at the foot of the canvas.
@spec shown_fields(StatifierBlocks.ViewModel.Node.t()) :: [ StatifierBlocks.ViewModel.Field.t() ]
A node's fields with the hidden ones rejected, or [] for a node with
no form.
hidden? is a field flag the view model sets and every surface honours
(ADR-0005 decision 11's field-flags amendment): the view model lists
every declared field and the surface filters. Repeating that filter per
surface is how a hidden field gets drawn once by accident, so it is
written here once instead.
@spec subtitle(StatifierBlocks.ViewModel.Node.t()) :: String.t() | nil
The block type's own label, drawn under the title when the title is the
author's - the one thing a renamed card says nowhere else, and nil
when the author's name and the type's label are the same word.
nil for every block the author has not named, because there the second
line is the type's summary of this block's config and that line is a row
of chips rather than a string: it is summary_chips/1, drawn as its own
markup (ADR-0005's 2026-08-30 amendment, decision 10, the summary chip
row). This function and that one are the two arms ADR-0002 amendment H5
describes, and exactly one of them answers for any card.
iex> alias StatifierBlocks.ViewModel
iex> ViewModel.subtitle(%ViewModel.Node{
...> block_id: "b", type: "core.wait", type_version: 1, status: :ok,
...> entry: %{label: "Wait"}
...> })
nil
iex> alias StatifierBlocks.ViewModel
iex> ViewModel.subtitle(%ViewModel.Node{
...> block_id: "b", type: "core.wait", type_version: 1, status: :ok,
...> entry: %{label: "Wait"}, summary: ["timer 30s"]
...> })
nil
iex> alias StatifierBlocks.ViewModel
iex> ViewModel.subtitle(%ViewModel.Node{
...> block_id: "b", type: "host.step", type_version: 1, status: :ok,
...> entry: %{label: "Intake"}, title: "Collect the details"
...> })
"Intake"
@spec summary_chip_titles(StatifierBlocks.ViewModel.Node.t()) :: [String.t() | nil]
The raw text behind each chip summary_chips/1 draws, nil where the
chip is drawn as its type declared it, and always exactly as long as
summary_chips/1.
ADR-0005 decision 10w's other half. A chip whose text has the shape of a
generated done-event name is drawn as <block label> and the outcome,
and the raw name goes on the chip's title attribute - verbatim and
untruncated, because that is what keeps the translation lossless for an
author reading a screenshot beside generated SCXML.
The length is realigned against summary_chips/1 rather than trusted,
so a Node built by hand - a doctest, a host's fixture - answers one
nil per chip instead of an empty list the caller would zip away.
iex> ViewModel.summary_chip_titles(%ViewModel.Node{
...> block_id: "blk_ON", type: "core.on_event", type_version: 1, status: :ok,
...> entry: %{label: "On event"}, summary: ["Abandon", "fraud.aborted"]
...> })
[nil, nil]
@spec summary_chips(StatifierBlocks.ViewModel.Node.t()) :: [String.t()]
The chips on the card's second line: the type's summary of this block's
config, one element per chip, and [] when there is no row to draw.
The other arm of ADR-0002 amendment H5, and the reader ADR-0005's
2026-08-30 amendment (decision 10, the summary chip row) describes. []
means no row at all rather than an empty one, which is the card every
type had before it declared a summary: summary/1 is optional and eight
of the thirteen core types declare none.
Empty for a block the author has named, because that card's second line
is already the type's label (subtitle/1) and a card has one second
line. A string summary arrives here as a one-element list, so the one-chip
case draws one chip and no separator of any kind.
Nothing is refused or clipped here. A newline-carrying chip was already
dropped, and an over-cap one already clipped, where the node was built
(StatifierBlocks.BlockType.summary/2, under ADR-0002 B3's discipline and
the summary-chip carve-out ADR-0005's Note of 2026-09-08 item 2 records),
so this reads the result. Neither is silent: build/3 raises a :lint
warning against the block for each entry, so the difference between
"declared nothing", "declared something unusable" and "declared something
too long" is readable.
iex> alias StatifierBlocks.ViewModel
iex> ViewModel.summary_chips(%ViewModel.Node{
...> block_id: "b", type: "core.on_event", type_version: 1, status: :ok,
...> entry: %{label: "On event"}, summary: ["Abandon", "fraud.aborted"]
...> })
["Abandon", "fraud.aborted"]
iex> alias StatifierBlocks.ViewModel
iex> ViewModel.summary_chips(%ViewModel.Node{
...> block_id: "b", type: "core.sequence", type_version: 1, status: :ok,
...> entry: %{label: "Sequence"}
...> })
[]
iex> alias StatifierBlocks.ViewModel
iex> ViewModel.summary_chips(%ViewModel.Node{
...> block_id: "b", type: "host.step", type_version: 1, status: :ok,
...> entry: %{label: "Intake"}, title: "Collect the details",
...> summary: ["from the type"]
...> })
[]
@spec title(StatifierBlocks.ViewModel.Node.t()) :: String.t()
The name on the face of a card: the author's own, when this block carries one, and the block type's palette label otherwise.
Two questions, not one, which is why this and subtitle/1 are a pair
rather than one field. A card answers "what is this step" with the most
specific name available, and "what kind of step is it" underneath - and
when the only name available IS the type's, there is nothing for the
second line to add.
iex> alias StatifierBlocks.ViewModel
iex> ViewModel.title(%ViewModel.Node{
...> block_id: "b", type: "core.wait", type_version: 1, status: :ok,
...> entry: %{label: "Wait"}
...> })
"Wait"
iex> alias StatifierBlocks.ViewModel
iex> ViewModel.title(%ViewModel.Node{
...> block_id: "b", type: "host.step", type_version: 1, status: :ok,
...> entry: %{label: "Intake"}, title: "Collect the details"
...> })
"Collect the details"
@spec transparent?(StatifierBlocks.ViewModel.Node.t(), [ StatifierBlocks.Block.type_name() ]) :: boolean()
Whether this node is one a caller draws through rather than draws.
A host that flattens containers in its own outline needs the same test in several places - the row it draws, the parent it climbs past, the slot it appends to - and a type comparison written three times is a type comparison that will disagree with itself once a fourth container joins the list.
types is the caller's list of transparent type names, not the package's:
core_containers/0 is the documented default, not a hidden one.
iex> alias StatifierBlocks.{Block, Document, Palette, ViewModel}
iex> root =
...> Block.new("core.sequence",
...> id: "root",
...> slots: %{"body" => [Block.new("core.wait", id: "wait", config: %{"duration" => "30s"})]}
...> )
iex> vm = root |> Document.new() |> ViewModel.build(Palette.core(), [])
iex> ViewModel.transparent?(ViewModel.find_node(vm, "root"), ViewModel.core_containers())
true
iex> ViewModel.transparent?(ViewModel.find_node(vm, "wait"), ViewModel.core_containers())
false
@spec tray?(StatifierBlocks.ViewModel.Slot.t()) :: boolean()
Whether one slot is a detached shelf rather than either a body slot or an attached rail (ADR-0005's amendment of 2026-08-31, section 10s).
:tray is deliberately not in the rail partition. 10h made "is this
container a boundary box" a question asked of that partition, on 10c's
grounds that an attached rule is about a region and a region needs a
visible edge. A tray is not attached to a region; it is beside the
document. Folding it in would put a boundary box around the root block of
every document that has a shelf - a frame drawn around the entire
workflow to say something about a shelf beside it (10t).
It is not in the body partition either, which is what body_slots/1
spells: a tray is not one of the things a container fans into, and its
contents take no entry edge.