StatifierBlocks.Palette (StatifierBlocks v0.26.0)

Copy Markdown View Source

A palette names the block types a host makes available: a map from a block's type_name to the entry implementing StatifierBlocks.BlockType for it (ADR-0002 decision 2).

An entry is a type_ref/0 - a module, or a module paired with an opaque term it carries (ADR-0002's 2026-09-07 amendment). One type name still resolves to one entry, and nothing in this package calls a callback on an entry except through call/4, the one call seam. That is what lets a host whose users save block types register a declaration held as data: StatifierBlocks.Composite.Data is such an entry, and no caller of a callback can tell which kind it got.

It is a caller-supplied value, nothing more. A palette is built once for an editing or compiling operation and passed explicitly into whatever needs it - document validation, the editor's session state, the compiler - the same way any other value is threaded through a pipeline. Nothing in this package holds a palette across operations, and no cadence beyond "one value per operation" is implied: a caller that wants the same set of types for its next operation builds (or reuses) the value again, deliberately.

It is explicitly not:

  • an application-configuration lookup keyed by block type
  • a table of shared entries reachable by name from anywhere in the process tree
  • a lookup registered under a well-known process name
  • anything wired up automatically when this package (or a host application) starts

Any of those would make two hosts sharing one runtime step on each other's block types - the multi-tenant property this design exists to keep. Two StatifierBlocks.Palette values built with different modules under the same type_name in the same running system resolve independently; neither can see or clobber the other.

Every consumer that walks a document and resolves blocks against a palette carries the case where a block's type_name has no entry as an ordinary pattern-matched arm, not as an exception - fetch/2 never raises, so there is nothing to rescue.

Recipes, the second map

A palette also names recipes (ADR-0005 clause 1C): arrangements an author picks the way they pick a block type, implemented by modules behind StatifierBlocks.Recipe. Everything above about what a palette is applies to the second map unchanged - it is a caller-supplied value, there is no global registry, and two hosts in one runtime resolve independently.

The names live in one namespace per map, not one across both. A recipe named "deadline" and a block type named "deadline" do not collide, because nothing resolves a name without knowing which map it is asking: a document's type_name is looked up in types and only there, and a palette browser entry carries which of the two it came from.

Validators, the list

A palette also carries validators (ADR-0005 clause 11p): modules behind StatifierBlocks.DocumentValidator, each stating one of the host's own whole-document rules. They ride the palette for the reason assignability and recipes do - it is already the value a host builds and hands in, so a host declaring a rule adds a module to a value it was building anyway, with no assign, no mount option and no editor callback added.

They are a list, not a third map. Nothing resolves a validator by name, so there is nothing to key on and nothing to collide: every module in the list runs, in list order.

Ordering a group

A palette_entry/0 declares a group and an order, and ADR-0005 decision 10 sorts a browser group by order. Two entries of one group declaring the same number have no answer - the pick between them is whatever the sort happened to do - so new/2 refuses that palette, and from_modules/2 refuses it because it builds through new/2.

The numbering convention this package follows, and the one a host extending a group is asked to follow:

  • One number, one entry - with one exception. A composite's types entry and that same composite's derived <Module>.Recipe declare the same card by construction, and a host may register both (StatifierBlocks.Composite, "The derived recipe"). That pair is admitted; any other two entries at one number are refused.
  • Numbers are append-only. A new entry takes the next number after the highest one its group already uses. Existing numbers are not renumbered to open a hole in the middle, because an author picks a block by where it sits and a renumber moves every entry below it.
  • A group is dense, and that is a consequence, not a rule. The core "Structure" group runs 0 through 17 with no gap: the seventeen core_types/0 entries and the "deadline" recipe, which took 17 as the last one added. There is no free interior number, so the next core entry takes 18.
  • A host merging into a core group numbers above the core's last. new(Map.merge(core_types(), %{...})) puts a host's entry into the same "Structure" group, where the core numbers are already taken; a host that wants its own numbering from 0 declares its own group instead, which is a separate namespace for ordering as much as the browser's headings.

None of this is enforced beyond the collision refusal: order is an optional key, a gap is legal, and an entry that declares no order at all is skipped rather than refused.

Summary

Types

One line of a palette's manifest: a block type's name beside its module's current_version/0, or a recipe's name beside :recipe.

A recipe's name, as the palette browser and a pick name it.

One registration: the name a document uses, and the entry implementing it. ADR-0002 decision 1 puts the string in the document and the mapping in the palette, so a registration carries both halves - see from_modules/2 for why the name is not derived from the module.

t()

What one types entry may be: a module, or a module paired with an opaque term carried beside it (ADR-0002's 2026-09-07 amendment).

Functions

Calls callback on a palette entry, answering default when the entry does not declare it.

The core.* structural vocabulary as a palette (ADR-0002 decision 10).

The name => module map of core recipes, beside core_types/0 (ADR-0005 clause 4C).

The type_name => module map behind core/0, for a host merging the core vocabulary with its own entries

Whether the entry declares callback at the declared arity arity, without calling it.

Resolves a type_name to its entry. Total; never raises (ADR-0002 decision 3). Map.fetch/2 rather than a sentinel default, so a palette that genuinely maps a name to nil stays distinguishable from a name no entry carries.

Resolves a recipe name to its module. Total; never raises, for fetch/2's reason.

Builds a palette from an ordered, explicit list of registrations - the shape a host uses to contribute its own block types.

The palette as a sorted list of {name, version} entries - the one value a host pins to assert what its palette carries.

Builds a palette from a type_name => module map. Defaults to an empty palette.

Builds an unconfigured block of type_name from this palette: the type's StatifierBlocks.BlockType.config_schema/1 defaults as the config, and its StatifierBlocks.BlockType.current_version/0 as the stored type_version.

Resolves block through palette and, if needed, migrates its config in memory (ADR-0002 decision 8).

Types

manifest_entry()

@type manifest_entry() ::
  {StatifierBlocks.Block.type_name(), pos_integer()} | {recipe_name(), :recipe}

One line of a palette's manifest: a block type's name beside its module's current_version/0, or a recipe's name beside :recipe.

A recipe carries the marker rather than a number because a recipe has no version to carry - StatifierBlocks.Recipe declares no current_version/0, and a placeholder integer would read as one.

recipe_name()

@type recipe_name() :: String.t()

A recipe's name, as the palette browser and a pick name it.

registration()

@type registration() :: {StatifierBlocks.Block.type_name(), type_ref()}

One registration: the name a document uses, and the entry implementing it. ADR-0002 decision 1 puts the string in the document and the mapping in the palette, so a registration carries both halves - see from_modules/2 for why the name is not derived from the module.

The second element is a type_ref/0, so a host registers a stateful entry in the ordered list it already writes.

t()

@type t() :: %StatifierBlocks.Palette{
  assignability: module() | nil,
  recipes: %{optional(recipe_name()) => module()},
  types: %{optional(StatifierBlocks.Block.type_name()) => type_ref()},
  validators: [module()]
}

type_ref()

@type type_ref() :: module() | {module(), state :: term()}

What one types entry may be: a module, or a module paired with an opaque term carried beside it (ADR-0002's 2026-09-07 amendment).

One type name still resolves to one entry. state is opaque to this module - it is carried and prepended, and nothing here fixes its shape. StatifierBlocks.Composite.Data is the one module in this package that declares a shape for its own, and a host's stateful type declares its own.

There is no :kind key on an entry, and a pair is not one: it is one entry that carries a term. Every callback on an entry goes through call/4, which is where the pair stops being visible.

Functions

call(module, callback, args, default)

@spec call(type_ref(), atom(), [term()], term()) :: term()

Calls callback on a palette entry, answering default when the entry does not declare it.

This is the one call seam. Nothing in this package calls a callback on a module a palette resolved by writing module.callback(...); it writes Palette.call(ref, :callback, args, default), and three things that would otherwise be repeated at every site live here instead:

  • the arity arithmetic - a callback declared at arity n is exported at arity n + 1 by a stateful entry's module, and asking about the wrong arity would silently answer "not declared" and degrade a stateful type into the absent-callback path, which looks exactly like a type that declared nothing;
  • the absent-callback default, which is why this takes four arguments and not three: nine of the fourteen callbacks are optional and every site is a probe followed by a fallback, so folding the probe into the seam is what makes the arity arithmetic unrepeatable;
  • the state-prepending, which is the whole of what a caller must not know.

For a bare module entry it calls module.callback(args...); for a {module, state} entry it calls module.callback(state, args...).

For one of the five required callbacks the default is unreachable - a module that does not export emit/2 is not a block type at all - and a caller passes a value whose appearance would be a bug rather than a degradation. The seam does not distinguish the two cases; the behaviour's required list already does.

It does not rescue on behalf of a site that does not rescue today, it does not memoize, and it changes no callback's declared arity in StatifierBlocks.BlockType. A rescue is B3's degradation, about a callback that raises; the seam is about how an entry is reached.

iex> alias StatifierBlocks.Palette
iex> Palette.call(StatifierBlocks.Core.Send, :current_version, [], nil)
1

iex> alias StatifierBlocks.Palette
iex> Palette.call(StatifierBlocks.Core.Sequence, :sentence, [%{}], :absent)
:absent

iex> alias StatifierBlocks.Palette
iex> Palette.call(NoSuchModule, :current_version, [], :absent)
:absent

core()

@spec core() :: t()

The core.* structural vocabulary as a palette (ADR-0002 decision 10).

The core vocabulary's entries, described in StatifierBlocks.Core. They are ordinary palette entries with no privileged path anywhere in this package - a palette without them is as valid as a palette with them, and a host that wants only some of them builds a map with only those.

Palette.core()
#=> %StatifierBlocks.Palette{types: %{"core.sequence" => ..., ...}}

core_recipes()

@spec core_recipes() :: %{optional(recipe_name()) => module()}

The name => module map of core recipes, beside core_types/0 (ADR-0005 clause 4C).

One entry, "deadline": the core.send and core.on_event pair ADR-0010 decision 1 spells, as one palette pick. A palette built without it is as valid as a palette with it, which is the property core_types/0 already has - nothing in this package has a privileged path to a recipe either.

iex> Map.keys(StatifierBlocks.Palette.core_recipes())
["deadline"]

core_types()

@spec core_types() :: %{optional(StatifierBlocks.Block.type_name()) => module()}

The type_name => module map behind core/0, for a host merging the core vocabulary with its own entries:

Palette.new(Map.merge(Palette.core_types(), %{"myapp.authorize" => MyApp.Blocks.Authorize}))

A host entry sharing a name with a core one wins, because that is what Map.merge/2 does and a palette is just a value: nothing in this package reserves the core. prefix, and a host deliberately swapping in its own core.wait is doing something this design allows on purpose.

declares?(module, callback, arity)

@spec declares?(type_ref(), atom(), arity()) :: boolean()

Whether the entry declares callback at the declared arity arity, without calling it.

arity is the arity StatifierBlocks.BlockType declares, not the one a stateful entry's module exports: this function does the same arithmetic call/4 does, so a caller never writes arity + 1.

It exists for the two sites that need declaredness alone rather than a value - sentence/1 feeding ADR-0005's three-way chain, and outcomes/1 deciding whether a card draws an outcome row at all. Both feed a presentation branch rather than a fallback value, so neither can be expressed as call/4 with a default. It is one predicate, not a second seam: call/4 is written in terms of it.

iex> alias StatifierBlocks.Palette
iex> Palette.declares?(StatifierBlocks.Core.Send, :sentence, 1)
true

iex> alias StatifierBlocks.Palette
iex> Palette.declares?(StatifierBlocks.Core.Sequence, :sentence, 1)
false

fetch(palette, type_name)

@spec fetch(t(), StatifierBlocks.Block.type_name()) ::
  {:ok, type_ref()}
  | {:error, {:unknown_block_type, StatifierBlocks.Block.type_name()}}

Resolves a type_name to its entry. Total; never raises (ADR-0002 decision 3). Map.fetch/2 rather than a sentinel default, so a palette that genuinely maps a name to nil stays distinguishable from a name no entry carries.

It answers the entry as stored: it neither normalizes a bare module into {module, nil} nor unwraps a pair into its module. That is what keeps this source-compatible for every host that has a palette today - a host matching {:ok, module} still matches, because a host that registered no stateful entry can be handed no pair. Call a callback on what comes back through call/4, never directly.

fetch_recipe(palette, name)

@spec fetch_recipe(t(), recipe_name()) ::
  {:ok, module()} | {:error, {:unknown_recipe, recipe_name()}}

Resolves a recipe name to its module. Total; never raises, for fetch/2's reason.

The second map only. A name that is a block type and not a recipe answers {:error, {:unknown_recipe, name}}, which is clause 1C's two-namespace rule as a function: nothing resolves a name without knowing which map it is asking.

iex> StatifierBlocks.Palette.fetch_recipe(StatifierBlocks.Palette.core(), "deadline")
{:ok, StatifierBlocks.Core.DeadlineRecipe}

iex> StatifierBlocks.Palette.fetch_recipe(StatifierBlocks.Palette.core(), "core.send")
{:error, {:unknown_recipe, "core.send"}}

from_modules(registrations, opts \\ [])

@spec from_modules(
  [registration()],
  keyword()
) :: t()

Builds a palette from an ordered, explicit list of registrations - the shape a host uses to contribute its own block types.

Palette.from_modules(
  [
    {"myapp.risk_hold", MyApp.Blocks.RiskHold},
    {"myapp.settle", MyApp.Blocks.Settle}
  ],
  core: true
)

This is new/2 with the ergonomics the registration story actually wants, and nothing more: it is still a value, built where the editor is mounted and handed in explicitly. There is no global registry, no application-configuration lookup, and no compile- or boot-time discovery of modules implementing the behaviour - every reason the moduledoc gives for that applies here unchanged, and a discovery pass would additionally make two tenants in one runtime share whatever the code path happened to find.

Options:

  • :core - when true, the registrations sit on top of core_types/0 rather than on an empty map, and the recipe registrations sit on top of core_recipes/0. Defaults to false, so from_modules([]) is the empty palette.
  • :recipes - an ordered list of {name, module} recipe registrations (clause 1C), read the same way and with the same "later entries win" rule. A host registering its own recipe under a core recipe's name reads its own, because it wrote it later.
  • :assignability - passed through to new/2.

The list is ordered and later entries win, which is what makes it a list rather than a map: a host that deliberately swaps in its own core.wait writes it after core: true and reads the override in the order it happens, and the same name appearing twice in one list has an answer rather than a coin flip.

Why a name per entry, and not a name per module

A bare [module] list would be shorter, and this function does not take one, because nothing in StatifierBlocks.BlockType declares a type name. ADR-0002 decision 1 is explicit that the document names a type by string and the palette resolves the string - the mapping is the host's fact, not the module's, which is exactly what lets one module serve two names in two tenants' palettes. Deriving a name from a module would need a declaration the accepted behaviour does not have, and adding one is a change to that record rather than an implementation convenience.

What it checks, and what it does not

It does not assert the behaviour, and it does not resolve a module it cannot load: a palette is a value that may name a module compiled later, and every consumer already carries the unresolvable case as an ordinary arm (ADR-0002 decision 3).

It refuses two things, both mount-time programmer errors with no sensible degraded reading, each raising ArgumentError rather than quietly building a palette a host would misread:

  • a registration that is neither a {type_name, module} nor a {type_name, {module, state}} pair - the message names the offending entry. This one is from_modules/2's own, because a map has no registrations to malform;
  • two entries of one palette-browser group declaring the same order - the message names both, by name and module. That one is new/2's, and reaches here because this function builds its palette with new/2: both builders refuse the same collision the same way.

manifest(palette)

@spec manifest(t()) :: [manifest_entry()]

The palette as a sorted list of {name, version} entries - the one value a host pins to assert what its palette carries.

Palette.manifest(Palette.core())
#=> [{"core.assign", 1}, {"core.await", 1}, ..., {"deadline", :recipe}]

Types and recipes share one sorted list, and the second element says which map an entry came from: an integer is a block type at that current_version/0, :recipe is a recipe. The two names are still two namespaces (see the moduledoc), so a palette carrying a type and a recipe both named "deadline" produces both entries; sorted, the type comes first.

It is a list rather than a hash or a count, because the point is the failure message. A count moves from 27 to 29 and says nothing about which types arrived; a hash says only that something moved. A list diffs entry by entry, so the assertion that fails names the type that was added, removed, or version-bumped.

Sorting is what makes two palettes comparable: new/2 takes a map and from_modules/2 a list, so insertion order is not a fact about a palette and the manifest does not carry one.

This function calls into the modules a palette names - current_version/0 on each, the same call resolve/2 makes - so a palette naming a module that is not compiled raises here, where fetch/2 would not. (new/2's duplicate-order check also calls a module, but only one it can already load, and it skips the rest rather than raising.)

new(types \\ %{}, opts \\ [])

@spec new(
  %{optional(StatifierBlocks.Block.type_name()) => type_ref()},
  keyword()
) :: t()

Builds a palette from a type_name => module map. Defaults to an empty palette.

Options:

  • :assignability - a module implementing StatifierBlocks.Assignability.Relation (ADR-0003 decision 6). Defaults to nil, meaning the palette declares no widening relation - new(types) and new(types, assignability: nil) are the same palette.
  • :recipes - a name => module map of StatifierBlocks.Recipe implementations (clause 1C). Defaults to %{}. It is a second map rather than a second kind of entry in the first, because the two names are two namespaces.
  • :validators - a list of StatifierBlocks.DocumentValidator implementations, the host's own whole-document rules (ADR-0005 clause 11p). Defaults to [], and a palette that declares none pays nothing: there are no modules to call and the view model is identical. It is a list rather than a map, and ordered - there is no name to key on, nothing resolves a validator by name, every module in it runs in list order, and a later entry does not replace an earlier one. That is deliberately not types/recipes' rule, because those are lookups and this is not.

It refuses one thing, and raises ArgumentError rather than building a palette a host would misread: two entries of one palette-browser group declaring the same order (see "Ordering a group" in the moduledoc). Types and recipes are checked together, because the browser draws them into one group. An entry whose module is not loaded, exports no palette_entry/0, or declares no order is skipped, not refused, and a composite beside its own derived recipe is admitted - that pair is one declaration read twice, not two entries disagreeing about a number.

The check lives here rather than in from_modules/2 because a host that merges maps - the shape core_types/0 documents - never goes through from_modules/2, and the collision is the same mount-time programmer error whichever builder produced it.

new_block(palette, type_name)

@spec new_block(t(), StatifierBlocks.Block.type_name()) ::
  {:ok, StatifierBlocks.Block.t()} | :error

Builds an unconfigured block of type_name from this palette: the type's StatifierBlocks.BlockType.config_schema/1 defaults as the config, and its StatifierBlocks.BlockType.current_version/0 as the stored type_version.

This is what "insert this type" means as a value, with no editor in it. It lives beside fetch/2 because it is the same question one step further on - a name resolved to a module, then that module asked what a fresh block of it looks like - and a host view that inserts from a palette should not have to reimplement the answer.

Total, for fetch/2's reason: a type_name no entry carries is :error rather than a raise. The schema defaults are the only source of config here. A palette entry's :default_config is a separate declaration and is deliberately not merged in (that is the editor's insert probe, and whether an inserted block should start from it is an open question, not a settled one).

iex> {:ok, block} =
...>   StatifierBlocks.Palette.new_block(StatifierBlocks.Palette.core(), "core.send")
iex> block.type
"core.send"
iex> block.config
%{"delay" => "", "event" => ""}
iex> block.type_version == StatifierBlocks.Core.Send.current_version()
true

iex> StatifierBlocks.Palette.new_block(StatifierBlocks.Palette.core(), "no.such.type")
:error

resolve(palette, block)

@spec resolve(t(), StatifierBlocks.Block.t()) ::
  {:ok, type_ref(), StatifierBlocks.Block.t()}
  | {:error, {:unknown_block_type, StatifierBlocks.Block.type_name()}}
  | {:error, {:block_type_too_new, StatifierBlocks.Block.id(), pos_integer()}}
  | {:error, {:migration_failed, StatifierBlocks.Block.id(), term()}}

Resolves block through palette and, if needed, migrates its config in memory (ADR-0002 decision 8).

Four distinguishable outcomes, checked in this order:

  • the block's type has no entry in palette -> {:error, {:unknown_block_type, type}}
  • block.type_version == module.current_version() -> {:ok, module, block}, block returned exactly as given
  • block.type_version > module.current_version() -> {:error, {:block_type_too_new, block.id, block.type_version}}. Hard error, never a best-effort read: the code is older than the data, and guessing is how a rollback corrupts documents
  • block.type_version < module.current_version() -> module.migrate_config/2 is called once, straight from the stored version to current (never a version-by-version ladder). A successful migration rewrites only block.config on the returned struct; a failing one, or a module that does not export migrate_config/2 at all, becomes {:error, {:migration_failed, block.id, reason}}

The migrated config is applied to the returned struct only - resolve/2 never calls Document.to_json/1, from_json/1, or anything else that could persist. Persisting a migration is the caller's decision. The returned block's type_version is left as stored, never bumped to current_version(), so the result can never be mistaken for a block that was migrated on disk.

resolve/2 takes one block, not a document - it never walks a document; the caller owns the walk and what to do with a per-block failure.