# Editor-swap playbook: the legacy document retires, one stage at a time

The step-by-step guide for phase 6 of `docs/collab-migration-playbook.md` —
the editor swap. The migration playbook says *what* ("the editor's document
becomes the doc crate's") and holds the coverage checklist
(`docs/document_mutations.md`); this file says *how*, in what order, and why
that order is safe. Written 2026-08-16 from a three-way survey of the model
gap, the mutation surface, and the app wiring; the load-bearing findings are
folded into the steps below with their citations.

Each step has the port playbook's three parts — **Why**, **The work**,
**Prove it** — and its rules: one step, one commit, `cargo xtask ci` green,
box ticked and decisions recorded here in the same commit, `todo.md`
alongside. The one sanctioned exception is step 12, which lands as a short
series green at its end (see D6). Steps are sized for session execution,
not manual execution — phase 2's constraint, not this phase's.

## The strategy: strangle through the waist

The swap looks unboundable — 23,537 lines of tools and widgets read and
write the legacy document — until one structural fact bounds it:
**`Drawing` is already the narrow waist.** Every tool receives `&mut
Drawing` and nothing else (`src/tools/tool.rs:19-38`); the app's own edits
funnel through `App::drawing()` / `mutate_and_reroute`
(`src/app.rs:1295-1306`). What keeps the waist from being real today is six
escape hatches — `block_mut`, `shape_mut`, `auto_route_mut`, `pins_mut`,
`title_mut`, and the un-greppable sixth, the `pub(super) document` field
itself (`src/widget/drawing.rs:114`), which hands sibling widget modules
raw map access.

So the swap is a strangler, in four stages:

- **A — complete the model** (`crates/doc`, pure). Close the five authored-
  state gaps the survey found. Until the model can *hold* every authored
  field, nothing downstream can start.
- **B — make the waist real** (legacy side, behavior-preserving). Close
  the hatches behind named typed setters; move derived state out of the
  document into `src/derived/`; make previews stop writing the document.
  Every step here is proven by the *existing* suite: goldens and
  snapshots must not change, which is exactly what "behavior-preserving"
  is allowed to claim. At stage end the legacy document is authored-state-
  only, written only at gesture commit points, through named setters —
  i.e. shaped exactly like the fold target.
- **C — build the emitters** (doc crate + a new app module). A
  `CommitBuilder`, then one op-emitter per row of
  `docs/document_mutations.md`, each a pure function from (document,
  cache, parameters) to ops, unit-proven against the doc crate *before*
  any tool calls it. Plus the in-process host that lets tools be driven
  against a real session in tests.
- **D — the swap** (the flag-day series). Ids re-type, the waist's
  interior becomes emitters over `session.optimistic()`, undo re-points at
  the session journal, the projection consumers re-point, and the
  coverage checklist is swept row by row.

Stage B is not detour work. "Previews off the document" and "derived state
out of the document" must happen *somewhere* — the new document is an
immutable value and structurally cannot absorb per-frame preview writes.
Doing that work against the legacy model first means doing it exactly once
(the extraction survives the swap; only its id keys re-type) in the one
place the existing golden suite can prove it changed nothing.

## Decisions taken at planning (2026-08-16)

Recorded here and mirrored into the migration playbook's table as they are
exercised. D5 is flagged for user ratification — it answers an open item
the playbook reserved for the user; the rest follow from decisions already
taken.

- **D1 — execution shape.** Strangler through the `Drawing` waist, four
  stages as above.
- **D2 — pin accents split by writer.** `pin_accent` and `port_pin_accent`
  are written only by `update_route_roles` (`src/widget/drawing.rs:235`),
  i.e. derived from route roles — `docs/document_mutations.md:173` already
  classifies them so. They leave the `Pin` entity (log rule 3: nothing
  derived is stored); `port_accent` is user-authored and stays.
  Pre-deployment break; decode goldens regenerate.
- **D3 — the pin slot is one register.** `slot: PinSlot { side: PinSide,
  offset: u32 }`, atomic — a slot is one intent, and two registers could
  disagree mid-merge (east side, west offset). `flip_lr` keeps its disk
  semantics: `flip_lr == (port_orientation == Some(side))`
  (`src/document/schema_convert.rs:334`).
- **D4 — the top pointer is a `TitleBlock` register.** `Top => top:
  BlockId`. Validation is existence-only, like route endpoints: a
  stricter check ("top's parent must be NULL") is breakable by a
  concurrent reparent, and §8 of the spec promises a well-behaved client
  is never rejected by a race it could not see.
- **D5 — boot without `--connect` = in-process host** *(ratified
  2026-08-17)*. The
  verification plan already requires driving real tools against an
  in-process server (`Host` + `ClientSession`, loopback, no tokio); wiring
  serverless boot to the same machinery costs one step and keeps the dev
  loop, the tutorial, and the demo levels alive with **one** session code
  path. Nothing is persisted — the title says so. This is not the
  deferred "local mode" (no file, no save); refusing to boot was the
  alternative.
  **The converse, 2026-08-25 (R1):** boot *with* `--connect` opens no
  file at all. The document comes from the server's `Welcome`, so a path
  argument names something the session will never show, never write, and
  has no business locking.
- **D6 — the flag day lands as a series.** Step 12's id re-typing cannot
  be split into independently green commits (one id type flip propagates
  through selection, hit-testing, and every tool). It lands as a short
  reviewable series, `cargo xtask ci` green at the series end. Every
  other step keeps the one-green-commit rule.
- **D7 — assets ride the log** (executed at step 5, per the migration
  playbook's "decide when phase 6 reaches the image tools").
  Recommendation on record: content-addressed create-only payload ops —
  fat but simple, no second channel, GC deferred.
- **D8 — tutorial levels bridge through lowering.** `initial { … }` KDL
  keeps parsing through `schema_convert` until phase 7, lowered by a new
  `schema::model::Document -> Vec<Commit>` bridge. The bridge is not
  throwaway: it is the tool phase 7 runs to regenerate the level files
  into the new embedding. It is not a user-facing importer; the hard
  break stands.

Progress:

- [x] 1 — `Block.role` lands; derived pin accents leave the model
      (2026-08-16; golden re-cut at 64 ops, retired variants pinned as
      refusals via a retag probe built from `PortAccent`'s live bytes)
- [x] 2 — The pin slot register (2026-08-16; `PinSide` in `values.rs`,
      atomic `PinSlot { side, offset }` in `geometry.rs` with its own
      decode bound; golden re-cut at 65 ops. Parked questions closed:
      `PinDir`'s zero stays `Input` and the `LabelSide` default drift
      stands — inits are total, so the enum zeros never reach the wire
      from the editor, which mints `InOut` and its sides explicitly)
- [x] 3 — The top pointer (2026-08-16; `TitleBlock` gains `Top`,
      existence-only validation per D4, `Id` gains a `NULL` `Default` —
      a meaningful zero, not the review-flagged random mint; the
      race-convergence proof is the LWW fold test — a session-level race
      adds nothing the reconcile suite's generic properties don't cover.
      Golden re-cut at 66 ops)
- [x] 4 — The read surface: adjacency, order, geometry helpers
      (2026-08-16; `routes_by_endpoint` with a brute-force oracle,
      `chronological` — ascending `(max_order, id)` per the design
      notes' revised no-ties policy — and the legacy `coord.rs` algebra
      ported semantics-exact: `contains` closed, `intersects`
      open-right, `GridVec` deliberately unserializable so deltas
      cannot cross the wire)
- [x] 5 — Assets in the log (2026-08-19, executed inside op-emitter
      sub-step 10e per D7: `OpCodes::Asset(AssetHash, Asset)` — a bare
      key/payload pair, deliberately not `Crud`, create-only; blake3
      content hash with the format tag outside it, mirroring the legacy
      `<hash>.<ext>`; the fold's asset table is first-wins so replay
      and duplicate delivery are structural no-ops; validation refuses
      a hash/payload mismatch; the payload op inverts to nothing —
      bytes persist, GC stays deferred to compaction; the asset table
      joins `content_hash`. Golden re-cut 66 → 68 ops, sanctioned.
      Open item: payload bytes carry no decode-time size bound yet)
- [x] 6 — Named setters close the escape hatches (2026-08-17, five
      commits 6a-6e: accents/direction/tag/lock; rename + label
      placement; geometry commits; route labels + interim waypoint
      setters; text-box content + the demotion and the red-tested
      `waist` gate. One correction to this step's text: the
      `pub(super) document` field *stays* `pub(super)` — that is
      already the waist boundary (visible in `widget`, invisible to
      tools/app); "drops to private" was imprecise. The hatches are
      `pub(super) fn`, compiler-confined, and the gate guards the
      surface against re-promotion)
- [x] 7 — Derived state leaves the legacy document (2026-08-17/18,
      four commits: the threaded `Derived` store — with the load
      pipeline cut so `finish_load` stops materializing and the store's
      owner does; pin accents as fresh per-generation derivation — the
      accent keys stop being persisted, a recorded deviation since the
      save path's pure `From<&Document>` cannot see `Derived`; text
      extents as a self-validating `{text, size}` cache injected at the
      `ShapeRef` borrow; and route geometry — `RouteGeometry` beside
      the document, the ext-trait split into pure-geometry methods and
      geometry-taking authored ops, `Endpoints` bundling the resolved
      pair, undo/restore/tutorial-exit re-materializing wholesale.
      Solver output no longer touches the document; the writers still
      hitting it during previews are exactly step 8's list: approach
      trims, the pin temp-write dance, mid-drag waypoint creation, and
      per-frame label slides. Corrected 2026-08-19: the geometry store
      keyed by `RouteId` alone was a design flaw — legacy ids are
      minted per block map, so sibling scopes collide and each
      materialized scope clobbered the last (wrong wires at load and
      on navigation until a commit pass). Now `ScopedRoutes`, keyed by
      owning block first; the scoping collapses at step 12, whose
      globally-unique ids make a flat map safe by construction)
- [x] 8 — Previews stop writing the document (2026-08-18, four
      commits. The preview solve went pure: trims and backtracking
      prunes are *computed* as `PreviewExclusions` — pure policy twins
      `approach_waypoint_ids`/`backtracking_waypoints` share the code
      with the commit-side mutators — and routed around by
      `reroute_preview_excluding`, which replaces the mutating
      `rip_and_reroute_closed`; `GeometryOverrides` carries dragged-pin
      slots into both the anchor math and the router's channel seeding,
      killing the temp-write/restore dance. `RouteEditSession` moves
      the route editor's working corners into tool state — planned on
      the first drag frame, previewed via `materialize_corners_direct`,
      committed in one write on release. `MoveLabel` previews a
      `LinearDistance` (`RouteGeometry::slide_along`) and commits one
      `place_route_label`; its per-frame commit-pass-with-ripup died,
      as did `move_multi_pin`'s invalid-placement commit pass (an
      override-free preview restores geometry instead). The preview
      arms take only immutable borrows of the blocks, so the
      generation stamp itself proves purity — asserted, with document
      equality, by the drag-abort suite driving all seven drag flows
      through the real `ToolTrait::widget` dispatch with no release,
      and validated against deliberately injected writers. Enumerated
      fixes by construction: an aborted drag no longer loses trimmed
      approach corners, pruned reversal corners, or waypoint locks.
      Committed outcomes kept their goldens throughout)
- [x] 9 — `CommitBuilder` (2026-08-19: `new(label)` / `push` / `extend`
      / `seal(self) -> Option<Commit>` in `crates/doc/src/commit.rs`.
      Consumed-on-seal is the signature — a second seal is unwritable,
      not merely discouraged; an empty builder seals to `None`, so a
      gesture that edited nothing submits nothing; push order is `Seq`
      order, asserted. Deliberately blind to the document: non-edit
      filtering (dropping an update equal to what the document holds)
      is recorded at the type as the push site's job — step 10's
      emitters can see the document, the builder cannot. This also
      closes the port playbook's step-2 deferred proof obligations)
- [x] 10 — The op emitters, one per inventory row (2026-08-19,
      seven commits per `docs/op-emitter-playbook.md`: 10a scaffold +
      exemplars by the session lead, then the naming, create, geometry,
      assets (closing step 5), delete, and clipboard families executed
      by reviewed sub-agents. `src/edit/` holds ~45 emitter rows, each
      fold-and-assert tested with its no-op and refusal arms; every
      inventory row is ticked in the sub-playbook's coverage table
      except Restore History, deferred to step 12 as subsumed by the
      log. Behavior changes and legacy bugs found en route are
      enumerated per commit for step-13 ratification — notably the
      lock-guard unification on slot/facing writes, the wrap-top refit,
      the port-slot unit fix, and cut/paste's identity-preserving move
      derived from document state per spec §9)
- [x] 11 — The in-process host; the level-lowering bridge
      (2026-08-19. `LocalHost` in src/collab.rs: Host + ClientSession
      loopback mirroring the server writer's exact message flow —
      Welcome field-for-field, submit → accept → publish → one
      Committed delivered before submit returns, Rejected on refusal
      (unreachable by construction since the session pre-folds against
      an always-drained queue, kept as a mirror rather than a panic);
      the app holds `Link { Remote, Local }` — one session code path
      per D5, boot without --connect seeds an empty Local, legacy
      editing untouched and the "nothing persisted" title marker
      deferred to step 12, when it becomes true (D5's wording
      overstates step 11's ship). The D8 bridge `schema::lower::lower`
      emits one labeled commit in dependency order, semantics oracled
      by schema_convert (flip_lr per D3, derived accents dropped per
      D2, no re-snap, per-kind label-side defaults) — degrading
      per-element with warns where the legacy refused whole documents;
      not a shipped importer, phase 7's regeneration tool. Proof: every
      tutorial level lowers, folds, welcomes a real session via
      LocalHost, and matches the legacy parse on the cue-read fields
      (ids uncoverable — uuids vs positional; compared via names and
      containment). Rides along: 10c's block-title side corrected to
      the legacy Bottom default, caught by the bridge's oracle tests)
- [x] 12 — The flag-day series: ids, ownership, reads, undo,
      projections (2026-08-19, eleven commits 12·0–12·6 per
      `docs/flag-day-playbook.md`, green at the series end as D6
      allows: `cargo xtask ci` exit 0 with the three proofs — lowered-
      level golden replays on the F8 projection basis, the two-client
      convergence run with this client's real tools driving one side
      and a second client joining through the real welcome path, and
      the drag-abort suite proving sealed-empty end to end. The editor
      now reads `session.optimistic()` through `IndexedDocument`,
      writes exclusively through the op emitters under gesture
      seal-and-submit, undoes through the session journal, and boots
      serverless onto an in-process host with nothing persisted. The
      legacy model survives compiling, lidded and dormant, for phase
      7's demolition. Per-commit findings, behavior changes, and the
      step-13 ratification queue live in the sub-playbook)
- [ ] 13 — Coverage sweep and behavior-change ratification

---

## Stage A — complete the model

### Step 1 — `Block.role` lands; derived pin accents leave the model

**Why.** Two modeling defects, one commit because both are accent-shaped.
The legacy block's accent (`src/document/mod.rs:69-73`, authored by Set
Accent) has no home in the new `Block` — the survey's gap A; without it the
role picker cannot be compiled. And the new `Pin` carries three accent
registers (`block_model.rs:120-122`) where only one is authored: D2 says
the two derived ones leave now, before any emitter or golden calcifies
them. Modeling derived state as registers is not dead weight but a live
hazard — two clients' `update_route_roles` passes would race each other
through the server forever.

**The work.**

- `entity! Block` gains `Role => role: Role`.
- `entity! Pin` drops `PinAccent`/`PortPinAccent`; `port_accent` stays.
- Regenerate the decode goldens touched by both vocabularies; the encode
  suite's one-instance-per-op-variant coverage rule tells you which.

**Prove it.** Golden coverage stays total over the new vocabularies
(`encode.rs`'s coverage test); a fold test writes a block role and asserts
LWW; the removed variants no longer decode (refusal, not skip — assert
it).

### Step 2 — The pin slot register

**Why.** The single largest gap (survey gap B). Legacy pins carry two
independent geometries: `(side, offset)` — which edge and slot of the
block-as-child — and `rect`, the port body inside the block's own view
(`src/shape/port.rs:44-64`). The new `Pin` has only `rect`, so a pin
cannot be placed, moved, flipped, or pasted, and `flip_lr` is
uninterpretable without `side` (gap C). At least ten mutation rows write
the slot.

**The work.**

- `values.rs`: `PinSide { West, East }` — zero is `West` (every
  auto-placed port starts west: `add_port_auto_named`,
  `src/widget/drawing.rs:489`).
- `geometry.rs` (it is geometry, not a value enum): `PinSlot { side:
  PinSide, offset: u32 }`, decode-bounded like grid values (offset ≤
  2^20).
- `entity! Pin` gains `Slot => slot: PinSlot`.
- Record the parked `PinDir` zero question closed: inits are total, so
  the enum's zero never reaches the wire from the editor — `Input` stays,
  and the editor mints `InOut` explicitly at creation. Same reasoning
  retires the `LabelSide` default drift (legacy `Bottom`, new `Top`).

**Prove it.** Goldens re-cover `PinUpdate`; a fold test moves a slot and
asserts atomicity (a losing concurrent write cannot split side from
offset — write both sides concurrently, assert the winner's pair intact).

### Step 3 — The top pointer

**Why.** Gap D. The legacy editor's navigation, hit-testing, and Wrap Top
all need a designated root (`Document::top_id`, `src/document/model.rs:91`);
the new model has only "parent == NULL ⇒ document level", which is a *set*.
Two concurrent Wrap Tops must converge on one answer; a register gives LWW
that answer for free, where a "the unique NULL-parent block" convention
would leave two roots forever.

**The work.**

- `TitleBlock` gains `Top => top: BlockId` (`crates/doc/src/document.rs:26`).
- Validation: existence-only (D4). `BlockId::NULL` stays legal — an empty
  document has no top.
- The wrap-top emitter (step 10) will be the three-op commit: create the
  new root, reparent the old, write `top`.

**Prove it.** Golden for the new variant; a fold test races two wrap-top
commits and asserts both clients converge on the later one's root; an
existence-validation refusal test.

### Step 4 — The read surface: adjacency, order, geometry helpers

**Why.** The editor asks three questions per frame the new model cannot
yet answer without hand-rolling at every call site. (1) *Routes touching
this pin* — delete cascades, pin drags, endpoint suppression; the cache
indexes routes by owner only (`document.rs:359-366`). (2) *Draw and hit
order* — everything in `DocumentCache` is a `HashSet`; the decided policy
(chronological `max_order`; ties cannot occur through the fold, `id` as
the stable belt-and-suspenders key — the design notes' 2026-08-15
revision deleted the longest-route-first tie an earlier draft of this
step still quoted) must be encoded once, or drawing, hit-testing, and
the router's iteration order will disagree — the exact drift
`widget/hit_target.rs` exists to prevent.
(3) *Rect algebra* — the new `GridRect` has no methods; legacy `coord.rs`
has the semantics (open-on-the-right `intersects`, `coord.rs:127-132`)
that hit-testing depends on.

**The work.**

- `DocumentCache` gains `routes_by_endpoint: HashMap<PinId,
  HashSet<RouteId>>`, built in the same linear pass.
- The chronological comparator, homed in the doc crate beside the cache:
  one function ordering entities by (`max_order`, route-length desc, id),
  consumed later by draw, hit, and the router. The draw-order policy is
  already recorded in `docs/doc-ng-design-notes.md:433-467`; this step
  implements it where every consumer can share it.
- `GridRect`/`GridPoint` grow the legacy helper set with legacy
  semantics: `contains`, `intersects` (open-right), `translate`,
  `from_corners`, accessors. Port `coord.rs`'s tests as the spec.

**Prove it.** Ported `coord.rs` tests; a comparator test with the mutation
ritual (invert the tie-break, watch it fail); a cache test asserting
`routes_by_endpoint` agrees with a brute-force scan on a fixture with
suppressed and tombstoned routes.

### Step 5 — Assets in the log

**Why.** Gap F. The new `Image.asset` and `Block.icon` hold an
`AssetHash`; nothing anywhere stores bytes, so a placed image renders
blank and paste-with-image cannot round-trip. The migration playbook
parks the decision for "when phase 6 reaches the image tools" — that is
this step, and it may be executed out of order, immediately before step
10 reaches the image emitters.

**The work (recommendation on record, D7).** Content-addressed,
create-only: `Asset { bytes }` keyed by `AssetHash`, no registers, no
tombstone — an asset either exists or is unreferenced; identical hash ⇒
identical bytes, so replay and duplicate delivery are structural no-ops.
Decode verifies the hash (trust boundary). Server GC stays deferred with
snapshots/compaction.

**Prove it.** Golden + refusal (hash mismatch refused); a round-trip test
placing an image via ops and reading the bytes back through the fold.

---

## Stage B — make the waist real (legacy, behavior-preserving)

### Step 6 — Named setters close the escape hatches

**Why.** Tools and `app.rs` reach through the hatches to poke fields at
~30 production sites (full inventory in the 2026-08-16 survey; the dense
ones: `app.rs:978-1007` accents, `app.rs:1937` pin kind,
`rename_pin.rs:241` and `retype_pin.rs:135` with their width side
effects, `edit_route.rs:147-208` waypoints, `move_label.rs:55`). Every
such site is a place the swap would have to rewrite *inside a tool*;
behind a named setter it becomes a place the swap rewrites *inside the
waist*, once.

**The work.** Several commits, tool family by family:

- Add typed `Drawing` setters named for the mutation rows they implement
  (`set_block_role`, `set_pin_dir`, `rename_pin` carrying its
  widen-to-fit, `set_wire_label_pos`, `apply_resize`, …). Signatures take
  the same typed parameters the inventory's UI column lists.
- Re-point every tool/app call site; delete `block_mut`, `shape_mut`,
  `auto_route_mut`, `icon_mut`, `text_box_mut` from `Drawing`'s public
  surface. The `pub(super) document` field drops to private; sibling
  widget modules (`routing`, `movement`, `clipboard`, `materialize`,
  `auto_route`) *are* the waist and keep interior access.
- The line to hold: **no `&mut` document state reaches `src/tools/` or
  `src/app.rs`.** Add an `xtask ci` grep gate for the deleted hatch names
  outside the waist, like phase 3's headless gate — checked, not trusted.

**Prove it.** No test outcome changes — this is the stage's contract.
The grep gate lands red-tested (add a violation, watch it fail, remove
it).

### Step 7 — Derived state leaves the legacy document

**Why.** Four fields in the undo/save state are solver or cache output
(`docs/document_mutations.md:164-178`): `AutoRoute.{edges, start_pos,
end_pos, crossings}`, `TextBox.size`, and the two derived pin accents.
They cannot be sequenced (rule 3) and their presence in the document is
why previews "must" write it. Extracting them now, keyed by the legacy
ids, is the same extraction the new model needs, done where the golden
suite can prove it lossless.

**The work.** Several commits, biggest first:

- `src/derived/`: `RouteGeometry` per `RouteId` (edges, endpoints,
  crossings), `TextExtents` per `TextId`, propagated pin accents per
  `PinId`. Owned by `App` beside the spatial index, rebuilt via the
  existing `Document::generation` stamp, written by the solver passes
  that today write the document.
- `AutoRoute` keeps only authored state: anchors, name, role, waypoints,
  labels. `TextBox` loses `size`. `PinPort.accents` shrinks to
  `port_accent`. `update_route_roles` becomes a derived pass.
- Persistence: `schema_convert` sources the (still-persisted) accent
  fields from derived at save so the KDL goldens do not churn — the
  format dies in phase 7; buying golden stability with one read is
  cheaper than re-blessing every fixture twice.

**Prove it.** Router goldens and snapshots unchanged; the solve becomes
observably read-only — solving twice no longer bumps `generation`
(assert it), which also stops preview frames invalidating every cache.

### Step 8 — Previews stop writing the document

**Why.** The survey's blocker list: the four `update_routes_*` preview
entry points (`src/widget/routing.rs:54-170`) write route geometry every
drag frame and — worse — `trim_partial_route_approaches`
(`movement.rs:136`) *deletes waypoints during previews*, so an aborted
drag has already mutated the document. `edit_route.rs:147-208` creates
and moves waypoints mid-drag; `move_label.rs:55` has no preview/commit
split at all; the pin-drag path temp-writes the pin and restores it but
not the trims (`routing.rs:102-170`). The new document is immutable;
none of this survives the swap, and all of it is fixable now under
golden protection.

**The work.**

- `RoutePass::Preview` solves into the derived overlay (step 7's
  structures) against hypothetical geometry — dragged shapes, moved
  pins — without touching the document. Trims are *computed* into the
  preview solve, *applied* only by the commit pass at `DragStopped`.
  This one change serves all five drag tools at their shared funnel.
- `edit_route`: the working waypoint list lives in tool state; the
  commit arm writes the final list wholesale — which is precisely the
  new model's atomic `Vec<Waypoint>` write, arrived at independently.
- `move_label`: per-frame position becomes preview state; `DragStopped`
  commits one `LinearDistance`.
- The pin-drag write/restore dance dies; the preview solve takes the
  hypothetical slot as a parameter.

**Prove it.** Committed outcomes keep their goldens. New test, through
the full tool path (dispatch, drag, *no* release): an aborted drag
leaves the document byte-identical (`generation` unchanged and equality
asserted). One enumerated behavior change: aborting a drag no longer
loses trimmed approach waypoints — a bug today, fixed by construction.

---

## Stage C — build the emitters

### Step 9 — `CommitBuilder`

**Why.** No builder exists — `Commit::new(label, vec![...])` is
hand-rolled at every call site (`crates/doc/src/commit.rs`). The donor's
seal discipline (builder consumed on seal; an empty builder seals to
`None`, so a gesture that edited nothing submits nothing) was explicitly
deferred "until sealing has a caller"; step 10 is the caller.

**The work.** `CommitBuilder` in the doc crate: `new(label)`, op-pushing
methods, `seal(self) -> Option<Commit>`. Labels come from `CommandId`'s
stable names where a command triggered the edit (`tools/commands.rs:52`),
tool-authored strings otherwise.

**Prove it.** Ported discipline tests: consumed on seal, empty seals to
`None`, op order = seq order.

### Step 10 — The op emitters, one per inventory row

**Why.** The compound-operation rule says every gesture lands as the
primitive ops it produced — concrete values, pre-minted ids. Someone must
*compute* those ops from (document, cache, gesture parameters): the
free-slot search, the block growth in 2-cell steps, the waypoint
promotion, the paste remap. Writing these as pure functions against the
doc crate, before any tool calls them, makes the flag day a re-wiring
instead of a rewrite — and makes every row of
`docs/document_mutations.md` testable without a UI.

**The work.** A new app module, `src/edit/`: one emitter per inventory
row, signature `(doc: &Document<Provisional>, cache, params...) ->
ops-into-builder`. Grid policy stays in `src/grid.rs` and is consumed
here; measured text widths (pin widening) arrive as parameters so
emitters stay pure; id minting is client-side (the root package takes
`uuid/v4`; the doc crate deliberately does not). Group by family —
create, geometry, pins, routes, labels, clipboard, cascade delete — one
commit each. The clipboard emitter defines the v2 clipboard format:
new-model init structs as the value snapshot (spec §9), remapped on
paste.

**Prove it.** Per-row unit tests: emit, fold via `try_apply`, assert the
resulting document state matches the row's contract — including the
systemic side effects (`document_mutations.md:140-162`): block growth,
waypoint promotion, label re-anchoring, all-inside route translation.
The execution plan — sub-steps 10a–10g, the emitter contract (purity,
pre-minted ids, non-edit filtering, riders as ordinary ops), the
legacy→doc conversion table, and the per-row coverage table — is
`docs/op-emitter-playbook.md` (2026-08-19); rows tick there as they
land. The deferred step 5 (assets) closes inside sub-step 10e.

### Step 11 — The in-process host; the level-lowering bridge

**Why.** Three consumers need a server that is a function call: the
verification plan ("real gestures through the real tools against a real
in-process server"), the tutorial (its levels are single-user documents),
and D5's serverless boot. And tutorial levels embed KDL documents parsed
by `schema_convert` (`src/tutorial/level.rs:173-192`) — the swap needs
them as commit logs (D8).

**The work.**

- `LocalHost`: `Host` + `ClientSession` wired loopback, no sockets, no
  tokio — deliver-to-self on submit. Lives app-side; the server crate's
  integration harness already proves the session logic it reuses.
- The lowering bridge: `schema::model::Document -> Vec<Commit>` — walk
  the flat projection, mint ids, emit init ops in dependency order
  (blocks before pins before routes), one commit labeled per level. Used
  by tutorial-level load and by phase 7's regeneration; explicitly not a
  shipped importer.
- `App` boot: `--connect` dials as today; no `--connect` opens a
  `LocalHost` on an empty document (D5 — nothing persisted, title says
  so).

**Prove it.** The existing tutorial guard test
(`levels_parse_and_their_documents_load`) extends: every embedded level
lowers, folds, and the folded document's projection matches the
legacy-parsed one on the fields the cues read. A `LocalHost` smoke test:
submit, ack, converge.

---

## Stage D — the swap

### Step 12 — The flag-day series: ids, ownership, reads, undo, projections

**Why.** Everything before this step made it mechanical; it is still the
phase's center of mass, and it cannot be green mid-way (D6): the id type
flip alone touches selection, hit-testing, and every tool.

**The work**, as a reviewable series — planned in detail in
`docs/flag-day-playbook.md` (2026-08-19: the series as commits 12·1–12·6
with per-commit residual-red inventories per D6, decisions F1–F8 —
notably `LineAnchor` and `WaypointId` die with F1, files go read-only
through the bridge per F5, and the replay goldens re-cut onto a
projection basis per F8):

- **a — ids.** Legacy id newtypes → `Id<K>`; `ShapeId`, `LineAnchor`,
  `Deletable`, selections, `hit_target`, `spatial`, `nav_tree` re-type.
  The compiler drives; no behavior intent.
- **b — ownership and reads.** `EditorState { document, path }` becomes
  session views + path: the editor reads `session.optimistic()`,
  `Drawing` becomes `{ doc: &Document<Provisional>, cache, derived,
  sink: &mut CommitBuilder }`, the waist's setters become calls into
  step 10's emitters, `DragStopped` arms seal and `Collab::submit` (or
  `LocalHost::submit`). Field-access renames at read sites
  (`block.inner` → rect register reads) ride along.
- **c — undo.** The `Undoer`, the quiescence snapshot
  (`app.rs:2782`), and document autosave die; `Undo`/`Redo` actions call
  `session.undo()/redo()` through a new `Collab` passthrough that also
  ships `last_submission()` (the same seal-then-send shape as
  `submit`); `History` availability reads `can_undo`/`can_redo`. The
  `.bwx` container path goes dormant (deleted in phase 7); the server's
  log owns persistence.
- **d — projections.** Cue/script target resolution
  (`script/step.rs:131-172`) reads the new document through a thin query
  layer (block by title, geometry in cells, pin slots); the clipboard
  speaks v2 (step 10); tutorial load lowers through the bridge (step
  11); export keeps only what does not depend on the dying format,
  enumerated in the commit message.

**Prove it.** `cargo xtask ci` green at series end; tutorial golden
replays green over the lowered levels; the phase-4 two-client
convergence test re-run with *this* client's emitters driving one side
(via `script::Session` against `LocalHost`).

### Step 13 — Coverage sweep and behavior-change ratification

> **Step 13 is closed (2026-08-25), and with it phase 6.** All three of its
> obligations are met: the coverage sweep (every row of
> `docs/document_mutations.md` driven the way a user reaches it),
> ratification (`docs/ratification-sheet.md` — Parts A and B decided, Part C
> closed), and the two-client dress rehearsal, below. Phase 6 is ticked in
> `docs/collab-migration-playbook.md` and D5/D7 have moved into its
> Decisions table.
>
> **What this step handed forward**, both recorded in the migration
> playbook rather than here:
>
> - **R3 — resume on reconnect.** The rehearsal found the editor still
>   authoring after the link dies, into a session with no authority to
>   sequence. Decided: keep the queue and resume, not read-only. Its own
>   piece of work; the open sub-questions are in that playbook's open
>   items.
> - **Naming the accent index.** `Option<u8>` reaches the legacy layer
>   phase 7 deletes, so it is queued with the nomenclature sweep in
>   `todo.md` rather than swept now.
>
> Phase 7 opens: the demo (rehearsed), then the demolition.

**Why.** The migration playbook's phase-6 contract is "every row of
`docs/document_mutations.md`", and its intentional-behavior-changes list
(items 1-10, plus step 8's trim fix and any accumulated here) must be
confirmed as shipped-on-purpose, not discovered.

**The work.** Walk the inventory; for each row confirm an emitter, a
tool wiring, and a test through the real path; fix stragglers. Update the
migration playbook: tick phase 6, record deviations, move the asset and
boot-UX decisions into the Decisions table.

The ratification half is `docs/ratification-sheet.md` — every change with
what the legacy did, what it does now, where it is observable, and a
recommendation. **Closed 2026-08-25:** Part A ratified, Part B decided in
full. Two entries became work rather than confirmations — B3 (what a lock
protects, now enforced by type in `src/edit/lock.rs`) and B9 (a text box's
extent, now measured on demand) — and both are done. Part C's five
engineering items remain.

**Prove it.** The ticked coverage table in this file; `cargo xtask ci`;
a live two-native-client sanity run against `blockworx-server` — the
phase-7 demo's dress rehearsal, **run 2026-08-25**, below.

#### The two-client dress rehearsal (2026-08-25)

Phase 7's exit criterion, run early: two native `blockworx --connect`
clients against one `blockworx-server`, editing the same document at the
same time. Real windows, real websocket, real tools — the clients were
driven by synthesized pointer and keyboard input rather than by hands, so
every edit went through `tools::tool::frame` and out through `Collab` the
way a user's does.

**What held.**

- A client that joins mid-session folds the whole log and shows the
  document at the server's rev; a client that was already there sees a
  foreign commit land **without touching the mouse** (the `ewebsock`
  wake-up repaints).
- A foreign commit does not disturb what the local user is doing: the
  selection survives it, and so does an open in-place editor.
- Undo travels as an ordinary commit — the other client sees the edit
  come back out — and availability stays per-client: only the author's
  undo button lights.
- **Genuine concurrency converges.** With the server `SIGSTOP`ped, both
  clients authored a move against rev 7 and queued it unacknowledged;
  on `SIGCONT` both reached rev 9 holding both moves, and their canvases
  were pixel-identical apart from each client's own selection chrome.
  (Pausing the server is the only way to force real overlap from a driver
  with one pointer — worth reusing.)
- Restart-and-refold: the server restarted on the same log replayed to
  rev 11, and a fresh client reproduced the document exactly — including
  that an edit authored *after* the server died is nowhere in it.

**What it found.** Three things, all in the boot-and-link seam that no
test reaches, because no test opens a window and dials a socket.

- **R1 — `--connect` opened the path argument and then discarded it.**
  `App::new` lowered the file into a session; the first frame replaced
  that session with the server's `Welcome`. The window title still named
  the file, the block path still pointed at *its* top block — so the
  default invocation (`blockworx --connect …` with a `demo.json` in the
  working directory) opened on an empty canvas with a raw uuid in the
  navigator, showing a scope the server's document does not have. It also
  took a container's lock for a document it would never read or write.
  **Fixed:** `--connect` opens nothing, so the title names nothing; and a
  `Welcome` is now a document swap like any other — `drive_link` re-derives
  the path and calls `after_document_swap`, which also frames the arriving
  document instead of leaving it at the boot document's camera.
- **R2 — a dropped socket was reported as a desync.** A server shutting
  down resets the connection rather than closing it politely, which
  arrived as `WsEvent::Error` and became `Status::Failed`, putting
  `desynced: read: WebSocket protocol error: Connection reset without
  closing handshake` in the window title. **Fixed:** the transport losing
  the socket is `Closed` ("disconnected"), and the reason goes to the
  console; `Failed` now means only what it says — this client and the
  server disagree about the document.
- **R3 — the editor keeps authoring after the link is gone.** With the
  server dead, a tool still wrote, the gesture still sealed, and `submit`
  still queued it into a session that has no authority to sequence it.
  The block looks exactly like a real edit and exists in one process
  only. **Decided 2026-08-25 (user): keep the queue and resume**, rather
  than the read-only mode recommended here first. Requiring a live socket
  to edit is not robust against the connectivity people actually have,
  and it discards the one thing the client already is — an optimistic
  replica with a queue.

  The fold turns out to be built for it. Deletion is a tombstone, not a
  removal (`Live::delete` writes a `Liveness` register), and validation
  checks referential integrity against the tombstone-inclusive maps, so
  "you edited something someone deleted while you were away" is an
  ordinary LWW race rather than a refusal. And a commit is stamped with
  the rev it is *sequenced* at, not the one it was made at (`apply` reads
  `doc.rev.minting()`), so flushed work outranks what landed while the
  client was gone. The one refusal a stale queue can still draw is a
  block cycle — and that takes two clients moving sibling subtrees into
  each other at once (see "Only a race can cycle a block" below), so it
  is rare rather than routine.

  What it needs: redial with backoff; `Resume { from: Rev }` /
  `Catchup { commits }` — the incremental resume `collab-architecture.md`
  already names and defers, and the only new protocol piece; re-shipping
  the queue (the server keeps no per-connection nonce state, so the
  client's counter simply continues); and a title that says how many
  edits are waiting rather than "disconnected". Scope is a dropped
  socket, not a quit: the queue is in memory, and making it durable is a
  separate piece of work. The open sub-questions — surfacing rejection,
  and what a server whose log was reset does with a `from` it cannot
  honour — are in the migration playbook's open items.

#### Only a race can cycle a block (2026-08-25)

R3 leans on "a stale queued commit is almost never refused", and the
almost is a block cycle, so it is worth writing down how one is reached —
the question comes up every time reparenting does.

**A move keeps its ids; a copy does not.** `paste` reads which it is from
the *document*, never from clipboard state (`src/edit/clipboard.rs:374`):
a snapshot whose every source id this document holds tombstoned is the
cut this paste completes, so it is a `Move` — the same ids `Restore`d,
plus **one `Parent` register write**. Anything else is a `Duplicate` with
fresh ids. Pasting a second copy therefore duplicates: the first paste
brought those ids back to life, so the second no longer sees a cut.
A partial state duplicates wholesale rather than moving half, because
half restored and half re-minted would wire a copied route across one
original endpoint and one duplicate.

Re-minting on every paste would be simpler and is worse: a move would
become delete-and-recreate, so two clients moving one subtree at once
would land two subtrees instead of racing on one register.

**The editor cannot cycle a block by itself.** There are two production
writers of `Block.parent`:

- **wrap top** (`src/edit/create.rs:519`) parents the old root under a
  block minted in the same commit, which by construction has no
  ancestors;
- **paste-as-move** (`src/edit/clipboard.rs:560`) points its roots at
  `target.scope`, and that scope is always the one being viewed
  (`src/widget/clipboard.rs:127`). A scope you have just cut is
  tombstoned and gone from the view, so it cannot be the target.

**A race can.** A and X are siblings; one client cuts A and pastes it
into X while another cuts X and pastes it into A. Each is one legal
register write locally. Merged, the second to be sequenced trips
`no_block_cycles` (`crates/doc/src/document.rs:220`) and is `Rejected`.
This is a knowing exception to §8's "a well-behaved client is never
rejected by a race it could not see" — the same paragraph `validate`'s
`Document` arm cites when it refuses to check anything stricter than
existence. The alternative is a document with an unreachable loop in it.

#### The coverage walk (2026-08-21)

Every row of `docs/document_mutations.md` against the landed code. **All
45 rows have an emitter and a tool wiring** — no row lost its edit in the
swap, and no emitter is orphaned. Two rows are stale *text* rather than
missing work, corrected in the inventory: "Add Waypoint" was absorbed
into `RouteEditSession` + `commit_route_edit` at step 8b (the standalone
`add_waypoint` is gone), and "Restore History" was deleted outright at
12·4, subsumed by the log.

The third column is where the gaps are. Test layers, weakest to
strongest:

- **emitter** — a fold test in `src/edit/` (117 of them; every
  inventory-row emitter has one).
- **waist** — a test calling the `Drawing` setter.
- **real path** — driven through `tools::tool::frame` (tool dispatch) or
  `commands::apply_scripted` (the command registry), which is what the
  step-13 contract asks for.

| Real-path suite | Covers |
|---|---|
| `tools/drag_abort_tests.rs` | move shape, move group, resize, move pin, relocate pin group, edit route (edge + waypoint), move wire label — abort *and* release halves |
| `tutorial/runner.rs` goldens + `script/convergence_tests.rs` | new block, rename title, new route, rename pin, resize, move shape (levels 01–03 only) |
| `tools/commands.rs` effect tests | delete, flip L/R, flip U/D, lock, unlock, hide tags, show tags, reroute block |
| `tools/select_tool.rs` editor sweep | *opening* every in-place editor (not the commit) |

**Straggler pass (2026-08-21).** The headless frame driver moved out of
the drag suite into `tools/headless.rs` — shared rather than copied, since
a second "what one frame does" would drift the way the three synthetic-input
drivers did — and grew two things it needed to reach non-drag tools: it
publishes the tool's `EditText` (the seam the on-screen `View` renders, so
a test types where the user types), and it runs whatever action the tool
returns through `commands::apply_scripted`, the same document-scoped
dispatch the app and the scripted driver share. `tools/authoring_tests.rs`
then covers the create tools (comment, text box, pin-slot click, wire
label) and the in-place editors (title, pin tag, pin type, and the emptied
text box that deletes itself). Each of the seven setters they reach was
verified to fail when neutered.

**Rows with no real-path test** (emitter + waist only), the sweep's
straggler list. **All eighteen closed 2026-08-25**, struck through.
Every row of `docs/document_mutations.md` now has a test that drives it
the way a user reaches it:

- Create: ~~new image~~, ~~set block icon~~, ~~add port~~, ~~paste~~,
  ~~paste pins~~, ~~wrap top~~.
- Update: ~~keyboard nudge~~, ~~nudge pins~~, ~~move title / type
  label~~, ~~rename block type~~, ~~cycle pin direction~~, ~~set pin
  direction (bulk)~~, ~~set accent~~, ~~rename route~~, ~~reroute
  (single wire)~~.
- Delete: ~~cut selection~~, ~~cut pins~~, ~~delete wire label (empty
  rename)~~.

The seven landed as six tool flows in `tools/authoring_tests.rs` — which
now covers every row a tool reaches, grouped by the inventory's own CRUD
classification — plus single-wire reroute as another case in the command
registry's own effect table, since that is where its sibling
`reroute-block` already lives. Each was verified to fail with its setter
neutered.

**The "blocked" half was mostly a failure to look.** The walk called two
groups unreachable — the file dialog and the OS clipboard — and both
already had seams:

- **The clipboard is not the OS's.** `Action::Cut` and `Action::CutPins`
  write the document *before* handing the payload to `ctx.copy_text`, and
  `Action::Paste` takes the text as an argument. So the whole round trip
  runs against a bare `egui::Context`, reading the payload back out of its
  `OutputCommand::CopyText`. Cut-then-paste also pins down the move/copy
  rule: a cut block comes back under its own id, while `paste_pins` always
  mints, so a cut pin lands as a new pin on whatever boundary the paste is
  aimed at. That asymmetry is now asserted rather than assumed.
- **The file dialog is a channel.** `NewImage::Pending` and
  `IconTool::Pending` each hold a plain `Receiver`, so a test owns the
  other end and answers the pick itself — including answering `None`,
  which is what stops those tests passing on a tool that writes whatever
  it is handed.

What is genuinely left:
- ~~**The two live popups**~~ — set accent and bulk pin direction, closed
  by moving the write rather than by driving the popup (user's call). A
  picker used to call `commit_gesture` from inside its own UI callback,
  which made these the only two document mutations in the app reachable
  by no other route — and so the only two no test could drive. They now
  *report* a pick as `Action::SetRole` / `Action::SetPinsKind`, which
  `apply_scripted` applies like every other document-scoped action. The
  popup is an input widget again.

  Two things fell out of it for free. A script can set an accent, which
  it never could: the nine cells and three directions are registered as
  named commands (`accent-3`, `io-output`), so `command "accent-3"`
  resolves through the registry the palette and the script step already
  share. And they are registered *by name only* — a new `Offered` on a
  `Command`, filtered out of `iter()` — because nine more buttons would
  bury the selection bar while the picker is what the mouse should use.
  `kittest` was the alternative and is still there, but it renders and
  snapshots; it was never the right tool for reaching a write.
- ~~**App state, not document state**~~ — wrap top, keyboard nudge and
  nudge pins, **closed the same day** one layer up. The walk called them
  ordinary tool flows; they are not. `Action::GoUp` wraps the top only
  when the block path is empty and otherwise pops it, and `Action::Nudge`
  carries a delta but no selection, reading it from the tool the app
  holds. So `apply_scripted` hands both back and `dispatch_action`
  resolves them — which is what the three tests in `app.rs` drive, an
  `App` seeded through `LocalHost` with its tool set to the selection
  under test. Not a second tool driver: a different layer, and the one
  the toolbar and the arrow keys actually take.

  Go-up is driven through *both* arms in order, because the second is
  only reachable through the first: an editor opens inside the
  document's top block, so the root takes a go-up to reach, and the
  wrap is the go-up after that. Writing it any other way failed its own
  precondition, which is the point of asserting preconditions.

**Landed with the walk:** the command registry's effect tests. Its
existing tests proved which commands a selection *offers*; nothing proved
that invoking one writes. Two tests now drive each document-mutating
command through the same `apply_scripted` the scripted driver uses and
assert it authored an edit *of its own kind* — narrated through
`OpCodes::narrate`, because several arms ride a route trim alongside
their own write and "the gesture is non-empty" is satisfied by the rider.
Both facts were found by perturbation, not by reasoning: neutering
`SetBlockLocked` left the first draft green (the solve rider moved the
content hash), and neutering `flip_shape_pins` left the second draft
green (its trim still authored). All six covered arms are now caught.
