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 | the refusals params declare, over the composite's config | yes |
slots/1 | [] (RQ-SF037-3) | 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 |
palette_entry/0 | the map the declaration states | yes |
emit/2 | generated, and raises if reached (RQ-SF037-6) | no |
sentence/1, palette_entry/0 and validate_config/1 and no others
are overridable: they are the three 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,
summary/1 and donedata_type/1 are not derived - they stay optional and
absent unless a declaration writes them by hand.
validate_config/1 is left at ADR-0007's injected :ok, deliberately. 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-%{}; the composite declares no slots, so there is no slot name to accept intoconsumes- 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 one limitation in the derived io/1 and outcomes/1
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 these
two derivations resolve through 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: outcomes/1 to the behaviour's
default outcomes, and io/1 to [:step] kinds with no sugar. Both
reference composites root at core.* and are exact. The environment walk is
unaffected - it has a palette, and resolves every member through it.
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 single param is responsible for.
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.
Every block in an expansion, in pre-order: each block, then its slot children.
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() }
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 single param is responsible for.
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 both
when no param fed the block and when two did.
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.
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 expand/2 - the compiler at Resolve, the editor's
Expand operation and this module's own derivations - each have to ask
before they expand, so the question is answered once and here.
@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 the one expansion function. The compiler reads it at Resolve, the editor's Expand operation reads it to replace a composite block with its expansion in the document, 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.
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.