A composite whose declaration arrives as data rather than as a use
block (ADR-0002's 2026-09-07 amendment).
use StatifierBlocks.Composite derives a block type from a declaration
written in Elixir at compile time. This module derives the same block
type from the same declaration written as a JSON-shaped map at run time,
and it is the one stateful module this package ships: it is registered
as the {module, state} half of a StatifierBlocks.Palette.type_ref/0,
where state is the declaration.
{:ok, state} = StatifierBlocks.Composite.Data.declaration(row)
Palette.from_modules(
[{"myapp.guarded_step", {StatifierBlocks.Composite.Data, state}}],
core: true
)The forcing case is a host whose users save composites. A host that lets a tenant build one in a browser has no compile step in that loop: what the tenant saved is a row, and what the palette must carry is that row.
Why this module is stateful rather than generated
The obvious alternative - generate a module per saved composite - is rejected by the record on four grounds, each an existing decision rather than a taste: it mints atoms a tenant controls into a table that is never collected; a generated module is not a value, and ADR-0002 decision 2 requires one; two tenants naming a composite the same thing collide on one module name in one code server; and a module that can be purged weakens decision 3's totality. So there is one module, and the declaration rides beside it.
Nothing in this package may reach a stateful module except through
StatifierBlocks.Palette.call/4. That is why this module does not
@behaviour StatifierBlocks.BlockType: it implements the behaviour's
callbacks at one higher arity, with the state first, and the seam is
what does the arithmetic. The behaviour itself is untouched - fourteen
callbacks at the arities it declares.
Everything the composite amendment decides holds here unchanged
slots/1 is [], config_schema/1 is the params, emit/2 raises, the
expansion root is the subtree's head, ids are minted deterministically
from the composite block's id, and StatifierBlocks.Composite.expand/2 is
the one expansion function - a data composite is expanded by the same
function over the same subtree, which is why it answers the environment
walk the same way and compiles to the same bytes.
What differs is only where the subtree comes from: a use-composite
writes a subtree/1 function, and a declaration held as data holds a
template instead.
The declaration
| Key | Required | Shape |
|---|---|---|
"type_name" | yes | the name the document uses, and the key this entry is registered under |
"version" | yes | a positive integer; current_version/0 answers it |
"params" | yes | a list of field declarations, JSON-shaped (below) |
"subtree" | yes | a non-empty list of template nodes (below); the head is the expansion root |
"palette_entry" | no | the map palette_entry/0 answers, with string keys |
"sentence" | no | a template string; {{key}} is replaced by the param's value rendered as a string |
Two of the three overridables have a key here; the third cannot. A
use-composite may override sentence/1, palette_entry/0 and
validate_config/1. The first two are values, so they are the
"sentence" and "palette_entry" keys. The third is a function, and
a declaration held as data cannot hold one - the same ground the subtree
is a template rather than a subtree/1. So a data composite gets
validate_config/1 as its params alone refuse it, and a cross-param
refusal is one of the two things a host must still write a
use-composite module for (the other being a sentence that is not a
substitution). That is a cost of the data shape, not an oversight.
"params" is decision 7's StatifierBlocks.BlockType.field_decl/0, in JSON
Each entry is a map with string keys: "key", "type", "label",
"default", and whichever of "required?", "value_path",
"datamodel_path?", "hidden?" and "readonly?" the declaration writes.
No new key and no new field type is introduced here, and every refusal
decision 7 and its amendments state applies to a param unchanged.
"type" is a field type's name as a string, and all nine of decision
7's field types now have one (ADR-0005's 2026-09-07 amendment, clause
19E). The five that carry nothing - "string", "integer",
"boolean", "expression" and "duration" - are the name alone, and an
"options" on one of them is refused. The four that carry options
({:select, choices}, {:list, inner}, {:path, opts} and
{:type_expr, opts}) are the name plus one optional "options" key,
which is what the tuple's second element rides in:
"type" | "options" | The StatifierBlocks.BlockType.field_type/0 built |
|---|---|---|
"select" | %{"choices" => [[value, label], ...]}, a non-empty list of two-element lists of strings | {:select, [{value, label}, ...]} |
"path" | the path options map: whichever of "expects" and "writes" the field declares, each a type expression as ADR-0011 spells one - a non-empty string | {:path, %{expects: T}} / {:path, %{writes: T}} / {:path, %{}} |
"list" | %{"inner" => ...}, whose value is itself a "type" / "options" pair - the same spelling, one level down | {:list, inner} |
"type_expr" | %{"arms" => ["name", "inline"], "allow_empty?" => false}, both keys optional and "arms" any non-empty subset | {:type_expr, %{arms: [:name, :inline], allow_empty?: bool}} |
"select"'s choices are pairs and not a map because {:select, choices} is an ordered list and a JSON object does not promise order.
"path"'s options are the map itself rather than a wrapper, because
path_opts is already a map with two optional keys - and its values are
the strings ADR-0011 already writes, so a writes: carrying a
{:list, T} or a {:shape, members} term has no spelling here and is
refused rather than half-carried. "list" recurses through the same
spelling, so an inner kind that is itself unspellable makes the whole
field unspellable by the ordinary rule rather than by a special case.
"type_expr" needs nothing from statifier_datamodel: the value such a
field holds is already JSON (StatifierBlocks.BlockType's
StatifierBlocks.BlockType.type_expr_opts/0), so the spelling carries
opts and no type expression crosses a package boundary.
The list is decoded once, when the entry is built, and
config_schema/1 answers the decoded list. It is not decoded per call,
because decision 4 makes config_schema/1 pure and a decode that could
fail on the hot path is a callback that can fail. That is also why
declaration/1 refuses a param with no "default" rather than leaving
F3's missing-default: refusal to the compile: this is the last moment a
malformed declaration can be refused, and it is where the use macro
refuses the same thing.
The subtree template
A template node is a map with string keys:
%{
"type" => "core.invoke",
"id_suffix" => "call",
"config" => %{"invoke_type" => %{"$param" => "invoke_type"},
"assign_to" => ""},
"slots" => %{"on_error" => [ ...nodes... ]}
}"type"is atype_nameresolvable in the same palette. It is not checked here - the palette is not built yet - and an unresolvable one is the compiler's ordinary unknown-block-type arm on the expanded block, which decision 3 already makes total."id_suffix"matches~r/\A[a-z0-9]+(_[a-z0-9]+)*\z/and is unique within one declaration. The minted id is the composite block's own id, an underscore, and the suffix, so ablk_GScomposite mintsblk_GS_call. That pattern can produce no__, which is what keeps ADR-0004 decision 3's uniqueness argument and itsunstate_id/1invertibility holding."config"is a map of the type's config keys to JSON values."slots"is optional, defaulting to%{}: a slot name to a list of nodes. This is the node-level key; the declaration-level"slots"below is a different one, and the nesting says which is meant - the node-level one is reached only through"subtree".
The declaration-level "slots" key: pass-through slots
Optional, defaulting to %{}: a map of the slot name the composite
exposes to the [local_id, inner_slot] it maps to - a two-element JSON
array, because JSON has no tuple. A value may instead be a map carrying
"to" and the optional "label" (defaulting to the slot name) and
"arity" (one of "any", "one", "zero_or_one", "one_or_more",
defaulting to "any"); the array is sugar for that map with the two
defaults. It decodes to the same list use StatifierBlocks.Composite's
:slots option writes, so slots/2 here and slots/1 there answer the
same thing from the same shape.
A mapping that does not fit its own subtree is refused here, where
a module composite's is refused at its first expansion: the template is
static, so declaration/1 sees all three cases - a local_id naming no
node, an inner_slot the named node's own "slots" does not write, and
a mapped inner slot the template also fills - and this is the last moment
a malformed declaration can be refused. Two slots mapped to one inner
slot is refused with them.
The placeholder vocabulary is one arm, and one escape
A map with exactly the single key "$param", whose value is a declared
param key, is a placeholder: it is replaced whole by that param's
value, at that param's declared type, so a :boolean param substitutes a
boolean and not the string "true". A map with exactly the single key
"$literal" is its value, unsubstituted - the escape that keeps a config
value which genuinely is a one-key "$param" map expressible. Every other
JSON value is a literal, including every other map. A "$param" naming an
undeclared key is refused by declaration/1.
Whole-value substitution is the whole vocabulary, and that is a
decision, not an omission. There is no interpolation of a param into a
larger string, no expression, no conditional and no default-if-blank. The
reason is Collapse: the gesture that lifts an arrangement's config
values into params emits whole values, so whole-value substitution is
exactly the arm it needs. A template arm Collapse cannot emit would
exist only for a declaration written by hand, in a feature whose whole
point is declarations that were not.
param_map is derived here, not declared
A node is attributed to param key K when the placeholders in its own
"config", not its slots' children, name exactly one distinct param,
and to nil when they name none or more than one. That is what makes a
finding inside an expansion attributable for a declaration nobody wrote by
hand, and it is the same answer StatifierBlocks.Composite's own
param_map reaches by comparing values.
The declaration-level "migrations" key
Optional, defaulting to []: an ordered list of migration steps, each a
map with string keys carrying "from" and at least one of "rename",
"drop" and "default" (ADR-0002's 2026-09-07 migrations amendment).
%{
"from" => 1,
"rename" => %{"limit" => "amount_limit"},
"drop" => ["legacy_mode"],
"default" => %{"currency" => "USD"}
}"from"(required) is a positive integer, thetype_versionthe step carries a config from; the step carries it tofrom + 1."rename"is a map of old config key to new config key. The value moves; nothing else about it changes."drop"is a list of config keys removed."default"is a map of config key to a JSON value, the keys the old config gains with that value.
Within one step the three parts run in a fixed order: rename, then
drop, then default, so "drop" and "default" are written in the
names the step produces rather than the names it consumes. A key named by
both "drop" and "default" in one step is a contradiction rather than
an ordering question, and is refused.
The "from" values are strictly ascending and contiguous, and the last
is version - 1: a list that cannot carry its own earliest version to its
current one is broken, not usable-in-part. migrate_config/3 applies every
step at or above the stored version and below "version", in ascending
order, in one call - StatifierBlocks.Palette.resolve/2's one-call rule
is untouched, and the ladder runs inside the call.
A step naming an unknown key is refused. Known is defined by walking
the chain backwards from the declared param keys - the shape at
"version" - undoing each step in descending "from" order and, within a
step, in the reverse of the forward order: undo "default" (each key must
be present; remove it), then "drop" (each key must be absent; add it),
then "rename" (each new name present and each old name absent; put the
old name back). A declaration states its current params and not the shape
it started from, so the current end of the chain is the only known one.
A step's values are not type-checked here, for the reason a template
node's "config" values are not: the migrated config meets decision 7's
refusals at the compile, exactly as a stored config does.
Every one of these refusals is declaration/1's, at entry-build time, so a
palette can never hold a broken chain and migrate_config/3 can never meet
one.
"default" here is not a param's "default"
A param's "default" is decision 7's field_decl/0 key, one level inside
"params", and it is the value a new block starts with. A step's
"default" is one level inside "migrations" and it is the value an
old stored block's config gains. The nesting depth says which is meant.
What a step cannot express is still a refusal
rename, drop and default are the whole vocabulary: no value
transform, no merge, no split, no conditional. A declaration held as data
cannot hold a function, the same ground the subtree is a template. So a
change no step can express keeps the answer it has: the declaration writes
no step for that version, a block stored below the earliest step's
"from" answers {:error, {:no_migration_from, from}}, and a host that
needs more writes a use-composite module and its own migrate_config/2.
A declaration that writes no "migrations" key, or writes the empty list,
keeps that refusal for every stored version.
A module composite is untouched. use StatifierBlocks.Composite gains
no migrations: option: a module composite has the whole of Elixir for the
job and writes migrate_config/2 itself, with ADR-0007's injected refusal
as the default for one that does not.
The hygiene obligation a bump is for
StatifierBlocks.Palette.manifest/1's entries are {name, version} and
the compiler's palette_hash/1 triples are {type_name, module, version}, where every data composite's module element is this one. So
both distinguish two data composites by type name and version, and
neither distinguishes two revisions of one declaration registered under
one name at one version. StatifierBlocks.CompilationRecord already says
the hash is "a hygiene aid, not a commitment"; what this module adds is
who carries it: "version" is required, and a host that changes a
data composite's params or subtree bumps it.
Summary
Types
One migration step, decoded: the type_version it carries a config from,
and the three parts, each defaulting to empty.
One template node, decoded: the member's type name, its local id suffix, its config template and its slot templates.
The decoded declaration - what a palette entry carries beside this module.
Functions
The declaration, in the shape StatifierBlocks.Composite.subtree/1's
use counterpart answers it.
The declaration's params, in declaration order.
The version the declaration states.
Decodes a stored declaration into the state a palette entry carries, or
refuses it with every reason it found.
Raises. A composite is replaced by its expansion at Resolve, so no composite block survives to Emit; reaching this means the expansion did not run.
The derived io/1, over the same expansion.
Walks the declared "migrations" chain once, from the stored version to
the declaration's current one (ADR-0002's migrations amendment, M3).
The expansion root's outcomes, over its expanded config.
The declaration's palette entry.
The declaration's sentence template rendered over the config, or the palette label when it declares none.
The declared pass-through slots, in declaration order, and [] for a
declaration that names none - the same answer, from the same shape, that
a use-composite's slots/1 gives (ADR-0002's pass-through amendment,
P2).
The blocks this composite stands for, given its params: the template with its placeholders substituted.
ADR-0007's injected :ok, for the reason the use-composite leaves it
there: the refusals a param declares are declaration-level, and the
compile already runs every one of them over config_schema/1, which for a
composite is the params.
Types
@type migration_step() :: %{ from: pos_integer(), rename: %{optional(String.t()) => String.t()}, drop: [String.t()], default: %{optional(String.t()) => term()} }
One migration step, decoded: the type_version it carries a config from,
and the three parts, each defaulting to empty.
The parts run rename, then drop, then default (ADR-0002's
migrations amendment, M2).
@type node_template() :: %{ type: StatifierBlocks.Block.type_name(), id_suffix: String.t(), config: %{optional(String.t()) => term()}, slots: %{optional(StatifierBlocks.Block.slot_name()) => [node_template()]} }
One template node, decoded: the member's type name, its local id suffix, its config template and its slot templates.
@type state() :: %{ name: StatifierBlocks.Block.type_name(), params: [StatifierBlocks.BlockType.field_decl()], version: pos_integer(), sentence: String.t() | nil, palette_entry: StatifierBlocks.BlockType.palette_entry(), subtree: [node_template(), ...], slots: [StatifierBlocks.Composite.pass_through_decl()], migrations: [migration_step()] }
The decoded declaration - what a palette entry carries beside this module.
StatifierBlocks.Palette treats it as opaque; this is the one shape this
package declares for a stateful entry's state, and a host's own stateful
type declares its own.
Functions
@spec __composite__(state()) :: StatifierBlocks.Composite.declaration()
The declaration, in the shape StatifierBlocks.Composite.subtree/1's
use counterpart answers it.
A use-composite reads a module attribute the macro wrote at compile
time. This module has no such attribute to read - its declaration arrives
at run time - so it answers the state, which is the whole reason the
declaration is the state rather than a pointer to one. The :subtree
template is not part of the declaration shape and is read by subtree/2.
@spec config_schema(state(), StatifierBlocks.Block.config()) :: [ StatifierBlocks.BlockType.field_decl() ]
The declaration's params, in declaration order.
@spec current_version(state()) :: pos_integer()
The version the declaration states.
Decodes a stored declaration into the state a palette entry carries, or
refuses it with every reason it found.
Every refusal is here, at entry-build time, and not at call time. That
placement is forced by decision 4 and decision 3 together: a callback must
be pure and total, and StatifierBlocks.Palette.fetch/2 must not raise,
so the last moment a malformed declaration can be refused is before it is
in the palette. A host registers {module, state} only on the :ok.
iex> alias StatifierBlocks.Composite.Data
iex> {:ok, state} =
...> Data.declaration(%{
...> "type_name" => "myapp.guarded_step",
...> "version" => 1,
...> "params" => [
...> %{"key" => "invoke_type", "type" => "string",
...> "label" => "Call", "required?" => true, "default" => ""}
...> ],
...> "subtree" => [
...> %{"type" => "core.invoke", "id_suffix" => "call",
...> "config" => %{"invoke_type" => %{"$param" => "invoke_type"},
...> "assign_to" => ""}}
...> ]
...> })
iex> state.name
"myapp.guarded_step"
iex> state.params
[%{key: "invoke_type", type: :string, label: "Call", required?: true, default: ""}]
iex> alias StatifierBlocks.Composite.Data
iex> Data.declaration(%{"type_name" => "x", "version" => 0,
...> "params" => [], "subtree" => []})
{:error,
[
~s("version" must be a positive integer, got: 0),
~s("subtree" must be a non-empty list of template nodes, got: [])
]}
@spec emit(state(), StatifierBlocks.Block.t(), term()) :: no_return()
Raises. A composite is replaced by its expansion at Resolve, so no composite block survives to Emit; reaching this means the expansion did not run.
@spec io(state(), StatifierBlocks.Block.config()) :: StatifierBlocks.Assignability.io()
The derived io/1, over the same expansion.
@spec migrate_config(state(), pos_integer(), StatifierBlocks.Block.config()) :: {:ok, StatifierBlocks.Block.config()} | {:error, term()}
Walks the declared "migrations" chain once, from the stored version to
the declaration's current one (ADR-0002's migrations amendment, M3).
Every step at or above from and below "version" runs, in ascending
"from" order, and the result comes back as one {:ok, config}. A stored
version below the earliest step's "from" - which includes every
version when the declaration writes no steps - is ADR-0007's refusal,
unchanged: a declaration says which versions it carries forward, and one
it wrote no step for is one it does not claim to understand (M6).
The chain was validated at declaration/1, so this function cannot meet a
gap, an out-of-order step or an unknown key (M5).
@spec outcomes(state(), StatifierBlocks.Block.config()) :: [ StatifierBlocks.BlockType.outcome_decl() ]
The expansion root's outcomes, over its expanded config.
@spec palette_entry(state()) :: StatifierBlocks.BlockType.palette_entry()
The declaration's palette entry.
@spec sentence(state(), StatifierBlocks.Block.config()) :: String.t()
The declaration's sentence template rendered over the config, or the palette label when it declares none.
@spec slots(state(), StatifierBlocks.Block.config()) :: [ StatifierBlocks.BlockType.slot_decl() ]
The declared pass-through slots, in declaration order, and [] for a
declaration that names none - the same answer, from the same shape, that
a use-composite's slots/1 gives (ADR-0002's pass-through amendment,
P2).
@spec subtree(state(), StatifierBlocks.Block.config()) :: [StatifierBlocks.Block.t()]
The blocks this composite stands for, given its params: the template with its placeholders substituted.
Pure in decision 4's sense, for the reason a use-composite's subtree/1
is: the template is a value, substitution is total over it, and the same
params answer the same blocks forever.
@spec validate_config(state(), StatifierBlocks.Block.config()) :: :ok
ADR-0007's injected :ok, for the reason the use-composite leaves it
there: the refusals a param declares are declaration-level, and the
compile already runs every one of them over config_schema/1, which for a
composite is the params.