StatifierBlocks.Edit.Targets (StatifierBlocks v0.30.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.

Both of the palette's maps, asked the same way

A palette carries two maps - block types and recipes (ADR-0005 clause 1C)

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.

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

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_recipes(document, palette, target, ctx \\ %{})

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.

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.

accepted_types_at(document, palette, target, candidates \\ nil, ctx \\ %{})

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?(document, palette, target, type, ctx \\ %{})

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.

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.

recipe_inserts(document, palette, name, target)

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