StatifierBlocks.Assignability (StatifierBlocks v0.19.0)

Copy Markdown View Source

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

context()

@type context() :: %{optional(:entry_type) => type_expr() | :unknown}

Caller-supplied, not stored in the document (ADR-0003 decision 4).

finding()

@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}

io()

@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.

kind()

@type kind() :: atom()

Structural tag. :step and :interrupt_handler ship here; hosts may mint more.

produces()

@type produces() ::
  type_expr() | :unknown | {:passthrough, StatifierBlocks.Block.slot_name()}

What a block produces to its next sibling. See ADR-0003 decision 4.

reason()

@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 :unknown and the consuming side is not. Admitted, unchecked.
  • :target_untyped - the consuming side is :unknown and 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_id is the declaration an author would change.

target()

A position, as ADR-0001 decision 5 defines one.

type_expr()

@type type_expr() :: String.t()

Opaque to this package. Never parsed, split, or normalized.

Functions

admits?(arg1, slot, arg2)

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.

assignable?(palette, same, same)

@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:

  1. either side :unknown -> assignable (decision 5's permissive default);
  2. produced == consumed -> assignable (decision 1's identity relation);
  3. palette.assignability is nil -> not assignable (the floor a host cannot lower);
  4. 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.

check(palette, document, target, candidate, ctx)

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.

finding_reason(palette, arg)

@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.

inbound_type(palette, document, target, ctx)

@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 resolved produces of the sibling at index - 1;
  • index == 0 -> the parent block's own inbound type, found by Document.fetch_path/2 on the parent's id and recursing on its last path step;
  • the root (fetch_path returns {: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.

io(module, config)

@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.

kinds(module, config)

@spec kinds(module(), StatifierBlocks.Block.config()) :: [kind()]

The block's kinds, defaulting to [:step] (ADR-0003 decision 5).

produces(palette, document, block, ctx)

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.

seam_reason(palette, produced, consumed, producing_ref)

@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:

  1. both sides :unknown -> :both_untyped;
  2. produced :unknown -> :source_untyped;
  3. consumed :unknown -> :target_untyped;
  4. assignable?/3 -> nil;
  5. producing_ref is a block id -> {:fixable_by, producing_ref};
  6. 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.

seam_reasons(palette, document, ctx)

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.

slot_accepts(module, config, slot)

@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).

target_verdicts(palette, document, candidate, ctx)

@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.

valid_targets(palette, document, candidate, ctx)

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.

validate(palette, document, ctx)

@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/5 is 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.