StatifierBlocks.Edit.Targets (StatifierBlocks v0.25.0)

Copy Markdown View Source

Drop-target enumeration (ADR-0005 decision 5): which slots would accept a dragged block, at slot granularity rather than gap granularity.

The stated reduction, from ADR-0003 positions to ADR-0005 slots

StatifierBlocks.Assignability.valid_targets/4 answers a finer question than this module needs: every {block_id, slot_name, index} position where a candidate may land. ADR-0005 decision 5 wants every {block_id, slot_name} slot that has at least one such position. This module - concretely, droppable_slots/3 - bridges exactly those two functions, and the bridge is existential quantification over the index:

{b, s} is droppable  <=>  exists i such that {b, s, i} is in valid_targets/4

Decision 5's own text says "nothing in that list depends on the index within the slot," and that sentence is literally true of rules 1, 3 and 4 but not of rule 2: Assignability.check/5 consults an upstream seam and a downstream seam, both of which read the neighbours at index - 1 and index, so both move with the index. (The third seam check/5 consults, the vacated one, depends on the candidate's own current position rather than on the target index, so it is constant across a slot's gaps and is not part of what the reduction has to answer for.) The reduction is sound anyway, for three reasons:

  1. The index-free half of rule 2 is preserved exactly. Kind admission - Assignability.admits?/3, ADR-0003 decision 3's structural gate - is a function of {parent module, parent config, slot, child kinds} with no index in it. A slot that fails kind admission fails it at every index, so existential quantification drops the whole slot, which is the same verdict a per-slot rule would give. This is the half rule 2 is really about: an interrupts slot does not accept a step, at any index.
  2. The index-dependent half is a seam check, and seams are validation, not admission. Decision 5 says the editor never blocks an edit for a validation reason outside its four rules, and ADR-0002 decision 6 already establishes that a document mid-edit is allowed to be invalid. A type mismatch at one gap of an otherwise acceptable slot is exactly that kind of finding, not a reason to darken the whole slot.
  3. The reduction is an over-approximation at gap granularity, never an under-approximation. Highlighting a slot when at least one of its gaps accepts the block can offer the author a gap that would produce a :type_mismatch finding; it can never hide a gap that would have been clean. The failure mode is a finding the author can see and fix, not a legal arrangement they cannot reach.

The residue is real. Per-slot highlighting is a superset of per-position validity, and a drop at a particular gap may still produce an assignability finding. That is the documented cost of decision 5's per-slot granularity, not a defect introduced here.

The context, and why it is carried rather than assumed empty

Every entry point here takes an Assignability.context() and defaults it to %{}. The default is ADR-0003 decision 5's permissive one - no entry_type, no :datamodel, so an undeclared spelling resolves to :unknown and is admitted - and it is what a caller with nothing to say gets.

A caller that does have the datamodel document passes it, and this is the seam sb-sy0q closed rather than a widening it introduced. The editor holds the document the compiler's :datamodel option carries; the check the editor runs and the check the compiler runs are one implementation by ADR-0002 decision 6 and ADR-0003 decision 6, and a call that hard-coded %{} here made that one implementation answer two different questions - the compiler's coverage step (sd-ADR-0001 decision 8: a record satisfying a shape by covering its required set) simply could not run in the editor, so a drop the compiled document accepts was drawn as refused. Passing the context does not change the check; it stops the editor asking it with a document it has and did not hand over.

Rules 3 and 4, which are this bead's alone

valid_targets/4 already enforces rules 1 and 2 (declared slots only, kind admission). It does not filter by room and does not exclude the candidate's own subtree - those two are added here, after the projection to slots:

  • Rule 3, room. Drop any {b, s} whose arity in module.slots(config) is :exactly_one or :zero_or_one and whose current child count (as document stores it) is 1 or more. A block already occupying such a slot excludes that slot for itself too; that only forbids moving a block to the position it already holds, which costs nothing.
  • Rule 4, subtree. Drop any {b, s} where b is the dragged block itself or a descendant of it. Computed once as a MapSet of ids walked from the dragged block the same way Document.blocks/1 walks a document, so the filter is a membership test rather than a repeated path walk.

The droppable_slots_for/3 widening

droppable_slots/3 takes a Block.id(), per the record; a block id that names nothing in document yields []. ADR-0005 decision 8 requires the palette's "+" button to filter using "the same predicate," and a palette insert has no block in the document yet to name by id - so this module also exports droppable_slots_for/3, taking a %Block{} that need not be in document at all. droppable_slots/3 is implemented as a lookup followed by a call to droppable_slots_for/3: there is one implementation, not two. This is the third documented widening this module carries, alongside the two argued above.

Summary

Types

A slot's verdict for one dragged block: :ok when the slot would accept it, {:refused, reason} when it would not - reason being the 2026-08-29 ADR-0003 amendment's vocabulary, or nil when the slot's refusal has no data-flow reason to give (see slot_verdicts/3).

Functions

Which of palette's block types would be accepted at target.

Slots that would accept id's block: declared, kind-admitted (rule 1 and the index-free half of rule 2), with room (rule 3), and outside the block's own subtree (rule 4). Per-slot, not per-gap - see the moduledoc for the reduction this projects from.

The same predicate as droppable_slots/3, taking a %Block{} directly so a block not yet in document - the palette's "+" button, ADR-0005 decision 8 - can be asked the same question. droppable_slots/3 is a lookup followed by a call to this function.

A block of type as the assignability questions should be asked about it.

Every slot this module considers, with its verdict for block - the accepting ones and the refusing ones, in one pass.

Types

slot_verdict()

@type slot_verdict() :: :ok | {:refused, StatifierBlocks.Assignability.reason() | nil}

A slot's verdict for one dragged block: :ok when the slot would accept it, {:refused, reason} when it would not - reason being the 2026-08-29 ADR-0003 amendment's vocabulary, or nil when the slot's refusal has no data-flow reason to give (see slot_verdicts/3).

Functions

accepted_types(document, palette, arg, ctx \\ %{})

Which of palette's block types would be accepted at target.

target is a {parent_id, slot} pair, the granularity droppable_slots_for/4 answers at; the index within the slot is not part of the question, for the reason the moduledoc's reduction gives.

This is the palette's own filter - what "+" offers here, and what an insert picker offers there - and it is droppable_slots_for/4's predicate asked once per candidate type against a probe block of that type. Not a parallel implementation: the same function, with a block that is not in the document yet, which is exactly the case droppable_slots_for/4 exists to serve.

The probe is probe/2's, which is the part a surface writing this filter by hand gets wrong: a type whose StatifierBlocks.BlockType.palette_entry/0 declares a default_config affecting what it reads is answered differently by a probe built from StatifierBlocks.Palette.new_block/2 alone. Two views filtering the same palette against the same document then disagree about whether a type fits, and neither is visibly wrong.

A type that does not resolve through palette is left out rather than raising, the way every other palette walk in this package treats one.

droppable_slots(document, palette, id, ctx \\ %{})

Slots that would accept id's block: declared, kind-admitted (rule 1 and the index-free half of rule 2), with room (rule 3), and outside the block's own subtree (rule 4). Per-slot, not per-gap - see the moduledoc for the reduction this projects from.

id naming no block in document yields []: there is no block to drag, so there is nothing to ask droppable_slots_for/3 about.

droppable_slots_for(document, palette, block, ctx \\ %{})

The same predicate as droppable_slots/3, taking a %Block{} directly so a block not yet in document - the palette's "+" button, ADR-0005 decision 8 - can be asked the same question. droppable_slots/3 is a lookup followed by a call to this function.

probe(palette, type)

A block of type as the assignability questions should be asked about it.

StatifierBlocks.Palette.new_block/2's block, with the type's StatifierBlocks.BlockType.palette_entry/0 default_config merged over its config. Without the merge, a block type whose read is declared on a config field - a path field, say - declares no read at all while that field holds its schema default, and every slot stamps ok: the drop-time refusal ADR-0011 promises never fires for exactly the blocks whose reads are worth checking. default_config is the entry saying what the author is about to configure, so the probe asks the question the configured block would ask.

This is the probe only. The block a pick or a drop actually inserts still comes from StatifierBlocks.Palette.new_block/2: the entry's declaration is what a type would be asked about, not a config the author never wrote.

palette_entry/0 is optional and a type name can resolve to a module that is not loadable, so both are checked the way StatifierBlocks.Environment.subject_path/2 checks them for the same callback.

slot_verdicts(document, palette, block, ctx \\ %{})

Every slot this module considers, with its verdict for block - the accepting ones and the refusing ones, in one pass.

droppable_slots_for/3 keeps the :ok rows of this list, so the accepting set and the reasons for the refusing set come from one enumeration and one decision. The rows are StatifierBlocks.Assignability.target_verdicts/4's positions projected to slots, first-appearance order preserved, with rules 3 and 4 applied after the projection exactly as before.

What a refused slot's reason is, and when there is none

A slot is refused when every gap in it was refused - that is the existential reduction the moduledoc argues for, read the other way round. So a slot-level reason exists only when the gaps agree:

  • each gap's reason is Assignability.finding_reason/2 of its first finding, in the order Assignability.check/5 documents - so a gap that fails kind admission reports nil, because that gate's finding names both kind sets itself and is the more interesting of the two things wrong with such a gap;
  • the slot's reason is that value when every gap gave the same non-nil one, and nil otherwise.

nil is therefore honest rather than lossy: it says this slot has no one data-flow reason to show the author - because the refusal was structural (:kind_not_admitted names both kind sets itself), because it was rule 3 or rule 4 (no room, or the block's own subtree, neither of which is an assignability question at all), or because different gaps refused differently and picking one of them would be picking arbitrarily.

Only the two refusing arms of the vocabulary can ever appear here. The three untyped arms sit on seams that were admitted, and an admitted gap makes its whole slot :ok - which is the amendment's "the reason explains, it never refuses more" holding at this layer too, by construction rather than by discipline.

Which refusing arm reaches slot level depends on which seam refused, and this is worth saying rather than leaving to be discovered. Through a slot's insertion seams only :not_assignable can: {:fixable_by, block_id} needs every gap in a slot to name the same producing block, and a slot's gaps name different ones by construction - gap 0's producing side is the slot's own inbound (:slot_entry) and gap i's is the sibling at i - 1 - so a slot with more than one gap refusing at all of them disagrees with itself and reports nil. Through the vacated seam (Assignability.check/5's third seam, present only for a move) {:fixable_by, _} is reachable and expected: a move that would break the seam it leaves behind yields the same :type_mismatch at every position in the document, naming the block before the candidate's current position, so every otherwise-accepting gap agrees and every such slot reads {:refused, {:fixable_by, id}} - including slots elsewhere in the tree, whose reason then names a seam in the source slot. A gap that also refuses on its own insertion seam takes that seam's reason instead (insertion seams precede the vacated seam in check/5's order). The finding still carries the position-level answer in every case, and the per-slot attribute does not pretend to an answer the granularity cannot support.