# doc_ng design notes

The design rationale for `src/doc_ng/` (the document model that survives the
collapse — see `docs/collab-migration-playbook.md`). This content originally
lived as doc-comment essays inside the source files; it was moved here on
2026-08-11 so the code stays lean. The code states *what*; this file holds
*why*. When a port step (see `docs/doc-ng-port-playbook.md`) makes a decision,
record it in the playbook; when it refines the model's shape, update here.
Nomenclature (2026-08-12, revised 2026-08-14): `Commit` (envelope
`CommitEnvelope::CommitV1` — the brief 2026-08-13 `ChangeSet` rename was
reversed before any code carried it), `OpCodes`, `Crud`, `*Update`
vocabularies, `Seq`, `Rev` — the full donor-to-adopted table is in the
port playbook.

**Pivot (2026-08-13):** the project is server-centric — the "Two orderings"
section below is *resolved*, server-sequenced. The total order component in
`WriteOrder` is the server-assigned `Rev`, not a causal `Stamp`; lamport
clocks, actor ids, content addressing and the DAG are deleted. Sections
below that speak of "stamps" describe the surviving mechanism correctly if
you read "write order (rev, seq)" for "stamp"; the load-bearing arguments
(zero values, atomicity, delete-wins, batching, derived draw order) are
order-source-independent and stand unchanged.

---

## The relation taxonomy

Every relationship in the document is one of four kinds, and the kind decides
its representation:

1. **Leaf values** — `Register<T>`: value + stamp, LWW. Atomicity is decided
   by the **Frankenstein test**: if concurrent edits to different sub-fields
   were both kept, is the result coherent? No → one register over the
   composite (`GridRect`, `Vec<Waypoint>` — one author's position plus
   another's size is a rect neither authored; a polyline with its locks is
   one author's coherent intent). Yes → split into a namespace.
2. **Namespaces** — a fixed composite inlined into its owner (`Label`): a
   plain struct whose fields are registers (or nested namespaces). Not
   entities: no id, no lifecycle. Addressed by the write enum's nesting
   (`BlockWrite::Title(LabelWrite::Name(..))`). A `Label` is four
   *independent* registers, so a concurrent rename and side-flip are both
   kept, coherently.
3. **Set membership** — variable-cardinality containment, authored
   **child-side** (an `owner`/`parent` register on the entity); parent-side
   sets are derived (`DocumentCache`).
4. **Value-keyed register families** — an available pattern with no current
   instances (route labels graduated to entities because entries are
   expected to grow properties).

Register placement **is** the atomicity decision: `Register<GridRect>`
declares an atomic composite; `title: Label` declares a namespace. The write
enums must mirror this structure exactly — one variant per register, nesting
per namespace.

Cardinality is enforced by **structure, not repair**: "at most one" is a
field on the owner (`Block::icon`); "zero or more" is entities with owners.

## The zero-value principle, and absence

Every type's default is meaningful: `Role::Accent0` is "plain", the null
`AssetHash` + empty rect is "no icon", `WriteOrder::BOTTOM` sorts below
every accepted write, `Liveness::Deleted` is the pre-creation state. The
model is `Option`-free:

- **Entity absence is map-absence.** Structural — unforgeable ids mean
  nothing can race a nonexistent entity.
- **Value-slot absence is the zero value.** `Block::icon` is an atomic
  `Register<Icon>` whose zero (null hash, empty rect) renders as nothing and
  means "no image". Clearing is a stamped write of the zero — so "when was
  it cleared" is the register's stamp, and clear-vs-edit is plain LWW. The
  atomicity is *required* by zero-as-absence: a namespace here could
  resurrect half an icon.

Because every register exists from time 0 holding its meaningful zero at
`WriteOrder::BOTTOM`, first-write needs no special case anywhere in the fold —
and the phase-1 lesson that forced optionality out of values (an
`Accent(None)` that read identically but encoded differently) is closed
outright: one state, one representation.

`Register<T>`'s `apply` is the **entire merge rule and the only write path**
— no code can update a value while forgetting its stamp, and the LWW
comparison lives in exactly one place. A losing write is reported
(`Applied::LostToNewer`); nothing consumes losses today — a future review
surface is the intended consumer, and returning it keeps the loss path
testable.

## Write orders are inline, not sidecar

Write orders are first-class merge state: a snapshot must contain them
(replaying a tail resolves LWW against snapshot-point orders) and they must
converge (snapshot == replay asserts them). So they live inside
`Register<T>`. A sidecar map keyed by (id, target-path) would be a parallel
structure whose keys must mirror every struct field — the two-homes pattern
in infrastructure form. "Document state has no notion of time" — the
pre-pivot spec's rule, still good — is a property of the user-facing
**projection** (export, review rendering), which strips orders
mechanically — not of the storage layout.

## Liveness: delete-wins is structural

`Live<T>` is stamped presence plus retained inner — **entities only** (value
slots use zero-as-absence instead). `presence` is an ordinary register, so
delete-vs-restore is plain LWW on it. Delete-wins-over-edits needs no
policy: **edit commands cannot target `presence`**, so no edit at any stamp
resurrects a tombstone. Losing edits apply to the retained inner and
surface on `Restore`.

The document itself is the **implicit root** of the containment tree: a
singleton whose own registers live in the `TitleBlock` (a mechanical
drawing's documentation block — `Name` today, more fields later; a
generated entity with `id ()`, renamed from the ad-hoc `DocumentUpdate`
arrangement 2026-08-16), not an element — it cannot be deleted,
moved, or raced. Blocks whose `parent` is `Id::NULL` live at document level;
ingress validation treats NULL as the tree root when checking acyclicity,
and an unknown non-null id is refused at ingress like any dangling
reference. Single-canvas presentation, if wanted, is a UI convention, not
a merge invariant.

## No dangling routes

A route exists only between live endpoints. Two mechanisms, by cause:

- **Sequential (bundled cascade):** deleting a block records concrete
  `Delete` commands *in the same change* for its pins, every route
  terminating on those pins (including routes owned by other blocks —
  deletion is inherently boundary-crossing), those routes' labels, and the
  owned subtree recursively. The change is the undo unit. A commit-time
  closure check (debug) asserts no command in the batch leaves a reference
  dangling within the batch's own view.
- **Concurrent (repair by suppression):** merges can compose a live route
  with a tombstoned endpoint. The derived view computes
  `effective_liveness = authored_presence && endpoints_live`
  (`DocumentCache::suppressed`), flags it, and **never authors state** (no
  synthesized stamps). Restoring the endpoint automatically revives
  suppressed routes. Suppressed routes render as deleted.

## Merge-coherence of batched restructures

All commands in a change share its stamp, so competing bulk operations
(e.g. two users each wrapping the top-level content in a new scope) resolve
**uniformly** — one side wins every contested register; no torn trees.
Divergent wrapper scopes do not coalesce (independent creations are
independent intents); the loser's empty scope is a post-merge lint entry
alongside spatial overlap. Batching is therefore load-bearing, not a
convenience.

## The derived view: `DocumentCache`, built per frame (revised 2026-08-15)

The incrementally-maintained cache is gone; the derived view is
`Document::cache() -> DocumentCache<'_>` — the whole document's reverse
indexes (top-level set, per-block members, per-route labels, suppressed
routes), built on demand in one linear pass and **borrowing the
document**. The client builds it once per frame and queries any scope;
the server never builds one. A block-scoped variant was tried the same
day and superseded within hours: rendering a scope needs the *child*
blocks' pins too, so per-scope O(document) calls multiply per frame,
while one whole-document pass costs the same as a single scoped call and
serves every scope. What the shape buys: no incremental maintenance to
prove against a rebuild oracle (that property died with the machinery),
no derived state riding the fold, the clone path, or the server, and
**structural coherence** — documents are immutable values, so a cache
can never drift from the document it borrows; the only hazard is
consulting a cache built from a *different* document, the same mistake
class as reading the wrong document directly. Never authoritative, never
targeted by commands, live entities only (a live child of a tombstoned
owner is indexed nowhere); suppressed routes stay indexed and are
flagged. If the per-frame O(document) build ever shows up in a profile,
reuse-per-installed-document at the owner is the escape hatch — keyed by
installed value, never by rev (predicted documents mint provisional revs
that recur across reconciliation rounds).

## The command layer

**Typing runs along the entity-kind axis, not the value-type axis.**
`OpCode<W, I>` is generic over a kind's write vocabulary and init shape; the
closed `Command` enum monomorphizes it per kind. This is the closed-world
polymorphic container: serializable with stable tags, exhaustively
matchable, deterministic bytes. (`Command<bool>` would erase the wrong axis
— the value type doesn't identify a register, the path does, and path/value
mismatches must be unrepresentable.)

**The register catalog (`paths.rs`) is gone — deleted 2026-08-14.** It
existed to key a coalescing commit builder's map, to name registers in
collision reports, and to open typed register access later. The first
died with the no-coalescing decision (redundant writes are legal; `Seq`
resolves them last-wins), the second with the pivot's `Collision`
machinery, and the third was speculative. Keeping 44 `path()` impls and a
bijection test 1:1 with the vocabularies was pure maintenance with no
consumer — exactly the parallel-code drift hazard this project deletes on
sight.

**Update variants carry only the new value (2026-08-13).** `old` never
crosses the wire: replay never reads it (the fold applies `new`; LWW
ignores the rest), so carrying it in every message would be dead weight
that implies the server consumes it. The undo baseline is captured
client-side at seal time into the session journal (see the undo section
below); review display ("changed a → b"), if a review surface is ever
built, derives from the log. `Swap { old, new }` and the `Invert` trait
leave the vocabularies with this decision (port step 3).

**Init shapes are total**: a create carries every leaf value, stamped by the
change envelope. Structurally, the init structs are the stamp-stripped
projections of the entity structs — if projection types exist for export,
consider unifying.

**The four shapes are generated, not transcribed (2026-08-16).** The
entity struct, its total `Init`, its `Update` vocabulary, and the
`Entity` impl all expand from one field list — the `entity!` macro
(`entity.rs`), invoked once per kind in `block_model.rs` with three
field classes: `registers`, `namespaces`, `constants`. The mirror guard
upgrades from compiler-checked transcription to generation: a register
cannot be dropped from `from_init` or `max_order`, and `apply` cannot
mistarget, because there is nothing to transcribe — the step-4 dropped-
`icon` bug and the step-7 fold-drift hazard are both unrepresentable.
Wire tags stay explicit in the invocations, never derived from field
names (`FlipLR` is not `FlipLr`), preserving the longevity rule. The
value-walk tests survive as proof of the one template rather than eight
transcriptions. Field order changed (registers, then namespaces, then
constants), which reorders serialized bytes — free while nothing is
persisted, and settled before step 9 pins decode goldens.

**The create/delete/restore asymmetry is deliberate**: `invert(Create)` is
`Delete` (un-creating is tombstoning), `invert(Delete)` is `Restore`
(resurrecting is restoring) — identity survives the round trip. `Restore`
of a never-created id resolves nowhere and is refused at server ingress
like any unknown reference; the fold treats it as a hard error.

**Compound-operation rule (critical):** high-level operations — align,
auto-route, duplicate, cascade delete — are recorded as the primitive
commands they produced: concrete values, pre-minted UUIDs, pre-remapped
references, wrapped in the change's semantic label. Replay is a dumb fold;
nothing is re-executed.

**Serialization longevity** (all vocabulary enums): serde variant names are
the wire tags — never rename or repurpose a variant (deprecate and add
instead); decode goldens per variant (old payloads decode forever — see
the spec's serialization section and port step 9); unknown tag at decode
⇒ refuse to load, never skip.

## The commit envelope

**Rewritten by the pivot (2026-08-13).** The envelope is a payload the
client seals — `{ label, ops }` inside a versioned enum — and everything
that *orders or dates* it lives outside: the server assigns `Rev` at
acceptance and stamps `wall_time` (one clock for the whole document); both
travel beside the payload on the wire and as columns in storage, never as
fields of the thing they order. Content addressing is gone with the causal
design: a linear log's identity is its `Rev`, there are no parents to
commit to, and bytes are no longer identities — the longevity obligation
shifts from byte-canonicality to *decode-forever* (every payload a server
database ever stored must decode in every future build; see the port
playbook, step 9).

**A commit is a batch with a total write order** (settled 2026-08-12,
re-based on `Rev` 2026-08-13). All ops share the commit's rev; an op's
index is its `Seq`; the register comparison key is `WriteOrder = (rev,
seq)`. The order over writes is total with no reliance on commit
atomicity or delivery discipline — the register is a self-contained
join-semilattice — and a commit that writes one register twice resolves
last-wins deterministically. Net-diff coalescing (merge successive updates
to one register; drop non-edits — an update equal to what the document
already holds, checked at the push site since ops carry no `old`) survives
as *builder hygiene*, not correctness. Rev totality across commits rests on a process fact, not a
protocol invariant: one server, one single-writer task, one counter that
never reuses a value.

## The rev lives in the document; the content hash (2026-08-15)

The head `Rev` is a field of `Document`, minted only inside a successful
`try_apply` (`Rev`'s constructor is test-only; production revs exist via
`next` in the fold or `Deserialize` off the wire). This makes two
properties structural rather than disciplined: a refused commit consumes
no rev (the log cannot gap — the server's standalone counter is deleted,
the head document *is* the counter), and a snapshot is self-describing
(serializing the document cannot lose or mislabel its log position — the
same "inline, not sidecar" argument as write orders). It does not violate
"rev is never a field of the thing it orders": the commit payload stays
unstamped; the document's rev is the head position, the standard
event-sourced version field. The predicted document falls out for free —
refolding pending commits on the confirmed head mints provisional revs
above every confirmed write, self-correcting on each reconciliation.

Consequences: `try_apply` takes no rev — the wire rev is demoted from
input to *integrity assertion* (the sync layer requires `wire_rev ==
confirmed.rev().next()` before folding; mismatch means a missed or
reordered commit and the response is resync, not fold). Rev assignment is
now derived state — a deterministic function of (snapshot, log tail) — so
replaying an exact tail reproduces exact revs, and any true history
rewrite would shift every subsequent rev. Forward-commit undo ("git
revert") is thereby *required*, not merely preferred.

**The head's *kind* is part of the type (2026-08-16).** `Document<R:
RevKind>`: a `Document<Confirmed>` names a real log position and can
report it; a `Document<Provisional>` — the client's `confirmed ⊕
pending` prediction — carries a rev that every rebuild re-mints, so it
has no accessor and no `Deserialize`, and it cannot be passed where the
authority's copy is expected. Only the *head* is typed: `WriteOrder` and
`Register` stay monomorphic, because a provisional write order and a
confirmed one must remain mutually comparable — that comparison is the
suppression rule, the thing that makes an unacknowledged local value
outrank an incoming confirmed one. `content_hash` likewise covers the
plain rev rather than its kind, so a drained prediction and the document
it drained to hash alike; whether that comparison is *meaningful* is the
runtime condition `pending.is_empty()`, which no type expresses.
`Document<Confirmed>::predict()` is the single conversion, run once per
rebuild — the barrier is crossed constantly by design, and what the
types prevent is crossing it by accident.

`Document::content_hash` (blake3 over an id-sorted canonical CBOR
serialization, covering rev, values, and write orders) is the divergence
check the pivot's no-floats determinism rule was preserving: `rev` is the
cheap compare and names a version; the hash proves a version's contents
match the server's fold bit-for-bit. Derived on demand, never stored,
never authoritative — a mismatch at equal revs means a divergent replica
and triggers resync.

## Undo, `old`, and concurrency

The `old` value — what an update displaced in the author's *optimistic*
document when the op was sealed — is redundant with the log (folding the
commit's prefix recomputes it deterministically) and never load-bearing
for replay: the fold applies the new value; LWW ignores the rest. It
therefore **does not cross the wire** (decided 2026-08-13): it is captured
where it is used — the client's session journal, at seal time. Its other
historical uses (review display "changed 2 → 4", staleness detection of
concurrent overwrites) derive from the log, if a review surface is ever
built.

Undo-by-inversion is `git revert`, not history removal: the inverse is an
ordinary new commit at the head, and under concurrency it can restore a
value that a concurrent winning write had displaced — exactly the case the
staleness check flags. The undo rule (each actor undoes their own latest
commit) narrows but does not eliminate this. "Undo as if the commit never
happened" (selective undo) is a different, costlier feature: for LWW it
means refolding without the commit — well defined and replica-independent
*because the linearization is canonical*, and derivable from the log
rather than from any carried field. A per-replica "old captured at
execution" is **not** usable for shared semantics: what a write displaces
at apply time depends on arrival order, which is precisely what the design
promises never to depend on.

**The derived-undo mechanism, sketched (2026-08-12).** Only `Update` ops
need a baseline: `Create` inverts to `Delete` and `Delete` to `Restore`
baseline-free, because the tombstone already retains the inner values —
that is what it is for. The baseline source splits by layer: (a) a local
**session journal** — when this replica authors a commit, it records the
displaced values (its own causal olds) on an undo stack; exact for undoing
your own latest commits, which is the whole single-user feature; (b) the
**log-derived runner-up** for reverting an arbitrary commit C: per
register, the max over writes excluding C's — deterministic on every
replica because the linearization is canonical. The baseline is *never*
"the value before C arrived here": arrival order is per-replica, and it
gives wrong answers — if Alice's `3` arrives first and Bob then authors
`2` having seen it, arrival-order undo of Alice restores `1` and destroys
Bob's newer `2`, while the canonical runner-up correctly leaves `2`
standing. "Local" enters an undo only through the undoer's *frontier*
(which commits they hold — the undo commit's parents), never through the
order they received them: same frontier ⇒ same undo commit, authored
anywhere. Either way the undo is an ordinary forward commit carrying
concrete values, stamped by the undoer's clock — the fold stays dumb.

**Prior art — Figma's live-collaborative undo** (figma.com/blog/
how-figmas-multiplayer-technology-works). Since the 2026-08-13 pivot this
is the *design*, not just prior art: Figma's total order is server-arrival
(a central server sequences every property write; clients apply their own
edits optimistically and suppress conflicting incoming values while
unacknowledged) — exactly our `Rev` order and `ClientSession`
reconciliation. Its undo is per-user
(your shortcut only undoes your own edits) and journal-based, with one
documented invariant: *"if you undo a lot, copy something, and redo back
to the present, the document should not change"* — achieved by **rewriting
the redo entry with current values at undo time, and the undo entry at
redo time**. That is capture-at-use-time applied in the one place it is
safe: the local session journal, never the wire. An undo may still stomp a
collaborator's newer concurrent value (it is just a new write that wins
LWW); what Figma refuses is undo/redo *round trips* corrupting state. The
round-trip invariant is a property our suite should adopt when undo lands.

~~Open (2026-08-12): whether `old` stays on the wire at all~~ — **resolved
2026-08-13 (user decision): it does not.** Ops carry only the new value;
`Swap` leaves the vocabularies; the session-journal design sketched above
is adopted (baselines captured at seal, redo entries rewritten with
current values at use time), and the invert round-trip property restates
as `fold(fold(s, C), journal_inverse(C)) == s`. (An earlier same-day
resolution kept `Swap` on the wire and was overridden.)

## Two orderings: causal stamps vs server arrival (2026-08-12)

The collaboration end-state has two families, distinguished by **who
defines the total order**, not by sync cadence:

- **Causal / local-first (git-shaped):** the order is carried in the data
  — `WriteOrder(stamp, seq)`, parents, content-addressed commits. Every
  replica computes the same order independently; a server, if present, is
  a dumb relay + backup, never an arbiter. Works offline and pairwise;
  costs the DAG/clock machinery (our deferred Layer B).
- **Server-sequenced / hosted (Figma-shaped):** the order is arrival at a
  central server ("**last** to the server wins" — not first; LWW keeps the
  latest write in server order). No stamps, no DAG, no parents — a
  sequence number suffices; content addressing is a property of the git
  family, not this one (Figma keeps current values + snapshot history).
  Clients are predictions of server state, continuously catching up.
  Cost: a server is *required* for collaboration, offline is weak
  (Figma's reconnect reapplies stale edits on top, making them newest).

They are not symmetric: a causal engine run through a relay in continuous
mode delivers the hosted UX (each client computes the order the server
would otherwise dictate); a server-arrival engine cannot be made
local-first without rebuilding its ordering layer. Causal is the semantic
superset; hosted is the build-cost minimum. They also hybridize — Onshape
serializes live co-editing on the server *and* keeps a microversion DAG
with explicit branch/merge, which is the shape of the pre-pivot spec's
check-out/check-in phase (git history).

Everything in Layer A is **invariant across the choice**: registers, the
opcode/operand vocabulary, the fold, the undo journal, draw order. Only
`stamp.rs`/`write_order.rs`'s stamp component and the commit's
`parents`/`CommitHash` are causal-family-specific.

**Resolved 2026-08-13: server-sequenced.** The product pivoted to a
server-centric design, which *is* this branch — chosen with eyes open to
its costs (a server is required for editing; offline is out; a later
"local mode" embeds the server in-process rather than reviving the causal
family). The invariance claim above is what made the pivot cheap: the
deletions are exactly the causal-family-specific pieces (`stamp.rs`,
`lamport.rs`, `ActorId`, `CommitHash`, parents, the DAG), and the
replacement is one server-assigned `Rev` in `WriteOrder`. The
build-vs-adopt spike is closed unexercised — no CRDT engine is needed
when a server defines the order. This section is kept as the record of
the road not taken and what re-opening it would cost (rebuilding the
ordering layer; a server-arrival engine cannot be made local-first).

## Draw order is a derived policy, not document state

Z-order stopped being a document property when children became unordered
sets (decided 2026-08-11). Blocks are non-overlapping by invariant, so
their paint order is immaterial. Kinds that can overlap within their layer
(routes above all; also comments, images, texts) draw in **chronological
order of last modification**: sort by `max_order(entity)` — the max write
order over presence and every register. **Ties cannot occur** (revised
2026-08-15): `WriteOrder` is total over ops and every op targets exactly
one entity, so no two entities share a max order — the earlier
longest-route-first tie-break is deleted, and `id` remains as the final
sort key only as a stable belt-and-suspenders. A pure sort over folded
state: every client renders identically with no ordinal anywhere in the
log, and under the pivot chronology *is* server order (pending local
edits carry provisional orders above the confirmed head, so
recently-touched-on-top holds mid-flight too).

Why it holds together: net-diff coalescing drops `old == new` writes, so
settle-pass waypoint re-promotions that change nothing reshuffle nothing —
only genuine edits surface a wire; and a pasted group reproduces its
source's internal stacking because a paste is one commit and `Seq`
preserves the builder's op order. Accepted quirk: undo appends inverse
writes with fresh stamps, so undoing an edit also brings the element to
the top.

One order, every consumer (revised 2026-08-15 — the earlier
two-sorts rule is reversed, user decision): the draw/hit comparator
(shared by draw lists and `widget/hit_target.rs`, so topmost-drawn is
topmost-clicked) and the **routing solver** both consume the
chronological order — routes are placed first-come-first-served, so
placement priority is age. Accepted consequence, same family as the undo
quirk: a genuine edit to a route re-places it at the back of the
placement queue.

## Geometry and shared values

No floats anywhere in authored state: coordinates are integers or
`FracVal` fixed-point (2⁻²⁴ units in an `i64`), so the server's fold and
every client's fold agree bit-exactly — cross-host determinism replaced
hashing as this rule's reason, and it is just as binding. `FracVal`
collapses `-0.0` and
`0.0`, refuses NaN at the boundary (debug assert), and round-trips any f32
of magnitude ≥ 0.5 bit-exactly. `Asset` payloads are `Arc<[u8]>` because
they cross the sync thread boundary.

Shared value enums (`Role`, `LabelSide`, `PinDir`) are closed vocabularies
whose defaults are the meaningful zeros the model rests on — `Role::Accent0`
is "plain", replacing today's `Option<u8>` accent (no `Some(0)`-vs-`None`
distinction to encode or race).
