# Document mutations: the complete edit inventory

> **Post-flag-day note (2026-08-21, step 13's coverage walk).** The
> "Commit point" column below cites *pre-flag-day* paths and line numbers,
> from before the editor swap; they read as history, not as directions. The
> current contract is: every row is a pure emitter in `src/edit/`, called by
> exactly one named `Drawing` setter, authored through
> `Gesture::author`. The walk confirmed all 45 rows have both, and its
> coverage table lives in `docs/editor-swap-playbook.md` under step 13. Two
> rows below are corrected in place; the rest of the column is left as
> written rather than re-pointed, because a second set of line numbers would
> go stale the same way.

The input to playbook step 5 (`docs/collab-migration-playbook.md`): every edit
the editor can make to a `Document`, classified by CRUD, with the parameter
types the UI supplies and the types the same edit carries once it reaches the
on-disk (KDL) representation. This is an inventory, not a design — it feeds the
command vocabulary but does not prescribe it.

## How to read the tables

Every edit passes through three layers:

```
gesture / chrome input        Pos2, Vec2, Rect, String, enum picks   (world px, f32)
        ↓  quantized by the tool or Drawing mutator
document field write          GridPos/GridRect/GridVec, u32 slots, String, u8, bool
        ↓  schema_convert (field copy for grid values; round1 for the few floats)
KDL node/property             i32/u32 args, f32 (1-decimal), kebab-case enums, id strings
```

The key structural fact: **grid quantization happens at edit time, not at save
time.** `Block.inner`, `Area.inner`, `PinPort.rect`, `TextBox.anchor` and
route waypoints are already `GridRect`/`GridPos` (`i32`/`u32`) in the in-memory
document; `schema_convert` copies those fields verbatim. So the "UI type"
column below describes the *gesture inputs* a tool consumes, and the
"document/KDL type" column is both the in-memory field type and (modulo node
syntax) the on-disk type. Only three properties stay float all the way to
disk: image/icon geometry (`egui::Rect`, world px), label offsets
(`f32` px, saved at 0.1 precision), and wire-label positions
(`LinearDistance`, px of arc length along the route, saved as `f32` at 0.1
precision).

Conversions live in `src/grid.rs` (`GRID_SIZE = 15.0` px/cell,
`GridPos::from_pos2_snapped` rounds, `GridRect::from_rect_snapped`,
`snap_block_height_cells` forces block heights onto 4, 7, 10, … cells,
`PIN_PITCH = 3` cells per pin slot). Serialization lives in
`src/document/schema_convert.rs` + `src/schema/{model,encode,decode}.rs`.

## Type legend

| UI / gesture type | Document type | KDL representation |
|---|---|---|
| `Pos2` (world px, f32) | `GridPos { x: i32, y: i32 }` (rounded) | `x=<i32> y=<i32>` |
| `Rect` / two `Pos2` corners | `GridRect { min: GridPos, size: GridSize { w: u32, h: u32 } }` | `x y w h` (i32, i32, u32, u32) |
| `Vec2` drag delta (px) | `GridVec { dx: i32, dy: i32 }` (delta snapped, then applied) | folded into the translated `x y` |
| pin slot: `f32` y-offset px | `offset: u32` slot index (`PIN_PITCH = 45` px per slot) | `loc="w<slot>"` / `"e<slot>"` (side char + slot) |
| `PinSide::{East, West}` | same | `'e'` / `'w'` prefix of `loc` |
| `PinType::{Input, Output, InOut}` | same | `dir="input"|"output"|"in-out"` (omitted for InOut) |
| `LabelSide::{Top, Center, Bottom}` | same | `side="top"|"center"|"bottom"` |
| label offset drag `Vec2.x` | `offset: f32` world px (grid-rounded at commit) | `offset=<f32>` (0.1 precision) |
| wire-label position `Pos2` | `LinearDistance` (i64 fixed-point, 2^24/unit) — arc length in world px along the polyline | `label <f32>` (0.1 precision) |
| accent pick | `Option<u8>` (UI uses 0..=7) | `role=<u8>` / `pin-accent=` / `port-accent=` / `port-pin-accent=` (omitted when None) |
| `Lock` / lock toggle | `locked: bool` | `locked=true` (omitted when false) |
| `TagVisibility::{Shown, Hidden}` | `tag_hidden: bool` | `tag-hidden=true` |
| text buffers | `String` | quoted arg / property |
| `ImageData::{Svg(String), Png(Vec<u8>)}` | `Asset` (interned) | `asset "<blake3[..16]>.<ext>"` reference + one top-level `asset` node |

### Identity

Ids are `usize` newtypes minted `max + 1`. Only two survive a save/load
round-trip; the rest are positional (re-minted in file order on load) — the
core motivation for `ElementId(Uuid)` in the migration:

| Id | KDL form | Stable on disk? |
|---|---|---|
| `RectId` | `"b<N>"` | yes (also referenced by `top`, `children`, route anchors) |
| `PinId` | `"p<N>"` | yes (referenced by `loc`-bearing pins and route anchors) |
| `AssetId` | `<hash>.<ext>` | yes (content-derived) |
| `RouteId`, `TextId`, `AreaId`, `ImageId`, `WaypointId`, `WireLabelId`, `EdgeId` | — | no (re-minted on load) |

Route endpoints serialize as `LineAnchor` strings: `"p2"` (a port of the
enclosing block) or `"b3:p2"` (pin `p2` of child block `b3`).

---

## Create

| Edit | CRUD | Commit point | UI parameters | Document / KDL parameters | Summary |
|---|---|---|---|---|---|
| New Block | Create | `tools/new_block.rs:122` → `Drawing::add_block` (`widget/drawing.rs:428`) | `start: Pos2, end: Pos2` (drag corners or two clicks) | `block "b<N>"` with `x: i32, y: i32` (snapped), `w: u32`, `h: u32` (`snap_block_height_cells`: 4, 7, 10, … cells); parent's `children += "b<N>"`; title `"Untitled"`, everything else default | Drag a rectangle to create a child block; drops into naming it. |
| New Area | Create | `tools/new_area.rs:58` → `Drawing::add_area` (`drawing.rs:461`) | `start: Pos2, end: Pos2` | `area` with `x: i32, y: i32, w: u32, h: u32` (snapped, no height quantization); title label `"Untitled"`, `side=Top` | Drag a boundary/group annotation box. |
| New Text Box | Create | `tools/add_text.rs:31` → `Drawing::add_text_box` (`drawing.rs:444`) | `pos: Pos2` (click) | `text ""` with anchor `x: i32, y: i32`; no size stored (extent is derived) | Place an empty text annotation and open its editor. |
| New Image | Create | `tools/new_image.rs:179` → `Drawing::add_image` (`drawing.rs:387`) | `Placement::Box(Rect)` or `Centered(Pos2)` + `ImageData` from file dialog | `image "<asset-hash>"` with `x, y, w, h: f32` (**world px, unsnapped** — the one float-geometry element) + a top-level `asset` node (SVG text / base64 PNG) | Place a background image from a picked SVG/PNG file. |
| Set Block Icon | Create/Update | the Add-icon command (`tools/commands.rs`) → `Action::SetIcon` → `Drawing::set_icon` (`drawing.rs:406`) | `block: RectId`, `ImageData` | `icon "<asset-hash>"` child of the block, `x, y, w, h: f32` (default box: square 60% of the shorter side, centered); replaces any existing icon | Attach or replace a block's foreground icon. |
| Add Port (boundary) | Create (+ Update) | `tools/add_port.rs:107` → `Drawing::add_port_auto_named` (`drawing.rs:489`) | `inner: Rect` (click or drag box) | `pin "p<N>" "Port <N>"` on the current block: `loc="w<slot:u32>"` (first free slot), `x: i32, y: i32, w: u32` (height never stored, always 2 cells), `tag="<N>"`, `dir` omitted (InOut). **Side effect:** current block `h: u32 += 2` per loop until a slot is free | Stamp a boundary port on the current level, auto-named and auto-slotted. |
| Add Pin (block edge) | Create | `tools/resize_block.rs:446,472` and `tools/route_tool.rs:428` → `add_named_pin` (`tools/new_pin.rs:169`) | `block: RectId`, `loc: PinLocation { side: PinSide, offset: f32 px }` (clicked "+" slot marker) | `pin "p<N>" "Port <N>"` in the block's pin map: `loc="<side><slot:u32>"` (`pin_slot(offset_px)`), default rect, `dir` InOut, `tag="<N>"` | Click a slot marker (or pull a wire out of one) to add a pin to a child block. |
| New Route (wire) | Create | `tools/route_tool.rs:455-456` → `Drawing::add_auto_route` (`drawing.rs:379`) | `start: LineAnchor`, finish target (`LineAnchor` or `NewPin { block, loc, center }`), staged corner clicks `Vec<Pos2>` | `route "<anchor>" "<anchor>"` (args, e.g. `"p4" "b8:p2"`): `name=""`, no role; solved path stored as `wp <i32> <i32>` children (corners promoted at commit); may also create the destination pin (previous row) | Draw a wire between two pins; the autorouted bends are stored as waypoints. |
| Add Waypoint *(absorbed)* | Create | **Step 8b:** the standalone `add_waypoint` is deleted; a grabbed corner is planned once by `RouteEditSession` and lands inside `edit::geometry::commit_route_edit` as part of the Edit Route row | `pos: Pos2` (grab a route edge or corner) | new `wp <x: i32> <y: i32> locked=true` inserted **in path order** (by arc length); reuses an existing waypoint within half a cell | Grab a wire to pin a corner where you want it. |
| Add Wire Label | Create | `tools/add_route_label.rs:124` → `allocate_name_label` (`widget/auto_route.rs:521`) | `pos: Pos2` (click on the route) | new `label <f32>` — `LinearDistance` arc-length projection of the click, px along the polyline, 0.1 precision on disk | Drop a name label on a wire (first one opens the rename editor). |
| Paste | Create ×many | `app.rs:2583` → `Drawing::paste` (`widget/clipboard.rs:315`) | clipboard `json: String`, `target_top_left: Option<Pos2>` | fresh `RectId`/`PinId`/… for every element; blocks into `Document::blocks` + `children`; pins slotted via `place_pin` (side/offset **overridden** to free slots, block grown `h += 2` as needed); texts/areas/images offset and inserted; routes with anchors remapped through the id maps, waypoints translated by `GridVec` (routes with a missing endpoint are dropped) | Insert a copied subtree at the target, with all ids re-minted and cross-references remapped. |
| Paste Pins | Create ×many | `app.rs:2594,2601` → `paste_pins_into_block` / `paste_pins_as_ports` (`clipboard.rs:595,618`) | `Vec<schema::Pin>`, target `RectId` (or current block) | `pin` entries with `side`/`offset: u32` overridden to the next free slot; block `h: u32` grown as needed; no-op on a locked block | Paste copied pins onto a block or as boundary ports. |
| Wrap Top ("Go Up" at root) | Create + Update | `app.rs:2490` → `wrap_top_in_new_parent` (`widget/document_ext.rs:63`) | none | new root `block "b<N>"` (title `top_<n>`, lowest unused); old root resized to `child_boundary_rect` (`x, y, w: kept; h: u32` snapped to fit `max_pin_offset`); `children = { old_top }`; **document `top "b<N>"` repointed** | Add a new level above the whole document. |

## Update

| Edit | CRUD | Commit point | UI parameters | Document / KDL parameters | Summary |
|---|---|---|---|---|---|
| Move Shape | Update | `tools/move_block.rs:73` → `move_shape` (`widget/movement.rs:119`) | `id: ShapeId`, accumulated `delta_pos: Vec2` (px, magnetically snapped) | translation of the shape's stored geometry: block/area `x, y: i32`; port rect `x, y: i32`; text anchor `x, y: i32`; image `x, y: f32` (**unsnapped**); icon `x, y: f32` (clamped inside its block; rides along with its block). Rejected wholesale if the destination collides | Drag a shape to a new grid position. |
| Move Group | Update | `tools/multi_select.rs:170` → `move_shapes` (`movement.rs:166`) | `ids: Vec<ShapeId>`, `delta_pos: Vec2` | same per-member translations with one shared snapped delta (images snapped here, unlike single move); routes fully inside translate their `wp` list by `GridVec { dx, dy: i32 }`; straddling routes get approach `wp`s trimmed | Rigidly move a marquee selection, keeping wires coherent. |
| Keyboard Nudge | Update | `app.rs:1391-1394` | arrow keys → `delta = (dx, dy) × GRID_SIZE`, or `slot_delta: i32` for pins | same as Move Shape / Move Group / Relocate Pins | One-cell (or one-slot) keyboard move of the selection. |
| Resize Shape | Update | `tools/resize_block.rs:598` → `ShapeRefMut::apply_resize` (`shape/mod.rs:371`) | `mode: ResizeMode` (corner/edge), accumulated `delta_pos: Vec2` (constrained: min sizes, icon containment, aspect snap, magnetism) | block `x, y: i32, w, h: u32` (snapped) with pin `offset: u32 -= shift` when the top edge moves and `icon` box recomputed (`f32`); port `x: i32, w: u32` (height fixed); area `x, y, w, h`; image `x, y, w, h: f32` free | Commit a handle-drag resize, carrying pins and icon along. |
| Move Pin | Update | `tools/move_pin.rs:85` → `move_pin_snapped` (`shape/block.rs:257`) | `anchor: LineAnchor`, release `pos: Pos2` (side from cursor x, offset from y) | `loc="<side><slot>"`: `side: PinSide`, `offset: u32` — only if the slot is free | Drag a pin to another slot or the opposite edge. |
| Relocate Pin Group | Update | `tools/move_multi_pin.rs:123` → `relocate_pins` (`drawing.rs:712`) | `moves: Vec<(LineAnchor, PinSide, u32)>` from a rigid slot shift of the drag | per anchor: `side: PinSide`, `offset: u32`; all-or-nothing (collision/bounds precheck) | Rigidly move a multi-pin selection to new slots. |
| Nudge Pins | Update | `Drawing::nudge_pins` (`drawing.rs:731`) | `anchors: Vec<LineAnchor>`, `slot_delta: i32` (clamped so no pin leaves its block) | delegates to Relocate Pin Group with `offset + delta` | Keyboard-shift a pin group by whole slots. |
| Flip Shape Pins | Update | `Action::FlipShapePins` (`tools/commands.rs:623` → `drawing.rs:585`) | `ShapeId` | block: every pin `side = side.flip()` with `port_orientation` frozen first (`fliplr: bool` on disk); port: `fliplr` toggled only | Mirror a shape's pins left↔right. |
| Flip Block Vertical | Update | `Action::FlipBlockVertical` (`commands.rs:628` → `drawing.rs:610`) | `RectId` | every pin `offset: u32 = max_slot − offset` (exact involution) | Mirror a block's pins top↔bottom. |
| Move Title / Type Label | Update | `tools/move_title.rs:61` / `tools/move_block_type.rs:51` | `delta_pos: Vec2` | `offset: f32` (grid-rounded px, clamped to the shape; 0.1 precision on disk), `side: LabelSide` from release y | Reposition a block/area title or a block's type label. |
| Rename Title | Update | `tools/rename_title.rs:135` | text buffer `String` (single line, max label chars) | `title "<name>"` — `Block.decorations.title.name` or `Area.title.name` | Rename a block or area. |
| Rename Block Type | Update | `tools/rename_block_type.rs:126` | `String` | `type "<name>"` — `type_label.name` | Edit a block's type label. |
| Rename Pin | Update | `tools/rename_pin.rs:241` | `String` (Name field) | `pin` arg 1 `name: String`; **side effect** `w: u32` widened to fit the labels | Rename a pin/port, widening the port body if needed. |
| Set Pin Tag | Update | `tools/rename_pin.rs:253` | `String` (Tag field) | `tag="<String>"` | Set a pin's location designator (e.g. `U3`). |
| Retype Pin | Update | `tools/retype_pin.rs:135` | `String` | `type="<String>"`; `w: u32` widened to fit | Edit a pin's type (subtitle) line. |
| Cycle Pin Direction | Update | `tools/select_pin.rs:94` | click on the selected pin's stub | `dir`: `PinType.cycle()` — InOut → Input → Output → InOut | Click the stub to cycle a pin's signal direction. |
| Set Pin Direction (bulk) | Update | pin-type picker, `app.rs:1902` | `Vec<LineAnchor>`, `PinType` pick | `dir="input"|"output"|"in-out"` per pin (locked owners skipped) | Bulk-set I/O direction on a pin selection. |
| Show/Hide Pin Tags | Update | `Action::SetPinTags` (`commands.rs:607`) / `Action::SetShapeTagHidden` (`commands.rs:617`) | `Vec<LineAnchor>` or `ShapeId`, `hidden: bool` (UI: `TagVisibility`) | `tag-hidden=<bool>` per pin | Toggle location-tag visibility on pins or a port. |
| Set Accent | Update | role picker, `app.rs:971-994` | element ref + `RolePick::Set(Option<u8>)` (0..=7 or clear) | block/route/area/text: `role=<u8>` (omitted when None); port: `port-accent=<u8>` | Recolor a block, wire, port, area or text. |
| Lock/Unlock Block | Update | `Action::SetBlockLocked` (`commands.rs:635`) | `RectId`, `locked: bool` | `locked=<bool>` | Freeze a block's pin interface against edits. |
| Rename Route | Update | `tools/rename_route.rs:102` | `String` (non-empty) | `name="<String>"` (shared by all the wire's labels) | Name a wire. |
| Move Wire Label | Update | `tools/move_label.rs:55` (per drag frame, no restore) | `delta: Vec2` per frame | `label <f32>` — `LinearDistance` re-projection of the dragged anchor | Slide a wire's name label along its route. |
| Edit Text Box | Update | `tools/edit_text_box.rs:85` | multi-line buffer `String` | `text "<String>"` (arg 0; `\n` escaped). The measured `size` also written — derived cache, not persisted | Commit text-box content on focus loss. |
| Edit Route (drag edge/corner) | Update | `tools/edit_route.rs:164,204` per frame; commit `:177,:215` → `commit_route_edit` (`widget/routing.rs:439`) | axis-constrained `delta: Vec2` on an edge or corner | per frame: `waypoint.pos: GridPos` moves; at release the whole `wp` list is **rebuilt from the canonical corners** (`promote_corners_to_waypoints`), `locked` preserved by position, and every `label: LinearDistance` re-anchored | Hand-edit a wire's path; the edited geometry persists as waypoints. |
| Reroute Wire / Block | Update (destructive) | `Action::Reroute`/`RerouteBlock` (`commands.rs:639-640` → `routing.rs:445,455`) | `RouteId` / `RectId` | `wp` list cleared (`clear_waypoints`), then re-solved and re-promoted | Rip up one wire (or all wires on a block) and autoroute fresh. |
| ~~Restore History~~ *(deleted)* | — | **Step 12·4:** the Undoer/quiescence/timeline complex died; the log is the history, and undo travels through the session journal | `seq: u64` (timeline pick) | entire document replaced by the parsed snapshot; live `name: Option<String>` deliberately preserved | Roll the document back to a history entry, as an undoable edit. |

## Delete

| Edit | CRUD | Commit point | UI parameters | Document / KDL effect | Summary |
|---|---|---|---|---|---|
| Delete Block | Delete (cascade) | `Action::Delete(Deletable::Shape(Rect))` → `delete_shape` (`drawing.rs:304`) | `RectId` | removes the block **and its entire descendant subtree** from `Document::blocks`; removes it from the parent's `children`; drops every parent-level route with an anchor into it | Delete a block; everything inside it and every wire to it dies too. |
| Delete Port | Delete (cascade) | same, `Deletable::Shape(Port)` | `PinId` | removes from the current block's pins; drops current-level routes on `Port(pid)` **and parent-level routes** on `Pin { current, pid }` (same pin seen one level up). No-op when the level is locked | Delete a boundary port and its wires on both sides of the hierarchy. |
| Delete Pins | Delete (cascade) | `Deletable::Pins(Vec<LineAnchor>)` → `delete_pin` (`drawing.rs:285`) | `Vec<LineAnchor>` | removes each pin from its block's pin map + every route anchored to it. Locked owner ⇒ no-op | Delete selected child-block pins plus their wires. |
| Delete Text / Area / Image | Delete | `Deletable::Shape(Text|Area|Image)` | id | removed from the owning map; no fallout | Delete an annotation. |
| Delete Icon | Delete | `Deletable::Shape(Icon)` | `RectId` | `block.icon = None` (block survives) | Remove a block's icon. |
| Delete Route | Delete | `Deletable::Route` | `RouteId` | route removed; endpoints untouched | Delete one wire. |
| Delete Selection | Delete ×many | `Deletable::Shapes(Vec<ShapeId>)` | `Vec<ShapeId>` | per-element dispatch of the rows above | Delete a marquee selection. |
| Cut Selection / Cut Pins | Delete (+ read) | `app.rs:2462,2468` → `clipboard.rs:554,562` | `Vec<ShapeId>` / `Vec<LineAnchor>` | copy to clipboard JSON, then identical to Delete Selection / Delete Pins | Cut = copy + cascade delete. |
| Delete Wire Label (empty rename) | Delete + Update | `tools/rename_route.rs:99` | empty/whitespace text buffer | removes `labels[label_id]`; `name = ""` | Clearing a wire label's text deletes the label. |
| Delete Text Box (emptied) | Delete | `tools/edit_text_box.rs:83` | trimmed-empty buffer | emits `Deletable::Shape(Text)` | An emptied text box is discarded rather than left invisible. |

## Systemic side effects at commit points

These are real document writes that ride along with nearly every geometry
commit — under a command log each is an ordinary update to the owning route,
not a separate verb:

- **Waypoint re-promotion.** Every commit-pass route solve
  (`update_routes`, called at the end of almost every gesture *and on every
  tool switch*, `commands.rs:597`) rewrites each settled route's `wp` list
  from its final polyline (`promote_corners_to_waypoints`,
  `widget/materialize.rs:291`), preserving `locked` by position.
- **Approach trimming.** Moving/flipping a shape trims up to two unlocked
  leading/trailing `wp`s from routes that straddle the move
  (`trim_partial_route_approaches`, `movement.rs:136`) — a persisted deletion,
  even during previews.
- **Label re-anchoring.** Any route relayout re-derives every
  `label: LinearDistance` so labels stay visually put (`reanchor_labels`).
- **Block growth for pins.** Adding or pasting a pin when no slot is free
  grows the owner's `h: u32` in 2-cell steps (`add_port_auto_named`,
  `free_pin_slot`).
- **Pin-drag previews are transient but not free.** `MovePin`/`MoveMultiPin`
  temporarily write `side`/`offset` and restore them (`widget/routing.rs:
  102-170`) — net-zero on the pin, but the approach trim inside is permanent.

## Derived state (not document edits)

Written by solvers/caches, excluded from the tables above, and slated for
`src/derived/` in the migration:

| Field | Writer |
|---|---|
| `AutoRoute.edges`, `.start_pos`, `.end_pos` | `write_geometry` (`materialize.rs:235`), preview solves |
| `AutoRoute.crossings` | `recompute_route_crossings` (`widget/auto_route.rs:568`) |
| `PinPort.accents.pin_accent`, `.port_pin_accent` | `update_route_roles` (`drawing.rs:235`) — propagated from route roles. (`port_accent` **is** user-authored, see Set Accent.) |
| `TextBox.size` | `edit_text_box.rs:91` — galley-extent cache, `None` on load |

All three accent fields and the waypoint list *are* persisted in KDL today;
the derived/authored split above is by *writer*, not by what the file happens
to contain.

## Persisted, but with no UI edit path

- **`version`** — always written from `schema::CURRENT_VERSION` (2); newer
  versions refuse to load.
- **`name` (document)** — serialized and part of undo state, but no live UI
  writes it; history restore deliberately copies the live name forward
  (`app.rs:1691`).
- **`top "b<N>"`** — written only by Wrap Top.
- **Asset ids** — content-derived (`blake3[..16]`), never authored.
- **`loc` / `fliplr`** — derived string/bool projections of
  (`side`, `offset`) and `port_orientation`.
