# `StatifierBlocks.Composite.Data`
[🔗](https://github.com/riddler/statifier_blocks/blob/v0.44.1/lib/statifier_blocks/composite/data.ex#L1)

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 `t: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 - fifteen
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 |
| `"slots"` | no | defaults to `%{}`; the pass-through slots this composite exposes, each mapped to the `[local_id, inner_slot]` it stands for (below) |
| `"migrations"` | no | defaults to `[]`; the ordered migration steps `migrate_config/3` walks from a stored version to the declaration's current one (below) |
| `"outcomes"` | no | defaults 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 five overridables have a key here; the other three are met
another way, and one of those is met only in part.** A `use`-composite may
override `sentence/1`, `summary/1`, `palette_entry/0`, `validate_config/1`
and `migrate_config/2` (`StatifierBlocks.Composite`, "What the `use`
derives"; the set is pinned by
`test/statifier_blocks/composite/overridables_test.exs`). Two of them are
values, so they are the `"sentence"` and `"palette_entry"` keys. `summary/1`
needs no key of its own: a data composite takes the derived chips over its
own `"params"`, which is what a module composite overriding `summary/1`
would be replacing. `migrate_config/2` is met by the `"migrations"` key,
which states the chain `migrate_config/3` walks (below), and a declaration
needing more than the declared steps writes a `use`-composite module.

`validate_config/1` is the one a declaration cannot reach at all. It 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 `t: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 `t: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
`t: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.

# `migration_step`

```elixir
@type migration_step() :: %{
  from: pos_integer(),
  rename: %{optional(String.t()) =&gt; String.t()},
  drop: [String.t()],
  default: %{optional(String.t()) =&gt; 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`

```elixir
@type node_template() :: %{
  type: StatifierBlocks.Block.type_name(),
  id_suffix: String.t(),
  config: %{optional(String.t()) =&gt; term()},
  slots: %{optional(StatifierBlocks.Block.slot_name()) =&gt; [node_template()]}
}
```

One template node, decoded: the member's type name, its local id suffix,
its config template and its slot templates.

# `state`

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

# `__composite__`

```elixir
@spec __composite__(state()) :: StatifierBlocks.Composite.declaration()
```

The declaration, in the shape `c: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`

```elixir
@spec config_schema(state(), StatifierBlocks.Block.config()) :: [
  StatifierBlocks.BlockType.field_decl()
]
```

The declaration's params, in declaration order.

# `current_version`

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

The version the declaration states.

# `declaration`

```elixir
@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`

```elixir
@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`

```elixir
@spec io(state(), StatifierBlocks.Block.config()) ::
  StatifierBlocks.Assignability.io()
```

The derived `io/1`, over the same expansion.

# `migrate_config`

```elixir
@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`

```elixir
@spec outcomes(state(), StatifierBlocks.Block.config()) :: [
  StatifierBlocks.BlockType.outcome_decl()
]
```

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`

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

The declaration's palette entry.

# `sentence`

```elixir
@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`

```elixir
@spec slots(state(), StatifierBlocks.Block.config()) :: [
  StatifierBlocks.BlockType.slot_decl()
]
```

The declared pass-through slots, in declaration order, then one
`on_<name>` slot per declared outcome (`ADR-0002`'s Amendment of
2026-09-13, `C7` item 1), and `[]` for a declaration that names neither -
the same answer, from the same shape, that a `use`-composite's `slots/1`
gives (`ADR-0002`'s pass-through amendment, P2).

# `subtree`

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

# `summary`

```elixir
@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`

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

---

*Consult [api-reference.md](api-reference.md) for complete listing*
