# Ratification sheet — step 13

Every behavior change the editor swap made, gathered for one decision pass.
The migration's rule is that intentional changes are *enumerated*, never
discovered later (`CLAUDE.md`, "Testing and verification"), so this is the
enumeration cashed in.

**How to use it.** Each item says what the editor used to do, what it does
now, where you would notice, and a recommendation. Record your call by
editing the **Decision** line — `keep`, `revert`, or a note. Nothing here is
blocked on the others; partial rulings are fine.

**Status: closed 2026-08-25.** Part A ratified (A2–A10; A1 superseded by
B12), Part B decided in full. B9 was the one entry that became *work* rather
than a ratification, and is fixed. Part C's five engineering items remain
open — as work, not as decisions. Assembled 2026-08-21 from the queue in
`docs/flag-day-playbook.md`, the per-step notes in `todo.md`, and the
commit record of steps 7–12.

---

## Part A — decided at plan time, confirm as shipped

These were written down in `docs/collab-migration-playbook.md` §"Intentional
behavior changes" before the work started, and the work matches them. They
need a confirmation, not a debate.

| # | Change | Shipped |
|---|---|---|
| ~~A1~~ | ~~Undo no longer restores the navigation path~~ | **superseded — see B12** |
| A2 | Undo granularity is the gesture, not one-second quiescence | yes (12·3/12·4) |
| A3 | Undo inverts this client's last commit, not the global head | yes (12·4) |
| A4 | Waypoints lose individual identity (atomic list register) | yes (12·2c: `WaypointId` → `PathOrdinal`) |
| A5 | Z-order stops being document state; chronological derived policy | yes (12·2b) |
| A6 | Asset GC changes — nothing collected while the log is complete | yes (10e) |
| A7 | "No accent" becomes `Role::Accent0` | yes |
| A8 | Editing requires a server (serverless boot = in-process host) | yes — **D5, ratified 2026-08-17** |
| A9 | The document lives in the server's SQLite file, not a `.bwx` container | yes (phase 4) |
| A10 | A concurrent edit can visibly correct an optimistic one on ack | yes (by construction) |

**Decision (2026-08-24):** **A2–A10 ratified** — each confirmed as shipped on
purpose. A1 is not ratified; it was superseded by B12 before this pass, and
undo now restores the view. **Part A is closed.**

Two open items from the same section resolved during execution and belong
in the Decisions table rather than here: **asset payloads over the wire**
became D7 (create-only, content-addressed, payload in the commit, 10e), and
**boot UX without a server** became D5. **`PinDir`'s meaningful zero** is
still open — the editor mints `InOut`, the entity default is `Input`.

---

## Part B — accumulated during execution

The ones that actually need a ruling. Ordered by how likely you are to
notice.

### B1 — Which shape wins a click when two overlap

**Was:** two rules. Blocks, ports and text boxes gave the *oldest*
overlapping shape the hit; icons, comments and images gave the *newest*.
**Now:** one rule — within a layer, the top-painted shape wins, which is the
newest. (12·2b; the layer order itself is unchanged.)
**Where you'd see it:** click where two blocks overlap. You now select the
one drawn on top, which is the one you can see.
**Recommendation:** keep. Two rules for one question is the drift the
one-order policy exists to stop, and "you get what you can see" is the rule
a user can predict.
**Decision:** keep — top-painted wins (2026-08-24).

### B2 — Which wire you pick, and which one hops at a crossing

**Was:** map insertion order — an implementation detail of the legacy id
map, not a policy.
**Now:** `chronological()` — oldest first, the same order the router and the
draw pass use. (12·2c.)
**Where you'd see it:** clicking where two wires overlap; which of two
crossing wires draws the hop.
**Recommendation:** keep. It makes route hit order agree with route *draw*
order, which is the same argument as B1. Note it is the opposite direction
from B1 (oldest, not newest) because wires are not layered — flagging it so
the asymmetry is a decision rather than an accident.
**Decision:** keep — chronological (2026-08-24).

### B3 — What a lock protects

**Was:** inconsistent, in both directions at once. Step 10's "lock
unification" had *widened* the guard to cover pin slots and flips — layout,
not interface — while leaving `cycle_dir` unguarded, which is the one edit
that most obviously changes what a pin means. The two errors cancelled out
enough that neither was noticed.

**Now (your decision, 2026-08-24):** the lock protects what a pin *is*, not
where it sits.

| Material — a lock refuses | Presentation — a lock allows |
|---|---|
| add a pin, add a port | move a pin to another slot or edge |
| delete a pin or port | nudge or relocate a pin group |
| set the I/O direction (`dir`) | flip pins left/right or top/bottom |
| rename a pin | show or hide a tag |
| edit a pin's type line | recolor, resize, retitle the block |
| edit a pin's tag text | move or delete the whole block |

**Changed behaviour:** slot moves, group relocation, nudges and both flips
now go through on a locked block, where they were refused; cycling a pin's
direction is now refused, where it went through.

**Enforced by type** (`src/edit/lock.rs`), per your instruction. A material
emitter takes a `MaterialPin` or an `UnlockedScope` and there is no other way
to name its target — `NewPin`/`NewPort` carry the proof, so one for a locked
owner is unconstructible. A presentation emitter takes a plain id and is
offered no proof, which is how the table above reads in the signatures.
Verified by perturbation: a setter that skips the mint no longer compiles.

**Supersedes** `TRACKER.md` item 4's "retitle/retag ... allowed" — a pin's
name, type line and tag text are material, which is where that line and this
ruling part company.

**Decision:** keep — as ruled (2026-08-24).

### B4 — A wire edited across a block re-solves immediately

**Was:** dragging a wire's edge through an obstacle left the crossing
in place; the re-solve happened on the *next* gesture.
**Now:** the re-solve rides the same commit as the edit (12·3), so it
happens on release and undo takes both together.
**Where you'd see it:** drag a wire edge across a block. It routes around on
release instead of on your next click.
**Recommendation:** keep. The old behavior meant a wire could sit in an
invalid state that no command had asked for, and undo would have split the
edit from its correction.
**Decision:** keep — re-solve in the same commit (2026-08-24).

### B5 — Opening a file drops a wire with a missing endpoint

**Was:** the legacy loader kept a route whose endpoint pin is absent,
leaving it dangling.
**Now:** the bridge drops it (12·2c-ii). **`demo.json` carries one**, so
this is observable on a file you have.
**Where you'd see it:** open `demo.json`; one wire is gone.
**Recommendation:** keep, but worth your eye on the demo file first — if
that dangling wire is load-bearing for a demo, the fix is the file, not the
loader.
**Decision:** keep — a dangling wire is syntactically invalid, so dropping it on load is correct (2026-08-24).

### B6 — Files are read-only

**Was:** open, edit, save, back to the same file.
**Now:** F5 — a file argument is lowered through the bridge into the boot
session and the save paths are gone, because saving died with the container.
Export also lost JSON/KDL and the SVG-embed round-trip (the payload *was*
the legacy serialization); rendering export and selection export stay.
**Where you'd see it:** everywhere. There is no way to write a document to
disk from the editor right now.
**Recommendation:** keep for the branch, but this is the item I would most
want a plan attached to before phase 8. The rejected alternative — refusing
file arguments outright — is recorded in F5; the eventual answer is
presumably an export-to-log or a local server file.
**Decision:** accepted for now — read-only is fine on this branch; revisit before phase 8 (2026-08-24).

### B7 — Aborting a drag no longer loses trimmed waypoints

**Was:** a bug. An aborted drag had already written trims and prunes into
the document, so abandoning it lost authored corners.
**Now:** fixed by construction (step 8); an aborted gesture seals to `None`
and submits nothing.
**Where you'd see it:** start dragging a block with a hand-placed wire
corner, then abandon the drag. The corner survives.
**Recommendation:** keep. This is a bug fix, listed because it changes
observable behavior.
**Decision:** keep (2026-08-24).

### B8 — Accents recompute instead of persisting stale

**Was:** pin/port accents were persisted and could go stale; a pasted pin
carried the accent of whatever it had been copied from.
**Now:** derived fresh per generation (step 7). Stale persisted accents
self-heal on load; pasted pins shed foreign accents.
**Where you'd see it:** open an old file whose pin accents disagree with
their wires — they correct themselves.
**Recommendation:** keep.
**Decision:** keep (2026-08-24).

### B9 — A text box's bounds go stale until you re-edit it

**Was:** the measured galley extent was stored with the text.
**Became:** the extent is derived and measured on edit, so a box whose text
changed under it — an undo, or a foreign commit — shows the *estimated*
boundary instead of the measured one.
**Your ruling (2026-08-24):** not acceptable as behaviour. Undoing a text
edit must update the bounding box. The extent should be recomputed
automatically whenever it is needed — on the next frame if that is when it is
needed — rather than persisting wrong until a double-click starts another
edit cycle, which is what it does today.
**Decision:** **fix, not ratify.** Tracked as work rather than as a behaviour
change; the measured extent becomes a genuine on-demand derivation like the
route geometry beside it.

### B10 — Smaller fixes, listed for completeness

Each was found and fixed during the port; none is a design question, but
they change output.

| # | Fix | Where it shows |
|---|---|---|
| B10a | Wrap-top refit — the new parent is sized to fit its child properly | "Go Up" at the root |
| B10b | Port-slot unit fix | port placement arithmetic |
| B10c | Auto-name ordinals | the number a new auto-named pin/port gets |
| B10d | Block title side defaults to `Bottom`, not `Top` | titles on bridged documents (caught by the bridge's oracle at step 11) |
| B10e | Double-delete bumps write order | deleting an already-deleted entity |
| B10f | `can_relocate_pins`: own-scope pins accepted, capacity in cells, locked owners refused | dragging a pin group near a block edge |
| B10g | Breadcrumb spells the root `""` rather than `"top"` | the path display; level files no longer accept an omitted root prefix |
| B10h | A label slide no longer runs a commit pass per frame | undo granularity while sliding a wire label |

**Decision (B10a–h):** keep, all eight (2026-08-24).

### B12 — Undo restores the view, not only the document

**Was:** decision A1 — the navigation path is view state, so undo did not
restore it. In practice that meant an undo could change a scope you were not
looking at, and there was no way to take back a navigation at all.
**Now (your decision, 2026-08-23, narrowed 2026-08-24):** the undo stack
interleaves. **Navigation** gets its own step; **selection does not** —
selecting is how you reach an edit, so a step per selection would mean
several presses to get back past one, and Cmd+Z would stop meaning "take back
what I did". The selection still rides in every step and is restored with it,
so undoing an edit puts you back in the scope where it happened with what it
touched re-selected. A step carries **both ends** of its edit — what the edit
left selected, and what it was made with — and the landing takes the first
the document still holds: undoing a *move* re-selects the moved block (the
thing that changed), undoing a *delete* re-selects what it restored (which is
what was selected when the delete was made, and nothing was after).
**Deliberately excluded:** the camera. Zoom and pan are continuous — one
pinch would push dozens of entries unless coalesced — and framing already has
fit-to-view.
**Where it lives:** `src/history.rs`, in the app. The session journal holds
the inverse commits and is headless by construction (the `headless` CI step
fails on egui in its tree), so it cannot hold a `BlockPath` or a `Tool`. The
app owns the ordering and delegates the document half; the two depths are
asserted equal on every frame, because a stack that disagrees with the
journal would restore one editor's step and invert a different document's.
**How a step names its edit:** each `Step::Edit` carries the `Nonce` of the
commit it made, and the session refuses a step naming anything but its own
top. Position would not do: a *rejected* commit leaves the journal from the
middle (`Journal::discard` retains, it does not pop), so after one lands, "my
third step back" and "the journal's third entry" are different edits. A step
whose commit was rejected is dead — dropped, and the walk continues — rather
than taken, which would have silently inverted its neighbour.
**Decision:** keep — navigation steps, selection restored not stepped
(2026-08-24).

### B11 — The release frame paints what it is about to commit

**Was:** every drag painted the *pre-edit* geometry on its release frame,
then the commit's geometry appeared on the next one — one frame of flash.
(You reported this on route edit; it affected all six drag flows.)
**Now:** a drag supposes on the frame that ends it, so the last frame before
the commit shows what the commit lands.
**Consequence worth naming:** on a release frame the gesture's solve rider
now seeds from the supposed geometry rather than the settled geometry. That
is what `Presentation::scratch` documents itself as wanting — "the geometry
the editor is showing" — and the goldens regenerate unchanged.
**Recommendation:** keep (already landed, 2026-08-21).
**Decision:** keep — confirmed in use, the flash is gone (2026-08-25).


---

## Part C — engineering items deferred to step 13

Not behavior questions; listed so the queue is complete and nothing is
carried silently past phase 6. **All five closed 2026-08-25.**

- ~~**`RerouteTarget::Block`'s two rules.**~~ **Decided 2026-08-25 (user):
  the visible level wins, the other rule is deleted.** The waist scoped the
  reroute fan-out to the level being viewed; the emitter reached
  document-wide, including wires drawn a level down that nothing re-lays.
  `RerouteTarget` is gone with it — `reroute` now takes a `RouteId`, so
  there is no second rule to disagree with and P5 has nothing left to
  reconcile here. The block-wide fold test was replaced by the single-wire
  case it was really asserting: a wire the solver left out keeps the bare
  clear, which is what `reroute_block` relies on.
- ~~**Waypoint-insertion arithmetic** is implemented twice~~ (2026-08-25).
  The plan (`route_edit_session`) and its replay (`RouteEditSession::corners`)
  each built the ordinal-keyed working list themselves, and `InsertAt(i)`
  counts positions in *that* list — so two constructions were two meanings
  for one index. Both now build it through `keyed_corners`, `InsertAt`
  documents what it counts, and a test plans two insertions and asserts the
  replay keeps their order (the case where the second is planned against a
  list the first has already grown). The clamp in the replay is kept and
  explained: a collaborator's commit can shrink the stored list mid-drag.
- ~~**One now-trivial `lazy_edge_drag` assertion** to delete.~~
  (2026-08-25). Better than deleting it: `EditRoute::drag_from` now takes
  `&Drawing`, so "starting a drag stages no waypoints" is carried by the
  borrow instead of by a test. `start_drag_does_not_pre_mutate` went with
  it — every assertion in it was about a write the signature no longer
  permits.
- ~~**Asset payload size bound**~~ — **4 MB, decided 2026-08-25 (user)**;
  open since 10e. Assets ride the log (D7), so an unbounded payload is
  unbounded forever: replayed on every server start and shipped whole to
  every client that connects. `ASSET_LIMIT` lives beside `Asset` and is
  enforced by `validate`, so no replica can hold one and a client's own
  prediction refuses before anything reaches the wire — plus a refusal at
  the three places a file is picked, so the pick that caused it can name
  the file and both sizes rather than leaving the user with a silent
  no-op.
- ~~**An `entity!`-generated `to_init`** to replace `clipboard.rs`'s
  hand-written readers.~~ (2026-08-25). Eight hand-written readers, one
  per kind, each re-listing a field list the macro already has — the exact
  shape that silently drops the next register somebody adds. `Entity` gains
  `to_init`, generated from the same list as the struct, and the readers are
  deleted. Proved as a round trip against `from_init` for every kind: an
  `Init` that survives unchanged is one no field went missing from, and a
  new register joins that test by existing.
