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_errorand moves nothing else, andrefusal/1is 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.
Summary
Types
Why each draft was refused: the per-field findings the refusal carried, keyed by the same block id the draft is keyed by.
Per-block config the document refused, keyed by block id.
What a list control asks of one member list: append a blank, or drop the member at an index.
A gesture, optionally addressed at a nested member list.
Functions
The rows field would hold after gesture, as a value.
Writes config onto the block id, holding a refused config as a draft.
Applies command to the session's document through the gated history.
What is in last_error, as one sentence for a surface to draw.
Steps the history one move: :undo or :redo.
Applies a list gesture to field on the block id, and commits it.
Types
@type draft_findings() :: %{ optional(StatifierBlocks.Block.id()) => [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 drafts/0, and the findings are the
ones StatifierBlocks.Edit.History.commit/4 stated about that exact
config - not a re-derivation of them.
@type drafts() :: %{ optional(StatifierBlocks.Block.id()) => StatifierBlocks.Block.config() }
Per-block config the document refused, keyed by block id.
@type gesture() :: :add | {:remove, integer()}
What a list control asks of one member list: append a blank, or drop the member at an index.
@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.
@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() }
Functions
@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"]
@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
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.
@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"}
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."
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
@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 list_gesture/0: :add, {:remove, index}, or either
of those addressed at a nested member list by a {path, gesture} pair.