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/4Decision 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:
- 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: aninterruptsslot does not accept a step, at any index. - 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.
- 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_mismatchfinding; 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 inmodule.slots(config)is:exactly_oneor:zero_or_oneand whose current child count (asdocumentstores 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}wherebis the dragged block itself or a descendant of it. Computed once as aMapSetof ids walked from the dragged block the same wayDocument.blocks/1walks 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
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.
Every slot this module considers, with its verdict for block - the
accepting ones and the refusing ones, in one pass.
Types
@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
@spec droppable_slots( StatifierBlocks.Document.t(), StatifierBlocks.Palette.t(), StatifierBlocks.Block.id(), StatifierBlocks.Assignability.context() ) :: [{StatifierBlocks.Block.id(), StatifierBlocks.Block.slot_name()}]
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.
@spec droppable_slots_for( StatifierBlocks.Document.t(), StatifierBlocks.Palette.t(), StatifierBlocks.Block.t(), StatifierBlocks.Assignability.context() ) :: [{StatifierBlocks.Block.id(), StatifierBlocks.Block.slot_name()}]
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.
@spec slot_verdicts( StatifierBlocks.Document.t(), StatifierBlocks.Palette.t(), StatifierBlocks.Block.t(), StatifierBlocks.Assignability.context() ) :: [ {{StatifierBlocks.Block.id(), StatifierBlocks.Block.slot_name()}, slot_verdict()} ]
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/2of its first finding, in the orderAssignability.check/5documents - so a gap that fails kind admission reportsnil, 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-
nilone, andnilotherwise.
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.