May this block land in this slot? Answered by two independent gates - structural admission by kind tag (ADR-0003 decision 3, untouched), and data flow by the environment at the position, checked with the datamodel document's own read check plus the palette's host relation last (ADR-0011).
One implementation, consulted by both the editor and the compiler, because both are passed the same palette.
What the data-flow gate is a gate on
Nothing flows between adjacent blocks. ADR-0011 supersedes ADR-0003's seam:
a block declares the datamodel paths it reads and writes, and
StatifierBlocks.Environment carries a map from path to type through the
document in pre-order. The question at a position is whether the
environment there satisfies what the block reads, and the answer comes from
StatifierDatamodel.Types.satisfies/3 - the one read check, defined in the
package that owns the document. This package defines no second one, and no
Compatibility or Coverage module of its own.
consumes and produces survive as sugar over the document's subject
path, which the entry block's palette entry names (decision 6), so a
palette that declared them against ADR-0003 keeps saying what it said.
The contract
The five functions ADR-0003's "The relation as typespecs" section named keep their arities, and two of them keep their arities while changing what they are computed from:
@spec check(Palette.t(), Document.t(), target(), Block.t(), context()) ::
:ok | {:error, [finding()]}
@spec valid_targets(Palette.t(), Document.t(), Block.t(), context()) :: [target()]
@spec validate(Palette.t(), Document.t(), context()) :: :ok | {:error, [finding()]}
@spec inbound_type(Palette.t(), Document.t(), target(), context()) ::
type_expr() | :unknown
@spec assignable?(Palette.t(), type_expr() | :unknown, type_expr() | :unknown) ::
boolean()inbound_type/4 is now the environment's type at the subject path, and
answers :unknown for a document with no subject. assignable?/3 keeps
ADR-0003 decision 6's ordering property - the host relation is consulted
only after the check has already failed, so it can only widen - with the
floor raised: the record-into-shape coverage step runs before the host is
asked, per ADR-0011 decision 3's narrowing of decision 6.
The deliberate widening
This module ships more public functions than the records list, and none of
them is a second implementation of the relation: io/2, kinds/2,
slot_accepts/3 and admits?/3 are the structural gate's primitives,
produces/4 resolves a declared produces including {:passthrough, slot}, and seam_reason/4, finding_reason/2, seam_reasons/3 and
target_verdicts/4 read verdicts the deciding functions have already
reached and label or collect them.
Defaults, per ADR-0003 decision 5 (restated by ADR-0011 decision 3 as
sd-ADR-0001's): an absent io/1 callback, or a module that is not
loadable, is %{}; an absent :kinds key is [:step]; an absent
:slot_accepts entry for a given slot is :any; :unknown is permissive
in both directions.
Summary
Types
Caller-supplied, not stored in the document. StatifierBlocks.Environment
reads both keys: :datamodel is the datamodel document the type
declarations come from, and :entry_type seeds the subject path.
ADR-0003 decision 8's vocabulary, with the one member ADR-0011 decision 8
adds: a :type_mismatch carries the datamodel path the read was
checked at, last. It is added rather than left to be re-derived because a
block may carry several read signatures on several paths, so a message
saying which two types disagreed without saying where is one an author
cannot act on. :kind_not_admitted gains none - a structural refusal is
about a slot and has no path to name.
The return shape of StatifierBlocks.BlockType.io/1. Every key optional.
Structural tag. :step and :interrupt_handler ship here; hosts may mint more.
What a block declares it produces. See ADR-0003 decision 4.
Why a read came out the way it did (the 2026-08-29 amendment to ADR-0003
decision 8, with the arm ADR-0011 decision 8 adds). A reason explains a
verdict; it never changes one - see seam_reason/4.
A position, as ADR-0001 decision 5 defines one.
A type as a declaration spells it. Opaque to this package - never parsed,
split or normalized here; StatifierDatamodel.Types.parse/2 reads it
against the datamodel document, a list carries its item type, and an
inline unnamed shape carries its members.
What put the held type at the path a :type_mismatch names (ADR-0011
decision 8, amended 2026-09-06). A block id where a block's write
signature put it; :slot_entry where the seed's subject type - or a merge
with no single writer to name - did; :declaration where the datamodel
document's own declared type did.
Functions
ADR-0003 decision 3's structural verdict for placing child in slot of
parent: :any admits everything, otherwise the slot's accepted kinds
and the child's own kinds must intersect.
May a value the environment holds as held be read where expected is
required?
ADR-0003 decision 7's one decision function: kind admission for
candidate at target, plus the reads whose verdict a placement at
target can change.
The reason for one finding, derived rather than stored.
The type flowing into target: the environment's type at the document's
subject path, at that position (ADR-0011 decisions 1 and 6).
module.io(config), or %{} when io/1 is absent or module is not
loadable (ADR-0003 decision 5). Checked with Code.ensure_loaded?/1 plus
function_exported?/3, the pattern StatifierBlocks.Palette.resolve/2
already uses.
The block's kinds, defaulting to [:step] (ADR-0003 decision 5).
block's declared produces, resolved: a type expression resolves to
itself; :unknown resolves to :unknown; {:passthrough, slot} resolves
to the resolved produces of the last block in slot, or, when slot is
empty (or is not a slot block declares), block's own inbound type.
Why the read of expected against a held type came out the way it did.
Every read in document that has something to say about itself, in
Document.blocks/1's pre-order: {reading_block_id, producing_ref, reason}.
The accepted kinds for slot on module, defaulting to :any when the
slot has no entry in io/1's :slot_accepts map (ADR-0003 decision 5).
Every position valid_targets/4 enumerates, each paired with check/5's
full verdict rather than filtered down to the accepting ones.
Every position in document where candidate may be dropped, per
ADR-0003's valid_targets/4: for every block in Document.blocks/1, for
every slot that block's module declares (module.slots(config), not
the slot keys the stored document happens to carry), for every index from
0 to that slot's current child count inclusive, the position is kept
when check/5 returns :ok for it.
Every finding in document, per ADR-0003's validate/3: every read
already present in the document, checked with the same rules check/5
checks one with - the whole-document counterpart to check/5's
single-position query.
Types
@type context() :: StatifierBlocks.Environment.context()
Caller-supplied, not stored in the document. StatifierBlocks.Environment
reads both keys: :datamodel is the datamodel document the type
declarations come from, and :entry_type seeds the subject path.
@type finding() :: {:kind_not_admitted, StatifierBlocks.Block.id(), StatifierBlocks.Block.id(), StatifierBlocks.Block.slot_name(), [kind()], [kind()] | :any} | {:type_mismatch, StatifierBlocks.Block.id(), upstream_ref(), type_expr() | :unknown, type_expr() | :unknown, String.t()}
ADR-0003 decision 8's vocabulary, with the one member ADR-0011 decision 8
adds: a :type_mismatch carries the datamodel path the read was
checked at, last. It is added rather than left to be re-derived because a
block may carry several read signatures on several paths, so a message
saying which two types disagreed without saying where is one an author
cannot act on. :kind_not_admitted gains none - a structural refusal is
about a slot and has no path to name.
@type io() :: %{ optional(:kinds) => [kind()], optional(:consumes) => type_expr() | :unknown, optional(:produces) => produces(), optional(:slot_accepts) => %{ optional(StatifierBlocks.Block.slot_name()) => [kind()] | :any } }
The return shape of StatifierBlocks.BlockType.io/1. Every key optional.
@type kind() :: atom()
Structural tag. :step and :interrupt_handler ship here; hosts may mint more.
@type produces() :: type_expr() | :unknown | {:passthrough, StatifierBlocks.Block.slot_name()}
What a block declares it produces. See ADR-0003 decision 4.
@type reason() :: :source_untyped | :target_untyped | :both_untyped | {:shape_not_satisfied, [String.t()]} | :not_assignable | {:fixable_by, StatifierBlocks.Block.id()}
Why a read came out the way it did (the 2026-08-29 amendment to ADR-0003
decision 8, with the arm ADR-0011 decision 8 adds). A reason explains a
verdict; it never changes one - see seam_reason/4.
:both_untyped- neither side declared a type, so the read was admitted without checking anything.:source_untyped- the environment holds:unknownthere and the read declared a type. Admitted, unchecked.:target_untyped- the read declared:unknownand the environment holds a type. Admitted, unchecked.{:shape_not_satisfied, missing}- the environment holds a record at the path, the read expects a shape, and the record does not cover the shape's required set.missingnames the required fields it does not cover, in the shape's own field order, so a message can say which are absent without re-deriving them.:not_assignable- refused: both sides typed, identity failed, coverage did not apply or did not hold, and the palette's relation did not widen. Nothing names a block to go and look at.{:fixable_by, block_id}- the same refusal, where a block's write signature is what put the offending type at the path:block_idis the declaration an author would change.
@type target() :: {StatifierBlocks.Block.id(), StatifierBlocks.Block.slot_name(), non_neg_integer()}
A position, as ADR-0001 decision 5 defines one.
@type type_expr() :: StatifierBlocks.Environment.type_expr()
A type as a declaration spells it. Opaque to this package - never parsed,
split or normalized here; StatifierDatamodel.Types.parse/2 reads it
against the datamodel document, a list carries its item type, and an
inline unnamed shape carries its members.
@type upstream_ref() :: StatifierBlocks.Block.id() | :slot_entry | :declaration
What put the held type at the path a :type_mismatch names (ADR-0011
decision 8, amended 2026-09-06). A block id where a block's write
signature put it; :slot_entry where the seed's subject type - or a merge
with no single writer to name - did; :declaration where the datamodel
document's own declared type did.
Functions
@spec admits?( {module(), StatifierBlocks.Block.config()}, StatifierBlocks.Block.slot_name(), {module(), StatifierBlocks.Block.config()} ) :: boolean()
ADR-0003 decision 3's structural verdict for placing child in slot of
parent: :any admits everything, otherwise the slot's accepted kinds
and the child's own kinds must intersect.
@spec assignable?( StatifierBlocks.Palette.t(), type_expr() | :unknown, type_expr() | :unknown, StatifierDatamodel.Declarations.t() ) :: boolean()
May a value the environment holds as held be read where expected is
required?
ADR-0003 decision 6's ordered relation as ADR-0011 decision 3 narrows it. The order is the contract:
StatifierDatamodel.Types.satisfies/3- either side unknown, then identity, then a record covering a shape's required set;- otherwise, and only otherwise,
palette.assignability'sassignable?/2; - a palette with no relation, or a module that is not loadable or does
not export the callback, answers
false- the floor a host cannot lower.
Reflexivity holds regardless of the host, because step 1 decides identity before step 2 is reached, so the host callback can only widen the relation and never narrow it. The floor is higher than ADR-0003 left it: a record read as a shape it covers is admitted before the host is asked, so a host that was widening records into shapes by hand can delete that half of its module.
declarations is StatifierDatamodel.Declarations.from_document/1's index
over the datamodel document's types key. With none - which is what every
caller that has no datamodel to hand passes - the check reduces to unknown
and identity, which is exactly what it decided before there were
declarations to read.
@spec check( StatifierBlocks.Palette.t(), StatifierBlocks.Document.t(), target(), StatifierBlocks.Block.t(), context() ) :: :ok | {:error, [finding()]}
ADR-0003 decision 7's one decision function: kind admission for
candidate at target, plus the reads whose verdict a placement at
target can change.
candidate not yet in document (Document.fetch_path/2 returns
:error) is an insert: candidate's own reads against the environment
at target, and the reads of whatever block currently sits at target's
index against that environment with candidate's writes applied (nothing
to check when the slot ends there).
candidate already in document is a move: the same two, plus what it
vacates - at its current position {p, s, i}, removing it makes i - 1
and i + 1 adjacent, so the block at i + 1 is checked against the
environment as it stood at i, which is the environment without
candidate's writes (nothing to check when the slot has no i + 1).
Findings are decision 8's vocabulary verbatim, in this order when more than
one applies: kind admission first, then the reads at the insertion point,
then the vacated ones. A block carrying several read signatures contributes
one finding per unsatisfied read, each naming its own path, because the
signatures are independent (ADR-0011 decision 2). :ok when the list is
empty.
Degradation, per decision 5: a block that fails Palette.resolve/2 - the
candidate, the parent, or any block involved - contributes the permissive
default rather than a finding.
@spec finding_reason( StatifierBlocks.Palette.t(), finding(), StatifierDatamodel.Declarations.t() ) :: reason() | nil
The reason for one finding, derived rather than stored.
A :type_mismatch carries its producing ref, the held type, the expected
type and the path already, so its reason is seam_reason/5 applied to what
the tuple holds. A reason kept in the finding would be a second copy of a
verdict the tuple plus the palette already determine, free to disagree with
it; derived, it cannot.
:kind_not_admitted answers nil: the structural gate's reason is its own
finding code, and this vocabulary is the data-flow gate's.
A caller with no datamodel document to hand gets the classification the declarations-free check reaches, which is the one this package reached before there were declarations at all.
@spec inbound_type( StatifierBlocks.Palette.t(), StatifierBlocks.Document.t(), target(), context() ) :: type_expr() | :unknown
The type flowing into target: the environment's type at the document's
subject path, at that position (ADR-0011 decisions 1 and 6).
ADR-0003 decision 4 computed this by walking to the previous sibling's
resolved produces, and ADR-0011 replaces the seam rather than the
question. A document with no subject path - no entry block, or an entry
block whose palette entry declares no subject: - has nothing flowing into
any position and answers :unknown everywhere, which is the permissive
default an untyped palette has always got.
Total: a parent_id no block in document carries, or an index past the
end of the slot's children, resolves to :unknown rather than raising.
Inside a core.drafts shelf the answer is :unknown at every index.
ADR-0003's amendment of 2026-08-31, section A2, is why, and ADR-0011
decision 1 carries it forward in its own vocabulary: the walk does not
enter the shelf, and each parked fragment is walked from an empty
environment, so a fragment reads nothing as known because nothing put it
there. The walk inside each fragment is untouched - a parked fragment is
a subtree whose own writes and reads are real, and they are checked exactly
as they would be anywhere else.
@spec io(module(), StatifierBlocks.Block.config()) :: io()
module.io(config), or %{} when io/1 is absent or module is not
loadable (ADR-0003 decision 5). Checked with Code.ensure_loaded?/1 plus
function_exported?/3, the pattern StatifierBlocks.Palette.resolve/2
already uses.
@spec kinds(module(), StatifierBlocks.Block.config()) :: [kind()]
The block's kinds, defaulting to [:step] (ADR-0003 decision 5).
@spec produces( StatifierBlocks.Palette.t(), StatifierBlocks.Document.t(), StatifierBlocks.Block.t(), context() ) :: type_expr() | :unknown
block's declared produces, resolved: a type expression resolves to
itself; :unknown resolves to :unknown; {:passthrough, slot} resolves
to the resolved produces of the last block in slot, or, when slot is
empty (or is not a slot block declares), block's own inbound type.
A block that fails Palette.resolve/2 contributes :unknown rather than a
finding (ADR-0003 decision 5's degradation rule).
Under ADR-0011 nothing in the data-flow gate consults this: a passthrough container passes types through by carrying its slot's own writes out through the merge, which is the environment doing it rather than a declaration claiming it. It is kept because it reads a declaration a palette may still carry, and reading a declaration is not the same as deciding with it.
Why this terminates
Every recursive step lands at a strictly earlier position in the document's
pre-order than the position that made the original call. Resolving
{:passthrough, slot} on block B either descends to the last child of
B's own slot - later than B, but still strictly before whatever
position asked for B's produces, since that position comes after B
and every descendant of B is ordered before B's next sibling - or,
when the slot is empty, falls back to B's own inbound type, which is
B's previous sibling or, at index 0, B's parent. Pre-order rank over a
finite tree is a well-founded measure with no infinite descending chain, so
this cannot cycle even for a tree of nothing but empty passthrough
sequences.
@spec seam_reason( StatifierBlocks.Palette.t(), type_expr() | :unknown, type_expr() | :unknown, upstream_ref(), StatifierDatamodel.Declarations.t() ) :: reason() | nil
Why the read of expected against a held type came out the way it did.
producing_ref is the block whose write signature put held at the path,
exactly as ADR-0011 decision 8's :type_mismatch tuple names it, or
:slot_entry when the seed - or a merge with no single writer to name -
is what the read disagrees with, or :declaration when the datamodel
document's own declared type is.
nil means there is nothing to explain: both sides are typed and the
read passed, by identity, by coverage, or because the palette's relation
widened it. Every other outcome names itself. The classification is total
and follows the read check's own order:
- both sides
:unknown->:both_untyped; held:unknown->:source_untyped;expected:unknown->:target_untyped;- satisfied, or widened by the host ->
nil; - a record that does not cover the shape ->
{:shape_not_satisfied, missing}; producing_refis a block id ->{:fixable_by, producing_ref};- otherwise ->
:not_assignable.
Step 6 is why the two non-block refs land on step 7: neither
:slot_entry nor :declaration names a block whose declaration an
author would change, and for a :declaration the change is to the
datamodel document rather than to anything in this one.
This function decides nothing. It reads a verdict assignable?/4 has
already reached and labels it; nothing in this package branches a verdict
on what it returns, so the reason vocabulary stays strictly explanatory.
The first three arms sit on reads the permissive default admitted, and
adding them cannot refuse anything.
The two ADR-0003 refusing arms still differ only in whether the author has somewhere to go, and under ADR-0011 that block is found by name rather than by adjacency: it is the block whose write signature put the type at the path, which is a more useful answer than the sibling ADR-0003 could name.
Structural refusals carry no reason from this vocabulary.
{:kind_not_admitted, ...} already names both kind sets in the finding
itself, so finding_reason/2 answers nil for it rather than duplicating
it here.
@spec seam_reasons( StatifierBlocks.Palette.t(), StatifierBlocks.Document.t(), context() ) :: [ {StatifierBlocks.Block.id(), upstream_ref(), reason()} ]
Every read in document that has something to say about itself, in
Document.blocks/1's pre-order: {reading_block_id, producing_ref, reason}.
This is the queryable form of the cost ADR-0003's consequences state in prose - "a partially typed palette permits what a fully typed one would catch". The three untyped arms name exactly those reads, and they are the reason this vocabulary is not merely a refusal vocabulary: a host auditing its own palette's coverage asks here, gets back the reads that passed without being checked, and declares the signatures that produced them.
A block declaring no read at all still contributes a row, against the environment's type at the subject path - the block was reached without saying anything about what it needs, which is precisely the coverage gap this function exists to surface.
It changes no verdict and is not part of validation. Reads with nothing to
explain (typed on both sides and passing) are omitted rather than listed
with a nil.
@spec slot_accepts( module(), StatifierBlocks.Block.config(), StatifierBlocks.Block.slot_name() ) :: [kind()] | :any
The accepted kinds for slot on module, defaulting to :any when the
slot has no entry in io/1's :slot_accepts map (ADR-0003 decision 5).
@spec target_verdicts( StatifierBlocks.Palette.t(), StatifierBlocks.Document.t(), StatifierBlocks.Block.t(), context() ) :: [{target(), :ok | {:error, [finding()]}}]
Every position valid_targets/4 enumerates, each paired with check/5's
full verdict rather than filtered down to the accepting ones.
valid_targets/4 is defined as this function keeping the :ok rows, so
there is one enumeration and one decision, not two. It is exposed for the
same reason the moduledoc's other widenings are: a caller that has to say
why a position was refused - StatifierBlocks.Edit.Targets, which
projects positions to slots and needs a reason for a slot it darkens -
must not re-derive the position set, because a second enumeration is a
second answer waiting to drift from this one.
Same order and the same determinism guarantee valid_targets/4
documents.
@spec valid_targets( StatifierBlocks.Palette.t(), StatifierBlocks.Document.t(), StatifierBlocks.Block.t(), context() ) :: [target()]
Every position in document where candidate may be dropped, per
ADR-0003's valid_targets/4: for every block in Document.blocks/1, for
every slot that block's module declares (module.slots(config), not
the slot keys the stored document happens to carry), for every index from
0 to that slot's current child count inclusive, the position is kept
when check/5 returns :ok for it.
Deterministic: Document.blocks/1 is already pre-order, slots/1 is
visited in the order it declares slots, and indices ascend within each
slot - so the same call always returns the same list, in the same order.
A block whose type fails Palette.resolve/2 contributes no positions:
there is no module to ask for a declared slot set, so this is an absence
of positions rather than a refusal.
@spec validate(StatifierBlocks.Palette.t(), StatifierBlocks.Document.t(), context()) :: :ok | {:error, [finding()]}
Every finding in document, per ADR-0003's validate/3: every read
already present in the document, checked with the same rules check/5
checks one with - the whole-document counterpart to check/5's
single-position query.
Walking every block in Document.blocks/1 other than the root (which
occupies no slot) and checking its own reads against the environment at its
position visits every read in the document exactly once.
This is deliberately not check/5 called with a block already sitting
at its own current position as the candidate: check/5's downstream and
vacated reads are defined relative to a target the candidate is being
placed at or removed from, and a block that already occupies target
makes both of those check the document as though the block were somewhere
it is not. validate/3 reaches the same two checks check/5 runs for kind
admission and the block's own reads directly.
:ok when the document has no findings; otherwise {:error, findings}
with every block's findings concatenated, in Document.blocks/1's
pre-order.
A block named in ctx[:skip_blocks] is passed over: it produces no
finding of its own and its declared writes leave no entry in the
environment (StatifierBlocks.Environment's context documents why). The
walk continues past it - its siblings and its children are checked exactly
as they would have been - so this is an absence of one block's answers
rather than a shortened walk. check/5 reads the same key through the
environment it builds, and no caller that has not refused a config sets
one.