StatifierBlocks.BlockType behaviour (StatifierBlocks v0.16.0)

Copy Markdown View Source

The authoring-time extension seam. A host implements this behaviour once per palette entry; the core.* structural vocabulary this package will ship itself lands in a later record (ADR-0002 decision 10) and is not present yet.

Purity (decision 4)

Every callback is a pure function of its arguments: given the same arguments, a callback always returns the same result, and calling it has no side effect. Concretely, no callback may read the process dictionary, consult application configuration, perform IO, touch a database, read the system clock, or draw on randomness. This matters because validation runs on every edit and the compiler promises a deterministic build against a document hash - a callback that is not pure breaks both promises silently.

A block type that genuinely needs external data does not reach for it from inside a callback. The host resolves that data itself before the operation and threads it in as part of Block.config() or the emit/2 context - the callback stays a pure function of what it is handed.

This contract is enforced by convention, not by the quality gate: no credo check and no custom gate stage exist for it. The two test-only block types in test/support/block_type_fixtures.ex are the demonstration - neither one reaches outside its arguments.

Required and optional callbacks

Five callbacks are required; a module missing one of them is not a valid StatifierBlocks.BlockType and fails to compile as one. Five are optional (@optional_callbacks io: 1, migrate_config: 2, fixtures: 0, palette_entry: 0, outcomes: 1); a module that implements only the five required ones compiles cleanly, and each optional absence degrades to a stated default rather than an error:

CallbackRequired?Absent means
slots/1yes-
config_schema/1yes-
validate_config/1yes-
current_version/0yes-
emit/2yes-
io/1noassignability treats the block as unconstrained
migrate_config/2nothe type has never changed its config shape
fixtures/0nothe palette entry has no executable examples
palette_entry/0nothe editor falls back to the type name
outcomes/1nothe block has one outcome, {"done", "Done"}

Who owns what

This record fixes the callback surface: names, arities, and where a callback lives. It does not fix the shape of every return value - some of those are owned by later records:

CallbackShape owned by
io/1ADR-0003 (assignability)
emit/2ADR-0004 (compiler provenance)
palette_entry/0ADR-0005 (LiveView editor)
outcomes/1this record's amendment A; the emission is ADR-0004's

Declaring the defaults instead of spelling them (ADR-0007)

use StatifierBlocks.BlockType declares the behaviour and injects the answer a type gives when it has nothing of its own to say - no slots, no fields, nothing to refuse, version 1, unconstrained assignability, and no migration. Each injected callback is defoverridable, so a type writes only the rows where it differs:

defmodule MyApp.Blocks.Beep do
  use StatifierBlocks.BlockType

  @impl true
  def config_schema(_config), do: [%{key: "note", type: :string, label: "Note",
                                     required?: false, default: ""}]

  @impl true
  def emit(_block, context), do: {:ok, StatifierBlocks.Core.Emit.final(context.state_id)}
end

emit/2 is deliberately not among them. There is no emission a type can default to, and one injected here would let a type that forgot to compile anything look complete instead of failing to.

The defaults change nothing this record decides. They are ordinary function definitions, each a pure function of its arguments, so decision 4 holds for a use-ing type exactly as it does for a hand-written one, and a type that writes all nine callbacks out by hand is the same type it was before ADR-0007 existed.

Summary

Types

What emit/2 can refuse with (ADR-0004 decisions 4 and 10).

Names the offending config key; message is author-facing.

What join_label declares: a one-argument function of the block's config (ADR-0002 amendment B2).

One declared outcome: the name the compiled event carries, and human text on the same footing as a slot declaration's label (ADR-0002 amendment A1).

All keys optional. icon is a name resolved by a host-supplied component, never markup (ADR-0005 decision 10).

Name, arity, human label. Order is presentation order.

What a block type says about one block's config on the card's second line (ADR-0002 amendment H1).

Why one declared summary chip is not drawn, in the vocabulary of ADR-0002 amendment B3's refusal set as chip/1 applies it.

Keys and list indexes from the config root down to one value, as value_path carries them (ADR-0002 decision 7, amended 2026-08-27).

Callbacks

Ordered form fields for this config (ADR-0002 decision 7). Takes config because a branch gains a condition field per arm, and fields can be config-parameterized the same way slots are.

The version this module's config shape is at (ADR-0001 decision 4; ADR-0002 decision 8). Migration runs at resolution time, is applied in memory only, and is never written back by this package.

Emits this block's SCXML subtree (ADR-0004 decision 4).

Executable examples for this palette entry.

Type expressions for assignability. The return shape is StatifierBlocks.Assignability.io/0 (ADR-0003). Absent means assignability treats the block as unconstrained.

In-memory upgrade from an older stored type_version; never written back by this package (ADR-0002 decision 8). Absent means the type has never changed its config shape.

The ways this block can finish, in a fixed order (ADR-0002 amendment A1).

Palette presentation metadata. Contents are ADR-0005's. Absent means the editor falls back to the type name.

Slots this block carries given this config (ADR-0001 decision 5).

What this block's card says under its title, given this config (ADR-0002 amendment H1).

The authority on config validity (ADR-0002 decision 7). config_schema/1 is a rendering hint only; this callback is where the real rules - bounds, cross-field checks, identifier syntax - live. Findings name a config key and carry author-facing text.

Functions

Declares the behaviour and injects the overridable defaults ADR-0007 decision 1 names. See the moduledoc section above for what they are and why emit/2 is not one of them.

The chip a palette entry declares for its block type's card header, or nil when it declared none or declared one this package will not draw (ADR-0002 amendment B's badge).

Whether a field declaration says its value is a datamodel path (ADR-0002 decision 7's optional datamodel_path? key, amended 2026-08-29).

The config value at path, or :error when the path does not resolve.

What the join marker under this block type's side-by-side arrangement says for config, or nil when the entry declares none and the editor should use its own word (ADR-0002 amendment B's join_label).

The outcome config declares at key, or nil.

The names outcomes/2 declares, in declaration order.

module.outcomes(config), or [{"done", "Done"}] when outcomes/1 is absent or module is not loadable (ADR-0002 amendment A1). Checked with Code.ensure_loaded?/1 plus function_exported?/3, the pattern StatifierBlocks.Palette.resolve/2 already uses.

config with path's value replaced, or config unchanged when the path does not lead anywhere the value could go.

The config key the blocks in slot_name carry their outcome under, or nil when the entry declares none.

The chips module.summary(config) declares, or [] (ADR-0002 amendment H2).

What one summary_refusals/2 entry says to an author, in the words a :lint finding carries.

The chips summary/2 dropped, as {index, reason} in declaration order.

Where a field declaration's value lives, as a path from the config root.

Types

emit_error()

@type emit_error() ::
  [finding()] | {:invalid_role, StatifierBlocks.Block.id(), String.t()}

What emit/2 can refuse with (ADR-0004 decisions 4 and 10).

A list of finding/0 pairs is the ordinary case: a type that can validate its config but still cannot compile some combination of it reports findings against its own block id rather than raising. {:invalid_role, block_id, role} is what StatifierBlocks.Compiler.Context.role_id/2 hands back for a role the compiler could not invert, propagated as-is by a type that mints a role from config.

field_decl()

@type field_decl() :: %{
  :key => String.t(),
  :type => field_type(),
  :label => String.t(),
  :required? => boolean(),
  :default => StatifierBlocks.Block.json(),
  optional(:value_path) => value_path(),
  optional(:datamodel_path?) => boolean()
}

field_type()

@type field_type() ::
  :string
  | :integer
  | :boolean
  | {:select, [{value :: String.t(), label :: String.t()}]}
  | :expression
  | :duration
  | {:list, field_type()}

finding()

@type finding() :: {key :: String.t(), message :: String.t()}

Names the offending config key; message is author-facing.

join_label()

@type join_label() :: (StatifierBlocks.Block.config() -> String.t())

What join_label declares: a one-argument function of the block's config (ADR-0002 amendment B2).

It is the first executable thing to hang off a palette entry, so decision 4's purity rule applies to it in full - no process dictionary, no application-configuration lookup, no IO, no clock, no randomness. A captured named function (&MyApp.Blocks.Split.join_label/1) is the form to prefer over an anonymous closure, for exactly that reason: a closure over host state at registration time is the impurity decision 4 forbids, wearing a shape the type system cannot tell apart from a pure one.

outcome_decl()

@type outcome_decl() :: {name :: String.t(), label :: String.t()}

One declared outcome: the name the compiled event carries, and human text on the same footing as a slot declaration's label (ADR-0002 amendment A1).

Names match ~r/\A[a-z][a-z0-9_]*\z/ and contain no "__" - the role shape ADR-0004 decision 3 mints ids under, checked by StatifierBlocks.Compiler.StateId.role?/1.

palette_entry()

@type palette_entry() :: %{
  optional(:label) => String.t(),
  optional(:group) => String.t(),
  optional(:description) => String.t(),
  optional(:icon) => String.t(),
  optional(:keywords) => [String.t()],
  optional(:order) => integer(),
  optional(:layout) => :stack | :columns,
  optional(:slot_style) => %{
    optional(String.t()) => :primary | :secondary | :failure | :tray
  },
  optional(:slot_outcome_key) => %{optional(String.t()) => String.t()},
  optional(:accent_token) => String.t(),
  optional(:badge) => String.t(),
  optional(:join_label) => join_label()
}

All keys optional. icon is a name resolved by a host-supplied component, never markup (ADR-0005 decision 10).

accent_token, badge and join_label are ADR-0002 amendment B's presentation trio, whose contents stay ADR-0005 decision 10's (B1). The first two are inert data and the third is code (B2); all three are read through a total normalizer with refuse-never-truncate semantics (B3) - badge/1 and join_label/2 here, StatifierBlocks.ViewModel.accent_token/1 for the one whose value is interpolated into a style attribute.

slot_arity()

@type slot_arity() :: :any | :at_least_one | :exactly_one | :zero_or_one

slot_decl()

@type slot_decl() :: {StatifierBlocks.Block.slot_name(), slot_arity(), String.t()}

Name, arity, human label. Order is presentation order.

summary()

@type summary() :: nil | String.t() | [String.t()]

What a block type says about one block's config on the card's second line (ADR-0002 amendment H1).

nil is no second line and is what a type that exports no summary/1 means. A string is one summary line. A list of strings is a chip list, each entry read as one chip.

Every shape reaches consumers as a chip list through summary/2, so nothing downstream branches on which one a type chose.

summary_refusal_reason()

@type summary_refusal_reason() :: :too_long | :blank | :multiline | :not_a_string

Why one declared summary chip is not drawn, in the vocabulary of ADR-0002 amendment B3's refusal set as chip/1 applies it.

:not_a_string is anything that is not a binary, :blank an empty or all-whitespace string, :multiline one carrying a newline, carriage return or tab, and :too_long one past 24 characters. The order is chip/1's own: a string that is both newline-carrying and over-long reads as :multiline, because that is the arm that refused it.

value_path()

@type value_path() :: [String.t() | non_neg_integer()]

Keys and list indexes from the config root down to one value, as value_path carries them (ADR-0002 decision 7, amended 2026-08-27).

Callbacks

config_schema(config)

@callback config_schema(StatifierBlocks.Block.config()) :: [field_decl()]

Ordered form fields for this config (ADR-0002 decision 7). Takes config because a branch gains a condition field per arm, and fields can be config-parameterized the same way slots are.

This is a rendering hint, not the authority - validate_config/1 is. The seven closed field_type/0 values are :string, :integer, :boolean, {:select, options}, :expression, :duration, and {:list, field_type()}.

Where a field's value lives

A declaration's key addresses config[key], and the editor reads and writes exactly there. A field whose value lives elsewhere in the config says so explicitly with the optional value_path - a list of keys and list indexes from the config root down to the value, as StatifierBlocks.Core.Branch.config_schema/1 uses for a per-arm condition stored at config["arms"][i]["cond"].

The key stays the field's identity either way: it is what validate_config/1 anchors a finding to (ADR-0005 decision 11) and what the form keys its control by. value_path says only where the bytes are. Use value_path/1, fetch_value/2 and put_value/3 rather than reading the map key directly - they collapse both cases into one path.

What a field's value means

A declaration may also carry the optional datamodel_path?: true, declaring that the field's value is a path into the host's datamodel (ADR-0002 decision 7, amended 2026-08-29). It is orthogonal to value_path: one says where the value lives, the other says what the value means, and a field may carry both, either, or neither.

It is a claim, not a rule. validate_config/1 remains the authority on validity, and the annotation buys exactly one thing: the editor checks the value against a host-supplied datamodel and anchors an :info advisory on the field's key when the path is not declared (ADR-0005 amendment 11e-11g, implemented in StatifierBlocks.Datamodel). With no datamodel supplied nothing is produced, so a declaration carrying the key behaves exactly as one without it until a host opts in.

Read it through datamodel_path?/1, never by matching the key: a declaration that omits it is the common case.

current_version()

@callback current_version() :: pos_integer()

The version this module's config shape is at (ADR-0001 decision 4; ADR-0002 decision 8). Migration runs at resolution time, is applied in memory only, and is never written back by this package.

emit(t, t)

Emits this block's SCXML subtree (ADR-0004 decision 4).

context is a StatifierBlocks.Compiler.Context carrying the block's own id and state id, the document id, the ordered summaries of its already compiled children, and decision 3's role-minting function. It carries no palette and no child's emitted SCXML, so an emission is a function of this block's config and its children's ids - which is what makes decision 6's per-block byte stability hold.

The return is structural, never a string: see StatifierBlocks.Emission, and StatifierBlocks.Core.Emit for the shapes the shipped vocabulary uses.

fixtures()

(optional)
@callback fixtures() :: term()

Executable examples for this palette entry.

Provisional: the accepted spellings are not settled

PROVISIONAL - see ADR-0002 decision 9. The spellings below come from an amendment to that decision which has not been accepted. Until it is, treat this list as the intended target rather than a settled contract. The callback itself, and its term() return, are settled either way.

The return value is one of the four spellings statifier-ui's docs/fixture-bundles.md defines, and that page is the authority:

  • %StatifierUI.Fixtures{} - the struct
  • %{scenarios: ..., events: ..., datasets: ..., expressions: ...} - atom top-level keys, the Elixir spelling written by hand
  • %{"version" => 1, "datasets" => ...} - string top-level keys, the JSON spelling that survives a file
  • "palette/budget_check.fixtures.json" - a binary path

The bundle is addressed by the type_name the palette resolves it under, and discovery is per entry: one malformed bundle is reported against its own entry and every other entry still loads.

The spec stays term() deliberately. statifier-ui names no single type for the union of these four spellings, and this package must not assert a type it does not own. Nothing here calls the loader, and the statifier-ui package is not a dependency of this one: a host that wants palette-entry test panels depends on it itself.

Absent means the palette entry has no executable examples.

io(config)

(optional)

Type expressions for assignability. The return shape is StatifierBlocks.Assignability.io/0 (ADR-0003). Absent means assignability treats the block as unconstrained.

migrate_config(from, config)

(optional)
@callback migrate_config(from :: pos_integer(), StatifierBlocks.Block.config()) ::
  {:ok, StatifierBlocks.Block.config()} | {:error, term()}

In-memory upgrade from an older stored type_version; never written back by this package (ADR-0002 decision 8). Absent means the type has never changed its config shape.

outcomes(config)

(optional)
@callback outcomes(StatifierBlocks.Block.config()) :: [outcome_decl()]

The ways this block can finish, in a fixed order (ADR-0002 amendment A1).

A type that does not export it has exactly one outcome, {"done", "Done"}, which is the case every accepted core.* type is in and none of them changes meaning. outcomes/2's doc on this module is the resolver every consumer reads it through.

It takes config for decision 5's reason: a type whose alternative paths are config-parameterized declares one outcome per arm, the same shape slots/1 and config_schema/1 already have.

Order is declaration order and is never sorted. ADR-0004 decision 6's byte determinism reads it: outcomes serialize in the order this callback returns them, so reordering the list moves compiled bytes.

Stability rule (decision 6, and decision 4's purity): outcomes/1 is a pure function of config, returns the same list for the same config, and returns without raising for any config validate_config/1 accepts - the editor calls it mid-edit and the compiler calls it against config the type has already accepted.

A name failing the role shape, or declared twice, is an :invalid_outcome Emit finding against this block (ADR-0004's amendment, 2f), not a raise.

palette_entry()

(optional)
@callback palette_entry() :: palette_entry()

Palette presentation metadata. Contents are ADR-0005's. Absent means the editor falls back to the type name.

slots(config)

@callback slots(StatifierBlocks.Block.config()) :: [slot_decl()]

Slots this block carries given this config (ADR-0001 decision 5).

Declared slots are the complete set for this config: a slot name a document uses that slots/1 did not declare is :undeclared_slot, a finding a later record raises, not something this callback checks.

The four slot_arity/0 values:

  • :any - zero or more children
  • :at_least_one - one or more children
  • :exactly_one - exactly one child
  • :zero_or_one - zero or one child

Stability rule (decision 6): for any config validate_config/1 accepts, slots/1 returns without raising.

summary(config)

(optional)
@callback summary(StatifierBlocks.Block.config()) :: summary()

What this block's card says under its title, given this config (ADR-0002 amendment H1).

Optional. A type that does not export it has no summary, which is the card every block type had before the callback existed, and it is the case eight of the thirteen core.* types are in.

It exists so the editor can draw a per-type second line without ever naming a type: ADR-0005 decision 2 has the editor work off the caller-supplied palette, so a host type that declares a summary gets the same card face core.wait gets.

The three rules slots/1 and config_schema/1 already carry apply unchanged. It is a pure function of config, it is total - the editor calls it mid-edit, so it answers for config validate_config/1 rejects - and it never raises. A callback that raises anyway degrades to no summary rather than taking the canvas down (amendment H4), the bounded exception join_label/2 already documents.

Each chip is a presentation string, so amendment B3's refusal set governs it: an over-long chip is dropped, not clipped. See summary/2, which is the resolver every consumer reads it through.

validate_config(config)

@callback validate_config(StatifierBlocks.Block.config()) :: :ok | {:error, [finding()]}

The authority on config validity (ADR-0002 decision 7). config_schema/1 is a rendering hint only; this callback is where the real rules - bounds, cross-field checks, identifier syntax - live. Findings name a config key and carry author-facing text.

Functions

__using__(opts)

(macro)

Declares the behaviour and injects the overridable defaults ADR-0007 decision 1 names. See the moduledoc section above for what they are and why emit/2 is not one of them.

badge(entry)

@spec badge(palette_entry() | map()) :: String.t() | nil

The chip a palette entry declares for its block type's card header, or nil when it declared none or declared one this package will not draw (ADR-0002 amendment B's badge).

Total, under B3's refuse-do-not-truncate discipline. Refused: a non-string, an empty or all-whitespace string, one carrying a newline, carriage return or tab, and one longer than 24 characters. An over-long badge is dropped, not clipped, and one carrying a newline is dropped rather than collapsed to a space: a truncated chip reads as a rendering bug a host files against the editor, where a missing chip reads as the declaration it is.

nil means no chip, which is the card every block type had before the declaration existed. A malformed declaration in one host's registry produces the ordinary card, never a broken one and never an exception - decision 3's totality, arriving at presentation.

iex> StatifierBlocks.BlockType.badge(%{badge: "calls the host"})
"calls the host"

iex> StatifierBlocks.BlockType.badge(%{badge: "a chip that says altogether too much"})
nil

iex> StatifierBlocks.BlockType.badge(%{})
nil

datamodel_path?(arg1)

@spec datamodel_path?(field_decl()) :: boolean()

Whether a field declaration says its value is a datamodel path (ADR-0002 decision 7's optional datamodel_path? key, amended 2026-08-29).

Total, and true only for the literal true that the amendment admits. Absence means what it meant before the key existed, and any other value

  • a string, nil, a map - reads as absence rather than as truthiness, on the same normalizer discipline value_path/1 and StatifierBlocks.ViewModel.accent_token/1 are under: a typo in a host's registry degrades to the behaviour that predates the key.

    iex> StatifierBlocks.BlockType.datamodel_path?(%{key: "path", datamodel_path?: true}) true

    iex> StatifierBlocks.BlockType.datamodel_path?(%{key: "path"}) false

    iex> StatifierBlocks.BlockType.datamodel_path?(%{key: "path", datamodel_path?: "yes"}) false

fetch_value(value, arg2)

@spec fetch_value(StatifierBlocks.Block.json(), value_path()) ::
  {:ok, StatifierBlocks.Block.json()} | :error

The config value at path, or :error when the path does not resolve.

:error is a real answer, not a failure: an arm that carries no cond yet has no value at ["arms", 0, "cond"], and the form renders that field at its default. Total over any config and any path.

join_label(entry, config)

@spec join_label(palette_entry() | map(), StatifierBlocks.Block.config()) ::
  String.t() | nil

What the join marker under this block type's side-by-side arrangement says for config, or nil when the entry declares none and the editor should use its own word (ADR-0002 amendment B's join_label).

The declaration is a one-argument function of the block's config - join_label/0 - and it is host code on the editor's layout path, so two rules from amendment B2 and B3 apply and are implemented here:

  • It is a pure function of its argument. That is decision 4, and it is enforced by convention rather than by the gate, exactly as it is for every other callback. A host needing external data to phrase a marker resolves it before the operation and threads it through config.
  • A raise degrades to the default. The call happens inside a rescue, so a host type with a bug in its join_label gets an ordinary join marker rather than taking the canvas down. A throw and an exit are caught on the same grounds - B3's own heading names the throw. This is a deliberate, bounded exception to this package's "nothing rescued to a default" rule: the value being defaulted is one word of chrome and the alternative is a blank editor. validate_config/1, slots/1, emit/2 and every other callback keep the rule unweakened.

The return is then held to badge/1's refusal set, so a callback that answers with a sentence, a newline, or something that is not a string at all reads as no declaration rather than as a broken marker. A declaration that is not a one-argument function is refused without being called.

iex> StatifierBlocks.BlockType.join_label(%{join_label: fn _config -> "all lanes" end}, %{})
"all lanes"

iex> StatifierBlocks.BlockType.join_label(%{join_label: fn _config -> raise "boom" end}, %{})
nil

iex> StatifierBlocks.BlockType.join_label(%{join_label: "not a function"}, %{})
nil

outcome_name(config, key)

@spec outcome_name(StatifierBlocks.Block.config(), String.t() | nil) ::
  String.t() | nil

The outcome config declares at key, or nil.

The other half of slot_outcome_key/2: the container says where to look, this reads it out of one block's config. The value is an outcome name in ADR-0002 amendment A1's alphabet, so a config that holds something else there - a number, a sentence, a key that was never filled in - reads as no declared outcome rather than as a route nobody can render. nil for key is the no-declaration case, so a caller threads the pair without branching on it.

outcome_names(module, config)

@spec outcome_names(module(), StatifierBlocks.Block.config()) :: [String.t()]

The names outcomes/2 declares, in declaration order.

What the compiler mints ids and events from; the labels are the editor's.

Total over any return value, including one the outcome_decl/0 spec does not describe: a declaration that is not a {name, label} pair with a binary name comes back as its inspect/1 rendering, which no role shape matches, so a host type that declares nonsense gets the ordinary :invalid_outcome finding naming what it wrote rather than a crash inside the compiler.

outcomes(module, config)

@spec outcomes(module(), StatifierBlocks.Block.config()) :: [outcome_decl()]

module.outcomes(config), or [{"done", "Done"}] when outcomes/1 is absent or module is not loadable (ADR-0002 amendment A1). Checked with Code.ensure_loaded?/1 plus function_exported?/3, the pattern StatifierBlocks.Palette.resolve/2 already uses.

The list comes back in declaration order, never sorted: ADR-0004 decision 6's byte determinism reads that order, and a resolver that tidied it would move a host's compiled bytes for no reason it could name.

put_value(map, arg2, value)

config with path's value replaced, or config unchanged when the path does not lead anywhere the value could go.

The last segment is written whether or not something was already there - an arm missing its "cond" is exactly the arm an author is about to type a condition into, and refusing that write would make the field permanently uneditable. Every segment before it must already exist: a path is a way to reach a value the block type stores, not a licence for a form control to invent a shape the type never wrote. A list index out of range writes nothing, since a list has no gap to fill.

Only the map case needs a last-segment clause of its own. A list's does the same thing either way - replacing element i with the result of writing the empty path into it, which is that value - so the recursive clause covers a path ending at a list index too, and the empty path it bottoms out on addresses the config root. That is why value_path/1 never returns an empty path.

slot_outcome_key(entry, slot_name)

@spec slot_outcome_key(palette_entry() | map(), StatifierBlocks.Block.slot_name()) ::
  String.t() | nil

The config key the blocks in slot_name carry their outcome under, or nil when the entry declares none.

ADR-0005 decision 10's slot_outcome_key (proposed there as 10f): a block type with a statically-named slot whose children finish the container in more than one way may say where that answer lives, so a consumer routes on the value without knowing which type declared it. core.group declares %{"interrupts" => "outcome"}; the renderer reads the declaration, never the type name, which is the property decision 10 exists to preserve.

It names a key and never a value, for icon's reason: an entry carrying the outcome itself would be one declaration per slot for a fact that is per block, and the container does not know its children's config. Which outcome a given slot's completion reaches stays undeclared - ADR-0002's amendment A2 parks that deliberately, and this is not it.

Total, under ADR-0002 amendment B3's refuse-do-not-truncate discipline: a missing declaration, a non-map, a slot the map does not name, a non-binary key, and a key outside ~r/\A[a-z][a-z0-9_]*\z/ all read as nil, which means the uniform rendering every consumer did before the declaration existed. Nothing here raises and nothing is repaired into something almost right.

summary(module, config)

@spec summary(module(), StatifierBlocks.Block.config()) :: [String.t()]

The chips module.summary(config) declares, or [] (ADR-0002 amendment H2).

The one shape every consumer reads: a possibly-empty list of chips. A nil, a module that does not export summary/1, and a module that is not loadable all come back []; a string comes back as a one-element list; a list comes back filtered. Absence is checked with Code.ensure_loaded?/1 plus function_exported?/3, the pattern outcomes/2 already uses.

Each chip is held to badge/1's refusal set, unchanged: a non-string, an empty or all-whitespace string, one carrying a newline, carriage return or tab, and one longer than 24 characters are refused, never truncated (amendment H3). A refused chip is dropped and its siblings survive, so one over-long lane name costs its own chip and nothing else. A summary whose every chip is refused is [], which is the card that type had before it declared one.

A callback that raises, throws or exits answers [] on the grounds join_label/2 documents: this is host code on the editor's layout path, and the value being defaulted is one line of chrome.

iex> StatifierBlocks.BlockType.summary(StatifierBlocks.Core.Send, %{"event" => "order.paid"})
["order.paid"]

iex> StatifierBlocks.BlockType.summary(StatifierBlocks.Core.Sequence, %{})
[]

iex> StatifierBlocks.BlockType.summary(NoSuchModule, %{})
[]

summary_refusal_message(module, config, arg)

@spec summary_refusal_message(
  module(),
  StatifierBlocks.Block.config(),
  {non_neg_integer(), summary_refusal_reason()}
) :: String.t()

What one summary_refusals/2 entry says to an author, in the words a :lint finding carries.

The message names the chip's position, its length and the cap, because those are the three facts an author needs to fix the declaration and none of them is visible on a card that simply drew nothing. Position is one-based here and zero-based in the tuple: the tuple indexes a list and the sentence counts chips.

The cap lives in this module (ADR-0002 amendment H's Consequences: one number in one place), so the sentence is built here rather than by the editor. Total: an entry naming a position the type no longer declares still answers a sentence.

iex> StatifierBlocks.BlockType.summary_refusal_message(StatifierBlocks.Core.Parallel, %{"lanes" => ["capture", "balance_check_and_fraud_review"]}, {1, :too_long})
"summary chip 2 is 30 characters; the cap is 24, so it is not drawn"

summary_refusals(module, config)

@spec summary_refusals(module(), StatifierBlocks.Block.config()) :: [
  {non_neg_integer(), summary_refusal_reason()}
]

The chips summary/2 dropped, as {index, reason} in declaration order.

ADR-0005 decision 10's 2026-08-30 Note, "the cap signals". Refusing a chip removes the evidence that anything was declared, so the card of a block whose lane name is one character too long is indistinguishable from the card of a block that declared no lane at all. This is the reader that says which - the editor turns each entry into a :lint warning against the block (StatifierBlocks.ViewModel), and nothing about the card, the cap or summary/2 moves to make that possible.

index is the zero-based position in the list the type declared, so it survives a refusal in front of it - summary/2's output has already closed the gap and cannot be indexed against. Total for the same reasons summary/2 is: a type that exports no summary/1, a module that does not exist, and a callback that raises all answer [], which is "nothing was refused" and is honest in each case.

iex> StatifierBlocks.BlockType.summary_refusals(StatifierBlocks.Core.Parallel, %{"lanes" => ["capture", "balance_check_and_fraud_review"]})
[{1, :too_long}]

iex> StatifierBlocks.BlockType.summary_refusals(StatifierBlocks.Core.Wait, %{"duration" => "30s"})
[]

iex> StatifierBlocks.BlockType.summary_refusals(NoSuchModule, %{})
[]

value_path(map)

@spec value_path(field_decl()) :: value_path()

Where a field declaration's value lives, as a path from the config root.

The declared value_path when there is one, and [key] - the default ADR-0002 decision 7 states - when there is not. Callers get a path either way and never branch on which case they are in.

An explicitly empty value_path is read as no path at all rather than as "the config root": a field editing the whole config is not something decision 7 describes, and silently letting one through would let a form control overwrite every key the block carries.