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

The commit funnel: one place a gesture reaches the document, over
`StatifierBlocks.Edit.History.commit/4`.

A session is the editing state a surface holds between gestures - the
palette it resolves types through, the document it is editing, the undo
history behind it, the per-block config drafts the document has refused
with the findings each refusal carried, and the last refusal that had
nowhere else to go. Every function here is
a pure function of that value: it takes a session and returns a session,
with no socket, no assigns and no process state in it.

## Why the funnel is a module and not four copies

The package's own editor and every host embedding it face the same four
lines of `Edit.History.commit/4` handling, and the decisions inside them
are the package's, not the surface's:

  * a refused `{:update_config, id, config}` is held as a **draft** of
    what the author typed, keyed by block id, rather than being blanked
    back to the document's value (ADR-0005 decision 9). A host that
    re-implements the funnel re-implements that decision, and gets it
    wrong by keeping the keystrokes it should drop or dropping the ones
    it should keep;
  * a refusal that is not about a config lands in `last_error` and moves
    nothing else, and `refusal/1` is what a surface draws from it: the
    reason is a tuple aimed at a caller, and the sentence for it is the
    funnel's decision rather than each surface's (ADR-0005's Note of
    2026-09-08, item 6);
  * an undo or a redo drops **every** draft: a draft is by definition
    config the document never accepted, and carrying one across a
    history move shows the author a value belonging to a document state
    they just stepped out of;
  * a list gesture reads and writes through the field's own `value_path`
    (ADR-0002 decision 7, amended 2026-08-27) rather than through its
    key, and descends into nested members the same way the control that
    draws them does.

## The `{:ok, session}` / `{:error, session}` result

Every funnel function answers with a tagged session, and the tag says
exactly one thing: **did the document move**. `{:ok, session}` means it
did, and is the caller's cue to run whatever it does on a change - the
package editor notifies its `on_change` host callback, a host store
persists. `{:error, session}` means it did not, and the session still
comes back because a refusal is a state change too: it is where the
draft was recorded, or where `last_error` was set. There is nothing for
a caller to do with it but render it.

## Where this module is, and is not

It lives beside `StatifierBlocks.Edit.History` and
`StatifierBlocks.Edit.Targets` because it operates on exactly what they
operate on - a document, a palette and a history - and because, like
them, it compiles and runs with no LiveView in the tree. The alternative
home, under `StatifierBlocks.Editor`, is the LiveView component's own
namespace: ADR-0005 decision 1 compiles it out entirely when LiveView is
absent, and it is where the socket lives. The socket stays there; the
document, the history and the drafts come here.

## Building one

A session is an ordinary struct with no constructor to learn:

    %StatifierBlocks.Edit.Session{
      palette: palette,
      document: document,
      history: StatifierBlocks.Edit.History.new()
    }

`drafts` and `draft_findings` default to `%{}` and `last_error` to `nil`.

# `draft_findings`

```elixir
@type draft_findings() :: %{
  optional(StatifierBlocks.Block.id()) =&gt; [StatifierBlocks.BlockType.finding()]
}
```

Why each draft was refused: the per-field findings the refusal carried,
keyed by the same block id the draft is keyed by.

A key here always has a key in `t:drafts/0`, and the findings are the
ones `StatifierBlocks.Edit.History.commit/4` stated about that exact
config - not a re-derivation of them.

# `drafts`

```elixir
@type drafts() :: %{
  optional(StatifierBlocks.Block.id()) =&gt; StatifierBlocks.Block.config()
}
```

Per-block config the document refused, keyed by block id.

# `gesture`

```elixir
@type gesture() :: :add | {:remove, integer()}
```

What a list control asks of one member list: append a blank, or drop the member at an index.

# `list_gesture`

```elixir
@type list_gesture() :: gesture() | {[non_neg_integer()], gesture()}
```

A gesture, optionally addressed at a nested member list.

The bare form is the field's own list. A `{path, gesture}` pair steps
into that list's member at each index in turn - the same descent the
control makes when it nests - and applies the gesture there.

# `t`

```elixir
@type t() :: %StatifierBlocks.Edit.Session{
  document: StatifierBlocks.Document.t(),
  draft_findings: draft_findings(),
  drafts: drafts(),
  history: StatifierBlocks.Edit.History.t(),
  last_error: term() | nil,
  palette: StatifierBlocks.Palette.t()
}
```

# `apply_gesture`

```elixir
@spec apply_gesture(StatifierBlocks.ViewModel.Field.t(), list_gesture()) :: [term()]
```

The rows `field` would hold after `gesture`, as a value.

The document is not consulted and nothing is committed: this is the list
half of `update_list/4` on its own, for a surface that needs the rows
before it has somewhere to put them. The field's `type` says what a
blank member looks like and how a stored value reads as rows, and its
`value` is what the gesture is applied to.

A `{:list, t}` wraps whatever it finds, which is what a stored scalar
has always meant there. A `{:type_expr, opts}` does not: its inline arm
is a list and its name arm is a string, and wrapping the string would
turn a type name into a nameless member.

## Examples

    iex> alias StatifierBlocks.Edit.Session
    iex> alias StatifierBlocks.ViewModel.Field
    iex> field = %Field{key: "tags", type: {:list, :string}, label: "Tags", required?: false, default: [], value: ["a", "b"]}
    iex> Session.apply_gesture(field, :add)
    ["a", "b", ""]
    iex> Session.apply_gesture(field, {:remove, 0})
    ["b"]

# `change_config`

```elixir
@spec change_config(t(), StatifierBlocks.Block.id(), StatifierBlocks.Block.config()) ::
  {:ok, t()} | {:error, t()}
```

Writes `config` onto the block `id`, holding a refused config as a draft.

This differs from `commit/2` in one way and only one: an
`{:invalid_config, id, findings}` refusal is recorded in `drafts` under
`id` instead of in `last_error`. That is ADR-0005 decision 9's draft
treatment - a value the document refuses is still the value the author
is holding, and blanking it back to the document's would delete their
keystrokes to punish a typo. Every other refusal lands in `last_error`
exactly as `commit/2`'s does.

A config the document accepts clears that block's draft: the author has
resolved the refusal that produced it.

The refusal's per-field findings are kept beside the draft, in
`draft_findings` under the same id. They are the findings
`Edit.History.commit/4` already stated about this exact config, so a
surface drawing the refused form reads them rather than re-running
`c:StatifierBlocks.BlockType.validate_config/1` to re-derive the answer
the funnel was handed and threw away.
`StatifierBlocks.ViewModel.overlay_findings/2` is the other half: it
routes them onto the form's fields.

# `commit`

```elixir
@spec commit(t(), StatifierBlocks.Edit.t()) :: {:ok, t()} | {:error, t()}
```

Applies `command` to the session's document through the gated history.

The one place a command reaches the document, so the gate, the undo
stack and the caller's change notification each have one implementation
rather than one per gesture.

On `{:ok, session}` the document and the history have both moved and
`last_error` is cleared; drafts are untouched, because a command that is
not a config change says nothing about a config the document refused. On
`{:error, session}` the refusal is in `last_error` and nothing else
moved.

## Examples

    iex> alias StatifierBlocks.{Block, Document, Palette}
    iex> alias StatifierBlocks.Edit.{History, Session}
    iex> document = Document.new(Block.new("core.sequence", id: "blk_ROOT", slots: %{"body" => []}))
    iex> session = %Session{palette: Palette.core(), document: document, history: History.new()}
    iex> {:ok, block} = Palette.new_block(session.palette, "core.placeholder")
    iex> {:ok, moved} = Session.commit(session, {:insert, {"blk_ROOT", "body", 0}, block})
    iex> length(Document.blocks(moved.document))
    2
    iex> {:error, refused} = Session.commit(session, {:remove, "blk_NOPE"})
    iex> refused.last_error
    {:no_such_block, "blk_NOPE"}

# `refusal`

```elixir
@spec refusal(term()) :: String.t()
```

What is in `last_error`, as one sentence for a surface to draw.

`commit/2`, `change_config/3` and `step/2` each answer a refusal by
putting its reason in `last_error` and moving nothing else, and a
reason is a tuple aimed at a caller rather than at an author. This is
the other half: the same vocabulary said once, in the words the editor
uses for the gesture that was refused, so that a refused gesture reads
on screen as a refusal rather than as a gesture that did nothing
(ADR-0005's Note of 2026-09-08, item 6).

The sentence never names a block by its id. Ids under `blk_` are minted
by the package and an author never sees one, so a sentence that quoted
one would name the block in a language the author does not read; what
it quotes instead is what the author typed or picked - a slot name, a
type name, a recipe name, a config key.

A term this function does not recognize gets the generic sentence
rather than an inspected tuple, for the reason
`StatifierBlocks.Declarations.refusal/1` gives about its own: a term a
surface cannot phrase is a term the author cannot act on either. A
datamodel envelope is handed to that function rather than re-phrased
here, so the declarations panel and this surface say one thing about
one refusal.

    iex> StatifierBlocks.Edit.Session.refusal(:nothing_to_undo)
    "There is nothing to undo."

    iex> StatifierBlocks.Edit.Session.refusal({:unknown_block_type, "myapp.nope"})
    ~s(The palette has no block type "myapp.nope".)

    iex> StatifierBlocks.Edit.Session.refusal({:composite_expansion_failed, "blk_GS", :empty})
    "That block's declaration cannot be expanded, so nothing was replaced."

    iex> StatifierBlocks.Edit.Session.refusal(:something_a_later_amendment_adds)
    "That change was refused."

# `step`

```elixir
@spec step(t(), :undo | :redo) :: {:ok, t()} | {:error, t()}
```

Steps the history one move: `:undo` or `:redo`.

Both directions arrive at the same reconciliation, because `History` has
already done the part that differs by the time this runs. Drafts are
dropped wholesale on a successful step - see the moduledoc for why - and
an empty stack answers `{:error, session}` with `:nothing_to_undo` or
`:nothing_to_redo` in `last_error`.

## Examples

    iex> alias StatifierBlocks.{Block, Document, Palette}
    iex> alias StatifierBlocks.Edit.{History, Session}
    iex> document = Document.new(Block.new("core.sequence", id: "blk_ROOT", slots: %{"body" => []}))
    iex> session = %Session{palette: Palette.core(), document: document, history: History.new()}
    iex> {:error, nothing} = Session.step(session, :undo)
    iex> nothing.last_error
    :nothing_to_undo
    iex> {:ok, block} = Palette.new_block(session.palette, "core.placeholder")
    iex> {:ok, moved} = Session.commit(session, {:insert, {"blk_ROOT", "body", 0}, block})
    iex> {:ok, back} = Session.step(moved, :undo)
    iex> length(Document.blocks(back.document))
    1

# `update_list`

```elixir
@spec update_list(
  t(),
  StatifierBlocks.Block.id(),
  StatifierBlocks.ViewModel.Field.t(),
  list_gesture()
) ::
  {:ok, t()} | {:error, t()}
```

Applies a list gesture to `field` on the block `id`, and commits it.

The rows are read from the block's **effective** config - the draft if
one is held, the document's otherwise - and written back through the
field's own `value_path/1`, which is where the form's other writes go.
The commit is `change_config/3`'s, so a list edit that leaves the config
invalid is held as a draft like any other refused config rather than
being lost.

`gesture` is a `t:list_gesture/0`: `:add`, `{:remove, index}`, or either
of those addressed at a nested member list by a `{path, gesture}` pair.

---

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