The proposer half of Collapse: an arrangement an author built by hand,
read back as the StatifierBlocks.Composite.Data declaration that stands
for it (ADR-0005 part (iii), amended 2026-09-07, clauses 15E to 20E).
propose/3 reads; it writes nothing. It takes no socket, no assigns and
no StatifierBlocks.Edit.Session, it is callable from a test, a script or
a host's own code with no LiveView in the picture, and it is the only
entry point this package offers to Collapse's first half.
{:ok, declaration} = Collapse.propose(document, palette, ["blk_7", "blk_9"])What comes back is the storable row, minus its name
The {:ok, ...} value is JSON-shaped, in the shape
StatifierBlocks.Composite.Data.declaration/1 accepts, with "version",
"params", "subtree" and - where the selection holds an unfilled slot -
"slots". It carries no "type_name" (15E): a type name is a key
in the host's palette namespace, the host is the only party that knows
what is registered there, and a package that minted one would be minting
a collision it cannot see. So declaration/1 refuses the row until the
host names it, and that refusal is the seam working rather than a gap in
it.
"sentence" and "palette_entry" are omitted for the same reason at one
remove: both are prose or presentation the author never typed in this
gesture, and a proposer that invented an English sentence or an icon
would be inventing content and calling it a proposal.
Which values become params
18E. The proposed params are the config values the author marks in
the gesture; where the author marks none, every value that differs from
its field's declared default. A value left at its default is a value the
author never chose, and a param whose default is the field's default
parameterises nothing.
The marks are the gesture's, so they arrive beside the three arguments
15E fixes rather than inside them: propose/4's option list carries
marks: %{block_id => [field key]}, and propose/3 - the record's entry
point, and the whole of what a caller with no gesture behind it needs -
is the unmarked reading. Neither of the three arguments is a name, which
is the property RQ-SF038-1 fixes the arity for.
Each param's field declaration is the source field's: its "type",
"label" and whichever of "required?", "value_path",
"datamodel_path?", "hidden?" and "readonly?" the source field
declares, carried across unchanged, with "default" set to the value the
block's config held. Nothing is re-derived, so the control the author
sees on the composite's form is the control they were looking at on the
block.
A param's key is the source field's key. Where two blocks in one
arrangement declare the same field key - two core.assigns both declare
path - every colliding param takes <id_suffix>_<field key> instead
and the un-colliding ones keep their bare keys.
The template, and the ids it mints
The template is the subtree with each proposed value replaced by
%{"$param" => key} and every other config value carried across as the
literal it is. A value that genuinely is a one-key "$param" (or
"$literal") map is carried as %{"$literal" => ...}, which is what
that escape exists for.
"id_suffix" is minted from the source block's type, not from its
id: the type name's last dot-separated segment - core.invoke gives
invoke, core.assign gives assign - with a positional discriminator
appended where a type repeats, in document order: assign, assign_2,
assign_3. A document id is arbitrary (blk_7), carries no meaning to a
later reader, and need not match the "id_suffix" pattern, while a type
segment does.
One consequence, stated because it is sharp: a use-composite twin of a
collapsed declaration expands byte-identically only if its authored
id_suffixes are the ones this rule mints. ADR-0002's "Guarded step"
is authored with call and guard, so its expansion's ids are
blk_GS_call and blk_GS_guard where a collapse of the same arrangement
mints blk_GS_invoke and blk_GS_assign. The configs, the types and the
tree shape are identical; the ids are not, and the rule cannot ask an
author who is not there.
Slots
20E. Exactly one subtree under one parent (12E, unamended): two
siblings, a block and a cousin, or a partial subtree with a child left
outside are refused. A selection whose subtree holds an unfilled slot
is admitted, and that slot is proposed as a pass-through slot -
"slots" => %{name => [local_id, inner_slot]} - in the shape ADR-0002's
pass-through amendment fixes. A filled slot is not proposed and its
children are not lifted: they are part of what the author selected, and a
Collapse that silently turned them into an opening would be deciding for
the author that the blocks they put there were an example rather than the
thing.
The replacement is a separate function the host calls, or does not
17E. replacement/4 answers the {:compound, ...} that puts the
composite where the arrangement was. Nothing in this package calls it:
the gesture does not, and on_collapse does not. A host that saves a
declaration and never swaps the arrangement out has done a legitimate
thing, and the swap can only happen after the host has stored the
declaration, named it and rebuilt its palette with it - until then the
:insert names a type the document cannot resolve.
The composite it inserts takes the collapsed root's own id. An expansion
mints member ids as composite_id <> "_" <> local_id, so the id given
here is the prefix of every state id the chart grows where the arrangement
was, and a minted one would put a fresh UXID there on every commit.
Summary
Types
The proposed row: JSON-shaped, in Composite.Data's declaration shape and
without its "type_name".
Which config values the gesture marked, per block.
Functions
Reads a selection back as the declaration that stands for it.
The compound that puts a composite of type_name where the arrangement
was: the exact inverse of Expand.
Types
The proposed row: JSON-shaped, in Composite.Data's declaration shape and
without its "type_name".
@type marks() :: %{optional(StatifierBlocks.Block.id()) => [String.t()]}
Which config values the gesture marked, per block.
Functions
@spec propose( StatifierBlocks.Document.t(), StatifierBlocks.Palette.t(), [StatifierBlocks.Block.id()], keyword() ) :: {:ok, declaration()} | {:error, term()}
Reads a selection back as the declaration that stands for it.
Answers {:ok, declaration} - the storable row without its
"type_name" - or {:error, reason}. Nothing is written in either case
and the document is not touched.
The refusals, each a refused gesture and none of them a finding:
{:error, {:not_one_subtree, ids}}- the selection is not exactly one subtree under one parent (12E);{:error, {:no_such_block, id}}- an id the document does not hold;{:error, {:cannot_collapse_root, id}}- the document root, which has no target a composite could take;{:error, {:unspellable_field, block_id, field_key}}- a value noComposite.Dataspelling carries (19E). Two ids, because "this arrangement cannot be saved" is not actionable and "thepayloadfield onblk_13cannot be saved" is;- whatever
StatifierBlocks.Palette.resolve/2refuses a member with.
Options:
:marks-%{block_id => [field key]}, the config values the gesture marked. Absent, or an empty map - which is what a tray with nothing ticked hands over - is18E's unmarked reading: every value that differs from its field's declared default.iex> alias StatifierBlocks.{Block, Document, Palette} iex> alias StatifierBlocks.Composite.Collapse iex> document = ...> Document.new( ...> Block.new("core.sequence", ...> id: "blk_ROOT", ...> slots: %{ ...> "body" => [ ...> Block.new("core.assign", ...> id: "blk_9", ...> config: %{"path" => "signup.state", "value" => "done"} ...> ) ...> ] ...> } ...> ) ...> ) iex> {:ok, declaration} = Collapse.propose(document, Palette.core(), ["blk_9"]) iex> Enum.map(declaration["params"], & &1["key"]) ["path", "value"] iex> Map.has_key?(declaration, "type_name") false
@spec replacement( StatifierBlocks.Document.t(), StatifierBlocks.Block.id(), StatifierBlocks.Block.type_name(), declaration() ) :: {:ok, StatifierBlocks.Edit.t()} | {:error, term()}
The compound that puts a composite of type_name where the arrangement
was: the exact inverse of Expand.
{:compound, [{:remove, root_id}, {:insert, target, block}]}target is the arrangement's own - the same parent, the same slot, the
same index the selection's root held - and the inserted block's config is
each param's declared "default", which by 18E is the value the author
had selected, so the composite expands to the arrangement they started
with. Removing first and inserting at the freed position is Expand's
ordering read backwards, and one remove with one insert is one undo entry
(2n, 3E), so the author sees one gesture and no intermediate document
in which the arrangement is gone and the composite is not yet there.
The inserted block carries the arrangement's own id, not a minted one.
A composite's expansion mints its members' ids as
composite_id <> "_" <> local_id, so whatever id this block is given is
the prefix of every state id the compiled chart grows where the
arrangement stood. A minted id would make those ids a function of the
millisecond the host committed rather than of the document, and two hosts
committing the same replacement on the same document would get two
different charts. Taking root_id keeps the swap deterministic and the
ids anchored where the arrangement was; the {:remove, root_id} ahead of
it in the compound has already freed the id, so the insert is not a
duplicate.
The host commits it through StatifierBlocks.Edit.Session.commit/2. It
is answered rather than committed here, and refused rather than raised
where the document cannot carry it: {:error, {:no_such_block, id}} for
an id the document does not hold, {:error, {:cannot_collapse_root, id}}
for the root.