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.
Two derived finding sources, and the compiler adapter
build/3's third argument is a caller-supplied [StatifierBlocks.Finding.t()].
This module derives exactly two sources of its own, because decision 13
puts resolution, migration and validation inside ViewModel rather than
upstream of it:
: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.
:assignability and :lint findings are never produced here; their
producers 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
the first, the compiler's invoke-type lint for the second - 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).
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.
Summary
Types
Threaded through the recursive walk instead of two positional arguments.
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.
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).
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).
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 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.
Types
@type ctx() :: {StatifierBlocks.Palette.t(), %{optional(StatifierBlocks.Block.id()) => [StatifierBlocks.Finding.t()]}}
Threaded through the recursive walk instead of two positional arguments.
@type slot_presentation() :: {:primary | :secondary | :failure, 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
(StatifierBlocks.BlockType.summary_refusals/2), one :warning per
refusal - the only derived finding here that is not an error.
@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 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 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_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 here. An over-long or newline-carrying chip was already
dropped where the node was built (StatifierBlocks.BlockType.summary/2,
under ADR-0002 B3's refuse-never-truncate discipline), so this reads what
survived. What did not survive is not silent: build/3 raises a :lint
warning against the block for each refused chip, so the difference between
"declared nothing" 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"