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.
Both of the palette's maps, asked the same way
A palette carries two maps - block types and recipes (ADR-0005 clause 1C)
- and this module answers what-may-land-where for both:
accepted_types/4for the one,accepted_recipes/4andrecipe_inserts/4for the other.
The recipe pair was private to StatifierBlocks.Editor until ADR-0005's
Note of 2026-09-07, item 1, promoted it here. The reason it is public is
that a host drawing its own picker had to re-derive both answers, and
a re-derivation is where clause 3C's bound goes quiet. The reason it lives
in this module rather than on StatifierBlocks.Palette is that both
questions are asked of a target, not of a palette alone.
The editor keeps the half that does not generalise: assigning, minting a
selection, and committing the {:compound, commands} as one undo entry.
Which of the three type readers a "+" chooser calls
There are three, and a surface picks by the question it has rather than by the answer's shape (ADR-0005's Note of 2026-09-08, item 5):
admits_at?/5- "may this type go here". One probe and oneStatifierBlocks.Assignability.check/5at the target's append gap. This is what a "+" chooser drawing a single type's row asks, and what a host filtering its own shortlist should call once per row.accepted_types_at/5- the same question over a candidate list, defaulting to the palette's types. A surface that already knows its shortlist - one palette group, a recently-used row - pays for the shortlist rather than for the palette.accepted_types/4- the sweep. Every palette type against the whole document, at slot granularity, which is the right call for a palette browser filtering itself against a position and for anything that wants the slot-level over-approximation the reduction above argues for.
All three are the same predicate, so the answer does not depend on which
is asked, with the one documented exception admits_at?/5 names: the
sweep's per-slot verdict accepts a slot when any gap accepts, and the
per-target pair asks at the slot's append gap. What none of them is, is a
filter a surface writes by hand - that is the failure accepted_types/4's
own doc warns about, where two views filtering the same palette disagree
and neither is visibly wrong.
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 recipe names would land at target.
Which of palette's block types would be accepted at target.
admits_at?/5 over a candidate list: which of candidates would be
accepted at target, defaulting to the palette's own type names.
Whether type would be accepted at target, asked with one probe and one
StatifierBlocks.Assignability.check/5 (ADR-0005's Note of 2026-09-08,
item 5, under clause 4C).
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.
The command list that inserts name's arrangement at target, or the
refusal.
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 accepted_recipes( StatifierBlocks.Document.t(), StatifierBlocks.Palette.t(), StatifierBlocks.Edit.target(), StatifierBlocks.Assignability.context() ) :: MapSet.t(StatifierBlocks.Palette.recipe_name())
Which of palette's recipe names would land at target.
The recipe half of accepted_types/4, and a host drawing its own picker
asks the two maps the same way: the document, the palette, the armed
position and a context, in that order and with that default (ADR-0005's
Note of 2026-09-07, item 1).
target here is a full StatifierBlocks.Edit.target/0 - a
{parent_id, slot, index} position rather than the {parent_id, slot}
pair accepted_types/4 takes - because a recipe's fit has only one way of
being asked (ADR-0005 clause 3C): a recipe is not a block type, so no set
of type names answers for it. What answers is the recipe itself, handed
the armed position and the document. This function is therefore the filter
over palette.recipes whose test is recipe_inserts/4 answering
{:ok, _} - one implementation, so the paint and the write cannot drift
apart on which recipes fit.
ctx is carried for the shape a caller asks both maps in. It reaches no
recipe today: StatifierBlocks.Recipe.insert/2 is handed the target
and the document and nothing else, and whether a later clause should
thread the context further is a question that item's non-decisions leave
open, not a promise.
A recipe that does not resolve through palette is left out rather than
raising, the way accepted_types/4 treats a type that does not resolve.
@spec accepted_types( StatifierBlocks.Document.t(), StatifierBlocks.Palette.t(), {StatifierBlocks.Block.id(), StatifierBlocks.Block.slot_name()}, StatifierBlocks.Assignability.context() ) :: MapSet.t(StatifierBlocks.Block.type_name())
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.
@spec accepted_types_at( StatifierBlocks.Document.t(), StatifierBlocks.Palette.t(), {StatifierBlocks.Block.id(), StatifierBlocks.Block.slot_name()}, [StatifierBlocks.Block.type_name()] | nil, StatifierBlocks.Assignability.context() ) :: MapSet.t(StatifierBlocks.Block.type_name())
admits_at?/5 over a candidate list: which of candidates would be
accepted at target, defaulting to the palette's own type names.
The default makes this accepted_types/4's answer computed the per-target
way - one probe and one check/5 per candidate rather than a whole-document
walk per candidate - and the argument exists so that a surface which
already knows its shortlist pays for the shortlist rather than for the
palette: a "+" inside one palette group, a recently-used row, a host's own
five favourites.
A candidate that does not resolve through palette is left out, and a
candidate that is not in palette at all is the same case - probe/2
answers :error for both.
@spec admits_at?( StatifierBlocks.Document.t(), StatifierBlocks.Palette.t(), {StatifierBlocks.Block.id(), StatifierBlocks.Block.slot_name()}, StatifierBlocks.Block.type_name(), StatifierBlocks.Assignability.context() ) :: boolean()
Whether type would be accepted at target, asked with one probe and one
StatifierBlocks.Assignability.check/5 (ADR-0005's Note of 2026-09-08,
item 5, under clause 4C).
This is the question a "+" chooser at a gap actually has - "may this
type go here" - where accepted_types/4 answers the palette-wide one.
Both are the same predicate; the difference is how much of the palette is
paid for. accepted_types/4 probes every type in the palette and runs
droppable_slots_for/4 for each, and droppable_slots_for/4 walks every
block and every gap of the whole document; this function resolves one
parent, picks one gap and checks once.
Which gap, and why the answer is the slot's
target is a {parent_id, slot} pair, the granularity this module
answers at, so this function picks the gap: the slot's append gap,
the index one past its last child. It is the gap a "+" at the end of a
slot arms, it is the only gap an empty slot has, and it is the one gap of
a slot that has no block after it - so check/5's downstream seam is
empty there, and the verdict is about the candidate rather than about a
read some block already in the slot already fails. A slot whose first
child is broken today does not thereby refuse the whole palette.
Rules 1 and 3 are applied first and without a check: a parent_id that
names nothing, a slot the parent's StatifierBlocks.BlockType.slots/1
does not declare, and a full :exactly_one or :zero_or_one slot are all
false before check/5 is asked anything. Rule 4 cannot fire - a probe
block is not in document, so no target is inside its subtree.
What is left is check/5's own verdict at that one gap, which is rule 2.
Its index-free half - kind admission, ADR-0003 decision 3's structural
gate - is the half the moduledoc's reduction argues is the real
admission, and it gives the same answer at gap 0 as at any other. Its
index-dependent half is the seam check, and there one check is an
approximation of the sweep's existential over the gaps rather than an
equal, in one direction: a slot with an earlier gap that accepts a type
the append gap refuses on a seam is true from accepted_types/4 and
false here. That direction is the sweep's own documented
over-approximation not being made - a slot's gap 0 sits ahead of every
write the slot's children make, so it accepts what nothing upstream has
contradicted yet, and the existential then spreads that over the slot.
What this function answers instead is what appending would find, which
is the question a "+" at the end of a slot is asking. Either way the
drop is checked again at the position the author actually chose.
A type that does not resolve through palette is false rather than a
raise, the way every other palette walk in this package treats one.
@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 probe(StatifierBlocks.Palette.t(), StatifierBlocks.Block.type_name()) :: {:ok, StatifierBlocks.Block.t()} | :error
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.
@spec recipe_inserts( StatifierBlocks.Document.t(), StatifierBlocks.Palette.t(), StatifierBlocks.Palette.recipe_name(), StatifierBlocks.Edit.target() ) :: {:ok, [StatifierBlocks.Edit.t()]} | {:error, term()}
The command list that inserts name's arrangement at target, or the
refusal.
StatifierBlocks.Palette.fetch_recipe/2, then the recipe's own
StatifierBlocks.Recipe.insert/2 (ADR-0005 clause 2C), then
StatifierBlocks.Recipe.within_reach?/2 for clause 3C's bound. Three
refusals, distinguishable by their reasons:
{:error, {:unknown_recipe, name}}- no such recipe inpalette;- whatever
insert/2answered - the ordinary case clause 3C names, a deadline armed where the enclosing block has no interrupts rail; {:error, {:recipe_out_of_reach, name}}- a command list that reaches outside clause 3C's bound, which is a recipe module's bug and is refused here rather than trusted.
It commits nothing. Assigning, minting a selection and committing the
{:compound, commands} as one undo entry (ADR-0005 clause 2n) stay the
editor's, which is the half of the gesture that does not generalise.
Both checks still run at the write. A host that draws its picker from
accepted_recipes/4 and then commits its own compound is running the same
two checks in the same order; a host that skips the filter and calls this
function alone still gets the refusal.
@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.