StatifierBlocks.ViewModel (StatifierBlocks v0.26.0)

Copy Markdown View Source

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/2 failing on a block (:unknown_block_type, :block_type_too_new, :migration_failed), anchored {:block, id}, severity :error.
  • :config - validate_config/1 on a resolved block, one per {key, message} pair, anchored {:config, id, key}, severity :error.
  • :config again, this time about the whole document - a palette entry declaring singleton (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, anchored {:block, id}, severity :warning.
  • :lint again, this time from a host's own whole-document rule - a StatifierBlocks.DocumentValidator in the palette's validators (ADR-0005 clauses 11p to 11t) - anchored wherever the rule says, severity :warning unless 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:

AnchorPosition in the view model
any anchor naming a block id not in the documentt().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 fieldthat field's findings
{:config, id, key}, key matches no fieldthat 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.

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).

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

chart_outcomes()

@type chart_outcomes() :: %{optional(String.t()) => [String.t()]}

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.

ctx()

Threaded through the recursive walk instead of two positional arguments.

kind()

@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.

slot_presentation()

@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.

t()

@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

accent_token(entry)

@spec accent_token(map()) :: String.t() | nil

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

arrangement(node)

@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.parallel declares it.
  • More than one body slot is :fan, the exclusive one. core.branch reaches 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.

body_slots(node)

Every slot placed in the body flow, in order: the arrangement's columns.

boundary?(node)

@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.

build(document, palette, findings)

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, 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.

core_containers()

@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"]

drafted_field(field, draft)

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.

effective_parent(vm, id, types)

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}

end_of_list_target(vm, id, types)

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}

exit_edge(slot)

@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.

fan_label(node)

@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.

fields_for(view_model_or_node, id)

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.

find_node(node, id)

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

flow_children(slot)

A slot's children that are in the flow: everything but a shelf.

order_palette_groups(groups, order)

@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"]

outcome_findings(document, palette, chart_outcomes)

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 chart the 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 amendment 11f takes the same posture for a nil datamodel, 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, not outcome_names/1: the appended error is 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 conditioned done.invoke transition that can never match - a dead routing arm - which is exactly the "compiles, and may not behave as intended" :warning names. The source is :lint for the reason summary_findings/4 above is one: the rule is this package's reading of a host value, not the block type's validate_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.

outline(view_model)

@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.

kindThe slot the node's parent holds it in
:stepthe parent's body, where arrangement/1 is :stack
:armone of the parent's body slots, where arrangement/1 is :fan or :lanes
:raila slot rail?/1 accepts
:traya 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"}
]

overlay_draft(node, draft)

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.

overlay_findings(node, findings)

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.

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"]

parent_of(arg1, id)

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

positions(root)

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}}

rail?(slot)

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.

sentence(node)

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"

shelf?(node)

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.

shelf_children(slot)

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.

shown_fields(node)

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.

subtitle(node)

@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"

summary_chip_titles(node)

@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]

summary_chips(node)

@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"]
...> })
[]

title(node)

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"

transparent?(node, types)

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

tray?(slot)

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.