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

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;
  • two entries of one palette-browser group declaring the same order - the message names both, by name and module. ADR-0005 decision 10 sorts a group by order, so a duplicate leaves the pick between the two to whatever the sort happened to do. 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.

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. (from_modules/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.

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.