# `StatifierBlocks.Edit.Targets`
[🔗](https://github.com/riddler/statifier_blocks/blob/v0.44.1/lib/statifier_blocks/edit/targets.ex#L1)

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
a seam closed rather than a widening 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.

## 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/4`
for the one, `accepted_recipes/4` and `recipe_inserts/4` for 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 one
    `StatifierBlocks.Assignability.check/5` at 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.

# `slot_verdict`

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

# `accepted_recipes`

```elixir
@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 `t: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**: `c: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.

# `accepted_types`

```elixir
@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
`c: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.

# `accepted_types_at`

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

# `admits_at?`

```elixir
@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 `c: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.

# `droppable_slots`

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

# `droppable_slots_for`

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

# `probe`

```elixir
@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
`c: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.

# `recipe_inserts`

```elixir
@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
`c: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 in `palette`;
  * whatever `insert/2` answered - 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.

# `slot_verdicts`

```elixir
@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/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.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
