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

The editor's command algebra. Pure, serializable, invertible - and
deliberately free of any UI framework dependency, so it is tested with
no editor shell present at all (ADR-0005 decision 1).

`apply/2` takes no palette (ADR-0005 decision 3's spec), so it is a purely
**structural** rewrite: it never consults a block-type registry, never
runs `validate_config/1`, and never checks whether a slot is declared.
That gate lives one layer up, in `check_config/3` and `Edit.History`.

## The four structural rules

1. **A target's index is read against the slot's current children**,
   where an absent slot key means `[]`. Valid indices are
   `0..length(children)` inclusive; anything else is
   `{:error, {:index_out_of_range, target}}`.
2. **An absent slot key is created by an insert or a move into it.** This
   is not optional: a declared slot with no children carries no key in
   the document's `slots` map (`Document.to_json/1` omits it, ADR-0001
   decision 8), so refusing to create it would break the commonest drop
   there is.
3. **A remove prunes a slot key that it empties.** Symmetric with rule 2
   and with canonical JSON's omission of empty slots, and it is what
   makes `apply(apply(d, e), inverse) == d` hold as **struct equality**,
   not merely as equal canonical bytes.
4. **`:move` reads its target index against the slot with the moved
   block already removed** (decision 4). Concretely: detach, then
   insert. The inverse of a move from `{P, s, i}` to `{Q, t, j}` is a
   move to `{P, s, i}` - the same `i`, because removing at `i` and
   re-inserting at `i` in the shortened list is the identity, and that
   is true whether or not `P == Q and s == t`.

## The inverses

| Command | Inverse |
|---|---|
| `{:insert, target, block}` | `{:remove, block.id}` |
| `{:remove, id}` | `{:insert, original_target, detached_subtree}` |
| `{:move, id, target}` | `{:move, id, original_target}` |
| `{:update_config, id, config}` | `{:update_config, id, previous_config}` |
| `{:set_datamodel, entries}` | `{:set_datamodel, previous_entries}` |
| `{:set_accepts, names}` | `{:set_accepts, previous_names}` |
| `{:update_note, id, note}` | `{:update_note, id, previous_note}` |

## The fifth command (ADR-0005's 2026-09-01 amendment, 2g-2h)

`{:set_datamodel, entries}` replaces the document's whole `datamodel`
list - ADR-0001 decision 11's ordered declaration entries - in one
command. The four structural rules above are about the tree and none of
them reaches it: an entry is not a block, the list is not a slot, and
there is no `target()` to read an index against.

Whole-list replacement rather than per-entry insert, remove and move, for
decision 2's own reason for collapsing seven commands into four: one code
path that writes the key means the grammar check, the ordering and the
inverse each have one implementation. Add, edit, remove and reorder are
all the same command carrying a different list, the inverse is the list
that was there before, and the round-trip law of decision 3 holds by
construction because the command is its own kind of inverse.

This is the first command whose *content* is checked here rather than in
`check_config/3`. `check_config/3` is decision 9's block-type gate and it
asks a palette; a declaration has no block type and no palette to ask.
What a declaration has is a grammar - ADR-0001 11b's `{id, expr,
description}` and 11c's structural id uniqueness - and that is the same
kind of question rules 1 to 4 answer for the tree, so it is answered
here. The internal datamodel check `StatifierBlocks.Document.validate/1`
runs is the one implementation of it, so a list this command accepts is a
list `Document.validate/1` accepts, and `to_json/1` can never raise on a
document this command produced.

## The sixth command (ADR-0014 decision 6)

`{:set_accepts, names}` replaces the document's whole `accepts` list - the
names of the external events the document accepts - on the fifth
command's terms and for its reasons: whole-list replacement, the previous
list as the inverse, and the grammar checked here rather than in
`check_config/3`. The one implementation of that grammar is
the internal `accepts` check `StatifierBlocks.Document.validate/1` runs, so
a list this command accepts is a list `Document.validate/1` accepts. It
is a command rather than editor state because the list is in the document
and in its hash: a declared name an author deletes is document content,
and undo has to bring it back.

## The note command (ADR-0005's 2026-09-29 Amendment, 2s)

`{:update_note, id, note}` replaces the author-written `note` on the block
carrying `id` (ADR-0001's Amendment of 2026-09-28), and its inverse is
`{:update_note, id, previous_note}`. It is a command, like every other
write an editor makes to the document, so the note goes through
`Edit.History` and an undo brings back the note an author overwrote.

The empty string is the absent note: a note of `""` removes the block's
note, and canonical form omits it. Nothing is trimmed - a note is stored
as the author wrote it, whitespace included, and it is a reader of the
note that decides what a note of only whitespace says.

The note's grammar - a UTF-8 string - is checked here, as the two
declaration commands check theirs: a note is not config, so there is no
block type for `check_config/3` to ask. The one implementation of that
grammar is the block check `StatifierBlocks.Document.validate/1` runs, and
a note it refuses is refused here in the same
`{:malformed_block, id, {:note, :not_a_string}}` term, so `to_json/1`
can never raise on a document this command produced.

## The deliberate widening

The record's typespec block lists four error arms. This module ships
three more, and none contradicts a decision - each names a case the
record's list does not enumerate:

  * **`{:duplicate_block_id, Block.id()}`** - an `:insert` whose block (or
    whose subtree) carries an id already present in the document.
    ADR-0001 decision 1 makes document-wide id uniqueness an invariant
    and `Document.validate/1` enforces it, so `apply/2` must refuse
    rather than produce a document that fails its own validator. The
    spelling matches `Decode`'s existing `{:duplicate_block_id, id}`.
  * **`{:cannot_remove_root, Block.id()}`** - a `:remove` or `:move`
    naming the document root. The root occupies no slot, so there is no
    position to detach it from and no inverse to write.
    `{:no_such_block, id}` would be a lie about a block that plainly
    exists.
  * **`{:malformed_block, Block.id(), {:note, :not_a_string}}`** - an
    `:update_note` whose note is not a UTF-8 string. The term is the one
    `Document.validate/1` answers for such a note, for the reason the
    note-command section above gives.

The record's own `{:no_such_slot, block_id, slot_name}` arm is given the
one meaning left to it once rule 2 above allows slot creation: a target
whose slot name is not a usable slot key - not a binary, or empty.
Commands are serializable values that can arrive from a replayed log, so
this is a real arm, not a dead one.

## The composition (ADR-0005 clause 2n)

`{:compound, [t()]}` carries a non-empty list of commands. `apply/2`
applies them left to right against the intermediate documents, and the
inverse it returns is the compound of each step's inverse **in reverse
order**. A member that refuses refuses the whole compound: `apply/2`
answers `{:error, term()}` with the member's own error term, unchanged,
and no document at all, so there is no partially applied document for a
caller to mistake for a result.

**A compound is not another edit.** Its leaves are drawn from the commands
in the table of inverses above and nothing else - a list that is empty, or
that holds a `:compound` of its own, is refused rather than flattened - so
every edit a document can undergo is still one of those. What the
constructor buys is that `Edit.History` pushes one inverse per commit,
which makes a compound
**one undo entry**: one gesture in, one gesture out, and no state between
the halves that an author can stop in.

The two refusals a malformed compound produces reuse the
`{:malformed_envelope, term()}` arm above rather than minting an arm of
their own. An empty or nested list is a value claiming to be a shape it is
not, which is what that arm already means everywhere else in this package,
and clause 2n's consequence that nothing reading these terms learns a new
shape is why it is not a new one here.

# `t`

```elixir
@type t() ::
  {:insert, target(), StatifierBlocks.Block.t()}
  | {:remove, StatifierBlocks.Block.id()}
  | {:move, StatifierBlocks.Block.id(), target()}
  | {:update_config, StatifierBlocks.Block.id(), StatifierBlocks.Block.config()}
  | {:set_datamodel, [StatifierBlocks.Document.DatamodelEntry.t()]}
  | {:set_accepts, [String.t()]}
  | {:update_note, StatifierBlocks.Block.id(), String.t()}
  | {:compound, [t()]}
```

Ids in an `:insert`ed block are already minted, which is what keeps the
command replayable (ADR-0005 decision 2).

# `target`

```elixir
@type target() ::
  {StatifierBlocks.Block.id(), StatifierBlocks.Block.slot_name(),
   non_neg_integer()}
```

A position, not a block. ADR-0001 decision 5's path element.

# `apply`

```elixir
@spec apply(StatifierBlocks.Document.t(), t()) ::
  {:ok, StatifierBlocks.Document.t(), t()}
  | {:error, {:no_such_block, StatifierBlocks.Block.id()}}
  | {:error,
     {:no_such_slot, StatifierBlocks.Block.id(),
      StatifierBlocks.Block.slot_name()}}
  | {:error, {:index_out_of_range, target()}}
  | {:error, {:would_cycle, StatifierBlocks.Block.id()}}
  | {:error, {:duplicate_block_id, StatifierBlocks.Block.id()}}
  | {:error, {:cannot_remove_root, StatifierBlocks.Block.id()}}
  | {:error, {:malformed_envelope, term()}}
  | {:error,
     {:malformed_block, StatifierBlocks.Block.id(), {:note, :not_a_string}}}
```

Applies one command, returning the new document and the command that
undoes it. Total: refuses rather than raises. See the moduledoc's four
structural rules and its table of inverses.

# `check_config`

```elixir
@spec check_config(StatifierBlocks.Palette.t(), StatifierBlocks.Document.t(), t()) ::
  :ok
  | {:error,
     {:invalid_config, StatifierBlocks.Block.id(),
      [StatifierBlocks.BlockType.finding()]}}
```

ADR-0005 decision 9's config gate. `apply/2` above is purely structural
and cannot ask a block type whether a config is valid; this is where that
question actually gets asked, one layer up from the structural rewrite
and one layer below `Edit.History`, which is the only caller (see its
moduledoc).

`:ok` for every leaf command that is not `:update_config` - none of them
touches a block's config, so there is nothing for a block type to
validate.
For an `:update_config`, resolves the named block's current type through
`palette` and asks it the same two questions the compiler's `:config`
stage and the editor's view model ask, against the **candidate** config
the command carries (not the block's current config - that one already
validated, or the block would not exist in a valid document): the type's
own `validate_config/1`,
`StatifierBlocks.BlockType.type_expr_findings/2`, the one implementation
of what a `{:type_expr, opts}` field's value may be (ADR-0002 decision 7,
amended 2026-09-06), and the package's check of every other field against
its declared field type (decision 7, amended 2026-09-28). A value that is
no arm the field admits, or that its declared field type does not admit,
is refused here, at the edit gate, rather than passing the edit algebra
and being refused first at compile. The findings are reported together on
one `{:invalid_config, id, findings}`, the two type checks' first and
`validate_config/1`'s after them, in the order the compiler and the view
model already use.

Also `:ok` when the block does not resolve through `palette` at all -
unknown type, too-new version, or a failed migration. There is no
authority to consult in that case, and ADR-0005 decision 12 already
forbids the editor from offering a config form for an unresolvable
block's config in the first place; refusing here would be this function
inventing a rule that belongs to the editor, not restating one. The same
`:ok` covers a block id `apply/2` will itself refuse as
`{:no_such_block, id}` - there is no config to check, and this function
is not the one that reports that error.

---

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