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, so the five field types
that have one - "string", "integer", "boolean", "expression" and
"duration" - are what a declaration held as data can spell. The four
that carry options ({:select, choices}, {:list, inner},
{:path, opts} and {:type_expr, opts}) are tuples rather than names and
are refused here; a host needing one writes a use-composite module, and
a spelling for them is a later record's to decide, not this module's to
invent.
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.
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.
What this module does not decide: a migration
A declaration is the only thing that can supply a migration, and no key is
fixed for one. So a host that bumps "version" on a declaration with
stored blocks is choosing a refusal: ADR-0007's injected
migrate_config/2 answers {:error, {:no_migration_from, from}}, this
module leaves that injection alone, and StatifierBlocks.Palette.resolve/2
therefore refuses every stored block of that type at the older version.
That is not papered over with a derived {:ok, config}, which is exactly
the answer ADR-0007's refusal exists to refuse. How a data composite
declares a migration is an open question the record leaves open.
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 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.
ADR-0007's injected refusal, unchanged: a declaration held as data fixes
no migration key, so a "version" bump with stored blocks refuses rather
than guessing.
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.
[]: a composite exposes no slot of its own.
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 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(), ...] }
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()) :: {:error, term()}
ADR-0007's injected refusal, unchanged: a declaration held as data fixes
no migration key, so a "version" bump with stored blocks refuses rather
than guessing.
@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() ]
[]: a composite exposes no slot of its own.
@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.