# Op-emitter playbook: editor-swap step 10, one family at a time

The execution plan for step 10 of `docs/editor-swap-playbook.md` — one pure
op emitter per row of `docs/document_mutations.md`, written against the doc
crate before any tool calls them, so the flag day (step 12) is a re-wiring
instead of a rewrite. Sub-steps 10a–10g land **one commit each**, `cargo
xtask ci` green, coverage rows ticked here in the same commit.

Execution model: sub-steps are delegated to Opus sub-agents working from
this document plus the inventory; the session lead reviews every diff,
independently re-runs CI, and writes the commit. An agent that finds this
plan wrong *stops and reports* rather than improvising — deviations are
recorded here at review time.

## Ground rules (the emitter contract)

Every emitter lives in `src/edit/` (root crate — it consumes `src/grid.rs`
policy and mints ids; the doc crate deliberately does neither) and has the
shape:

```rust
pub fn <row_name>(
    doc: &Document<Provisional>,
    cache: &DocumentCache<'_, Provisional>,   // only when the row reads adjacency
    params: <RowParams>,                      // typed, per the conversion table
    builder: &mut CommitBuilder,
) -> <row outputs, e.g. minted ids echoed back>
```

- **E1 — Pure, given inputs.** No solver, no painter, no I/O. Anything the
  gesture *measured or solved* — text widths for pin widening, a re-routed
  polyline's promoted corners — arrives as a parameter. Anything derivable
  from `(doc, cache, params)` by list arithmetic — free-slot search, block
  growth, waypoint translation, approach trims, cascade closures — is the
  emitter's own job. That line decides every signature.
- **E2 — Pre-minted ids.** Emitters that create entities take the new ids
  as parameters (the compound-operation rule: ops land with concrete ids).
  The one exception is data-dependent minting (paste), which takes a
  `FreshIds` source — a struct the production caller backs with `uuid` v4
  and tests back with a deterministic counter.
- **E3 — Non-edit filtering happens here.** Per step 9's builder contract:
  before pushing an update, compare against what `doc` already holds and
  skip an equal write. A gesture of pure non-edits leaves the builder
  empty, and `seal()` then submits nothing.
- **E4 — Absent targets are no-ops.** A missing or dead target entity
  pushes nothing (mirroring the legacy `Option` no-op style). Emitters do
  not pre-validate what the fold refuses (`try_apply` is the authority);
  they only decline to write into the void.
- **E5 — Systemic side effects are ordinary ops in the same commit.** The
  inventory's rider effects (`document_mutations.md`, "Systemic side
  effects") each become register writes pushed by the same emitter call:
  block growth for pins is a `Block::Rect` update; waypoint re-promotion,
  translation, and approach trims are `Route::Waypoints` list writes; label
  re-anchoring is a `RouteLabel::Pos` write. No separate verbs.
- **E6 — Tests fold and assert.** Every row's test: build a small
  `Document<Provisional>` (via `try_apply` of hand-built commits or the
  emitters already landed), run the emitter, `seal()`, `try_apply`, assert
  the resulting state matches the row's contract — including its riders.
  Assert preconditions (house rule). Where a row has a refusal or no-op
  arm (locked owner, occupied slot, absent target), test it: the builder
  stays empty.
- **E7 — Comments per house style.** Doc comments say what a row means and
  which inventory row it implements (one line); no narration.

## Conversion table (legacy gesture → doc-crate types)

The `src/edit/` module owns a small `lower` submodule encoding these once:

| Legacy / gesture | Doc crate | Note |
|---|---|---|
| `Pos2` (world px) | `GridPoint` | snap via `grid.rs`, then `i32` copy; refuse-don't-clamp is decode's job, `GRID_LIMIT` never reachable from real gestures |
| `Rect` / `GridRect` (legacy) | `GridRect { min: GridPoint, size: GridSize }` | block heights via `snap_block_height_cells` |
| pin `(PinSide, u32)` | `PinSlot { side, offset }` | atomic register — side and offset race whole |
| `Option<u8>` accent (0..=7) | `Role` | `None → Accent0` (plain), `Some(n) → Accent(n+1)`; the involution lives in `lower` |
| `PinType` | `PinDir` | direct variant map |
| `LabelSide` | `LabelSide` (doc) | direct |
| label offset `f32` px | `FracVal` | `From<f32>`; NaN→0 documented at the type |
| `LinearDistance` | `FracVal` | both are arc-length px; fixed-point widths differ — convert via `f32` |
| `egui::Rect` (image/icon, unsnapped) | `ScreenRect` | the one float geometry |
| `LineAnchor` | `PinId` | the new model has no port/pin split: route endpoints are pin ids, globally unique |
| legacy `ImageData` | `Asset` + `AssetHash` | content-addressed; payload op is sub-step 10e |

## Sub-steps

### 10a — Scaffolding and the exemplar emitters *(session lead, not delegated)*

`src/edit/` module skeleton: `lower` conversions, the `FreshIds` type, the
test harness (`fold(builder, doc) -> Document<Provisional>` helper), and
two exemplars pinning the pattern end to end: **Rename Title** (a `Label`
namespace write through `BlockUpdate::Title(LabelUpdate::Name)`) and
**Set Accent** (a `Role` register write across the four accent-bearing
entities plus the pin's `PortAccent`). Both with fold-and-assert tests and
a non-edit test (renaming to the same name seals to nothing).

### 10b — Naming, flags, and text *(agent)*

The remaining one-or-two-register updates: Rename Block Type, Rename Pin
(+`Rect` widen from a measured-width param), Set Pin Tag, Retype Pin
(+widen), Cycle Pin Direction, Set Pin Direction (bulk, locked owners
skipped), Show/Hide Pin Tags (bulk + per-shape), Lock/Unlock Block, Rename
Route, Move Title / Type Label (`Label::{Offset, Side}`), Edit Text Box
(content write; **emptied → `Text` delete**), Delete Wire Label on empty
rename (`RouteLabel` delete + `Route::Name` clear).

### 10c — Create *(agent)*

New Block (parent's adjacency is derived — only `BlockInit` with `parent`;
there is no children list to update), New Comment, New Text Box, Add Port
(first-free-slot search against `cache.blocks[owner].pins` + block growth
in 2-cell steps as a `Rect` rider), Add Pin (slot from `PinLocation`), New
Route (`RouteInit` with endpoint constants; optional destination-pin
creation rider), Add Waypoint (path-order insertion into the `Waypoints`
list — port of the session's ordinal logic, arc-length ordinal arrives as
a param per E1), Add Wire Label (`RouteLabelInit` at a `FracVal` arc
length), Wrap Top (new root `BlockInit` + old root `Parent` repoint + old
root `Rect` fit + `TitleBlockUpdate::Top` — the spec §8 shape already
proven in the fold tests).

### 10d — Geometry *(agent)*

Move Shape (per-kind `Rect`/`Pos` writes; icon rides inside `Block::Icon`),
Move Group (shared snapped delta; **all-inside route `Waypoints`
translation and straddling-route approach trims are the emitter's own
list arithmetic** — the pure policy twins from step 8a port or move here),
Keyboard Nudge (delegates), Resize (+pin `Slot` shift when the top edge
moves, +icon box recompute), Move Pin (occupied-slot no-op), Relocate Pin
Group (all-or-nothing precheck), Nudge Pins, Flip Shape Pins
(`Slot.side` flip + `FlipLR` freeze), Flip Block Vertical (offset
involution), Edit Route commit (wholesale `Waypoints` write + per-label
`Pos` re-anchor from captured-anchor params), Reroute Wire / Block
(`Waypoints` clear; the re-solved list arrives as a param), Move Wire
Label (`RouteLabel::Pos`).

### 10e — Assets in the log *(agent; doc-crate model work — closes deferred step 5 via D7)*

The doc crate gains the content-addressed, create-only asset payload op:
`OpCodes::Asset(AssetHash, AssetPayload)` (exact shape per D7: create-only,
idempotent — same hash, same bytes; no update, no delete; validation
refuses a hash that doesn't match its payload). Decode golden **re-cut**
(sanctioned pre-deployment break, recorded at the constant), retag-refusal
probe extended, fold + cache carry the asset table. Then the emitters that
need it: New Image, Set Block Icon (replace-in-place), Delete Icon (zero
`Icon` value — not a lifecycle op).

### 10f — Delete *(agent)*

Cascade closures computed against the cache, emitted as flat op runs:
Delete Block (descendant subtree via `BlockIndex.children` transitively +
every route touching any deleted block's pins via `routes_by_endpoint` +
owned texts/comments/images/pins), Delete Port / Delete Pins (pin +
`routes_by_endpoint[pin]`; locked owner ⇒ empty builder; **one emitter**
— the port/pin distinction died with `LineAnchor`), Delete Text / Comment
/ Image / Route, Delete Selection (dispatch over the rows), Delete Icon
lands in 10e. Order inside the commit: children before parents is NOT
required (the fold is order-tolerant under one rev) — but keep
deterministic emission order (chronological) for review readability.

### 10g — Clipboard *(agent)*

The v2 clipboard format (spec §9): serialized **init structs** as the
value snapshot, versioned envelope, defined in `src/edit/clipboard.rs`.
Copy walks the cache closure (same closure as 10f's cascade — shared,
not duplicated). Paste remaps every id through `FreshIds`, offsets
geometry by the drop delta, re-parents to the target, slots pins through
the 10c free-slot policy (+growth), drops routes with a missing endpoint.
Paste Pins (onto block / as ports) reuses the same slotting.

Cut per spec §9 (corrected 2026-08-19 against the spec; the earlier
"copy + delete" wording here was wrong for the first paste): cut is
copy + the 10f delete emitters, **but the clipboard snapshot keeps the
source ids**, and the *first* paste after a cut is an
identity-preserving **move** — `Restore` + parent/owner register writes
on the original ids, never delete-and-recreate — so a concurrent
double-move of one subtree resolves as two writes to one parent
register under LWW. Copy-paste and every later paste are duplicates
with fresh ids. Round-trip tests: copy → paste into an empty document
folds to a projection-equal subtree modulo ids and offset; cut → paste
preserves identity (same ids, alive again, re-parented).

## Coverage

Ticked per row as its emitter lands (sub-step in parentheses).

| Inventory row | Emitter (sub-step) | Landed |
|---|---|---|
| New Block | `create::block` (10c) | [x] |
| New Comment | `create::comment` (10c) | [x] |
| New Text Box | `create::text_box` (10c) | [x] |
| New Image | `assets::image` (10e) | [x] |
| Set Block Icon | `assets::set_icon` (10e) | [x] |
| Add Port (boundary) | `create::port` (10c) | [x] |
| Add Pin (block edge) | `create::pin` (10c) | [x] |
| New Route (wire) | `create::route` (10c) | [x] |
| Add Waypoint | `create::waypoint` (10c) | [x] |
| Add Wire Label | `create::wire_label` (10c) | [x] |
| Paste | `clipboard::paste` (10g) | [x] |
| Paste Pins | `clipboard::paste_pins` (10g) | [x] |
| Wrap Top | `create::wrap_top` (10c) | [x] |
| Move Shape | `geometry::move_shape` (10d) | [x] |
| Move Group | `geometry::move_group` (10d) | [x] |
| Keyboard Nudge | via move/relocate emitters (10d) | [x] |
| Resize Shape | `geometry::resize` (10d) | [x] |
| Move Pin | `geometry::move_pin` (10d) | [x] |
| Relocate Pin Group | `geometry::relocate_pins` (10d) | [x] |
| Nudge Pins | `geometry::nudge_pins` (10d) | [x] |
| Flip Shape Pins | `geometry::flip_pins` (10d) | [x] |
| Flip Block Vertical | `geometry::flip_vertical` (10d) | [x] |
| Move Title / Type Label | `naming::place_label` (10b) | [x] |
| Rename Title | `naming::rename_title` (10a) | [x] |
| Rename Block Type | `naming::rename_type` (10b) | [x] |
| Rename Pin | `naming::rename_pin` (10b) | [x] |
| Set Pin Tag | `naming::set_tag` (10b) | [x] |
| Retype Pin | `naming::retype_pin` (10b) | [x] |
| Cycle Pin Direction | `naming::cycle_dir` (10b) | [x] |
| Set Pin Direction (bulk) | `naming::set_dirs` (10b) | [x] |
| Show/Hide Pin Tags | `naming::set_tag_visibility` (10b) | [x] |
| Set Accent | `naming::set_accent` (10a) | [x] |
| Lock/Unlock Block | `naming::set_locked` (10b) | [x] |
| Rename Route | `naming::rename_route` (10b) | [x] |
| Move Wire Label | `geometry::place_wire_label` (10d) | [x] |
| Edit Text Box | `naming::edit_text` (10b) | [x] |
| Edit Route (commit) | `geometry::commit_route_edit` (10d) | [x] |
| Reroute Wire / Block | `geometry::reroute` (10d) | [x] |
| Restore History | out of scope — subsumed by the log itself (step 12 decision) | [ ] |
| Delete Block | `delete::block` (10f) | [x] |
| Delete Port / Delete Pins | `delete::pins` (10f) | [x] |
| Delete Text / Comment / Image | `delete::annotation` (10f) | [x] |
| Delete Icon | `assets::delete_icon` (10e) | [x] |
| Delete Route | `delete::route` (10f) | [x] |
| Delete Selection | `delete::selection` (10f) | [x] |
| Cut Selection / Cut Pins | `clipboard::cut` (10g) | [x] |
| Delete Wire Label (empty rename) | `naming::clear_wire_label` (10b) | [x] |
| Delete Text Box (emptied) | folded into `naming::edit_text` (10b) | [x] |
