# `StatifierBlocks.Composite.Collapse`
[🔗](https://github.com/riddler/statifier_blocks/blob/v0.44.1/lib/statifier_blocks/composite/collapse.ex#L1)

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 the operator's ruling 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.assign`s 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_suffix`es 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.

# `declaration`

```elixir
@type declaration() :: %{optional(String.t()) =&gt; term()}
```

The proposed row: JSON-shaped, in `Composite.Data`'s declaration shape and
without its `"type_name"`.

# `marks`

```elixir
@type marks() :: %{optional(StatifierBlocks.Block.id()) =&gt; [String.t()]}
```

Which config values the gesture marked, per block.

# `propose`

```elixir
@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 no
    `Composite.Data` spelling carries (`19E`). Two ids, because "this
    arrangement cannot be saved" is not actionable and "the `payload`
    field on `blk_13` cannot be saved" is;
  * whatever `StatifierBlocks.Palette.resolve/2` refuses 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 - is `18E`'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

# `replacement`

```elixir
@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.

---

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