A block type derived from params plus a pure subtree (ADR-0002 decision 5's amendment of 2026-09-07).
use StatifierBlocks.Composite is a second use macro on the declaration
surface StatifierBlocks.BlockType owns. Where use StatifierBlocks.BlockType
injects a fixed answer per callback (ADR-0007), this one injects answers
derived from a declaration: the params an author fills in, and the
subtree/1 those params stand for.
The declaration
defmodule MyApp.GuardedStep do
use StatifierBlocks.Composite,
name: "myapp.guarded_step",
params: [
%{key: "invoke_type", type: :string, label: "Call",
required?: true, default: ""},
%{key: "failure_path", type: :string, label: "Record the failure at",
required?: true, default: "", datamodel_path?: true}
],
sentence: "Call {invoke_type}, recording failure at {failure_path}",
palette_entry: %{label: "Guarded step", group: "Structure"},
version: 1
@impl true
def subtree(params) do
[
Block.new("core.invoke",
id: "call",
config: %{"invoke_type" => params["invoke_type"], "assign_to" => ""},
slots: %{"on_error" => [
Block.new("core.assign",
id: "guard",
config: %{"path" => params["failure_path"], "value" => "failed"})
]}
)
]
end
endparams is a [t:StatifierBlocks.BlockType.field_decl/0] in decision 7's
shape - no new key, no new field type. Every flag decision 7 and its
amendments give a field means here exactly what it means on any other
declared field, and the F3 and F4 refusals apply to a param declaration
unchanged: because the composite's config_schema/1 is the params, the
compiler's own declaration checks run over them for free.
subtree/1 is pure in decision 4's sense - same params in, same subtree
out, forever - and answers a non-empty list of StatifierBlocks.Block.t/0
whose head is the expansion root. Several derivations below read that
block and no other.
The ids the subtree mints
The ids a subtree/1 writes are local: expand!/2 mints each expanded
block's real id from the composite block's own id, as
composite_id <> "_" <> local_id. They are not fresh UXIDs and not a
counter over the document, and two properties follow:
- No
__.ADR-0004decision 3 derives a state id as"s_" <> block_idor"s_" <> block_id <> "__" <> role, and its invertibility rests on a block id containing no__.expand!/2refuses a local id that would put one there. - Document-unique, for free. The composite block's id is
document-unique, opaque and never reused (
ADR-0001decision 3), and minting is injective over it, so the members inherit all three.
A local id that looks like a freshly minted UXID ("blk_"-prefixed) is
refused: StatifierBlocks.Block.new/2 mints one when a declaration writes
no :id, and a minted id is different on every call, which would make
subtree/1 impure and the expansion unstable.
What the use derives
| Callback | The composite's answer | Overridable |
|---|---|---|
config_schema/1 | params, in declaration order | no |
validate_config/1 | not derived: ADR-0007's injected :ok stands, and the refusals params declare are run by the compile over config_schema/1 (see below) | yes |
slots/1 | the declared pass-through slots, in declaration order (RQ-SF038-5) | no |
io/1 | see below | no |
current_version/0 | the version the declaration states | no |
outcomes/1 | the expansion root's, over its expanded config | no |
sentence/1 | the declaration's template rendered over the config | yes |
summary/1 | one chip per visible param (RQ-SF038-14) | yes |
palette_entry/0 | the map the declaration states | yes |
emit/2 | generated, and raises if reached (RQ-SF037-6) | no |
sentence/1, summary/1, palette_entry/0 and validate_config/1 and
no others are overridable: they are the four whose answers are about
presentation and refusal rather than about the expansion. migrate_config/2
keeps ADR-0007's injected refusal unchanged, and fixtures/0,
failure_outcomes/1 and donedata_type/1 are not derived - they stay
optional and absent unless a declaration writes them by hand.
The derived summary/1
ADR-0002's Note of 2026-09-07, item 4: the chips are the declaration's
params, minus those declared hidden?: true. hidden? is a rendering
claim (block_type.ex, "the field is never rendered by any form"), so a
param the author has already said no form draws is not a chip either - the
same reading StatifierBlocks.ViewModel takes of the flag. Each surviving
param draws as "<label>: <value>", and a param whose value renders blank
draws no chip at all rather than a label with nothing after it.
The value is read with Map.get/2 on the param's :key, which is the
reading render_sentence/2 beside it already takes of the same declaration:
a composite's params are its own config's keys, and the two derivations that
read them cannot be allowed to disagree about which key a param is at.
It is overridable for sentence/1's reason - a declaration whose card
wants to say something the params cannot spell says it itself.
validate_config/1 is left at ADR-0007's injected :ok, deliberately -
the macro never redefines it, and the row above states what the compile
guarantees rather than a generated function (ADR-0002's Note of
2026-09-07, correction 2). The refusals a param declares are
declaration-level - F3's missing default:,
F4's empty hidden default, and {:type_expr, opts}' allow_empty? - and
the compile already runs every one of them over config_schema/1, which for
a composite is the params. required? is a rendering hint and never an
authority (block_type.ex, "This is a rendering hint, not the authority"),
so it raises no finding here either: that is what makes the amendment's own
worked example - two required?: true params defaulting to "" - land
finding-free out of StatifierBlocks.Palette.new_block/2. A composite with
a cross-param refusal its params cannot state as a single field's flag
overrides the callback, which is why it is one of the three that may be.
emit/2 exists because the behaviour requires it and ADR-0007 deliberately
injects no default for it. It raises, because the compiler expands a
composite at Resolve and no composite block survives to Emit (sb-nzc1).
What a composite reads and writes
A composite's reads and writes, as the environment walk consumes them, are
the union of its expanded members', each taken over that member's
expanded config, at the composite's one position in the document
(RQ-SF037-15, ruled 2026-09-07: shape (A)). That union is computed by
StatifierBlocks.Environment.read_signatures/3 and write_signatures/3 -
the same two functions, run over expand!/2's subtree - and not by io/1,
which is single-valued in consumes and produces and carries no per-path
read or write at all.
The derived io/1 therefore answers, per key of
StatifierBlocks.Assignability.io/0:
kinds- the members'kindsconcatenated in expansion order, de-duplicatedslot_accepts- one entry per declared pass-through slot, at the mapped inner slot's own accepted kinds;%{}for a composite that declares none, which is every composite written beforeRQ-SF038-5consumes- the expansion root's, or absent when the root declares noneproduces- the expansion root's, or absent when the root declares none
Dropping a non-root member's sugar under-declares rather than over-declares,
which is the safe direction ADR-0011 decision 6 already takes.
The callbacks are core-only, and a reader with a palette is not
Both rows read a member's module, and a StatifierBlocks.Block.t/0
carries a type name. Resolving a name to a module is
StatifierBlocks.Palette's job, and neither StatifierBlocks.BlockType.io/1
nor StatifierBlocks.BlockType.outcomes/1 is handed a palette - so the two
callbacks resolve members through StatifierBlocks.Palette.core/0,
whose types are StatifierBlocks.Palette.core_types/0, the one type map this
package holds as a value. A composite whose expansion root is a host type
therefore falls back in the callback: outcomes/1 to the behaviour's
default outcomes, and io/1 to [:step] kinds with no sugar.
That fallback is the callbacks' answer and stays their answer (ADR-0002's
Note of 2026-09-07, item 3: no @callback is added, removed or re-arity'd,
and neither derivation gains a palette argument). What a reader holding a
palette does instead is call io/2 and outcomes/2 below, which take the
palette as their first argument and resolve every member through it - so the
same composite is exact wherever a palette is in hand and takes the
documented fallback only where none is.
The environment walk needs neither: it recurses into
StatifierBlocks.Environment.read_signatures/3 and write_signatures/3 over
the expansion, so no composite reaches its io/1 there at all.
The derived recipe
The declaration also derives a StatifierBlocks.Recipe at <Module>.Recipe,
whose insert/2 returns exactly one :insert of the composite block at
the armed target and whose palette_entry/0 is the block type's. One
command, not the expansion: what an author puts down is the composite, and
the expansion happens at compile.
It is a compatibility surface for a host that already ships the arrangement
as a recipe. A composite's own palette entry is its types entry;
StatifierBlocks.Palette keeps types and recipes in two maps, so a host that
registers both is choosing to show two entries.
Summary
Types
The normalized declaration, as __composite__/0 answers it.
Each expanded block's id to the param key that produced it, or to nil
for a block no param is responsible for.
One declared pass-through slot: a slot the composite exposes, and the
{local_id, inner_slot} of the expansion member its children are spliced
into (ADR-0002's pass-through amendment, P1).
Callbacks
The blocks this composite stands for, given its params.
Functions
Declares a composite block type from opts.
Whether module is a composite block type.
The blocks block stands for, and the param each one is blamed on, as a
tuple.
The blocks block stands for, and the param each one is blamed on.
Every block in an expansion, in pre-order: each block, then its slot children.
block's io, with every member resolved through palette (ADR-0002's
Note of 2026-09-07, item 3).
subtree's three pass-through refusals, as a list of messages, or [].
block's outcomes - its expansion root's, over the root's expanded config -
with the root resolved through palette (ADR-0002's Note of 2026-09-07,
item 3).
The pass-through slots block's type declares, each resolved to the
minted id of the member its children are spliced into.
Types
@type declaration() :: %{ name: StatifierBlocks.Block.type_name(), params: [StatifierBlocks.BlockType.field_decl()], version: pos_integer(), sentence: String.t() | nil, palette_entry: StatifierBlocks.BlockType.palette_entry(), slots: [pass_through_decl()] }
The normalized declaration, as __composite__/0 answers it.
@type param_map() :: %{optional(StatifierBlocks.Block.id()) => String.t() | nil}
Each expanded block's id to the param key that produced it, or to nil
for a block no param is responsible for.
A member carrying more than one param's value is blamed on the first
param in declaration order (ADR-0002's Note of 2026-09-07, item 5, ruling
RQ-SF038-14): the declaration has an order and an author reading a
finding needs one field to open, not none.
ADR-0004's amendment (sb-nzc1) re-anchors a finding raised inside an
expansion against the composite block, carrying the key this map names -
and config_key: nil when it names none, which is the honest answer when
no param fed the block.
@type pass_through_decl() :: %{ name: StatifierBlocks.Block.slot_name(), to: {String.t(), StatifierBlocks.Block.slot_name()}, label: String.t(), arity: StatifierBlocks.BlockType.slot_arity() }
One declared pass-through slot: a slot the composite exposes, and the
{local_id, inner_slot} of the expansion member its children are spliced
into (ADR-0002's pass-through amendment, P1).
:label and :arity are forced rather than added: a
StatifierBlocks.BlockType.slot_decl/0 is the 3-tuple
{name, arity, label} and two of the three have nowhere else to come from.
Callbacks
@callback subtree(StatifierBlocks.Block.config()) :: [StatifierBlocks.Block.t()]
The blocks this composite stands for, given its params.
Pure in ADR-0002 decision 4's sense: same params in, same subtree out,
forever, no I/O, no clock, no process dictionary. The list is non-empty
and its head is the expansion root. The ids are local - expand!/2
mints the document's ids from the composite block's own id.
Functions
Declares a composite block type from opts.
Options:
:name(required) - the composite's block type name, as a document stores it and a palette keys it. A block type does not otherwise know its own type name, and the derived recipe needs one to insert.:params(required) -[t:StatifierBlocks.BlockType.field_decl/0]. Each declares:key,:type,:label,:required?and:default.:sentence- a template whose{param_key}placeholders are replaced by the config's values. Absent means the palette label.:palette_entry-StatifierBlocks.BlockType.palette_entry/0. Defaults to%{label: name}; a map without a:labelgetsname.:version- the storedtype_versionthis declaration is current at. Defaults to1.:slots- the pass-through slots this composite exposes, a list ofpass_through_decl/0maps carrying:nameand:to, with optional:label(defaulting to:name) and:arity(defaulting to:any). Defaults to[], which is every composite written beforeRQ-SF038-5.
That list is also the recognized set: an option outside it is refused at
the use site, naming the key it did not recognize, the way :params refuses
a param that declares a key it cannot spell. A misspelled option is the one
declaration error the refusals below cannot catch, because nothing is wrong
with the declaration that results - it is simply not the one that was
written.
The using module must define subtree/1.
@spec composite?(StatifierBlocks.Palette.type_ref()) :: boolean()
Whether module is a composite block type.
The three callers of the expansion - the compiler at Resolve and this
module's own derivations through expand!/2, the editor's Expand operation
through expand/2 - each have to ask before they expand, so the question is
answered once and here.
@spec expand(StatifierBlocks.Block.t(), StatifierBlocks.Palette.type_ref()) :: {:ok, {[StatifierBlocks.Block.t()], param_map()}} | {:error, term()}
The blocks block stands for, and the param each one is blamed on, as a
tuple.
{:ok, {blocks, param_map}} for a declaration that expands - the same pair
expand!/2 answers - and {:error, reason} for one too broken to, which is
every case expand!/2 raises on. reason is the term the raise carries
where it carries one, and the raise's message otherwise; every refusal this
module raises today is an ArgumentError carrying a message.
This is the spelling for a caller that must not raise. The editor's Expand
operation reads it: a broken declaration is a refusal the author is told
about, not a crash of the LiveView they were clicking in. A caller that
wants the raise - the compiler's Resolve, the environment walk - reads
expand!/2, and this function calls it, so there is still one expansion
and one place the answer is derived.
ADR-0002's Note of 2026-09-08, item 3 rules the two spellings. Changing
this function's return from the bare pair is breaking.
@spec expand!(StatifierBlocks.Block.t(), StatifierBlocks.Palette.type_ref()) :: {[StatifierBlocks.Block.t()], param_map()}
The blocks block stands for, and the param each one is blamed on.
block is the composite block as the document stores it; ref is its
own palette entry - a module, or a {module, state} pair - which every
caller has already resolved through StatifierBlocks.Palette.fetch/2.
Every read of the declaration below goes through
StatifierBlocks.Palette.call/4, so a data composite expands through
this same function and nothing here knows which kind it got. The return is the expanded blocks in
document order - head first, and the head is the expansion root -
together with the param_map/0 over every block in the expansion,
nested members included.
This is one expansion, two spellings: one derivation of what a composite
stands for, and two return shapes over it. The compiler reads this one at
Resolve, the environment walk reads it, the editor's Expand operation reads
expand/2 for the same derivation as a tuple, and nothing else answers
"what does this composite stand for" - three implementations would be three
chances for the compiled chart and the expanded document to disagree, and a
second spelling that calls the first is not a second implementation.
Raises when the declaration is broken: a subtree/1 that answers an empty
list, a non-block, a duplicated local id, or a local id that would mint an
id carrying __.
@spec flatten([StatifierBlocks.Block.t()]) :: [StatifierBlocks.Block.t()]
Every block in an expansion, in pre-order: each block, then its slot children.
Slots are visited in sorted slot-name order, so the walk is deterministic
for a slots map that has no order of its own. ADR-0011's
last-write-wins by position holds inside the union in exactly that order.
@spec io(StatifierBlocks.Palette.t(), StatifierBlocks.Block.t()) :: StatifierBlocks.Assignability.io()
block's io, with every member resolved through palette (ADR-0002's
Note of 2026-09-07, item 3).
The exact answer for a composite whose expansion root is a host type,
which StatifierBlocks.BlockType.io/1 cannot give: the callback is handed
a config and nothing else, so it resolves members through
StatifierBlocks.Palette.core/0 and falls back for a name that map does not
carry. This is what a reader holding a palette calls instead - the whole
of the difference is which palette the members are resolved through, and the
per-key table above is unchanged.
block is the composite block as the document stores it, and palette must
carry its type: the type name is resolved here rather than taken as a second
argument, so a caller cannot pair a block with another type's entry. The
config is read as handed - resolve/2's migration, if the caller ran
one, is already on the block it passes.
Raises for a block whose type is not a composite, for expand!/2's reason:
there is nothing to derive from. Ask composite?/1 first, which is what
every routed reader does.
@spec mapping_errors([StatifierBlocks.Block.t()], [pass_through_decl()]) :: [ String.t() ]
subtree's three pass-through refusals, as a list of messages, or [].
expand!/2 raises them for a module composite, whose subtree exists only
once it has params; StatifierBlocks.Composite.Data.declaration/1 answers
them as declaration errors, its subtree being a static template. One
implementation, so the two kinds cannot disagree about what a broken
mapping is (ADR-0002's pass-through amendment, P5).
@spec outcomes(StatifierBlocks.Palette.t(), StatifierBlocks.Block.t()) :: [ StatifierBlocks.BlockType.outcome_decl() ]
block's outcomes - its expansion root's, over the root's expanded config -
with the root resolved through palette (ADR-0002's Note of 2026-09-07,
item 3).
io/2's companion, and everything that doc says about the palette, the
block, the config and the refusal holds here unchanged.
@spec pass_through(StatifierBlocks.Block.t(), StatifierBlocks.Palette.type_ref()) :: %{ optional(StatifierBlocks.Block.slot_name()) => {StatifierBlocks.Block.id(), StatifierBlocks.Block.slot_name()} }
The pass-through slots block's type declares, each resolved to the
minted id of the member its children are spliced into.
%{} for a composite that declares none, which is every composite written
before ADR-0002's pass-through amendment. ref is the block's own
palette entry, already resolved, exactly as expand/2 takes it.
It exists because the environment walk has to find the mapped inner
position (ADR-0011's amendment of 2026-09-07, section 2) and minting is
this module's rule: a caller deriving the id itself would be a second
implementation of mint_id/3.