StatifierBlocks.Composite behaviour (StatifierBlocks v0.25.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/1the refusals params declare, over the composite's configyes
slots/1[] (RQ-SF037-3)no
io/1see belowno
current_version/0the version the declaration statesno
outcomes/1the expansion root's, over its expanded configno
sentence/1the declaration's template rendered over the configyes
palette_entry/0the map the declaration statesyes
emit/2generated, and raises if reached (RQ-SF037-6)no

sentence/1, palette_entry/0 and validate_config/1 and no others are overridable: they are the three 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, summary/1 and donedata_type/1 are not derived - they stay optional and absent unless a declaration writes them by hand.

validate_config/1 is left at ADR-0007's injected :ok, deliberately. 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 - %{}; the composite declares no slots, so there is no slot name to accept into
  • 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 one limitation in the derived io/1 and outcomes/1

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 these two derivations resolve through 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: outcomes/1 to the behaviour's default outcomes, and io/1 to [:step] kinds with no sugar. Both reference composites root at core.* and are exact. The environment walk is unaffected - it has a palette, and resolves every member through it.

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 single param is responsible for.

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.

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

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()
}

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 single param is responsible for.

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 both when no param fed the block and when two did.

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.

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 expand/2 - the compiler at Resolve, the editor's Expand operation and this module's own derivations - 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.

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 the one expansion function. The compiler reads it at Resolve, the editor's Expand operation reads it to replace a composite block with its expansion in the document, 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.

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.