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.
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
@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.
@type recipe_name() :: String.t()
A recipe's name, as the palette browser and a pick name it.
@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.
@type t() :: %StatifierBlocks.Palette{ assignability: module() | nil, recipes: %{optional(recipe_name()) => module()}, types: %{optional(StatifierBlocks.Block.type_name()) => type_ref()}, validators: [module()] }
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
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
@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" => ..., ...}}
@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"]
@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.
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
@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.
@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"}}
@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- whentrue, the registrations sit on top ofcore_types/0rather than on an empty map, and the recipe registrations sit on top ofcore_recipes/0. Defaults tofalse, sofrom_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 tonew/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 byorder, 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 nopalette_entry/0, or declares noorderis skipped, not refused.
@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.)
@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 implementingStatifierBlocks.Assignability.Relation(ADR-0003 decision 6). Defaults tonil, meaning the palette declares no widening relation -new(types)andnew(types, assignability: nil)are the same palette.:recipes- aname => modulemap ofStatifierBlocks.Recipeimplementations (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 ofStatifierBlocks.DocumentValidatorimplementations, the host's own whole-document rules (ADR-0005 clause11p). 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 nottypes/recipes' rule, because those are lookups and this is not.
@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
@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
typehas no entry inpalette->{:error, {:unknown_block_type, type}} block.type_version == module.current_version()->{:ok, module, block},blockreturned exactly as givenblock.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 documentsblock.type_version < module.current_version()->module.migrate_config/2is called once, straight from the stored version to current (never a version-by-version ladder). A successful migration rewrites onlyblock.configon the returned struct; a failing one, or a module that does not exportmigrate_config/2at 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.