A palette names the block types a host makes available: a map from a
block's type_name to the module implementing StatifierBlocks.BlockType
for it (ADR-0002 decision 2).
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 module 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.
Functions
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
Resolves a type_name to its module. 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.
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(), module()}
One registration: the name a document uses, and the module 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.
@type t() :: %StatifierBlocks.Palette{ assignability: module() | nil, recipes: %{optional(recipe_name()) => module()}, types: %{optional(StatifierBlocks.Block.type_name()) => module()}, validators: [module()] }
Functions
@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.
@spec fetch(t(), StatifierBlocks.Block.type_name()) :: {:ok, module()} | {:error, {:unknown_block_type, StatifierBlocks.Block.type_name()}}
Resolves a type_name to its module. 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.
@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 does not check
It does not load the module, does not assert the behaviour, and does not
call a callback. 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). What it does refuse is an entry
that is not a {type_name, module} pair at all: that is a mount-time
programmer error with no sensible degraded reading, so it raises
ArgumentError naming the offending entry rather than quietly building
a palette missing a type the host believes it registered.
@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 is the one function here that 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.
@spec new( %{optional(StatifierBlocks.Block.type_name()) => module()}, 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 resolve(t(), StatifierBlocks.Block.t()) :: {:ok, module(), 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.