StatifierBlocks.Composite behaviour (StatifierBlocks v0.28.0)

Copy Markdown View Source

A block type derived from params plus a pure subtree (ADR-0002 decision 5's amendment of 2026-09-07).

use StatifierBlocks.Composite is a second use macro on the declaration surface StatifierBlocks.BlockType owns. Where use StatifierBlocks.BlockType injects a fixed answer per callback (ADR-0007), this one injects answers derived from a declaration: the params an author fills in, and the subtree/1 those params stand for.

The declaration

defmodule MyApp.GuardedStep do
  use StatifierBlocks.Composite,
    name: "myapp.guarded_step",
    params: [
      %{key: "invoke_type", type: :string, label: "Call",
        required?: true, default: ""},
      %{key: "failure_path", type: :string, label: "Record the failure at",
        required?: true, default: "", datamodel_path?: true}
    ],
    sentence: "Call {invoke_type}, recording failure at {failure_path}",
    palette_entry: %{label: "Guarded step", group: "Structure"},
    version: 1

  @impl true
  def subtree(params) do
    [
      Block.new("core.invoke",
        id: "call",
        config: %{"invoke_type" => params["invoke_type"], "assign_to" => ""},
        slots: %{"on_error" => [
          Block.new("core.assign",
            id: "guard",
            config: %{"path" => params["failure_path"], "value" => "failed"})
        ]}
      )
    ]
  end
end

params is a [t:StatifierBlocks.BlockType.field_decl/0] in decision 7's shape - no new key, no new field type. Every flag decision 7 and its amendments give a field means here exactly what it means on any other declared field, and the F3 and F4 refusals apply to a param declaration unchanged: because the composite's config_schema/1 is the params, the compiler's own declaration checks run over them for free.

subtree/1 is pure in decision 4's sense - same params in, same subtree out, forever - and answers a non-empty list of StatifierBlocks.Block.t/0 whose head is the expansion root. Several derivations below read that block and no other.

The ids the subtree mints

The ids a subtree/1 writes are local: expand!/2 mints each expanded block's real id from the composite block's own id, as composite_id <> "_" <> local_id. They are not fresh UXIDs and not a counter over the document, and two properties follow:

  • No __. ADR-0004 decision 3 derives a state id as "s_" <> block_id or "s_" <> block_id <> "__" <> role, and its invertibility rests on a block id containing no __. expand!/2 refuses a local id that would put one there.
  • Document-unique, for free. The composite block's id is document-unique, opaque and never reused (ADR-0001 decision 3), and minting is injective over it, so the members inherit all three.

A local id that looks like a freshly minted UXID ("blk_"-prefixed) is refused: StatifierBlocks.Block.new/2 mints one when a declaration writes no :id, and a minted id is different on every call, which would make subtree/1 impure and the expansion unstable.

What the use derives

CallbackThe composite's answerOverridable
config_schema/1params, in declaration orderno
validate_config/1not derived: ADR-0007's injected :ok stands, and the refusals params declare are run by the compile over config_schema/1 (see below)yes
slots/1the declared pass-through slots, in declaration order (RQ-SF038-5)no
io/1see belowno
current_version/0the version the declaration statesno
outcomes/1the declaration's outcomes names, or - absent - the expansion root's, over its expanded configno
sentence/1the declaration's template rendered over the configyes
summary/1one chip per visible param (RQ-SF038-14)yes
palette_entry/0the map the declaration statesyes
emit/2generated, and raises if reached (RQ-SF037-6)no

sentence/1, summary/1, palette_entry/0 and validate_config/1 and no others are overridable: they are the four whose answers are about presentation and refusal rather than about the expansion. migrate_config/2 keeps ADR-0007's injected refusal unchanged, and fixtures/0, failure_outcomes/1 and donedata_type/1 are not derived - they stay optional and absent unless a declaration writes them by hand.

The derived summary/1

ADR-0002's Note of 2026-09-07, item 4: the chips are the declaration's params, minus those declared hidden?: true. hidden? is a rendering claim (block_type.ex, "the field is never rendered by any form"), so a param the author has already said no form draws is not a chip either - the same reading StatifierBlocks.ViewModel takes of the flag. Each surviving param draws as "<label>: <value>", and a param whose value renders blank draws no chip at all rather than a label with nothing after it.

The value is read with Map.get/2 on the param's :key, which is the reading render_sentence/2 beside it already takes of the same declaration: a composite's params are its own config's keys, and the two derivations that read them cannot be allowed to disagree about which key a param is at.

It is overridable for sentence/1's reason - a declaration whose card wants to say something the params cannot spell says it itself.

validate_config/1 is left at ADR-0007's injected :ok, deliberately - the macro never redefines it, and the row above states what the compile guarantees rather than a generated function (ADR-0002's Note of 2026-09-07, correction 2). The refusals a param declares are declaration-level - F3's missing default:, F4's empty hidden default, and {:type_expr, opts}' allow_empty? - and the compile already runs every one of them over config_schema/1, which for a composite is the params. required? is a rendering hint and never an authority (block_type.ex, "This is a rendering hint, not the authority"), so it raises no finding here either: that is what makes the amendment's own worked example - two required?: true params defaulting to "" - land finding-free out of StatifierBlocks.Palette.new_block/2. A composite with a cross-param refusal its params cannot state as a single field's flag overrides the callback, which is why it is one of the three that may be.

emit/2 exists because the behaviour requires it and ADR-0007 deliberately injects no default for it. It raises, because the compiler expands a composite at Resolve and no composite block survives to Emit (sb-nzc1).

What a composite reads and writes

A composite's reads and writes, as the environment walk consumes them, are the union of its expanded members', each taken over that member's expanded config, at the composite's one position in the document (RQ-SF037-15, ruled 2026-09-07: shape (A)). That union is computed by StatifierBlocks.Environment.read_signatures/3 and write_signatures/3 - the same two functions, run over expand!/2's subtree - and not by io/1, which is single-valued in consumes and produces and carries no per-path read or write at all.

The derived io/1 therefore answers, per key of StatifierBlocks.Assignability.io/0:

  • kinds - the members' kinds concatenated in expansion order, de-duplicated
  • slot_accepts - one entry per declared pass-through slot, at the mapped inner slot's own accepted kinds; %{} for a composite that declares none, which is every composite written before RQ-SF038-5
  • consumes - the expansion root's, or absent when the root declares none
  • produces - the expansion root's, or absent when the root declares none

Dropping a non-root member's sugar under-declares rather than over-declares, which is the safe direction ADR-0011 decision 6 already takes.

The callbacks are core-only, and a reader with a palette is not

Both rows read a member's module, and a StatifierBlocks.Block.t/0 carries a type name. Resolving a name to a module is StatifierBlocks.Palette's job, and neither StatifierBlocks.BlockType.io/1 nor StatifierBlocks.BlockType.outcomes/1 is handed a palette - so the two callbacks resolve members through StatifierBlocks.Palette.core/0, whose types are StatifierBlocks.Palette.core_types/0, the one type map this package holds as a value. A composite whose expansion root is a host type therefore falls back in the callback: outcomes/1 to the behaviour's default outcomes, and io/1 to [:step] kinds with no sugar.

That fallback is the callbacks' answer and stays their answer (ADR-0002's Note of 2026-09-07, item 3: no @callback is added, removed or re-arity'd, and neither derivation gains a palette argument). What a reader holding a palette does instead is call io/2 and outcomes/2 below, which take the palette as their first argument and resolve every member through it - so the same composite is exact wherever a palette is in hand and takes the documented fallback only where none is.

The environment walk needs neither: it recurses into StatifierBlocks.Environment.read_signatures/3 and write_signatures/3 over the expansion, so no composite reaches its io/1 there at all.

The derived recipe

The declaration also derives a StatifierBlocks.Recipe at <Module>.Recipe, whose insert/2 returns exactly one :insert of the composite block at the armed target and whose palette_entry/0 is the block type's. One command, not the expansion: what an author puts down is the composite, and the expansion happens at compile.

It is a compatibility surface for a host that already ships the arrangement as a recipe. A composite's own palette entry is its types entry; StatifierBlocks.Palette keeps types and recipes in two maps, so a host that registers both is choosing to show two entries.

Summary

Types

The normalized declaration, as __composite__/0 answers it.

Each expanded block's id to the param key that produced it, or to nil for a block no param is responsible for.

One declared pass-through slot: a slot the composite exposes, and the {local_id, inner_slot} of the expansion member its children are spliced into (ADR-0002's pass-through amendment, P1).

Callbacks

The blocks this composite stands for, given its params.

Functions

Declares a composite block type from opts.

Whether module is a composite block type.

The blocks block stands for, and the param each one is blamed on, as a tuple.

The blocks block stands for, and the param each one is blamed on.

Every block in an expansion, in pre-order: each block, then its slot children.

block's io, with every member resolved through palette (ADR-0002's Note of 2026-09-07, item 3).

subtree's three pass-through refusals, as a list of messages, or [].

block's outcomes - its expansion root's, over the root's expanded config - with the root resolved through palette (ADR-0002's Note of 2026-09-07, item 3).

The pass-through slots block's type declares, each resolved to the minted id of the member its children are spliced into.

The declared outcome names of ref's expansion that nothing in it can raise, in declaration order, and [] for a composite that declares none.

Types

declaration()

@type declaration() :: %{
  name: StatifierBlocks.Block.type_name(),
  params: [StatifierBlocks.BlockType.field_decl()],
  version: pos_integer(),
  sentence: String.t() | nil,
  palette_entry: StatifierBlocks.BlockType.palette_entry(),
  slots: [pass_through_decl()],
  outcomes: [String.t()]
}

The normalized declaration, as __composite__/0 answers it.

param_map()

@type param_map() :: %{optional(StatifierBlocks.Block.id()) => String.t() | nil}

Each expanded block's id to the param key that produced it, or to nil for a block no param is responsible for.

A member carrying more than one param's value is blamed on the first param in declaration order (ADR-0002's Note of 2026-09-07, item 5, ruling RQ-SF038-14): the declaration has an order and an author reading a finding needs one field to open, not none.

ADR-0004's amendment (sb-nzc1) re-anchors a finding raised inside an expansion against the composite block, carrying the key this map names - and config_key: nil when it names none, which is the honest answer when no param fed the block.

pass_through_decl()

@type pass_through_decl() :: %{
  name: StatifierBlocks.Block.slot_name(),
  to: {String.t(), StatifierBlocks.Block.slot_name()},
  label: String.t(),
  arity: StatifierBlocks.BlockType.slot_arity()
}

One declared pass-through slot: a slot the composite exposes, and the {local_id, inner_slot} of the expansion member its children are spliced into (ADR-0002's pass-through amendment, P1).

:label and :arity are forced rather than added: a StatifierBlocks.BlockType.slot_decl/0 is the 3-tuple {name, arity, label} and two of the three have nowhere else to come from.

Callbacks

subtree(config)

The blocks this composite stands for, given its params.

Pure in ADR-0002 decision 4's sense: same params in, same subtree out, forever, no I/O, no clock, no process dictionary. The list is non-empty and its head is the expansion root. The ids are local - expand!/2 mints the document's ids from the composite block's own id.

Functions

__using__(opts)

(macro)

Declares a composite block type from opts.

Options:

  • :name (required) - the composite's block type name, as a document stores it and a palette keys it. A block type does not otherwise know its own type name, and the derived recipe needs one to insert.
  • :params (required) - [t:StatifierBlocks.BlockType.field_decl/0]. Each declares :key, :type, :label, :required? and :default.
  • :sentence - a template whose {param_key} placeholders are replaced by the config's values. Absent means the palette label.
  • :palette_entry - StatifierBlocks.BlockType.palette_entry/0. Defaults to %{label: name}; a map without a :label gets name.
  • :version - the stored type_version this declaration is current at. Defaults to 1.
  • :slots - the pass-through slots this composite exposes, a list of pass_through_decl/0 maps carrying :name and :to, with optional :label (defaulting to :name) and :arity (defaulting to :any). Defaults to [], which is every composite written before RQ-SF038-5.
  • :outcomes - the outcome names this composite declares, a list of strings in the order it declares them (ADR-0002's Amendment of 2026-09-12, C1). Present and non-empty, the list replaces the expansion root's derived outcomes, and every name in it is checked at Resolve against what the expansion can raise (C2). Absent it is [], which reads as "not declared" and leaves the derivation exactly as it was (C3); an explicit empty list is deliberately the same thing, since every block type answers at least the default done. The label of a declared name is the label the member that raises it already declares for it, so there is no second way to spell a label here.

That list is also the recognized set: an option outside it is refused at the use site, naming the key it did not recognize, the way :params refuses a param that declares a key it cannot spell. A misspelled option is the one declaration error the refusals below cannot catch, because nothing is wrong with the declaration that results - it is simply not the one that was written.

The using module must define subtree/1.

composite?(module)

@spec composite?(StatifierBlocks.Palette.type_ref()) :: boolean()

Whether module is a composite block type.

The three callers of the expansion - the compiler at Resolve and this module's own derivations through expand!/2, the editor's Expand operation through expand/2 - each have to ask before they expand, so the question is answered once and here.

expand(block, ref)

The blocks block stands for, and the param each one is blamed on, as a tuple.

{:ok, {blocks, param_map}} for a declaration that expands - the same pair expand!/2 answers - and {:error, reason} for one too broken to, which is every case expand!/2 raises on. reason is the term the raise carries where it carries one, and the raise's message otherwise; every refusal this module raises today is an ArgumentError carrying a message.

This is the spelling for a caller that must not raise. The editor's Expand operation reads it: a broken declaration is a refusal the author is told about, not a crash of the LiveView they were clicking in. A caller that wants the raise - the compiler's Resolve, the environment walk - reads expand!/2, and this function calls it, so there is still one expansion and one place the answer is derived.

ADR-0002's Note of 2026-09-08, item 3 rules the two spellings. Changing this function's return from the bare pair is breaking.

expand!(block, ref)

The blocks block stands for, and the param each one is blamed on.

block is the composite block as the document stores it; ref is its own palette entry - a module, or a {module, state} pair - which every caller has already resolved through StatifierBlocks.Palette.fetch/2. Every read of the declaration below goes through StatifierBlocks.Palette.call/4, so a data composite expands through this same function and nothing here knows which kind it got. The return is the expanded blocks in document order - head first, and the head is the expansion root - together with the param_map/0 over every block in the expansion, nested members included.

This is one expansion, two spellings: one derivation of what a composite stands for, and two return shapes over it. The compiler reads this one at Resolve, the environment walk reads it, the editor's Expand operation reads expand/2 for the same derivation as a tuple, and nothing else answers "what does this composite stand for" - three implementations would be three chances for the compiled chart and the expanded document to disagree, and a second spelling that calls the first is not a second implementation.

Raises when the declaration is broken: a subtree/1 that answers an empty list, a non-block, a duplicated local id, or a local id that would mint an id carrying __.

flatten(blocks)

Every block in an expansion, in pre-order: each block, then its slot children.

Slots are visited in sorted slot-name order, so the walk is deterministic for a slots map that has no order of its own. ADR-0011's last-write-wins by position holds inside the union in exactly that order.

io(palette, block)

block's io, with every member resolved through palette (ADR-0002's Note of 2026-09-07, item 3).

The exact answer for a composite whose expansion root is a host type, which StatifierBlocks.BlockType.io/1 cannot give: the callback is handed a config and nothing else, so it resolves members through StatifierBlocks.Palette.core/0 and falls back for a name that map does not carry. This is what a reader holding a palette calls instead - the whole of the difference is which palette the members are resolved through, and the per-key table above is unchanged.

block is the composite block as the document stores it, and palette must carry its type: the type name is resolved here rather than taken as a second argument, so a caller cannot pair a block with another type's entry. The config is read as handed - resolve/2's migration, if the caller ran one, is already on the block it passes.

Raises for a block whose type is not a composite, for expand!/2's reason: there is nothing to derive from. Ask composite?/1 first, which is what every routed reader does.

mapping_errors(subtree, slots)

@spec mapping_errors([StatifierBlocks.Block.t()], [pass_through_decl()]) :: [
  String.t()
]

subtree's three pass-through refusals, as a list of messages, or [].

expand!/2 raises them for a module composite, whose subtree exists only once it has params; StatifierBlocks.Composite.Data.declaration/1 answers them as declaration errors, its subtree being a static template. One implementation, so the two kinds cannot disagree about what a broken mapping is (ADR-0002's pass-through amendment, P5).

outcomes(palette, block)

block's outcomes - its expansion root's, over the root's expanded config - with the root resolved through palette (ADR-0002's Note of 2026-09-07, item 3).

io/2's companion, and everything that doc says about the palette, the block, the config and the refusal holds here unchanged.

pass_through(block, ref)

The pass-through slots block's type declares, each resolved to the minted id of the member its children are spliced into.

%{} for a composite that declares none, which is every composite written before ADR-0002's pass-through amendment. ref is the block's own palette entry, already resolved, exactly as expand/2 takes it.

It exists because the environment walk has to find the mapped inner position (ADR-0011's amendment of 2026-09-07, section 2) and minting is this module's rule: a caller deriving the id itself would be a second implementation of mint_id/3.

unraisable_outcomes(palette, ref, members, param_map)

The declared outcome names of ref's expansion that nothing in it can raise, in declaration order, and [] for a composite that declares none.

ADR-0002's Amendment of 2026-09-12, C2 item 3: the check lives in the compiler's Resolve stage, where the expansion has already been taken, and it is reported as a StatifierBlocks.Compiler.Finding rather than raised. The expansion is handed in rather than re-taken so that the stage checks the members it is about to resolve and not a second expansion of the same block.

members and param_map are expand!/2's own return: param_map is keyed by exactly the minted members (ADR-0004's T3), which is what C2 item 2 means by "the declaration's own members, not the author's" - a block an author dropped into a pass-through slot has no entry there and does not widen what the composite may declare.