StatifierBlocks.Composite.Data (StatifierBlocks v0.28.0)

Copy Markdown View Source

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

KeyRequiredShape
"type_name"yesthe name the document uses, and the key this entry is registered under
"version"yesa positive integer; current_version/0 answers it
"params"yesa list of field declarations, JSON-shaped (below)
"subtree"yesa non-empty list of template nodes (below); the head is the expansion root
"palette_entry"nothe map palette_entry/0 answers, with string keys
"sentence"noa template string; {{key}} is replaced by the param's value rendered as a string
"slots"nodefaults to %{}; the pass-through slots this composite exposes, each mapped to the [local_id, inner_slot] it stands for (below)
"migrations"nodefaults to []; the ordered migration steps migrate_config/3 walks from a stored version to the declaration's current one (below)
"outcomes"nodefaults to []; a JSON array of outcome names this composite declares, which replaces the expansion root's derived list and is checked against what the "subtree"'s members can raise (ADR-0002's Amendment of 2026-09-12, C4)

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 a type_name resolvable 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 a blk_GS composite mints blk_GS_call. That pattern can produce no __, which is what keeps ADR-0004 decision 3's uniqueness argument and its unstate_id/1 invertibility 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, the type_version the step carries a config from; the step carries it to from + 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 declaration's "outcomes" names, or - for a declaration that writes none - 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.

The declaration's chips: one per param the config gives a value to, minus those declared hidden?: true.

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

migration_step()

@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).

node_template()

@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.

state()

@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()],
  outcomes: [String.t()]
}

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

__composite__(state)

@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.

config_schema(map, config)

The declaration's params, in declaration order.

current_version(map)

@spec current_version(state()) :: pos_integer()

The version the declaration states.

declaration(row)

@spec declaration(term()) :: {:ok, state()} | {:error, [String.t()]}

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: [])
 ]}

emit(state, block, context)

@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.

io(state, config)

The derived io/1, over the same expansion.

migrate_config(map, from, config)

@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).

outcomes(state, config)

The declaration's "outcomes" names, or - for a declaration that writes none - the expansion root's outcomes, over its expanded config.

ADR-0002's Amendment of 2026-09-12, C4: the data spelling of the same declaration key, read through the same derivation, so a data composite and the module composite of the same shape answer the same list.

palette_entry(map)

@spec palette_entry(state()) :: StatifierBlocks.BlockType.palette_entry()

The declaration's palette entry.

sentence(state, config)

@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.

slots(state, config)

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).

subtree(map, params)

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.

summary(state, config)

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

The declaration's chips: one per param the config gives a value to, minus those declared hidden?: true.

The same derivation the use block injects (ADR-0002's Note of 2026-09-07, item 4), reached at one higher arity like every other callback here - so a data composite's card draws the summary its module twin draws.

validate_config(state, config)

@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.