# Type-level invariants: what the collab migration's bugs are asking for

> **Status (2026-08-20): P2, P8 and P1 landed; P6 deferred with reasons.**
> Two series against 12·6's green baseline — the write door (P2+P8, which
> turned out to be one seam) and the supposition token (P1). P6's mode type
> parameter is deferred to phase 7: it reaches ~151 `Drawing` signature sites
> for a yield of two `debug_assert!`s, one of which P2 already dissolved. P7
> and P5 are carried into the step-13 ratification queue
> (`docs/flag-day-playbook.md`); P4 and P3 into phase 7
> (`docs/collab-migration-playbook.md`). Per-series detail is in `todo.md`.

A review of the defects that surfaced while executing
`docs/collab-migration-playbook.md` (editor-swap steps 6–12, the flag-day
series, and the two user-reported regressions after it), classified by the
invariant each one broke, with the compile-time construction that would have
made it unwritable.

The premise: every bug below was found by a test, a review pass, a grep gate,
or a user. None of them were found by the compiler. Each class has a shape the
compiler *could* have checked, and in three of the five cases this repository
has already built that shape somewhere else — the fix is to generalize a
pattern already proven here, not to invent one.

---

## The bug inventory, by class

### Class 1 — a temporal ordering nobody can see (5 defects)

| Defect | Where | Cost |
| --- | --- | --- |
| Block drag, group drag, resize, route-edge drag and waypoint drag painted **before** supposing their route preview, so every preview was computed and wiped unseen | `daaa865`; `move_block.rs`, `multi_select.rs`, `resize_block.rs`, `edit_route.rs` | Frozen wires in four flows, shipped; latent since the flag day |
| `EditRoute` released **and committed** on any non-`Dragging` event, so an event-less repaint mid-drag ended the edit | `daaa865` | Caught only because the regression tests for the first bug drove an event-less frame |
| A gesture cannot read its own writes (**B1**): `Drawing` reads `session.optimistic()`, which only advances at submit | 12·5 → 12·6 | Create-then-edit broke everywhere; "a placed block no longer opens its title editor" |
| `refresh_routes` must claim the stamp *before* running the pass, because materialization re-enters `Drawing::new` | `presentation/mod.rs:76` | A re-entrancy hazard held by a comment |
| The solve rider must run before the seal, and must author nothing of its own | `routing.rs:87` | `debug_assert!` |

The signature of the class: **correctness depends on the order of two calls,
and the type system sees two independent calls.** The paint reads
`Presentation::routes` through a shared field; the suppose writes it through a
shared field; nothing connects them. The comment
*"the supposition has to precede the paint"* is currently pasted into five
files, which is the DRY smell CLAUDE.md names — except the duplicated thing is
a **rule**, not code, so it cannot even drift into a compile error.

### Class 2 — one value, several meanings (5 defects)

`BlockId::NULL` simultaneously means: the document root scope, "no parent",
"no top block yet", and `Default`. Every one of these bugs is a site that read
one meaning and got another:

- `owner_scope` conflated *"the block is missing, so it declines a port"* with
  *"the root is a scope with no entity"* — the document root refused a port,
  breaking `AddPort` on a serverless boot. Fixed by inventing `OwnerScope
  { Block, Root, Absent }` — **which is exactly the right move, applied at one
  site.**
- The cue-field walk panicked on the root's entity-less row.
- `DocIndex` needed `scope()` split from `is_live_block()` so the root could
  never read as a delete target (F9).
- An empty boot seeded the legacy default document, whose invented top block is
  F9's explicitly rejected alternative; `opened()` became `Option`.
- Documents opened at the root instead of inside their top block —
  `BlockPath::opening` had to be remembered at three construction sites
  (`app.rs:957`, `app.rs:1065`, `script/session.rs:65`).

`Drawing::current()` becoming "honestly `Option`" in 12·2b is the same class
seen from the read side.

### Class 3 — a key that does not carry its scope (4 defects)

- Route-id collisions across sibling scopes: legacy ids were minted per block
  map, and the flat `HashMap<RouteId, RouteGeometry>` let each materialized
  scope clobber the last (user-reported, 2026-08-19). Patched with
  `ScopedRoutes`, then collapsed back to flat once ids became uuids.
- `PinAccents` had documented the identical hazard for `PinId` before it bit.
- Group move and `reroute_block` reached a moved block's interior wires through
  the **document-wide** adjacency (12·3 review).
- `RerouteTarget::Block`'s two-rules conflict: the waist scopes the reroute
  fan-out to the current level, the emitter reaches document-wide. **Still
  open**, deferred to step 13.

Uuid ids killed the *collision* half of this class. The *query-scope* half is
untouched: a scope-blind iteration and a scope-correct one have the same type
today, so the two rules can disagree indefinitely.

### Class 4 — two implementations of one policy (7+ defects)

- Preview vs. commit geometry (the whole of step 8a/8b/8c, and B2's 24 test
  failures).
- Lock guards: unified on `Slot`/`FlipLR`/`move_pin`/flips, but *not* on tag
  visibility or direction cycling — an enumerated inconsistency awaiting
  ratification.
- Hit order: the legacy took the oldest for blocks/ports/texts and the newest
  for icons/areas/images.
- Route hit and crossing-hop order: `chronological()` vs. map insertion order.
- 10c's block-title side: `Top` for everything, where the legacy defaulted to
  `Bottom` — a porting bug caught only by the bridge's oracle tests.
- The clamped-vs-unclamped label position (pre-migration, same class): the
  renderer clamped, the hit test and the editors did not.
- Waypoint-insertion arithmetic, unification deferred to step 13.

The project already knows the answer here and applies it well —
`PreviewExclusions`' pure twins, `From<ShapeId> for edit::geometry::Shape`
"so the two vocabularies cannot disagree", `relocation_fits` as one predicate
for preview and commit. The remaining instances are the ones where the shared
policy is a *guard* or an *order* rather than a computed value.

### Class 5 — invariants guarded by greps and floors

`xtask`'s `waist` gate greps for `pub(super) indexed|presentation|sink`,
greps `pub fn` signatures for leaked types, and asserts `src/widget/` contains
at least 35 `target: "edit"` string literals. The first two guard against
someone widening a visibility the compiler would otherwise enforce; the third
is a proxy metric with no semantic content, which the 12·6 addendum already
caught drifting (the old floor of 25 "happened to still pass").

---

## The proposals

Ordered by defects-killed per unit of effort. Each says what it deletes,
because a type-level fix that only adds is not a simplification.

### P1 — A preview is a value the paint consumes, not a side effect it hopes ran

**Kills:** the paint-before-suppose class outright; makes the "re-suppose on
every in-drag frame" rule structural instead of a comment in five files.

The root problem is that `Presentation::routes` is *one* map holding two things
with different validity rules: a pure function of the document (valid while the
stamp holds) and a one-frame hypothesis (valid until the next `Drawing`
construction). Merging them is what made call order load-bearing, and what
makes `routes_supposed()` — a manual "please forget" — necessary at all.

Split them, and make the hypothesis flow *into* the paint as an argument:

```rust
pub struct Presentation {
    /// A pure function of the document, stamp-gated. Nothing outside the
    /// refresh pass writes here, so no preview can be mistaken for settled
    /// geometry and no re-derive can eat a preview.
    settled: Stamped<RouteGeometries>,
    ...
}

/// Geometry the document does not hold, computed for exactly one paint.
/// Borrowed by the passes that draw it, so it cannot outlive the frame and
/// cannot be read by a frame that did not produce one.
pub struct Supposition<'f> { routes: &'f RouteGeometries }
```

and have `DrawingPasses::new(data, wires: Wires<'_>)` take the geometry rather
than reach for it, where `Wires` is `Settled | Supposed`. The ordering
constraint stops being a rule and becomes a data dependency the borrow checker
already checks: you cannot paint supposed wires you have not computed, and you
cannot compute them after the paint because the paint needed them.

Then go one step further and make the tool stop *doing* the supposing:

```rust
pub trait ToolTrait {
    /// What this tool's stored state implies the wires would be if the
    /// gesture ended now. A pure function of tool state, evaluated by the
    /// driver before every paint — so there is no frame in which a tool can
    /// forget, and an abandoned drag takes its preview back by ceasing to
    /// exist rather than by calling `routes_supposed()`.
    fn preview(&self) -> Preview { Preview::None }
    ...
}

pub enum Preview {
    None,
    Move { shapes: Vec<ShapeId>, delta: Vec2 },
    Resize { shape: ShapeId, rect: Rect },
    Pins(PinMove),
    RouteEdit { route: RouteId, corners: Vec<Waypoint> },
}
```

Two dispatch sites (`app.rs:1760`, `script/driver.rs:108`) sequence
`preview → suppose → widget`. **Deletes:** the suppose block at the top of five
`widget()` bodies, five copies of the ordering comment, `routes_supposed()`,
and `Presentation::scratch`'s whole-map clone. The step-8d drag-abort suite's
event-less-frame property becomes a statement about the driver, provable once.

**Effort:** M. **Breaking:** yes (`ToolTrait`, `DrawingPasses::new`).

### P2 — The gesture's read view includes the gesture's own writes

**Kills:** B1 structurally, and with it the four `Arming` states
(`rename_title`, `rename_route`, `rename_pin`, `edit_text_box`), the
"deferred resolution" dance, and the survey that had to hunt for the one
unnamed site (`resize_block`'s new-pin click).

`gesture::drawing()` hands `Drawing` the session's optimistic document; the
gesture's ops sit in the sink until `close()`. The staging fold that fixes this
**already exists** — `solve_rider` does exactly it (`routing.rs:74-81`:
`Commit::new` over `sink.ops()`, `try_apply`, `DocIndex::of`), but only at
gesture end. Hoist it to the gesture's read door:

```rust
/// The document the gesture is editing: the session's prediction with this
/// gesture's own ops folded on, so a tool that just created a block reads it
/// on the same frame. Refolded when the sink grows, not per frame.
pub struct GestureView {
    staged: Option<Document<Provisional>>,
    index: DocIndex,
    ops_folded: usize,
}

impl GestureView {
    pub fn drawing<'a>(&'a mut self, link: &'a Link, path: &'a BlockPath,
                       presentation: &'a mut Presentation,
                       sink: &'a mut CommitBuilder) -> Drawing<'a>;
}
```

Give it teeth the same way `IndexedDocument` got them: make the pairing
constructor the only door. A `Staged<'a>` newtype whose sole constructor takes
`(&Document<Provisional>, &CommitBuilder)` means a document that has *not* had
the sink folded on cannot be handed to `Drawing::new` — the same trick that
retired the "wrong-document mistake class" at step 6, applied to the
"stale-document mistake class" here.

**Effort:** M. **Breaking:** internal only. **Deletes:** four `Arming` enum
variants and their fall-through arms; `solve_rider`'s bespoke staging.

### P3 — One drag lifecycle, not fourteen

**Kills:** `EditRoute`'s release-on-any-event bug, and the next one like it.
Fourteen tool files currently hand-match `DragStarted` / `Dragging` /
`DragStopped`; "which event ends a drag" is re-decided in each.

```rust
pub enum Drag<S> { Idle, Active(S) }

pub enum Phase<'s, S> { Idle, Started(&'s mut S), Continuing(&'s mut S), Released(S) }

impl<S> Drag<S> {
    /// The one place that decides what starts, continues and ends a drag.
    /// A tool that wants a different answer has to change it here, where
    /// every other tool sees the change.
    pub fn advance(&mut self, event: Option<&Event>,
                   start: impl FnOnce(Pos2) -> Option<S>) -> Phase<'_, S>;
}
```

`Released(S)` yields the state *by value*, so committing an edit and clearing
the drag are one move — a tool cannot commit and stay armed, or clear and
commit twice. Compose with P1 by bounding `S: Previewable`, so a drag state
that carries no preview is a compile error in the tools where a preview is the
point.

**Effort:** M. **Breaking:** yes, but mechanically. This is CLAUDE.md's
"policy several call sites must agree on is encoded once", applied to a policy
that is currently encoded fourteen times.

### P4 — `Scope`, not `BlockId::NULL` — **landed 2026-08-26** (phase 7 step 12)

**Kills:** class 2 entirely. `OwnerScope { Block, Root, Absent }` is the right
answer discovered at one site; promote it to the editor's vocabulary.

```rust
/// A scope holds shapes: either the document root, or a block. The root has
/// no entity — it is never locked, never deleted, never renamed — so it is a
/// variant, not a sentinel id.
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
pub enum Scope { Root, Block(BlockId) }

/// What a scope reference resolves to. `Absent` is a third answer, and the
/// compiler makes every caller say which of the three it means.
pub enum Resolved<'a> { Root, Block(&'a Block), Absent }
```

The wire format keeps `NULL` (registers default to it, and `Option<BlockId>` in
the encoding is a bigger change than the payoff justifies) — convert once, at
the document boundary, exactly as `lower` already converts register reads. What
changes is that `Drawing`, `BlockPath`, the emitters and the cue layer speak
`Scope`, so *"is this the root or a missing block"* has no place to go wrong.

Pair it with making `BlockPath` unconstructible in the wrong state: if
`BlockPath::opening` is the only public constructor from a document, the
open-at-the-root bug has no third site to be forgotten at.

**Effort:** M–L (wide but mechanical). **Deletes:** the `scope()` /
`is_live_block()` split, `current()`'s Option-ness at read sites that only
wanted the root answer, and the per-site root special-cases.

### P5 — A scoped query and a document-wide one are different types

**Kills:** class 3's surviving half, including the open
`RerouteTarget::Block` conflict.

Today `indexed.doc.routes()` and "the routes in this scope" have the same type,
so the waist's rule and the emitter's rule can disagree and did. Make the
default door scoped and the global door loud:

```rust
impl<'a> IndexedDocument<'a, Provisional> {
    /// Everything in one scope. The ordinary door.
    pub fn scope(&self, scope: Scope) -> ScopeView<'a>;

    /// Every scope at once — for the fold, the bridge, and validation.
    /// Named so a reader can see the fan-out is deliberate.
    pub fn document_wide(&self) -> DocumentView<'a>;
}
```

`ScopeView` cannot be iterated without having named a scope, so
"reroute reached document-wide" becomes a visible, greppable, *reviewable*
call rather than the path of least resistance. Resolve the two-rules conflict
by construction: whichever rule wins, there is one function that answers it.

**Effort:** M. This is the cheapest way to close a step-13 ratification item
permanently rather than deciding it once.

### P6 — A reading `Drawing` and a writing one

**Kills:** the `debug_assert!(discarded.seal().is_none())` in `solve_rider`,
the throwaway `CommitBuilder` it exists to catch, and the whole class where a
"pure" pass writes. Step 8's purity is currently proven by comparing document
stamps in tests; it can be a fact about the signature instead.

```rust
pub struct Drawing<'a, W = Writing<'a>> { indexed: View<'a>, path: &'a BlockPath,
                                          index: Option<&'a SpatialIndex>,
                                          presentation: &'a mut Presentation, sink: W }

pub struct Reading;                       // no sink exists
pub struct Writing<'a>(&'a mut CommitBuilder);

impl<'a, W> Drawing<'a, W> { /* every reader, hit test, and query */ }
impl<'a> Drawing<'a, Writing<'a>> { /* every named setter */ }
```

The SVG export, the popups, the hit-test paths, the solve and the tests all
take `Drawing<'_, Reading>` and cannot author an op — not by discipline, by
absence. **Effort:** M; the split is along a line the code already draws in
prose.

### P7 — A lock is a capability, not a thing to remember to check

**Kills:** the lock-guard unification item in the ratification queue, and
prevents the next omission.

The queue currently reads as an audit list: "the lock unification reached
`move_pin` and the flips", "vs the ported lock inconsistencies (tag
visibility, direction cycling)". Every one of those is an emitter that must
remember a rule. Make the rule mintable in one place and required at every
call:

```rust
/// Proof that `block` accepts an interface edit. Minted only here, which is
/// where the lock rule lives; an emitter that takes one cannot run without it.
pub struct Unlocked(BlockId);

impl Unlocked {
    pub fn of(indexed: &IndexedDocument<'_, Provisional>, block: BlockId) -> Option<Self>;
}

pub fn move_pin(owner: Unlocked, ...);
pub fn set_flip_lr(owner: Unlocked, ...);
```

The audit becomes a compile error list: change the signature, and the compiler
enumerates every site that was silently skipping the guard. Applying the same
shape to `Chronological<T>` (an order you must have *obtained*, not a `Vec` you
happen to have sorted) closes the hit-order and crossing-hop-order items the
same way.

**Effort:** S per rule, and it is the highest-value single change on this list
relative to size.

### P8 — Retire the grep gates by making them types

Two of the `waist` gate's three checks guard visibility the compiler enforces
*until a human widens it*. Wrap the state instead of guarding the keyword:

```rust
/// The gesture's sink, as `Drawing` holds it. Public visibility leaks nothing
/// usable: `push` is `pub(super)`, so widening the field does not widen the
/// surface.
pub struct Sink<'a>(&'a mut CommitBuilder);
impl<'a> Sink<'a> { pub(super) fn push(&mut self, op: OpCodes); }
```

Do the same for the document handle, and both greps retire — a `pub` field of
an opaque type hands a caller outside `src/widget/` nothing.

The 35-event floor is different: it counts `target: "edit"` string literals,
which is a proxy for "the mutation log is alive" with no semantic content, and
it has already drifted once. Emit the log line from `CommitBuilder::push`
instead — it sees the opcode, which carries the id and the value the
hand-written lines carry today. The log becomes 1:1 with ops **by
construction**, forty-odd hand-written lines disappear, and the floor has
nothing left to guard. The tradeoff to weigh: per-setter phrasing is lost, and
the gesture label plus the opcode have to carry the meaning instead.

---

## Sequencing: when to cut these in

The discriminator is not size — it is whether a proposal's cost overlaps work
the playbook is already going to do. Three buckets.

### Now, against the green baseline (P2, P8, then P1+P6)

12·6 left the tree green. That is the only state in which a refactor declared
behavior-preserving can be *proved* so — the project's own rule — and the next
two scheduled items (step 13's sweep, phase 7's demolition) will both churn the
tree again. None of these four touch code phase 7 deletes, so waiting refunds
nothing.

- **P2 (staged sink)** is the most time-sensitive item on the list. `Arming` is
  a workaround for a missing concept, landed in 12·6 — and this repository has
  already learned that lesson once ("a per-feature fallback that patched around
  a missing input model lived exactly one day before the model grew the missing
  concept"). Every new create-then-edit tool now has to know to add an `Arming`
  state; the 12·6 survey already had to hunt for one that was missing. The tax
  compounds. Cost is hours: the staging fold already exists in `solve_rider`.
- **P8 (opaque `Sink`/`View`)** is an afternoon and retires gate machinery
  rather than adding any.
- **P1 (+P6)** costs a day or so and buys back a class that has now shipped a
  user-visible regression twice — frozen wires in four flows, and the
  `EditRoute` release found only because the first bug's tests drove an
  event-less frame. It is also the class most likely to bite again, because
  the queued work (commit-driven cue advancement, the presence channel,
  gesture streaming) all adds frame-phase writers.

### In step 13, as the ratification itself (P7, P5)

These are not refactors that could be scheduled anywhere; they are the
*implementation* of items already sitting in the queue:

- **P7 (`Unlocked`)** is how the lock-guard unification gets enforced rather
  than merely decided. Ratifying the rule and then separately deciding how to
  make it stick is doing the work twice.
- **P5 (scoped vs `document_wide`)** resolves `RerouteTarget::Block`'s
  two-rules conflict by construction, which is the queue item.

### In or after phase 7's demolition (P4, and P3 by appetite)

- **P4 (`Scope`)** is the one proposal whose blast radius genuinely overlaps
  the demolition: the legacy vocabulary and `schema_convert` are where several
  of the `NULL` conversions live, and the "convert once at the document
  boundary" shape is cleaner with those readers gone. Doing it inside phase 7
  costs one traversal instead of two.
- **P3 (`Drag<S>`)** touches fourteen tool files for a one-bug yield so far,
  and it wants P1's `Preview` to exist first. Schedule it by appetite, not by
  urgency.

### The case for waiting on all of it

It is real and worth stating: the exit criterion is two native clients
live-editing one document, step 13 and phase 7 are already scoped, and a
detour delays it. If that priority dominates, the minimum defensible split is
**P2 and P8 now** (hours, and P2's scaffolding is actively accruing callers)
with **P1+P6 immediately after step 13** — before phase 7, so the demolition
happens over the simpler structure rather than the other way round.

What should *not* happen is landing several of these as one series. Each is a
separate commit series against a green `cargo xtask ci`, with its enumerated
behavior changes — of which there should be none.

## What this does not claim

Three of the recorded defects have no type-level construction worth building:
the 12·6 addendum (a diff described in a commit message but never staged — a
process failure), B2's twenty-four fixture-staleness failures (test data, not
code), and the auto-name-ordinal and wrap-top refit findings (genuine behavior
decisions awaiting ratification, not invariant violations). Enumerating them
matters, because a review that claims every bug is a type-system failure is
not making an argument.
