May this block land in this slot? Answered by two independent gates - structural admission by kind tag, and data-flow compatibility by opaque string identity plus a host-supplied widening relation (ADR-0003).
One implementation, consulted by both the editor (sb-w50) and the
compiler (sb-iwz), because both are passed the same palette (ADR-0003
decision 6).
The contract
ADR-0003's "The relation as typespecs" section is this module's contract,
taken verbatim - the seven types above (type_expr/0, kind/0,
produces/0, io/0, context/0, target/0, finding/0) and five
functions:
@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()Everything else this module exports is built out of that contract, not alongside it.
The deliberate widening
This module ships five more public functions than the record lists:
io/2, kinds/2, slot_accepts/3, and admits?/3 (the structural
kind-admission gate), plus produces/4 (data-flow resolution, including
{:passthrough, slot}). None of these is a second implementation of the
relation - they are the one implementation check/5, valid_targets/4
and validate/3 are built out of, exposed because the record's own
acceptance criteria need them independently testable: the kind gate has
to be assertable in isolation to state the both-directions placement
property over the core vocabulary, produces/4 has to be assertable on
its own to state the termination property below, and
StatifierBlocks.CoreFixtures's stand-in document walk has to delegate
to the shipped rule rather than keep a private copy of it. A host that
calls only check/5, valid_targets/4, validate/3, inbound_type/4
and assignable?/3 sees exactly ADR-0003's contract; the five extra
functions widen what is callable, never what is decided.
Four more were added by the 2026-08-29 amendment to decision 8, and they
hold to the same line. seam_reason/4, finding_reason/2 and
seam_reasons/3 are the reason vocabulary: each reads a verdict
assignable?/3 has already reached and labels it, and nothing in this
package branches on what they return, so no seam is admitted or refused
differently for their existing. target_verdicts/4 is the enumeration
valid_targets/4 is now defined in terms of - the same positions in the
same order, with check/5's verdict kept instead of discarded, so a
caller that has to explain a refusal does not enumerate a second time.
Why produces/4 terminates
{:passthrough, slot} resolves recursively - to the last block in
slot, or, when slot is empty, to the resolving block's own inbound
type. Every such step moves to a strictly earlier position in the
document's pre-order than the position that asked: descending into a
slot still lands before the position that asked (everything under a
block is ordered before that block's own next sibling), and falling back
to a block's own inbound type moves to its previous sibling or its
parent, both earlier than the block itself. Pre-order rank over a finite
tree has no infinite descending chain, so resolution cannot cycle even
over a document built entirely of empty passthrough sequences. See
produces/4 for the full argument.
Defaults, per ADR-0003 decision 5: 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.
Summary
Types
Caller-supplied, not stored in the document (ADR-0003 decision 4).
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 produces to its next sibling. See ADR-0003 decision 4.
Why a data-flow seam came out the way it did (the 2026-08-29 amendment to
ADR-0003 decision 8). A reason explains a verdict; it never changes
one - see seam_reason/4.
A position, as ADR-0001 decision 5 defines one.
Opaque to this package. Never parsed, split, or normalized.
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.
ADR-0003 decision 6's ordered relation. Four steps, checked in this order, and the order is the contract
ADR-0003 decision 7's one decision function: kind admission for
candidate at target, plus the seams whose verdict a placement at
target can change.
The reason for one finding, derived rather than stored.
The inbound type at target (ADR-0003 decision 4), computed by walking -
never stored
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 produces, resolved (ADR-0003 decision 4): 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 - decision 4 is silent on that, and treating it as empty is the
permissive, total reading decision 5 already establishes elsewhere),
block's own inbound type.
Why the seam produced -> consumed came out the way it did, as the
2026-08-29 amendment to ADR-0003 decision 8 defines it. producing_ref
is the producing side of the seam exactly as decision 8's
:type_mismatch tuple already names it: a block id, or :slot_entry
when the type came from the slot's own inbound rather than from a block.
Every seam in document that has something to say about itself, in
Document.blocks/1's pre-order: {consuming_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 seam
already present in the document, checked with the same rules check/5
checks a seam with - the whole-document counterpart to check/5's
single-position query.
Types
@type context() :: %{optional(:entry_type) => type_expr() | :unknown}
Caller-supplied, not stored in the document (ADR-0003 decision 4).
@type finding() :: {:kind_not_admitted, StatifierBlocks.Block.id(), StatifierBlocks.Block.id(), StatifierBlocks.Block.slot_name(), [kind()], [kind()] | :any} | {:type_mismatch, StatifierBlocks.Block.id(), StatifierBlocks.Block.id() | :slot_entry, type_expr() | :unknown, type_expr() | :unknown}
@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 produces to its next sibling. See ADR-0003 decision 4.
@type reason() :: :source_untyped | :target_untyped | :both_untyped | :not_assignable | {:fixable_by, StatifierBlocks.Block.id()}
Why a data-flow seam came out the way it did (the 2026-08-29 amendment to
ADR-0003 decision 8). A reason explains a verdict; it never changes
one - see seam_reason/4.
:both_untyped- neither side declared a type, so decision 5 admitted the seam without checking anything.:source_untyped- the producing side is:unknownand the consuming side is not. Admitted, unchecked.:target_untyped- the consuming side is:unknownand the producing side is not. Admitted, unchecked.:not_assignable- refused: both sides are typed, identity failed, and the palette's relation did not widen. The producing side is:slot_entry, so the refusal names no block to go and look at.{:fixable_by, block_id}- the same refusal, where the producing side is a block: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() :: String.t()
Opaque to this package. Never parsed, split, or normalized.
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 ) :: boolean()
ADR-0003 decision 6's ordered relation. Four steps, checked in this order, and the order is the contract:
- either side
:unknown-> assignable (decision 5's permissive default); produced == consumed-> assignable (decision 1's identity relation);palette.assignabilityisnil-> not assignable (the floor a host cannot lower);- otherwise
module.assignable?(produced, consumed).
Reflexivity holds regardless of the host, because step 2 short-circuits
before step 4 is ever reached - the host callback is consulted only after
identity has already failed, so it can only widen the relation, never
narrow it. Step 4 is guarded the same way every other optional seam in
this package is: a module that is not loadable, or does not export
assignable?/2, yields false rather than raising, so a misconfigured
palette degrades to the floor instead of turning a validation pass into
an exception.
@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 seams whose verdict a placement at
target can change.
candidate not yet in document (Document.fetch_path/2 returns
:error) is an insert: two seams, inbound_type(target) against
candidate's consumes, and candidate's resolved produces against
the consumes of whatever block currently sits at target's index
(nothing to check when the slot ends there).
candidate already in document is a move: the same two seams, plus
the seam it vacates - at its current position {p, s, i}, removing it
makes i - 1 and i + 1 adjacent, so the resolved produces of the
block at i - 1 (or the slot's own inbound type when i == 0) is
checked against the consumes of the block at i + 1 (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 insertion seams, then
the vacated seam. :ok when the list is empty.
Degradation, per decision 5: a block that fails Palette.resolve/2 - the
candidate, the parent, or any block on a seam - contributes the
permissive default rather than a finding, the same way io/2 already
degrades a module that is not loadable.
@spec finding_reason(StatifierBlocks.Palette.t(), finding()) :: reason() | nil
The reason for one finding, derived rather than stored.
A :type_mismatch carries its producing ref, produced type and consumed
type already, so its reason is seam_reason/4 applied to what the tuple
holds - which is why the 2026-08-29 amendment adds no element to
decision 8's tuples. 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.
@spec inbound_type( StatifierBlocks.Palette.t(), StatifierBlocks.Document.t(), target(), context() ) :: type_expr() | :unknown
The inbound type at target (ADR-0003 decision 4), computed by walking -
never stored:
index > 0-> the resolvedproducesof the sibling atindex - 1;index == 0-> the parent block's own inbound type, found byDocument.fetch_path/2on the parent's id and recursing on its last path step;- the root (
fetch_pathreturns{:ok, []}) ->ctx[:entry_type], defaulting to:unknown.
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, consistent with decision 5's permissive default. See
produces/4 for the termination argument this function's own recursion
(through own_inbound_type/4 and back) relies on - every step here moves
to a strictly earlier pre-order position too: a previous sibling or a
parent, both earlier than target itself.
Inside a core.drafts body the answer is :unknown at every index, and
this record's amendment of 2026-08-31, section A2, is why. Between two
parked fragments there is no seam: their order is shelf order, and
ADR-0002's amendment of the same date, section G9a, fixes that the
compiler never reads it as sequencing, so deriving one fragment's inbound
type from the fragment above it would make rearranging a shelf produce and
clear findings. At index == 0 the recursion would reach the shelf's own
inbound, and the shelf has none - it declares no consumes, it is not in
the flow, and nothing reaches it. Under decision 5 :unknown is
assignable to any consumes, so no fragment is ever refused for the
position it holds on the shelf.
The walk inside each fragment is untouched. A parked fragment is a subtree, its own internal seams are real sequencing, and they are checked exactly as they would be anywhere else - which is the property that makes the shelf worth having rather than a hole in the checker.
@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 produces, resolved (ADR-0003 decision 4): 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 - decision 4 is silent on that, and treating it as empty is the
permissive, total reading decision 5 already establishes elsewhere),
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; unresolvability is
ADR-0002 decision 3's finding, reported by the walk that owns 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 in the first place, 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 either B's previous sibling or, at
index 0, B's parent - both strictly earlier than B. 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, StatifierBlocks.Block.id() | :slot_entry ) :: reason() | nil
Why the seam produced -> consumed came out the way it did, as the
2026-08-29 amendment to ADR-0003 decision 8 defines it. producing_ref
is the producing side of the seam exactly as decision 8's
:type_mismatch tuple already names it: a block id, or :slot_entry
when the type came from the slot's own inbound rather than from a block.
nil means there is nothing to explain: both sides are typed and the
seam passed, by identity (decision 6 step 2) or because the palette's
relation widened it (step 4). Every other outcome names itself.
The classification is total and follows decision 6's own order:
- both sides
:unknown->:both_untyped; - produced
:unknown->:source_untyped; - consumed
:unknown->:target_untyped; assignable?/3->nil;producing_refis a block id ->{:fixable_by, producing_ref};- otherwise ->
:not_assignable.
This function decides nothing. It reads assignable?/3's verdict and
labels it; it never gates on its own answer, and no caller in this
package branches a verdict on it. That is what makes the reason
vocabulary strictly explanatory: the first three arms sit on seams
decision 5 admitted, and adding them cannot refuse anything, because
:unknown stays permissive in both positions and step 4 short-circuits
before either refusing arm is reachable.
The two refusing arms differ only in whether the author has somewhere to
go, and the split is decision 8's own: producing_ref is exactly the
third element of a :type_mismatch tuple, so {:fixable_by, block_id}
names the block that finding already names, and :not_assignable is the
case where that element is :slot_entry.
Two limits, both deliberate, both consequences of deriving the split
from decision 8's tuple rather than from a second walk. At a slot's
index 0 the producing ref is :slot_entry by decision 4's definition of
the slot inbound, even when the type reached that position from a real
block through a container - so a refusal there says :not_assignable
though a block upstream of the container did declare the type. And when
the named block passes a type through ({:passthrough, slot}), the
declaration to change is inside it rather than on it.
Both could be closed by tracing a type to its declaring block, and neither is, on purpose: that trace is a second walk producing a second answer, and a second answer that can disagree with the finding the author is reading is the exact failure ADR-0003 decision 7 exists to prevent. One rule, one ref, one answer - see the amendment's consequences.
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(), StatifierBlocks.Block.id() | :slot_entry, reason()} ]
Every seam in document that has something to say about itself, in
Document.blocks/1's pre-order: {consuming_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 seams a fully typed one would catch". The three untyped arms name exactly those seams, 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 seams that passed without being checked, and types the blocks that produced them.
It changes no verdict and is not part of validation. A document whose
every seam answers :source_untyped is exactly as valid as one whose
seams answer nil - validate/3 is unchanged and still reports only the
findings decision 8 defines. Seams 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. Offering a target inside a slot that
is not even declared would be offering a position the document walk that
owns undeclared-slot findings (ADR-0002 decision 6) then rejects.
@spec validate(StatifierBlocks.Palette.t(), StatifierBlocks.Document.t(), context()) :: :ok | {:error, [finding()]}
Every finding in document, per ADR-0003's validate/3: every seam
already present in the document, checked with the same rules check/5
checks a seam with - the whole-document counterpart to check/5's
single-position query.
A document's seams are exactly its blocks' own upstream seams: the slot's
own inbound (or the previous sibling's resolved produces, when there is
one) against the block's consumes, plus the same kind-admission check
check/5 runs for a candidate at a target. Walking every block in
Document.blocks/1 other than the root (which occupies no slot and has
no seam of its own) and checking each one this way visits every seam in
the document exactly once - a block never shares its upstream seam with
any other block, and nothing in a slot has a seam after its last child
that some other block's own upstream check does not already cover.
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 seams 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 seams check the block against
itself, or against neighbors as though the block were not there at all -
neither is a seam that exists in document as given. validate/3
reaches the same two checks check/5 runs for kind admission and the
upstream seam - kind_admission_finding/5 and upstream_seam_finding/5
- directly, so the decision stays the one
check/5is built from without running its candidate-already-there degenerate case.
:ok when the document has no findings; otherwise {:error, findings}
with every block's findings concatenated, in Document.blocks/1's
pre-order.