# Port playbook: `src/log` → `src/doc_ng`, one fix at a time

The step-by-step guide for phase 2 of `docs/collab-migration-playbook.md` —
the model collapse — written to be executed **manually, one step per commit**,
with each step small enough to hold in your head. The migration playbook says
*what* and *why at the strategy level*; this file says *why at the code
level*, and in what order.

**Rewritten 2026-08-13 for the server-centric pivot.** The transplant is now
smaller than the version this file previously described (14 steps → 12): the
`Clock` step and the DAG step are deleted outright — the server's rev counter
replaces the clock, and a linear log replaces the DAG. Steps that survive are
carried with their donor references intact; what changed under the pivot is
called out per step. The previous (causal) version of this file is in git
history at `b8b73d7`.

Each step has four parts:

- **Why** — the defect or gap, and the concrete failure it causes. If you
  can't reproduce the failure in your head, don't start the step.
- **Donor** — where the working implementation (and its tests) live in
  `src/log`. Port, don't rewrite: the donor's tests are specifications.
- **The work** — what changes in `src/doc_ng`.
- **Prove it** — the test that must exist (usually ported) before the commit.

Rules of the road:

- One step, one commit, `cargo xtask ci` green. Tick the box here in the
  same commit; record any decision or deviation under the step.
- **This is a transplant, not a third design.** If a step starts growing new
  design, stop, write the question down here, and re-read the collapse
  section of the migration playbook.
- The donor's *semantics* port; its *shape* does not. Where the donor is
  causal-specific (stamps, parents, buffering), the pivot's shape wins —
  translate through the nomenclature table below.
- ~~`src/log` stays untouched and compiling until step 12 deletes it
  whole.~~ **Held for the whole transplant; the donor was deleted
  2026-08-16.** Every `src/log/...` citation below now resolves in git
  history rather than the tree — they are kept because they say *what was
  ported from where*, which is the record this file exists to carry.

Progress:

- [x] 1 — `Rev` replaces `Stamp`: the pivoted write order (2026-08-14)
- [x] 2 — The `Commit` envelope (2026-08-14; `order_of` dropped, no
      coalescing — `paths.rs` deleted, builder deferred and then landed
      2026-08-19 with editor-swap step 9)
- [x] 3 — Strip `Swap` from the wire; the undo baseline moves to the
      journal (2026-08-14)
- [x] 4 — The entity trait: `from_init` + `apply` (2026-08-14)
- [x] 5 — The fold, and `validate` (2026-08-15; fold + commit-end
      validate + donor proofs + mutation ritual)
- [x] 6 — `DocumentCache` on demand, per frame (2026-08-15; incremental
      maintenance + oracle dropped by design)
- [x] 7 — Draw order: `max_order` on entities (2026-08-15)
- [x] 8 — `Host` and `ClientSession` (2026-08-16; `Document<RevKind>`,
      `Entity::invert` generated, undo journal, four ritual kills)
- [x] 9 — Encoding: decode goldens, refusal, boundary validation
      (2026-08-16; CBOR, `Id`/`Hash` made transparent, three ritual kills)
- [x] 10 — The reconciliation suite (2026-08-16; the four kills, plus a
      semantic oracle the first kill forced)
- [x] 11 — Comments move out; module docs move in (2026-08-16)
- [x] 12 — Demolition: delete `src/log` (2026-08-16)

---

## Nomenclature (2026-08-12, extended 2026-08-13)

Donor and pre-pivot names are replaced throughout; steps below quote donor
code under its own names — translate through this table:

| Donor / pre-pivot term | Adopted term (module) |
|---|---|
| `Change` (donor) | `Commit` + `CommitEnvelope::CommitV1` (`commit.rs`) — the 2026-08-13 `ChangeSet` rename was reversed 2026-08-14, before any code carried it |
| `ChangeBuilder` (donor) | `CommitBuilder` — landed 2026-08-19 (editor-swap step 9) |
| `Command` | `OpCodes` — target id bundled with the op (`opcode.rs`) |
| `OpCode<W, I>` | `Crud<I, U>` (`opcode.rs`) |
| `*Write` vocabularies | `*Update` (`operands.rs`) |
| `BatchIndex` | `Seq` (`write_order.rs`) |
| `Stamp { lamport, actor }` | **deleted** — `Rev` (server-assigned) takes its slot in `WriteOrder` |
| `Lamport`, `Clock` | **deleted** — the server's rev counter is the clock |
| `ActorId` | **deleted** — attribution, if ever wanted, is a server-side column |
| `ChangeHash` / `CommitHash`, `parents` | **deleted** — a linear log's identity is `Rev` |
| `Dag`, linearization, causal buffering | **deleted** — per-connection ordered delivery + contiguity asserts |
| `Replica` (test type) | `Host` and `ClientSession` (`session.rs`) |
| `Swap { old, new }` | **deleted** (2026-08-13) — update variants carry only the new value; undo baselines live in the client journal |

---

## Step 1 — `Rev` replaces `Stamp`: the pivoted write order

**Why.** The 2026-08-12 ordering work landed `WriteOrder { stamp, seq }`
with `Stamp { lamport, actor }` — the causal design's arbiter. Under the
pivot the total order is server arrival: each accepted commit gets the
next `Rev(u64)`, and the register key becomes `WriteOrder { rev, seq }`.
The lamport/actor machinery is not merely unused, it is misleading — a
client-authored stamp would imply clients participate in ordering, which
is exactly what the pivot removed. Where totality now comes from: **the
server never reuses a rev** (a single-writer task incrementing a counter,
`INTEGER PRIMARY KEY` in the store — phase 4's concern, stated here
because it replaces the entire clock-resume invariant), and `Seq`
separates writes within one commit.

**Donor.** None — this is the one step that *removes* rather than ports.
The landed `Seq`, `WriteOrder`, and `Register` tests survive re-based.

**The work.**

- Delete `lamport.rs` and `stamp.rs`; delete `ActorKind`/`ActorId` from
  `id.rs` (and its `Default`).
- Add `Rev(u64)` — `ZERO`, `new`, `get`, `next()` (plain increment; a u64
  of one-per-millisecond outlives the sun — no saturation ceremony).
  **Decision (2026-08-14): homed in its own `rev.rs`**, keeping the
  one-load-bearing-type-per-module convention, rather than folded into
  `write_order.rs` as first drafted here.
- `WriteOrder { rev: Rev, seq: Seq }`; `BOTTOM = (Rev::ZERO, Seq::FIRST)`.
  `Register<T>` is generic over the key and needs no change beyond the
  type it names.
- Re-base the ordering tests: the stamp-pair tests (actor tiebreak,
  lamport-outranks-actor) die with `stamp.rs`; the `write_order.rs` and
  `register.rs` tests survive with `stamp(lamport)` fixtures becoming
  `rev(n)`.

**Prove it.** `write_order.rs`: seq orders writes within one rev (with the
shared-rev precondition asserted); rev outranks seq; `BOTTOM` sorts below
every minted order. `register.rs`: later-write-wins, stale-write-dropped,
same-rev-seq resolution, re-delivery no-op — all as landed, re-keyed.

**Understand before moving on:** why `max` over a *total* order is still
the whole merge (commutative, associative, idempotent), and that the
order's totality is now a property of a *process* (one server, one
counter) rather than a *protocol* (clock discipline across replicas).
That is the pivot in one sentence.

---

## Step 2 — The `Commit` envelope

**Status — landed 2026-08-14.** `commit.rs` holds
`Commit { label, ops }` (private fields; `new`/`label()`/`ops()`
accessors) and the versioned wrapper `CommitEnvelope::CommitV1(Commit)`.
The causal fields (`version: u16`, `parents`, `stamp`, `wall_time`) and
`CommitHash` are deleted; `chrono` left the module. Wall time and `Rev`
are server-assigned at acceptance and travel beside the payload, never
inside it. Nomenclature note: this step reversed the 2026-08-13
`ChangeSet` rename before any code carried it — `Commit` stands, and the
docs were swept back.

Three decisions recorded with the landing:

- **`order_of` is dropped.** Its only job was to encode the rule "op *i*
  lands at `WriteOrder { rev, seq: Seq::new(i) }`" as a method. The fold
  is that rule's only consumer, so it mints the order inline in its op
  walk (step 5) — one home either way, one fewer envelope method.
- **No in-commit write coalescing** (user decision). A commit may legally
  record redundant writes to one register; `Seq` resolves them last-wins
  — that was already the correctness story, coalescing was only hygiene.
  With no coalescing there is no map-keyed builder, and **the entire
  path-based mechanism goes: `paths.rs` deleted** (the register catalog's
  other prospective consumers — collision naming, typed register access —
  died with the pivot or were speculative). The one hygiene that still
  matters — dropping *non-edits* (an update equal to what the document
  already holds), which keeps step 7's chronological draw order from
  churning on settle passes — needs the document, not a catalog: it lives
  at the push site when sealing lands.
- **`CommitBuilder` was deferred** (landed 2026-08-19, editor-swap step 9) to whenever sealing has a real caller
  (the session, step 8, or the editor swap). `Commit::new(label, ops)` is
  enough until an incremental push-ops-across-a-gesture API is actually
  consumed; the donor's seal discipline (consumed on seal; an empty seal
  yields no commit) ports then. The donor's `inverted_commands` does
  **not** port onto the envelope — inversion moves to the client journal
  (step 3) — but its **reversal discipline** (`src/log/change.rs:193-199`:
  a commit may write one register twice, so its inverse must run
  backwards) is load-bearing there; carry the lesson, not the method.

**Prove it (closed 2026-08-19 — the builder landed with editor-swap
step 9):** empty-seal-yields-no-commit and seal-preserves-op-order are
in `commit.rs`'s tests; consumed-on-seal is the `seal(self)` signature.
The donor's parent-order and repeated-parents tests died with
`parents`; the inverted-batch test moved to step 3's journal.

---

## Step 3 — Strip `Swap` from the wire; the undo baseline moves to the journal

**Status — landed 2026-08-14.** The update vocabularies carry plain new
values; `Swap` and the `Invert` trait are deleted from `operands.rs`.
Shape decision for the lifecycle arms: `Crud::invert_lifecycle(self) ->
Option<Crud<I, U>>` — `Some(Delete)` for `Create`/`Restore`,
`Some(Restore)` for `Delete`, `None` for `Update`, whose inverse only the
journal can build. The asymmetry tests are in `opcode.rs`; the journal
round-trip proof is deferred to steps 8/10 as planned.

**Why.** Decision (2026-08-13, user): **`old` values do not cross the
wire.** Messages to and from the server carry only what replay needs, and
replay never reads `old` — the fold applies `new`, LWW ignores the rest.
So the update vocabularies slim to one new value per variant
(`Rect(Swap<GridRect>)` → `Rect(GridRect)`), and the undo baseline moves
to where it is actually used: a **journal in the client**, captured at
seal time. What this costs, accepted with the decision: review display
("changed 2 → 4") must be derived from the log when a review surface is
ever built, and the cheap staleness signal (`old` ≠ displaced value ⇒
concurrent overwrite) is gone. Neither is a v1 feature.

**Donor.** Nothing mechanical — `Swap::invert` and the `Invert` trait die
rather than port. The donor's `inverted_commands` **reversal**
(`src/log/change.rs:193-199`) survives as the journal's discipline: a
commit may write one register twice, so its inverse must run its
inverse ops in reverse order. (**Its stated reason is wrong for this
model** — corrected in step 8, 2026-08-16: repeated writes to one
register are order-independent under LWW-by-seq; what needs the
reversal is a lifecycle pair in one commit.) The asymmetry rule also survives
(`src/log/command.rs:424-438`): `Create` and `Restore` invert to
`Delete`, `Delete` to `Restore` — baseline-free, because the tombstone
retains the inner values; that is what it is for.

**The work.**

- `operands.rs`: every update variant carries the plain new value; delete
  `Swap` and the `Invert` trait. (The enums get *simpler* — this is the
  step that makes the wire format the user asked for.)
- `opcode.rs`: `Crud::invert` can no longer be total from the op's own
  fields — `Update` inversion needs a baseline. Keep the baseline-free
  arms as a lifecycle inversion (`Create(_) | Restore → Delete`,
  `Delete → Restore`); update inversion belongs to the journal. Record
  the shape you land on (a narrower method, or inline matches in the
  journal builder).
- The journal, sketched here and landed with `ClientSession` (step 8):
  at seal time, walk the commit's ops against the optimistic document
  *before* applying them — for each `Update`, read the targeted
  register's current value and push the inverse update; `Create` pushes
  `Delete`, `Delete` pushes `Restore`. Reverse the list; that payload
  (an ordinary `Commit` labeled "Undo …") is the undo entry. Undo
  submits it through the normal path; nothing about it is special on the
  wire. Redo entries are rewritten with current values at undo time —
  the Figma capture-at-use-time rule, sanctioned exactly here (local
  journal, never the wire).

**Prove it.** The lifecycle asymmetry test (Create/Restore → Delete,
Delete → Restore). The journal's round-trip —
`fold(fold(s, C), journal_inverse(C)) == s` on the visible projection,
including a commit that writes one register twice (the reversal
discipline observable end to end) — lands with steps 8 and 10; note the
deferral here when you commit this step.

---

## Step 4 — The entity trait: `from_init` + `apply`

**Status — landed 2026-08-14** (`entity.rs`). Decisions taken with it:
the trait is `pub` (the fold consumes it from its own module);
`Document` joins the trait as the singleton exception (`Init = ()`,
`from_init` returns the zero document — it is never created by an op);
`Label` implements it too, as the namespace-descent helper. The proof
tests walk **values as well as orders** — deliberately non-default
fixtures caught a real dropped-init bug during the step (`Block::
from_init` writing `Icon::default()` instead of `init.icon`), which an
orders-only walk cannot see. Also covered: descent mistargeting (a
`Title` rename must not land in `TypeLabel` — the twins share one
vocabulary) and the LWW refusal path through an entity.

**Why.** The fold needs two operations per entity kind: build one from its
init (`Create`) and apply one update. Writing these as exhaustive
`match`es **is the mirror guard** the model needs: add a `Register` to
`Pin` without a `PinUpdate` variant and the update can't be authored; add
a variant without a register and `apply` fails to compile; add an init
field and `from_init` fails to compile.

**Donor.** Semantics only — `src/log` has no per-kind anything. Keep the
shape boring:

```rust
trait Entity {
    type Update;
    type Init;
    fn from_init(init: Self::Init, order: WriteOrder) -> Self;
    fn apply(&mut self, update: Self::Update, order: WriteOrder) -> Applied;
}
```

**The work.** Implement for all eight kinds (`Document` is the singleton
exception: `DocumentUpdate` only, no init/liveness). Namespace descent
(`Title(LabelUpdate)`) forwards to a `Label::apply`. `from_init` writes
every register at the op's write order — every field, no defaults left
standing; the init structs are total by design.

**Decisions that stand:** `Route.from`/`to` are creation-time constants
(no endpoint updates — re-pointing is delete-and-recreate).
**The `PinDir` question no longer gates this step:** init structs are
total, so a created pin never shows the type's default — the zero is
pre-creation-only. Which variant is the right meaningful zero remains
parked (see the parking lot), to be settled before anything user-visible
reads a pre-creation value.

**Prove it.** Per kind: `from_init` leaves every register at the create
order (walk them); `apply` of each variant lands in the right register
(spot-check, plus the LWW refusal path through one entity).

---

## Step 5 — The fold, and `validate`

**Status — landed 2026-08-15, complete** (fold, commit-end validate,
donor proofs, mutation ritual — see the record at the end of this
step). The fold lives in `document.rs`, not a separate `fold.rs`: the
interior went immutable (`HashMap<Id, Arc<Live<T>>>` behind private
fields, entity-level copy-on-write via `Arc::make_mut`) and the fold is
the one writer, so it lives where the fields are visible. The signature
is pure — `try_apply(&self, &Commit) -> Result<Document, FoldError>` —
and an `Err` drops the clone, so refusal-over-skipping is structural and
the trial fold doubles as the ingress check for structural errors (no
separate validator to drift for those). Decisions and deviations:

- **The head `Rev` is a `Document` field**, minted only by a successful
  `try_apply` (`Rev::new` is test-only). `try_apply` takes no rev; the
  wire rev becomes the sync layer's contiguity assertion. Rationale in
  the design notes ("The rev lives in the document"). Step 8's
  `Host { state, rev }` collapses to the document alone, and
  `ClientSession::confirmed_rev` likewise.
- **Duplicate `Create` refuses**, deviating from this step's "do nothing
  (idempotent re-apply)". Fold-level re-apply idempotence is gone by
  design — a re-folded commit would mint a different rev — so
  idempotence is the session's contiguity job (step 8), and a duplicate
  create can only be a replay or a bug.
- **Errors are per-kind typed-id variants** (`FoldError::InvalidPinId(id)`
  …, thiserror): "invalid for the op" covers both unknown-id and
  create-collision; split them if the reject path ever needs the
  distinction.
- `Live<T>` grew the lifecycle helpers (`new`/`delete`/`restore`/
  `apply_update`); updates route to the retained inner
  **unconditionally** — a liveness gate that dropped them was written
  and removed (it contradicted delete-wins-structural).
- `Document::content_hash` (blake3 over an id-sorted CBOR view,
  covering rev, values, and orders) is step 10's divergence check: rev
  is the cheap version compare, the hash proves contents match.
- **Validation runs once, at commit end, over the successor** (a
  `validate` pass inside `try_apply` after all ops apply — no separate
  `validate.rs`, no pre-pass to drift). Payload references (pin/route/
  text/comment/image owners, route endpoints, block parents) resolve
  against the commit's *final* view, so op order within a commit is
  free for references — a pin may precede its owner's create; only
  op-target existence stays order-sensitive (an `Update` needs its
  `Create` earlier in the same commit). Reference checks are **map
  presence** — "ever existed", tombstones included — per the
  absorb-don't-reject stance; effective liveness is the cache's job.
  The containment-cycle walk (up from each touched block, seen-set)
  refuses parent loops: `FoldError::BlockCycle`. Post-apply checking is
  sound only because `Err` discards the whole successor — in a
  mutate-in-place fold this ordering would be a bug.
- Value access is `AsRef` on `Register` and `Live` (`Register::get`
  deleted); `Arc` still never escapes the read API.

**The donor proofs, ported or re-based (2026-08-15, ticking the
step):**

- `a_batch_writing_one_register_twice_ends_on_its_last_write` → seq
  end to end: the last write lands, at the last op's order.
- `concurrent_writes_converge_whichever_order_they_arrive` → re-based
  as later-rev-displaces with untouched registers keeping their
  orders — under minted revs, apply order *is* rev order, so
  either-order convergence has nothing left to test.
- `deleting_hides_an_element_but_keeps_it_restorable` +
  `an_edit_concurrent_with_a_delete_converges` → the tombstone keeps
  identity and inner; an edit sequenced after the delete still folds,
  lands in the retained inner without resurrecting, surfaces on
  restore; delete/restore never touch inner registers.
- `replay_is_a_function_of_the_log_alone` +
  `replicas_agree_byte_for_byte_not_just_structurally` → two replays
  of one log (create/update/delete/restore) agree by `content_hash`,
  the pivot's `state_to_bytes`.
- Died, with reasons: `delivering_the_same_change_twice` (duplicate
  create refuses; idempotence is the session's contiguity check,
  step 8); `the_create_field_beats_a_parent_in_the_init_set` (total
  inits removed the second channel); `an_edit_of_an_uncreated_element_
  is_refused` had already landed as `a_bad_op_refuses_the_whole_commit`.

**The mutation ritual — five kills, each observed then reverted:**
inverted LWW in `Register::apply` → 14 tests failed across
register/entity/document; writes silently dropped → 13 value-walk
tests failed; fold minting `Seq::FIRST` for every op → exactly the two
seq-pinning tests failed (twice-written register; delete/restore
inner-order stability); cycle walk forced permissive →
`a_parent_cycle_is_refused` failed; liveness-gated `apply_update` →
the edit-after-delete proof failed. Every landed behavior has a test
that dies with it.

**Why.** The fold is the only writer of document state on **both hosts**:
the server folds at ingress (after validating), every client folds
confirmed state and re-folds pending on top. Everything the model
promises is a property of this one function: purity, refusal over
skipping, delete-wins structural, idempotence under re-apply. The pivot
adds a sibling with a different job: `validate(&Document, &Commit) ->
Result<()>` — the **ingress precondition check** the server runs before
assigning a rev (and clients run against `optimistic` before submitting).
Validation is what lets the log stay clean; the fold's hard errors are
what make a dirty log loud.

**Donor.** `src/log/fold.rs` — the skeleton (`apply` walks the ops, each
at its order; `FoldError`) and its module-doc rules. The dispatch retypes
over `OpCodes`/`Crud`:

- Signature: `apply(&mut Document, &Commit, Rev) -> Result<(), FoldError>`
  — op *i* lands at `WriteOrder { rev, seq: Seq::new(i) }`.
- `OpCodes::Block(id, Crud::Create(init))` → if the map already contains
  `id`, do nothing (idempotent re-apply); else insert
  `Live { presence: alive-at-order, inner: Block::from_init(init, order) }`.
- `Crud::Update(u)` → lookup (`FoldError::Unknown*` if absent) →
  `inner.apply(u, order)`. **Route the update to `inner` whether or not
  presence is `Deleted`** — delete-wins-structural: the losing edit lands
  in the retained inner and returns with `Restore`. Donor test
  `an_edit_concurrent_with_a_delete_converges` (`fold.rs:252-271`) is the
  specification.
- `Crud::Delete`/`Restore` → `presence.apply(Deleted/Alive, order)`.
- `OpCodes::Document(u)` → the singleton's registers.

The donor's "unknown id is a hard error, not a skip" rule survives with a
**new justification**: in the causal world, causal delivery made absence
unreachable; here, *server validation* makes it unreachable in any
sequenced commit — so hitting it means the log itself is corrupt (or a
host bug), which must halt, not fork.

**The work — `validate.rs`, kept minimal:** every id an op references
resolves in the right map (typed ids already make kind confusion
unrepresentable); a `Create`'s id is not already present; a parent/owner
write does not create a containment cycle (walk up from the proposed
parent; the one genuinely global check). Geometry bounds are the decode
boundary's job (step 9), not `validate`'s. Return a reason string worth
putting in a `Rejected`.

Note what stays deleted from the donor's Create arm: the "`parent` field
beats an init `Parent` write" special case — init structs are total;
there is no second channel to race.

Return `Applied` from the write paths even though nothing consumes losses
yet — the loss path stays testable; a future review surface is its
consumer.

**Prove it.** Port every fold test, retyped. The critical five:
commit-writes-one-register-twice-ends-on-last (seq end to end),
two-commits-converge-applied-in-either-order (the register keys make
rev order and apply order independent), re-apply-changes-nothing,
edit-racing-delete (delete-wins + retained inner), unknown-id-refused.
New for `validate`: cycle-creating reparent refused; unknown reference
refused; a refused commit leaves the document untouched (validate
never mutates).

---

## Step 6 — `DocumentCache`: rebuild oracle + incremental maintenance

*(Unchanged by the pivot, minus the `created` ordering.)*

**Status — re-scoped 2026-08-15, closed.** The *incremental
maintenance* and its rebuild oracle are not built (user decision); the
derived view is `Document::cache() -> DocumentCache<'_>` — the whole
document's reverse indexes built on demand in one linear pass,
**borrowing** the immutable document. The client builds it once per
frame and queries any scope; the server never builds one. Coherence is
structural (a cache cannot drift from the document it borrows; the only
hazard is consulting a cache built from a different document — the
wrong-document mistake class, not a staleness class). The donor's query
semantics survive as direct tests (live-children-only, no entries for
tombstoned elements, suppression by effective liveness +
revive-on-restore). Two intermediate shapes were tried and superseded
the same day, lessons recorded: an owned rebuild-oracle
`From<&Document>` (its suppression check tested endpoint *presence*,
which can never fire — entries never leave the maps; **suppression
tests liveness**), and a block-scoped `BlockCache` (rendering a scope
needs child blocks' pins, so per-scope O(document) calls multiply per
frame — one whole-document pass serves every scope for the same cost).
If the per-frame build ever shows in a profile, reuse per installed
document at the owner — keyed by installed value, never by rev
(provisional revs recur across reconciliation rounds).

**Why.** Membership is authored child-side; every parent-side collection
is derived. The editor queries these constantly, and the donor's
equivalent (`DocState::children`) is a full scan per call — untenable at
10⁵ elements. The cache also sets the derived-state pattern: ground truth
is a pure function of the document; the incremental version must be
*proved* equal, not trusted.

**Donor.** The query semantics and their tests: live children only
(`state.rs:360-371` — a tombstoned child leaves the parent's list).
Donor ordering was `created` then id; `created` is gone (z-order
decision) — plain id order.

**The work.** `fn rebuild(&Document) -> DocumentCache` first — dumb,
total, obviously correct. Then hooks in the fold: creates insert,
presence flips insert/remove, owner/parent register wins move entries.
`suppressed` (routes with a tombstoned endpoint) can land here or defer
to the editor swap when routes render — record which.

**Prove it.** The oracle property: fold a random commit sequence,
assert `incremental == rebuild(&doc)` after *every* commit. Break one
maintenance hook deliberately once and watch it fail.

---

## Step 7 — Draw order: `max_order` on entities

*(Carried from the old step 3; "max stamp" becomes "max write order".)*

**Status — landed 2026-08-15.** `Entity::max_order` on all seven kinds
plus `Label` (namespace fold), each a `fold(BOTTOM, max)` over its
registers; creation constants (`Route::from`/`to`) carry no order and
are excluded. `Live::max_order` joins presence with the inner. Proofs:
per-kind value walks apply **every update variant** at a distinct
ascending order and assert the max tracks each write — the update
vocabularies mirror the registers 1:1, so variant coverage *is*
register coverage; presence writes raise a `Live`'s max through
delete/restore; untouched entities tie at their creation order (and
only there — distinct ops can never produce equal max orders). Ritual
kill: `flip_lr` dropped from `Pin::max_order` → exactly
`max_order_walks_every_pin_register` failed; reverted. The comparator
and its consumers (draw lists, hit targets, the solver's
first-come-first-served placement) remain editor-swap work.

**Why.** Z-order is not document state. Overlap-capable kinds (routes,
comments, images, texts) draw in **chronological order of last
modification** — and under the pivot, chronology *is* server order: sort
by `max_order(entity)` (the max `WriteOrder` over presence and every
register). Ties cannot occur (corrected 2026-08-15): `WriteOrder` is
total over ops and each op targets exactly one entity, so no two
entities share a max order — longest-route-first is deleted; `id` stays
as the final sort key only for stability. A pure sort over folded
state, so every client renders identically. Pending local edits carry
provisional orders above the confirmed head, so recently-touched-on-top
holds mid-flight too. The push-site hygiene (step 2) is what keeps
settle-pass non-edits from reshuffling anything.

**Donor.** Nothing — `src/log`'s `Element::created` and its restack tests
are deliberately not ported.

**The work (model side only — the comparator is editor-swap
presentation).** `max_order()` on `Live<T>` and the entities: max over
presence and all registers, namespaces included. Record where the policy
will live: **one comparator, every consumer** (corrected 2026-08-15,
user decision, reversing the earlier two-sorts rule) — draw lists,
`widget/hit_target.rs`, *and* the routing solver, which places routes
first-come-first-served, so placement priority is age. Accepted
consequence: a genuine edit to a route re-places it at the back of the
placement queue. Hazard to design around: `max_order` is a fold over
fields, not an exhaustive `match`, so the compiler cannot catch a
register added to an entity but missing from its fold — prove it with
step-4-style value walks (every field's order deliberately distinct, so
a skipped register fails the walk).

**Prove it.** `max_order` unit tests: a write to any register (or
presence) raises it; untouched entities tie at their creation order.

---

## Step 8 — `Host` and `ClientSession`

*(Replaces the old DAG and `Replica` steps. **Re-specified 2026-08-16**
for the rev-in-document model — see "What step 5 collapsed" below; the
pre-rev-move text is in git history.)*

**Status — landed 2026-08-16** (`session.rs`, 66 doc_ng tests green).
Decisions taken with it, beyond the re-spec below:

- **`Document<R: RevKind>` — the head's *kind* is part of the type**
  (user decision, 2026-08-16, superseding an `Optimistic(Document)`
  newtype landed hours earlier). `Confirmed(Rev)` names a real log
  position and exposes it; `Provisional(Rev)` is scratch, re-minted by
  every rebuild, and has **no accessor and no `Deserialize`** — so
  "report a prediction's rev" and "snapshot a prediction" are not
  mistakes to avoid, they are unwritable. `Document<Provisional>` also
  cannot stand in for `Document<Confirmed>`, which is what the newtype
  was reaching for. Both compile failures were confirmed, not assumed.
  - **Only the head is typed.** `WriteOrder` and `Register` stay
    monomorphic and `RevKind::minting()` yields a plain `Rev`, because
    a provisional write order and a confirmed one must remain *mutually
    comparable* — that comparison is exactly what makes an unacked
    local value outrank an incoming confirmed one. Typing the registers
    would break the suppression rule the design depends on.
  - **`content_hash` covers `minting()`, not the kind**, so a drained
    prediction and the document it drained to hash alike. The useful
    cross-kind comparison (`pending` empty ⇒ prediction == confirmed)
    is a runtime condition no type expresses, so banning it was not an
    option.
  - `Document<Confirmed>::predict()` is the one conversion, run per
    rebuild by design. The barrier is crossed routinely and on purpose;
    what the types stop is crossing it *accidentally*.
  - Timing was the deciding factor: `Document` had two consumers today
    and will have every tool, widget, and hit test after the phase-3
    editor swap. Generifying was now-or-never, not now-or-later.
  - Wart, accepted: `Document::rev()` collides with `Iterator::rev` in
    diagnostics, so calling it on a prediction reports "not an
    iterator" rather than "no method named `rev`". Misleading on
    exactly the mistake this design targets; rename to `head()` if it
    grates.
- **`Entity::invert` is generated by `entity!`**, from the same
  `$variant => $field` pair as `apply`, so the two cannot disagree.
  `Crud::invert_lifecycle` took `&self` (it returns data-free variants,
  so consuming it only forced callers to clone an init they discard).
- **The fold keeps its signature.** `try_apply -> (Document, Commit)`
  was considered so the inverse could never be forgotten, and declined:
  three of four fold sites (server ingest, confirmed fold, rebuild)
  would carry an undo concern they never use. The narrower mistake —
  reading the baseline from `confirmed` while folding `optimistic` — is
  closed at the call site by one `pre_image` binding feeding both.
  There is no ordering hazard to design against: the fold returns a new
  document, so the pre-image outlives the commit applied to it.
- **`submit` refuses locally what the server would refuse**, and queues
  nothing when it does — the client half of validate-at-ingress.
- **`last_submission()`** is how the transport learns what to send:
  undo and redo build their own commits, so the caller has no other
  handle on them.
- **Journal entries are tagged with the nonce they invert while it is in
  flight**, so a `Rejected` drops the inverse of an edit that never
  landed — undoing one would write stale values at a fresh order, and
  the inverse of a refused create cannot fold at all.

**The ritual — four kills:** `invert` returning the new value instead of
the displaced one → 6 tests failed (all three entity proofs, three
journal round-trips); rejection no longer discarding its journal entry →
exactly the rejection test; rebuild dropping every pending commit → the
oracle and the skip test. The fourth found a real gap: **deleting
`ops.reverse()` left the entire suite green.** The reversal's stated
justification (inherited from the donor) was wrong for this model, the
suite had been written to match it, and the corrected test —
`[Create X, Delete X]` in one commit — now dies with it. See "What the
reversal is for" below.

**Why.** The donor's test file invented `Replica { clock, dag, state }`
because that triple was the module's real abstraction. Under the pivot
the real abstractions are the **two ends of the wire**, and they belong
in the module so the reconciliation suite exercises the exact ingest
paths the server and the editor will use:

```rust
/// The authority: what the server wraps. Also the future "local mode".
struct Host { state: Document, log: Vec<Commit> }
impl Host {
    /// `try_apply` validates, folds into a clone, and mints the rev; an
    /// `Err` drops the clone, so "nothing was sequenced" is structural.
    fn ingest(&mut self, commit: &Commit) -> Result<Rev, FoldError>;
    fn rev(&self) -> Rev;                            // = state.rev()
    fn commits_after(&self, rev: Rev) -> &[Commit];  // Welcome; step 10's oracle
}

/// The edit surface: what the client wraps.
struct ClientSession {
    confirmed: Document,             // its rev *is* the confirmed head
    pending: VecDeque<Pending>,      // sealed, submitted, unacked; FIFO
    optimistic: Document,            // confirmed ⊕ pending — what the editor reads
}
struct Pending { nonce: Nonce, commit: Commit }
```

**What step 5 collapsed.** The head `Rev` became a `Document` field
minted only by a successful `try_apply`, and validation became a
commit-end pass inside it. Four things this step used to carry are gone:

- **`Host.rev`** — `state.rev()`. The counter *is* the head document.
- **`ClientSession.confirmed_rev`** — `confirmed.rev()`. The session
  exposes `rev()` reading it; see the hazard below.
- **`RejectReason`** — `FoldError` is the reject reason, already typed
  per kind with `thiserror`; the wire's `Rejected { reason: String }` is
  its `Display`. No new error type.
- **"validate → assign → fold"** as three host-side steps — one
  `try_apply` call. Refusal-leaves-nothing-sequenced stops being
  discipline (nothing to unwind, no rev consumed) and becomes a property
  of dropping the returned clone.

What `Host` keeps is the one thing that cannot live inside the
document: **the log**. `log[i]` is the commit at `Rev(i + 1)` —
contiguous *because* a refused commit consumes no rev, which is what
lets it be a `Vec` rather than a map. Phase 4 swaps the `Vec` for the
store; the shape of `ingest` does not change.

`ClientSession`'s three rules (the spec §6): local edit ⇒ optimistic
apply + push pending; `Apply { rev, cs }` ⇒ confirm + rebuild optimistic;
`Committed { nonce, rev }` ⇒ pop front (nonce must match), confirm,
rebuild. Contiguity is asserted **before** folding — `wire_rev ==
confirmed.rev().next()` — the linear-log replacement for all of the
DAG's causal buffering; after folding, `confirmed.rev() == wire_rev`
holds by construction and is worth the matching `debug_assert`.
Rejection ⇒ drop the pending commit, rebuild, report.

**Donor.** `convergence.rs:35-87` for the shape of commit/ingest/rebuild;
everything DAG-specific dies.

**The work.** `session.rs`, kept minimal — no storage, no transport;
those wrap it in phases 4–5.

- **Rebuild is honest and dumb, and now needs no rev arithmetic:**
  `pending.iter().fold(confirmed.clone(), |doc, p|
  doc.try_apply(&p.commit).unwrap_or(doc))`. Each successful `try_apply`
  mints the next provisional rev itself, so `confirmed_rev + 1 +
  position` disappears as an expression rather than moving. The
  document's interior is `Arc`'d with entity-level copy-on-write, so a
  clone is a pointer bump per entry and the rebuild's real cost is
  proportional to what `pending` touches.
- **The hazard the rev move introduced is now closed by the type
  system**: a prediction's rev is provisional, and `Document
  <Provisional>` has no accessor for it. The sync layer reads
  `ClientSession::rev()` (= `confirmed.rev()`) because it is the only
  rev it *can* read. Corollary for step 10 that no type can carry: an
  optimistic-vs-host hash assertion is only meaningful with `pending`
  empty, since `content_hash` deliberately ignores the head's kind.
- **A pending commit that no longer folds is skipped by the rebuild;
  `pending` is untouched** (decision 2026-08-16, user). A foreign
  reparent can turn a pending reparent into a cycle — the only reachable
  case, since entries never leave the maps, so unknown-id and dangling
  reference cannot arise from a foreign commit. The queue records what
  is in flight and is mutated only by `Committed`/`Rejected`; the
  rebuild is a per-frame prediction, and a commit that will not fold
  contributes nothing to this frame. It is never re-submitted. The
  prediction is right by the time the verdict lands: per-connection FIFO
  means the submitter's stream carries every sequenced commit in rev
  order (others' as `Apply`, its own as `Committed`), so the client has
  folded exactly what the server folded before validating it — if a
  later commit removes the cycle, the client repaints the edit *before*
  the server accepts it. Dropping it locally instead would make a
  prediction into a verdict and leave `Committed` arriving for a nonce
  the session no longer holds.
- **`Nonce(u64)`, minted by the session** — a monotone per-connection
  counter, homed in `session.rs` until `protocol.rs` exists (phase 3),
  which closes the parking-lot entry. The server never persists it.
- The **undo journal** (step 3's sketch) lands here:
  `ClientSession::seal` captures displaced values from `optimistic`
  *before* applying
  and pushes the ready-made inverse commit on the undo stack — undo is
  then an ordinary seal-and-submit of that entry. Redo entries are
  rewritten with current values at undo time (the Figma rule, sanctioned
  only here). Baselines never leave the client; nothing journal-shaped
  appears in `protocol.rs`. Two pieces of mechanism:
  - **`entity!` generates the baseline reader** — a trait method
    `fn invert(&self, update: &Self::Update) -> Self::Update`, so the
    journal is written once, generically, over all eight kinds: a
    register variant reads its own register
    (`Variant(self.field.as_ref().clone())`), a namespace variant
    descends. Hand-matching eight vocabularies would be exactly the
    transcription the macro exists to delete, and it would drift the
    first time a register is added.
  - **The commit inverse:** walk the ops forward against the pre-image
    document, emitting `Create → Delete`, `Delete → Restore`, `Update(u)
    → Update(entity.invert(u))`, then **reverse the list**. Only
    `Update` reads the pre-image (the lifecycle arms are baseline-free —
    `Crud::invert_lifecycle`, landed in step 3), which is what makes a
    single pre-image sufficient with no incremental fold, given two
    rules: an `Update` whose target the pre-image lacks is **skipped**
    (the entity was created in this same commit, so the `Create`'s
    `Delete` already covers everything it wrote); and a register written
    twice yields two inverses *both* carrying the pre-image value, so
    last-wins-by-seq restores it whichever way round they run. Lookup is
    map presence, not liveness — the tombstone retains the inner, which
    is what makes `Delete → Restore` baseline-free.

    **What the reversal is for (corrected 2026-08-16 by the mutation
    ritual).** The donor's justification — carried into step 3 of this
    file — was "a commit may write one register twice, so its inverse
    must run backwards". Under LWW-by-seq that case is
    order-independent, as the rule above says, and deleting `reverse()`
    left the whole suite green. The reversal is load-bearing for
    lifecycle **pairs** inside one commit: `[Create X, Delete X]`
    inverts to `[Delete X, Restore X]`, which run forwards leaves X
    *alive* — resurrecting an entity that never existed before the
    commit. That is the test the ritual bought.

    The round trip is exact only on the **visible projection**, and one
    case shows why the property is stated that way: a commit that
    creates X and then deletes it inverts to `[Restore X, Delete X]`,
    leaving X present-but-tombstoned where it had been absent
    altogether. Invisible either way; not byte-identical.

**Prove it.** Direct invariants here; the suite (step 10) proves the rest
wholesale.

- Commit-then-ack ⇒ `optimistic == confirmed`, by `content_hash` (revs
  included, which is why the equality is worth asserting at all).
- A foreign `Apply` arriving while one commit is pending ⇒ optimistic
  equals a from-scratch fold of (confirmed log + pending) — the oracle in
  miniature, and the place `Host::commits_after` earns its keep.
- Non-contiguous rev ⇒ debug panic.
- Rejected ⇒ pending dropped and optimistic rebuilt without it.
- **The skip decision's proof:** a pending reparent invalidated by a
  foreign reparent vanishes from `optimistic`, stays in `pending`, and
  **reappears** when a later foreign commit removes the cycle — one test
  covering all three claims, or the decision is untested policy.
- **Refusal consumes no rev, observed at the host:** a refused `ingest`
  leaves `state` and `log` untouched, and the next accepted commit takes
  the rev the refused one did not.
- The journal round-trip deferred from step 3: `fold(fold(s, C),
  journal_inverse(C)) == s` on the visible projection, over a commit
  writing one register twice (the reversal discipline), a
  `Create`/`Delete` pair, and an update to an entity the same commit
  created (the skip rule).

---

## Step 9 — Encoding: decode goldens, refusal, boundary validation

**Status — landed 2026-08-16** (`encode.rs`, 9 tests). **CBOR on the
wire** (user decision); one codec for wire and log. Decisions and
findings:

- **`Id<K>` and `Hash<K>` are `#[serde(transparent)]`** with the
  `PhantomData` skipped. Without it the compile-time kind tag encoded as
  a trailing `null` on *every id in every commit* — a representation
  accident, found by probing the bytes rather than by reading the types.
  Fixed before goldens pinned anything, which was the last free moment.
- **Trailing bytes are refused** — and were not, for free. ciborium
  stops at the end of the first value, so a payload with anything
  appended decoded happily and two byte strings meant one commit.
  `from_bytes` now checks the reader is drained. This is the
  "free-but-must-be-tested" category catching a real gap on its first
  outing.
- **Bounds: `GRID_LIMIT = 2²⁰` cells, `FracVal::LIMIT = 2⁴⁸`
  quantized units**, enforced by `#[serde(try_from)]` at decode only,
  and **refused, never clamped** — clamping would silently relocate
  geometry rather than report a bad payload. Two of either sum far
  inside their integer type, so no rect accessor can overflow a value a
  decoder accepted. Recorded consequence: the authoring side must
  respect the same bounds or it will write commits it cannot read back.
- **Hex serialization does not port** (the parked `AssetHash`
  question). Hex existed for JSON legibility; under CBOR it doubles the
  bytes for a format nobody reads by eye. `Debug`/`Display` serve
  legibility, and `ciborium::Value` decodes a frame for inspection.
- **The goldens pin a prefix.** `every_op()` is append-only and
  `GOLDEN_V1_OPS` records how much of it `commit_v1.cbor` covers, so
  adding a variant does not invalidate pinned bytes — it fails
  `every_variant_is_covered_by_a_golden`, whose fix is to cut a *new*
  golden beside v1, never to regenerate v1. The `tag()` match makes a
  new variant a compile error first.
- **`PartialEq` on the wire types** (macro-generated `*Init`/`*Update`,
  `Crud`, `OpCodes`, `Commit`, `CommitEnvelope`) so goldens assert
  decoded *values* rather than Debug strings.

**The ritual — three kills.** (1) `#[serde(rename_all)]` on generated
update enums → **only** `the_golden_decodes_to_its_pinned_values`
failed; the round trip passed, which is the whole argument for goldens:
a round trip renames both sides at once and agrees with itself. (2)
reverting `Id`'s `transparent` → the golden failed, confirming it pins
representation, not just decodability. (3) dropping the coordinate
bound → the out-of-extent test failed. The trailing-byte gap was
observed *before* its fix existed, which is the same evidence in the
other direction.

**Re-scoped 2026-08-16 (user question: isn't this already done?).** Very
nearly, for the half the title used to imply. `Commit` and
`CommitEnvelope` already derive `Serialize`/`Deserialize`; so does every
op, init, and update vocabulary, generated by `entity!`; and `ciborium`
is already a dependency, used by `content_hash`. So
`ciborium::into_writer(&envelope, …)` works **today**, with no new
code — `to_bytes`/`from_bytes` is a two-line wrapper, not a task. What
survives is the three guarantees below, none of which any derive
provides. The step is smaller and its title is now accurate rather than
aspirational.

**One decision this raises that the step did not previously carry:
JSON or CBOR on the wire?** Spec §7 says JSON text frames, and the
donor's readability test assumed it; `ciborium` is already present but
was brought in for hashing, not transport. They are different
tradeoffs (legible frames and trivial debugging vs. compact and
already-vendored), and the wire format need not match the storage
format. Settle it when the step starts, and record it.

**Why — reframed by the pivot.** Bytes are no longer identities (nothing
is hashed), so byte-exact *encode* stability stops being load-bearing.
What remains load-bearing is **decode-forever** — every payload a server
database ever stored must decode in every future build — and the decode
boundary is now a genuine trust boundary in production, not just at
fsck: the server deserializes payloads sent by arbitrary clients. A
`GridRect` with `i32::MAX` extents overflow-panics in `right()` in debug
and wraps in release; "values are bounded by the document extent" is true
of editor-produced values and false of whatever a decoder just accepted.

**Donor.** `src/log/encode.rs` post-spike: `to_bytes`/`from_bytes` over
the envelope, and the test list (`encode.rs:157-311`) — reinterpreted:
port the JSON-readability, tagged-by-name-not-position, unknown-variant
refusal, future-version refusal, and truncation-refusal tests as they
are; the "one change, one encoding" byte-canonicality test is demoted to
a plain encode→decode round trip. The hex-serde pattern for hashes
(`id.rs:291-342`) applies only to `AssetHash` now — decide whether legible
hex serialization ports with it; record it.

**The work.**

**What serde gives free, and what it does not (2026-08-16).** Worth
splitting before the step starts, because the free half invites
assuming the rest is too:

- **Free, and verified**: confining serialization to confirmed
  documents. `Provisional` implements no `Serialize` (dropped
  2026-08-16 — `content_hash` covers `minting()`, so nothing needed
  it), so a plain `#[derive(Serialize)]` on `Document<R>` generates
  `where R: Serialize` and `Document<Provisional>` is simply not
  serializable. No hand-written impl, no discipline. Note this does not
  arise *in* step 9 — the envelope is what crosses the wire, not the
  document — it lands when phase 4 adds snapshots.
- **Free, but must still be tested**: unknown-variant refusal,
  unknown-envelope-version refusal, and truncation refusal all fall out
  of serde/the format today. They are one `#[serde(other)]` or one
  lenient reader away from silently becoming permissive, which is what
  the refusal tests are guarding — the behavior, not the implementation.
- **Not free, and the reason this step exists**: **decode goldens**.
  Serde makes the failure *easier*: renaming a Rust variant renames its
  wire tag by default, compiles clean, and orphans every stored payload.
  Nothing in the type system or the round-trip tests notices — only a
  pinned fixture of bytes-plus-expected-values does.
- **Not free**: boundary validation. Serde will deserialize `i32::MAX`
  extents happily; range-checking needs `#[serde(try_from = "…")]` and
  a `TryFrom` impl. This is the actual trust boundary — the server
  deserializes payloads from arbitrary clients.

- `to_bytes`/`from_bytes` for the step-2 envelope — a wrapper over what
  the derives already do, once the format is chosen.
- **Decode goldens: one instance of every op variant of every kind** — a
  pinned fixture file of encoded commits that must *decode to expected
  values* in every future build. An exhaustiveness `match` in the fixture
  builder keeps a new variant from dodging the golden. This is the only
  thing standing between a well-meaning variant rename and a server
  database that no longer loads.
- Refusal tests: unknown update variant, future envelope version (a
  hand-written `V2` blob), truncation.
- Validation at the boundary: `GridRect`/`GridSize` deserialize through
  `TryFrom` that range-checks extents (pick bounds the document already
  implies; record them). Reject, never clamp.

**Prove it.** The suite above. Before committing, the donor's mutation
ritual: rename a variant via `#[serde(rename)]` and confirm a golden
fails.

---

## Step 10 — The reconciliation suite

**Status — landed 2026-08-16** (`reconcile.rs`, 8 tests: one proptest
over ≤80 randomized steps and seven scenarios). One `Host`, three
`ClientSession`s, per-client inbox/outbox queues; interleaving
randomized across queues, never within one. `ServerMsg` shapes are
modelled inside the suite rather than in a `protocol.rs`, so phase 3's
transport design is not pre-empted. Fixtures consolidated into
`fixtures.rs` (typed ids, `commit`, `projection`) — the six-copies
problem this step was told to fix; per-suite builders that encode a
*test's* intent stayed with their tests.

**The first kill found a hole, and it was the donor's hole.** Inverting
`Register::apply`'s comparison failed six scenarios but **the proptest
passed** — convergence and the prediction oracle are both structurally
blind to the merge rule, because everyone folds one canonical
linearization and the oracle rebuilds with the same fold: `f(x) ==
f(x)`, exactly the defect this step's *Why* describes in the donor. The
randomness having moved to the client's optimistic layer did not save
it. Fixed by adding **`assert_log_semantics`**: replay the host log with
dumb sequential last-wins bookkeeping — plain assignment in log order,
no `WriteOrder`, no `Register` — and require the folded document to
agree. That is an independent implementation of the merge rule, which is
what the property was missing.

**The four kills, after the fix:** (1) inverted LWW → 7 of 8 fail,
proptest included; (2) `apply` drops every write → 7 fail; (3) every op
minted `Seq::FIRST` → exactly the two seq-sensitive tests
(intra-commit ordering, and the proptest's `MoveTwice` edits); (4)
rebuild skips one pending commit → the oracle and the
suppression scenario. Each observed, then reverted.

**Two properties restated rather than ported.** *Idempotence*:
fold-level re-apply idempotence went away with step 5 (a re-folded
commit mints a different rev, so its writes would win rather than be
dropped), so it lives in the register (equal write order loses —
`register.rs`) and in the session's contiguity check, which refuses
re-delivery loudly. *Rejection safety*: provoked deliberately rather
than randomly — the random pool authors only always-valid edits (no
reparenting), which keeps the prediction oracle's pending fold
unconditional and therefore independent; the cycle race gets its own
scenario.

**Why.** The convergence suite was the most valuable artifact in
`src/log`, and its sharpest lesson ports even though its properties do
not: the original properties passed with the entire LWW rule deleted,
because replicas folded one canonical linearization — `f(x) == f(x)`.
Under the pivot, **one canonical linearization is the design**, so
server-side convergence is trivially true and testing it proves little.
The order-sensitivity — the thing that can actually be wrong — moved
into the **client's optimistic layer**: rebuilds against a moving
confirmed head, ack/broadcast interleavings, pending-queue discipline.
That is where the randomness now aims.

**Donor.** `convergence.rs` — the op pool and strategies, the
driver-loop shape, the scenarios with semantic assertions, pairwise
`sync`'s role as "the thing that shuffles". The causal-order machinery
(`causal_order`, whole-DAG sync, causally-closed subsets) dies.

**The work.** A simulated topology in the doc crate's terms: one `Host`,
N `ClientSession`s, and explicit message queues — submissions in flight
toward the host, one FIFO delivery queue per client. Randomize the
interleaving *across* queues (which client's submission the host takes
next; how many deliveries each client drains between its own edits) but
never within one (per-connection FIFO is the transport's guarantee and
the sessions assert contiguity). Properties:

- **Optimistic convergence**: random edit/submit/deliver interleavings ⇒
  after quiescence, every client's `optimistic == confirmed ==` host
  state, byte-identical.
- **Prediction reconciliation (the oracle)**: at every step of the
  drive, each client's `optimistic` equals a from-scratch fold of the
  host log it has confirmed plus its pending queue.
- **Idempotence**: re-applying an already-confirmed commit changes
  nothing (register keys, not protocol discipline).
- **Intra-commit ordering**: a commit writing one register twice
  resolves last-wins by seq, end to end through a session.
- **Undo round-trip**: `fold(fold(s, C), journal_inverse(C)) == s` on the
  user-visible projection, where `journal_inverse` is the entry
  `ClientSession::seal` captured — with the edit asserted **observable**,
  and covering `Create→Delete`, `Delete→Restore`, multi-op commits,
  and a commit writing one register twice (the journal's reversal
  discipline). (The donor's invert property only inverted a visible
  `SetProp`; that vacuity was a review finding — fix it in the port, not
  after.)
- **Rejection safety**: an invalid commit (cycle, unknown ref) is
  refused; host state unchanged; the submitting session rebuilds without
  it and re-converges.
- **Semantic oracles**: keep the donor's scenario style — at least one
  assertion per merge decision about *which value won* (two clients race
  one register: the later rev's value stands everywhere). A fold that
  dropped every write agrees with itself perfectly.

Test hygiene, fixed in the port: one `#[cfg(test)] mod fixtures`
(id/commit/session builders), not six copies.

**Prove it — the mutation ritual, non-negotiable.** After the suite is
green: (1) invert the LWW comparison in `Register::apply` — the
optimistic-convergence property must fail; (2) make `apply` drop every
write — the semantic scenarios must fail; (3) ignore the seq tiebreak —
the intra-commit tests must fail; (4) make `ClientSession`'s rebuild
skip one pending commit — the reconciliation oracle must fail. Record
the four kills under this step. A green suite you haven't watched fail
proves nothing.

---

## Step 11 — Comments move out; module docs move in

**Status — landed 2026-08-16.** `src/doc_ng/mod.rs` carries the ported
front door: the one-way layering, and the load-bearing rules restated for
this model — order from a **process, not a protocol**; the fold pure *and*
the only writer, producing a new document so a refusal cannot leave a
partial write; nothing derived in the log; unknown encodings refused, and
the decode boundary a production trust boundary; global invariants as
ingress preconditions rather than repairs. Two additions the donor had no
need of: predictions are a different *type* from the authority's copy, and
per-kind structure is generated rather than transcribed. The donor's
`#![allow(dead_code, unused_imports)]` did **not** port — `doc_ng`'s items
are `pub` in a `pub mod`, so nothing is dead.

The comment sweep cut roughly a third of what steps 8–10 introduced,
almost all of it decision-narration this playbook already carries: who
decided what and when, what a change superseded, why an approach was
declined. What stayed is what a reader would otherwise misread as a bug —
the skipped `Err` in `rebuild`, `invert_crud` returning `None`, the
reversal in `inverse_of` (with its corrected reason), `log[i] == Rev(i+1)`,
the refuse-don't-clamp rule. Ceremonial `# Errors` blocks that only
pointed at another method were removed with their doc comments, since an
undocumented item does not trip `missing_errors_doc`. Test doc comments
were held to stating the invariant proven, not its history. 84 tests pass
unchanged, which is the whole claim.

**Mostly done early (2026-08-11):** the design essays already live in
`docs/doc-ng-design-notes.md`. What remains:

- Port the donor's `src/log/mod.rs:1-30` module doc — the load-bearing
  rules — as the surviving module's front door, updated for the typed
  model and the server-centric ordering.
- Sweep the comments steps 1–10 introduced back down to the project bar;
  anything cut that isn't already in a playbook or the design notes gets
  added there first.

**Prove it.** `cargo xtask ci` (doc build included). No behavior change.

---

## Step 12 — Demolition: delete `src/log`

**Status — landed 2026-08-16.** `src/log/` deleted (9 files, 3201
lines) and `pub(crate) mod log;` removed from `src/lib.rs`. Nothing
outside the module referenced it — the only `log::` matches in the tree
were `rfd::FileDialog` — so the deletion needed no other code change, and
`cargo xtask ci` was green before and after.

Two decisions this file accumulated that belong to the migration
playbook, now absorbed there: **CBOR on the wire** (§7, superseding JSON
text frames) and the **still-open question of what the SQLite payload
column holds** — binary rows would end the sqlite3/ripgrep greppability
goal, and JSON there costs no second codec since both formats drive the
same derived impls. Phase 4 decides it.

Left as recorded: the parking lot's `PinDir::default()` question, still
unsettled and still not gating anything, and the route-owner/endpoint-scope
validation gap, whose natural home is the cascade-delete closure check.

**Why.** The exit criterion: the day the reconciliation suite is green,
the fork stops existing. Same commit, so there is never a moment with two
models and no pressure to finish.

**The work.**

- `git rm -r src/log/`; fix `src/lib.rs`.
- Sweep: `grep -rn "src/log\|log::" src/ docs/ todo.md` — update the
  migration playbook's phase-2 box in the same commit.
- The `src/doc_ng` → `crates/doc` move (and any rename) is **phase 3's
  first commit**, not this one — one import-churning move, not two.

**Prove it.** `cargo xtask ci` green. Tick phase 2 in
`docs/collab-migration-playbook.md`; record the transplant's actual
duration against the time-box, and any decision this file accumulated
that the migration playbook should absorb.

---

## Module layout (2026-08-11 restructure, amended by the pivot)

| Module | Contents | Pivot fate |
|---|---|---|
| `rev.rs`, `write_order.rs` | `Rev`; `RevKind` + `Confirmed`/`Provisional` (the document head's kind); `Seq`, `WriteOrder` (+ ordering tests) | step 1 done (2026-08-14); `Rev` in its own module; kinds added in step 8 (2026-08-16) |
| `lamport.rs`, `stamp.rs` | — | **deleted** in step 1 (2026-08-14) |
| `paths.rs` | — | **deleted** in step 2 (2026-08-14): no in-commit coalescing, no consumer |
| `opcode.rs` | `Crud<I, U>` (+ `invert_lifecycle`), `OpCodes` | step 3 done (2026-08-14) |
| ~~`operands.rs`~~ | — | deleted 2026-08-16: per-kind vocabularies generated in `block_model.rs`; the singleton's became `TitleBlockUpdate`, generated in `document.rs` |
| `commit.rs` | `Commit`, `CommitEnvelope`, `CommitBuilder` | step 2 done (2026-08-14); builder landed 2026-08-19 |
| `register.rs` | `Register<T>`, `Applied` (+ LWW/write-order tests) | re-keyed in step 1 |
| `document.rs` | `Document<R: RevKind>` (private `Arc`'d interior, typed head, `try_apply` fold, `predict`, `content_hash`), `TitleBlock` (the singleton's generated registers), `FoldError`, `DocumentCache<'_, R>`, index types | fold homed here in step 5 (2026-08-15); `TitleBlock` and the rev-kind parameter 2026-08-16 |
| `block_model.rs` | `Liveness`, `Live<T>`, `Icon`, the `entity!` invocations (structs + `*Init` + `*Update` + impls) | generated since 2026-08-16 |
| `entity.rs` | the `Entity` trait + the `entity!` macro | step 4 (2026-08-14); impls generated, not transcribed, since 2026-08-16 |
| `geometry.rs` | grid/screen geometry, `FracVal` (+ canonicality proptests) | unchanged; `TryFrom` bounds in step 9 |
| `values.rs` | `LabelSide`, `Role`, `PinDir` (+ zero/serde-name tests) | unchanged; `PinDir` zero decision at step 4 |
| `id.rs`, `hash.rs` | `Id<K>`; `AssetHash`, `DocHash` | `ActorId` deleted (step 1); `DocHash` + `io::Write` hasher added in step 5 (`CommitHash` died with `commit.rs` in step 2) |
| ~~`validate.rs`~~ | — | never created: validation is a commit-end pass inside `try_apply` (step 5, 2026-08-15) |
| `session.rs` | `Host` (document + log), `ClientSession` (confirmed/pending/optimistic), `Nonce`, the undo journal | new — step 8; no rev fields, the documents carry theirs, typed by kind |
| `encode.rs`, `goldens/` | CBOR `to_bytes`/`from_bytes` over the envelope, decode goldens, refusal, boundary bounds | step 9 done (2026-08-16) |
| `reconcile.rs`, `fixtures.rs` | the reconciliation suite (proptest + scenarios); shared test builders | step 10 done (2026-08-16) |

## Parking lot

Questions that come up mid-step get written here instead of derailing the
step.

- ~~Where does `old` come from — authored or derived?~~ **Resolved
  2026-08-13 (user decision)**: `old` never crosses the wire. Update ops
  carry only the new value; the undo baseline is captured into the
  client's journal at seal time (step 3, landed in step 8). An earlier
  same-day resolution kept `Swap` on the wire and was overridden within
  hours — recorded so the reversal is visible.
- **`PinDir::default()` is `Input`**, but today's editor creates pins as
  `InOut`. Narrowed 2026-08-14 (step 4): total init structs mean a created
  pin never shows the default, so this stopped gating the entity trait —
  the zero is pre-creation-only. Still worth settling (if `InOut`, move
  the `#[default]` and update `defaults_are_the_meaningful_zeros`) before
  anything reads a pre-creation value.
- ~~**`Nonce` type and home**~~ **Resolved 2026-08-16 (step 8, completed
  in phase 3)**: `Nonce(u64)`, a monotone counter minted by the session
  that owns the connection. It moved to `protocol.rs` when phase 3
  created it, as planned. The server never persists it.
- **Route-owner/endpoint-scope validation** (surfaced 2026-08-15 by the
  `BlockCache` review): `block_cache(A)` returns routes *owned by* A on
  the invariant that a route's owner is the scope containing its
  endpoints' owners — but nothing validates it. A commit could create a
  route owned by A whose endpoint pins live in an unrelated scope; it
  would render in A connecting pins that aren't there. Natural home: the
  cascade-delete closure check / ingress validation, when that lands.
