StatifierBlocks.Composite.Data (StatifierBlocks v0.25.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

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

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

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(), ...]
}

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(state, from, config)

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

outcomes(state, config)

The expansion root's outcomes, over its expanded config.

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)

[]: a composite exposes no slot of its own.

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.

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.