From 0b658fab1262c6fdbf16cdd32d249380df909bf6 Mon Sep 17 00:00:00 2001 From: Lucas Vieira Date: Sun, 9 Aug 2026 08:36:52 -0300 Subject: [PATCH 1/4] fix(core): one rank owns a sequence id, so two ops at one stamp do not resolve by arrival order (C40) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `List::insert_at` was idempotent on the id, so two `ListInsert` ops under one `ClientId` at the identical `Stamp` left whichever landed first holding the slot with its value and its anchor, and the two orders encoded different snapshots. Every seat path now ranks a claim by `(kind tag, position, encoded value)` — the plain insert, a `Text` run's codepoints, a children-list birth, the document's eviction and join, and `merge`. The tag leads as the number it is, so mixed scalar/composite falls out of the same comparator; the position outranks the value because it is the only key a delete leaves behind, which makes a contest a delete lands between converge too. Two composites are still ranked at the `(list, stamp)` placement key (C24) before the sequence is touched. --- ARCHITECTURE.md | 2 + DECISIONS.md | 14 + KANBAN.md | 4 +- crates/core/src/list.rs | 212 ++++++++-- crates/core/tests/list_stamp_collision.rs | 484 ++++++++++++++++++++++ 5 files changed, 669 insertions(+), 47 deletions(-) create mode 100644 crates/core/tests/list_stamp_collision.rs diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 2d24f324..270bd027 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -325,6 +325,8 @@ The fold is a pure function of the move-set + tombstone-set, **independent of th **Fugue** (Weidner & Kleppmann 2023, "The Art of the Fugue"). Tree-based, formally proven no-interleaving on concurrent inserts at the same point. Same algorithm reused for Text. +**A sequence id is owned by the kind tag, then the position, then the encoded value.** Two ops can carry one `Stamp` into one sequence and both be admissible — op dedup is by `OpId`, and the id-space record bounds only an *honest* mint — so which of them the id ends at is decided by that rank, never by which arrived first, and the winner takes the id with its value **and** its anchor. Every seat path runs the one rank: a plain `ListInsert`, a `Text` run's codepoints, a children-list birth whose placement key was free, an eviction or a join the document ranked, and a sequence merge. A claim owns the key it named and not the id, so a path that skipped the rank would keep arrival-order ownership in that path alone — which is how this class hides. The **tag leads** because it is the one key a scalar and a composite both have, and it is read as the number it is rather than as a preference for either: a numeric order stays total when a kind is added, where a semantic one re-opens the question on every new op. The **position outranks the value** because it is the only part of a claim a delete leaves behind — a tombstone drops the value and keeps the anchor, so a claim landing on a tombstoned id is ranked on the position alone, and that settles the whole difference, since the position is also all a tombstone encodes. Two *composites* are ranked one layer up instead: the document's `(list, stamp)` rank (§Tree Moves) decides them before the sequence is touched, which is why the tag decides across the scalar/composite boundary and never inside it. That layering is also its one open edge — a tombstone holds no kind to read, so a scalar and a composite claim that a delete lands between are still ordered by arrival (C133). + ## LWW Used by Register values, Map scalar set, XmlElement attr values, mark values of `kind: value`. Resolution: higher lamport wins, tiebreak by client_id. diff --git a/DECISIONS.md b/DECISIONS.md index 934f7937..9de22b0c 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -34,6 +34,20 @@ The entries below (2026-07-02) are a backfill: design changes made during the v0 **The one element the pass found chosen rather than forced, named rather than smuggled:** the JS gate's *fold-outcome* check (`lastApplied`). The carrier is correct without it — the frontier carries tag 54 and never matches the catch-up allowlist — and the bug it closes (a catch-up reply that names the right tag and channel and then fails to decode, opening the socket against an empty replica) pre-dates this unit and is reachable without any frontier. It ships here because the gate it belongs to is being rewritten in this diff and leaving a known hole in a predicate one is authoring is worse for the next reader than a three-line fix with a test; the boundary is recorded so that judgement is visible rather than absorbed. Nothing else survived the question. **Mutation-swept under statement deletion, re-run against the shipped code — and the re-run is the reason four more tests exist.** Seventeen branches, each deleted in turn: the frame never sent → 9 named tests; sent on every catch-up → 6; naming every author rather than the recipient → 6; the connection id in place of `for_channel` → the second-channel test here and in C9's suite; the frame trailing the delta → 1; a client-sent frame accepted → 1; the server always sending `reach: 0` → 1; the client ignoring the frame → 6; the reservations folded into `seen` → 3; `free_seq` not consulting them → 16; its walk bound excluding them → 12; `note_published` ignoring `reach` → 3; the decoder's reserved-over-buffered check → 1. **Four survived the first pass and none of them was redundant code** — every one was a hole in the suite, and the encoding is why: a reservation is written as a bare *sequence*, correct only while every entry belongs to the document's own client. `apply`'s clear (an id in both sets is a snapshot the replica cannot read back), `adopt_as`'s clear (inert immediately after, real on the next round trip, where the sequences are re-read under the adopter's identity — the existing test looked only immediately after and was vacuous), the decoder's reserved-over-applied check, and both projections' clear. The last of those exposed a second vacuous test: reading a projected snapshot back through `decode_state_as` proves nothing about the projection, because `adopt_as` clears reservations as it takes the snapshot over and masks the rule entirely — so the assertion moved onto the projected *bytes*. With those four pinned the sweep leaves no survivor, and the unmutated tree fails 0. → *Server / Fan-out*. +## 2026-08-09 · C40 sequence stamp collision · one rank owns a sequence id, and the position outranks the value + +**Changed:** ARCHITECTURE §List gains the rank a sequence id is owned by — the kind tag, then the position, then the encoded value — the list of seat paths that run it, and the one edge it leaves open (C133). No wire or snapshot format change: the rank is computed from what a node already carries. + +**The gap, measured.** `List::insert_at` was idempotent on the id. Two `ListInsert` ops under one `ClientId` at the identical `Stamp` are both admissible — dedup is on `OpId`, and the id-space record bounds only an *honest* mint — so the first to land kept the id with its value and its Fugue anchor, and the two delivery orders encoded different snapshots (the node carried `2` on one and `99` on the other). C24 (#371) closed the same collision one layer up, at the `(list, stamp)` placement key; it does not reach here, because a scalar has no element id to rank by and a plain `ListInsert` never passes through the placement index at all. + +**The tag leads, and it is read as a number.** A scalar and a composite have exactly one key in common, and ordering them by it is what makes the mixed case fall out of the same comparator instead of being a rule of its own. The alternative — a semantic "a composite outranks a scalar" — is rejected: it is a preference, so it has to be re-decided every time the op set grows a kind, where the numeric order is total on its own and extends mechanically. Two *composites* are not ranked here at all: the document ranks them at the placement key first (a birth over a move, then the smaller element id) and hands the sequence its verdict, which is why the tag decides across the scalar/composite boundary and never inside it — inside it, tagged and tagless children carry *different* tags, and letting the tag speak there would have silently reversed C24 for the pair it was built for. + +**The position outranks the value, and that ordering is forced by a measurement rather than chosen.** A delete drops a node's value and keeps its anchor — §Tombstone GC, a tombstone holds a position and nothing else. So the anchor is the only key of the rank that survives a delete, and it is also the only thing a tombstone encodes. Read the value first and a contest a delete lands in the middle of is decided by *which claim the delete buried*: `[insert 2, delete, insert 99]` freezes one anchor and `[insert 99, delete, insert 2]` the other, and the two encode different bytes while rendering the same sequence. Read the position first and the winner is the claim with the smaller anchor whether or not its value is still there to read, so all four orders of `[insert, insert, delete]` agree. The value stays in the rank below it, so two claims at one position still separate — and where a claim ties outright, nothing is contested and the id is left alone, which is what keeps a replay inert. + +**The fix is that every seat path runs it, which is the half the filing said would hide.** A claim owns the key it named, not the id. `Claim::Fresh` therefore found `insert_at` inert against a scalar already sitting at the id, while an eviction or a join re-seated over it — so `[ListInsert, tagged, tagless]` replaced the scalar and `[ListInsert, tagless, tagged]` kept it, one op set folding to two trees. Stating the rule in terms of the *key* means five paths take it: the plain `ListInsert`, every codepoint of a `Text` run, a children-list birth whose key was free, the document-ranked eviction and join (which now yield to a live scalar on the id — the half no placement key ranks a composite against), and `List::merge`, which was the fourth route and had been answering by which side received. A path that skipped it would have kept arrival-order ownership in that path alone, which is exactly how this class hides. + +**What is left open, and why it is a separate ruling.** A tombstone holds no kind to read, so a *mixed* pair that a delete lands between is ranked on the anchor alone and lands somewhere the tag would not have put it: the two delete-between orders agree with each other, the two delete-last orders agree with each other, and the classes differ. Measured, and filed as C133. This unit narrows it — on `main` the same three ops split on *which claim arrived first* — but does not close it, because closing it needs the winning rank to outlive the delete, and a sequence has nowhere to put one: a tombstone is a run record covering any number of ids, so a rank per dead id is O(deletions) in memory and on the wire and defeats the compression §Tombstone GC exists for. The composite half is total across a delete only because the *document* persists that rank, which is a record the scalar half does not have and cannot be given without deciding what a sequence may remember about a dead id. + ## 2026-08-09 · C53 branch catch-up floor · a shared base is only as durable as `main`'s retained log, and a catch-up that cannot serve it refuses rather than serving what is left diff --git a/KANBAN.md b/KANBAN.md index ba254e7c..b7c89ac0 100644 --- a/KANBAN.md +++ b/KANBAN.md @@ -33,6 +33,8 @@ _Derived from code + git; a convenience view, not the source of truth._ **C14 — a redacted op delta left the same re-mint hole, and no frame could carry the ids it withheld (crates/server + crates/core) — DONE (#398).** C9 (#357) closed the *snapshot* seam by keeping the recipient's own ids in a projected snapshot's frontier; an uncompacted room serves an op delta, and the per-op read filter is authorship-blind, so it withheld the recipient's own ops on paths it may no longer read. Reproduced on C9's own fixture before anything was written: a reader with write at `/` and read on `/a` writes into `/b` then `/a`, restarts, `next_seq` reports **0**, and the next write mints straight onto its own withheld id and dedups away at ingest, silently. **The state encoding was the carrier for the snapshot; an `Ops` frame has none, so one is added.** `Message::Frontier { channel, seqs, reach }` (tag 54, server→client) leads a redacted catch-up delta; `Document::note_published` folds what it names into a `reserved` set the mint searches. **C9 explicitly refused a wire frontier and every one of its four reasons is seam-specific**, which is why the answer flips here: it supplies no number to the *sequence* counter (one named sequence is one held id and one search step — a frame naming `u64::MAX` leaves a fresh replica minting `0`), and the `reach` half, which is a wire number reaching the id-space floor, buys nothing an op carrying this replica's id does not already buy under the same ceiling; it carries the codebase's own decode bound; "a scalar cannot express a hole" is exactly why this is a **set**; and "`seen` rides `encode_state`" was the case *for* `seen` on the snapshot seam, while the delta frame has no state to ride — and what it installs rides `encode_state` anyway (`STATE_VERSION` 15). **Sequences, not `OpId`s**, so no other replica's id space is representable and the "only the recipient's own" rule is a property of the frame rather than a check on it — and the frame carries none of the targets or content that made the filing's other candidate shape (stop scrubbing the reader's own ops) unshippable. **A falsification pass broke the first cut twice and both are the substance.** *(1) A mint reads two records and the first cut carried one.* The lamport position comes from `mint_floor`, and the snapshot seam keeps it (`scrub_high_water_to`); the first frame carried only sequences, so every id derived from a stamp **alone** — an ACL tuple's, a ranged element's, an XML sequence child's — re-derived across a redacted delta. Measured: a restarted reader's next mark took the byte-identical `ElementId` of the mark its delta withheld, and the room dropped it at ingest. The frame now carries `reach`, taken from the withheld ops themselves so a branch stream reports its own and the figure is the tighter of the two available, clamped exactly as `record_stamp` clamps a folded stamp. *(2) Naming a sequence must not be refusing its op.* The first cut folded the run into the dedup set following C9, whose justification — "the ops sit below the snapshot's sequence, so no later delivery meets them" — a delta does not have: the client's cursor advances by the *delivered* batch length (C117), so a reader whose run ends in the withheld subtree resumes from below its own last ops and a widened grant re-serves exactly them. Measured: both dropped as replays while another author's write into the same subtree folded normally. The ids now go to `reserved`, cleared by `apply` when the op arrives. **The frontier leads the delta** because a provider opens the socket to app traffic *on a frame* and an app can author between two. Both the Python and JS providers completed the initial sync on the wrong frame (JS's gate read `lastSeenSeq(channel) !== undefined`, satisfied from the moment the channel is held, so *any* frame completed it); both now match the catch-up reply by tag and channel — an allowlist, Go's shape, chosen over excluding the new tag because a denylist is re-broken by the next server-directed frame. **The live fan-out is deliberately given nothing**, on the invariant that a replica applies its own op at authoring time — which the falsification pass broke as stated (two connections declaring one `ClientId` under one actor), so it is restated as a property of one replica per identity and filed as **C130**. Spec `crates/server/tests/delta_frontier.rs` (12), `crates/core/tests/op_seq_high_water.rs` (+20), `crates/core/tests/state_project_seen.rs` (+2), `crates/core/tests/protocol.rs` (+4), `sdks/python/tests/test_provider_lifecycle.py` (+1), `sdks/js/test/provider.lifecycle.test.ts` (+1). **A seventeen-branch statement-deletion sweep against the shipped code left four mutants green and none was redundant code** — each was a hole in the suite, all four in the new reservation set (`apply`'s clear, `adopt_as`'s clear, the decoder's reserved-over-applied check, both projections' clear), and the last exposed a second vacuous test since `adopt_as` masks the projection rule from anything read back through `decode_state_as`. Pinned, and the sweep now leaves no survivor. ARCHITECTURE §Wire-Level Redaction + DECISIONS 2026-08-09. Three residues filed: **C130** (the live seam's exemption rests on one replica per identity), **C131** (the read half of the identity boundary C23 closed for writes), **C132** (the frame buys durably-retained ids at 8 wire bytes each, bounded only by the frame cap). → *Server / Fan-out*. **C53 — a log-shared branch materializes truncated once `main` compacts, and says nothing (crates/server) — DONE (#399).** Filed by review during C27 (#375) as reasoned-not-reproduced; **reproduced first**, five shapes, each a failing test before a line of the fix. `catch_up_branch`'s shared-base slice clamped its low end up to the compaction floor (`last_seen_seq.max(r.base_seq) - r.base_seq`), so every record at or below the floor was dropped from the served base and the answer was still `Catchup::Ops`. Measured on `main`: a fork at 4 in a room compacted to 2 folds, for a fresh subscriber, to `one=None two=None three=3 four=4 tail=9`; a floor raised to the fork point folds to the divergent tail over nothing, with the same fork serving its whole base one statement earlier as the control; `base_seq >= fork_point` reaches the same place by the other route, an empty retained slice rather than a clipped one; `install_snapshot` reaches it with no compaction configured anywhere, which is the ordinary below-floor follower state-transfer; and **at the wire**, on a room holding no doc-ACL tuples so that C60's `no_tree` refusal is skipped, a `Subscribe` at `last_seen_seq = 0` is answered with one `Ops` frame carrying the branch's tail op and nothing else. That last is the reachability claim the filing rested on, and it holds. The read was the only consequence left: C60 (#386) made a clipped shared base yield no redaction tree and C51 (#393) split `materialize_branch` four ways, so a publish of such a stream already froze nothing and a diff already answered `UnreadableBranch` — the catch-up was the seam still answering `Some`, to the one caller that acts on it by *becoming* the stream. **Ruling: the refusal is bounded by what the subscriber needs, not by what the branch is.** `catch_up_branch` answers `Catchup::Unavailable` when `min(base_end, base_seq) > last_seen_seq`. Asked of a fold from zero that condition is *identical* to `stream_doc`'s own clipped-base check, so the tree a read is redacted against and the stream that read is served cannot describe different branches — a second, differently-shaped notion of "clipped" at the serve seam is exactly the drift C60's lesson warns about. The refusal takes **both** ends of the window, and standing above either is enough to be served — at or above the floor nothing the subscriber still needs was dropped, at or above its own fork point it needs none of the base whatever the floor has since done. Stating one end as *the* bound is wrong in both directions, which a falsification pass measured (a subscriber three records below the floor, served on its fork point) after a first correction over-corrected the other way. That a subscriber past the fork point keeps its tail is `Catchup::Unavailable`'s own standing ruling, which C60 relies on, pinned at the hub and at the wire rather than left to the enum. **The filing's alternative — a compaction floor that will not advance past a live fork point — is rejected, on two counts.** It cannot be complete: `install_room_state` lands an empty log at a raised floor with no `compact` call to guard, so the same wrong `Some` would survive on the path needing no operator action at all. And it converts a correctness bug into an availability one: a branch is long-lived by design (§Per-User Branches), so one forgotten per-user fork would hold a room's log unreclaimable forever, and `set_compaction_threshold` — which compacts inline from `ingest` — would then never reclaim on exactly the rooms that grow. **What this does not fix, stated because three filings share the signature.** The refusal is not a repair: the pre-floor content is still gone and the branch is now unservable at every seam rather than wrong at one. **C88** owns the repair (an owned base materialized for every registered fork whose fork point sits above a floor about to rise, at both compaction and the snapshot install) and its cost — one whole-replica copy per live-log fork, paid where the reclaim was — is the ruling this unit deliberately does not pre-empt. **C104** is untouched and unreachable by this guard: an orphaned base reclassified as a live-log fork produces wrong bytes only at floor **0**, which is precisely where `min(base_end, base_seq) > last_seen_seq` never fires. **The cost of the refusal, measured rather than left to be discovered.** The filing's framing is an aging one — a floor rising past a fork that already existed — and the dominant case is the opposite: a fork taken on an **already-compacted** room is unservable from birth. `fork_branch` clamps to `main`'s head, which is the only point the wire's own fork command names, the floor is already there, and the first fresh subscriber is refused; nothing repairs it, and deleting the name and re-forking reaches the same missing log. Pinned as a test rather than described — on a room whose floor has left zero, which is the condition, a fork on a room still at floor 0 being a pinned control. **And compaction is not the only way a room is born at its floor**: `install_room_state` lands an empty log at a floor equal to the op count the installed state carries, so a **cloned or imported** room has `floor == head` from birth — and `clone_room` is the "duplicate this doc as a template" primitive, which plainly fits the per-user-fork workflow though ARCHITECTURE §Per-User Branches names the room's own `main` rather than a clone. The mechanical fact stands without the linkage and is pinned: a fork on a cloned room is refused from birth on a node that has compacted nothing, and a later `main` write does not rescue it: the fork point stays pinned at the old head, which is the floor. Pinned too. It is the right trade anyway, for two reasons that are not shrugs: it replaces *silent divergence*, and it is the last seam to take a refusal the branch already took everywhere else, so it makes the set consistent rather than inventing a policy. **The cheap repair the reach points at is recorded on C88 rather than taken here** — where a fork point sits at `main`'s head, `main`'s replica *is* the branch's base byte for byte, so a base could be written at the fork seam for one copy at fork time; doing it here would flip the publish and diff answers `branch_state_taxonomy` pins for exactly that fixture, which is re-litigating two merged rulings inside a third. **Two residuals the falsification passes measured, stated rather than closed.** A subscriber's position is what it *claims* — `last_seen_seq` is a client-supplied wire field and nothing verifies it — and the general statement is stronger than the fork-point case: a client asserting **the room's floor** computes the identical `lo = 0` the pre-fix code computed, so wherever the floor sits at or below the fork point it receives byte-identical pre-fix output — measured on four of the spec's five clipped fixtures. It is not universal, and the exception is instructive: where the floor has run *past* the fork point, claiming it also skips past the fork point and so truncates the branch's own tail, leaving the liar with less than the pre-fix answer. The refusal's threshold is discoverable by binary search over subscribes, which recovers `min(fork_point, head, floor)` rather than the floor itself, and only on a room holding no doc-ACL tuples — with tuples, C60's refusal is cursor-independent and every probe returns the same frame. The two flagship cases in the file share a fixture and differ only in that integer. That is the protocol's standing cursor model — the read-your-writes floor is the same field — and not this seam's to close; a server-side per-channel cursor would. It also means a client caught up short by the *pre-fix* server is not healed either. And the guard tests the **floor** end of the window only, so a `main` whose head has *regressed* below a live fork point — reachable through `install_snapshot` at a sequence below one, whose `seq` is peer-supplied — still serves a base short of the fork point with no signal; filed as **C135**, a head regression being a different cause and a different fix. One pre-existing clause is now implied rather than load-bearing and **stays** with a comment saying so — past the refusal the window sits wholly inside the retained log, so `base_end > r.base_seq` and the `.max(r.base_seq)` clamp narrow nothing, but they are what makes the slice arithmetic total on its own inputs rather than on the guard above holding. Spec `crates/server/tests/branch_catchup_floor.rs` (15), with an oracle indifferent to *how* a stream is served — a delta folds from the empty document exactly as a subscriber does, a snapshot decodes — so every case reads "the pre-floor content is there, or the catch-up said no" and stays true under C88's repair as well as under this refusal. Seven reproduce (the straddled floor, the floor at the fork point, the floor past it, the state-transfer route, the wire subscribe, the fork born on an already-compacted room — which also pins that re-forking the name recovers nothing — and the fork born on a *cloned* room, with no compaction anywhere), each reddened by deleting the guard; seven are the controls the refusal must not swallow (a subscriber at the fork point and at the branch head, an uncompacted fork, a floor of 0, a fork at 0, `main`'s compacted catch-up, a snapshot fork, the two boundaries again at the wire, and the one arm where the slice arithmetic still does real work — forked at 4 over a floor of 2, a subscriber at the floor is owed records 3 and 4 and its tail, asserted by identity rather than by count, while one record lower is refused, the `>` boundary itself); the fifteenth measures the identity claim instead of arguing it, across a grid of pre/post-compaction op counts and every fork point from 0 to one past the head, reaching **one** direction and saying so, since `stream_doc` answers the clipped case before it folds but past that check the diff seam *is* this catch-up and an over-refusal propagates into both answers. Mutation-checked against the committed tree: deleting the guard reddens nine, `min(base_end, base_seq)` → `base_seq` alone reddens two, → `base_end` alone reddens three, `min` → `max` reddens five, `>` → `>=` reddens seven; two cases are reddened by no mutation and are named rather than counted as coverage, sitting above the guard's own early returns. ARCHITECTURE §Branches + DECISIONS 2026-08-09. → *Server / Branches*. +**C14 — a redacted op delta left the same re-mint hole, and no frame could carry the ids it withheld (crates/server + crates/core) — DONE (#PR).** C9 (#357) closed the *snapshot* seam by keeping the recipient's own ids in a projected snapshot's frontier; an uncompacted room serves an op delta, and the per-op read filter is authorship-blind, so it withheld the recipient's own ops on paths it may no longer read. Reproduced on C9's own fixture before anything was written: a reader with write at `/` and read on `/a` writes into `/b` then `/a`, restarts, `next_seq` reports **0**, and the next write mints straight onto its own withheld id and dedups away at ingest, silently. **The state encoding was the carrier for the snapshot; an `Ops` frame has none, so one is added.** `Message::Frontier { channel, seqs, reach }` (tag 54, server→client) leads a redacted catch-up delta; `Document::note_published` folds what it names into a `reserved` set the mint searches. **C9 explicitly refused a wire frontier and every one of its four reasons is seam-specific**, which is why the answer flips here: it supplies no number to the *sequence* counter (one named sequence is one held id and one search step — a frame naming `u64::MAX` leaves a fresh replica minting `0`), and the `reach` half, which is a wire number reaching the id-space floor, buys nothing an op carrying this replica's id does not already buy under the same ceiling; it carries the codebase's own decode bound; "a scalar cannot express a hole" is exactly why this is a **set**; and "`seen` rides `encode_state`" was the case *for* `seen` on the snapshot seam, while the delta frame has no state to ride — and what it installs rides `encode_state` anyway (`STATE_VERSION` 15). **Sequences, not `OpId`s**, so no other replica's id space is representable and the "only the recipient's own" rule is a property of the frame rather than a check on it — and the frame carries none of the targets or content that made the filing's other candidate shape (stop scrubbing the reader's own ops) unshippable. **A falsification pass broke the first cut twice and both are the substance.** *(1) A mint reads two records and the first cut carried one.* The lamport position comes from `mint_floor`, and the snapshot seam keeps it (`scrub_high_water_to`); the first frame carried only sequences, so every id derived from a stamp **alone** — an ACL tuple's, a ranged element's, an XML sequence child's — re-derived across a redacted delta. Measured: a restarted reader's next mark took the byte-identical `ElementId` of the mark its delta withheld, and the room dropped it at ingest. The frame now carries `reach`, taken from the withheld ops themselves so a branch stream reports its own and the figure is the tighter of the two available, clamped exactly as `record_stamp` clamps a folded stamp. *(2) Naming a sequence must not be refusing its op.* The first cut folded the run into the dedup set following C9, whose justification — "the ops sit below the snapshot's sequence, so no later delivery meets them" — a delta does not have: the client's cursor advances by the *delivered* batch length (C117), so a reader whose run ends in the withheld subtree resumes from below its own last ops and a widened grant re-serves exactly them. Measured: both dropped as replays while another author's write into the same subtree folded normally. The ids now go to `reserved`, cleared by `apply` when the op arrives. **The frontier leads the delta** because a provider opens the socket to app traffic *on a frame* and an app can author between two. Both the Python and JS providers completed the initial sync on the wrong frame (JS's gate read `lastSeenSeq(channel) !== undefined`, satisfied from the moment the channel is held, so *any* frame completed it); both now match the catch-up reply by tag and channel — an allowlist, Go's shape, chosen over excluding the new tag because a denylist is re-broken by the next server-directed frame. **The live fan-out is deliberately given nothing**, on the invariant that a replica applies its own op at authoring time — which the falsification pass broke as stated (two connections declaring one `ClientId` under one actor), so it is restated as a property of one replica per identity and filed as **C130**. Spec `crates/server/tests/delta_frontier.rs` (12), `crates/core/tests/op_seq_high_water.rs` (+20), `crates/core/tests/state_project_seen.rs` (+2), `crates/core/tests/protocol.rs` (+4), `sdks/python/tests/test_provider_lifecycle.py` (+1), `sdks/js/test/provider.lifecycle.test.ts` (+1). **A seventeen-branch statement-deletion sweep against the shipped code left four mutants green and none was redundant code** — each was a hole in the suite, all four in the new reservation set (`apply`'s clear, `adopt_as`'s clear, the decoder's reserved-over-applied check, both projections' clear), and the last exposed a second vacuous test since `adopt_as` masks the projection rule from anything read back through `decode_state_as`. Pinned, and the sweep now leaves no survivor. ARCHITECTURE §Wire-Level Redaction + DECISIONS 2026-08-09. Three residues filed: **C130** (the live seam's exemption rests on one replica per identity), **C131** (the read half of the identity boundary C23 closed for writes), **C132** (the frame buys durably-retained ids at 8 wire bytes each, bounded only by the frame cap). → *Server / Fan-out*. +**C40 — two ops carrying one stamp into one plain list resolved by arrival order (crates/core) — DONE (#400).** Filed during C24 (#371) and renumbered from C27, which C15 (#368) had already taken. C24 closed the *children-list* half — a placement key is owned by a birth over a move, then by the smaller element id, whatever the arrival order — and one layer down the same collision was still first-wins: `List::insert_at` was idempotent on the id, so two `ListInsert` ops under one `ClientId` at the identical `Stamp` left whichever landed first holding the slot, with its value *and* its Fugue anchor (measured: the encoded node carried `2` on one replica and `99` on the other). Both are admissible for the reason C24 gives — dedup is on `OpId`, and the id-space record bounds only an *honest* mint. **Fixed by one rank over `(kind tag, position, encoded value)`, in `List` rather than the document, so it reaches `Text` runs on the same seam.** The smaller rank takes the id with its value **and** its anchor, and a claim that ties changes nothing, which keeps a replay inert and makes two claims on one node the meet of their positions — the scalar image of `rejoin`. The two shapes the filing left open are settled: **the anchor travels with the value**, since a tombstone must not remember the loser's position, and **mixed scalar-against-composite is not a special case** — the tag is the first key and is read as the number it is, not as a semantic "composite beats scalar", because a numeric order stays total when a kind is added where a semantic one re-opens the question on every new op. **The load-bearing half is that every seat path runs the one rank**, since a claim owns the key it named and not the id: `Claim::Fresh` found `insert_at` inert against a scalar already at the id while an eviction or a join re-seated over it, so `[ListInsert, tagged, tagless]` replaced the scalar and `[ListInsert, tagless, tagged]` kept it — one op set, two trees. The rank now runs at the plain `ListInsert`, at every codepoint of a `Text` run, at the birth whose key was free, at the eviction and the join (which yield to a *scalar* on the id, the half no placement key ranks them against), and at `List::merge`, which was the fourth path and answered by which side received. **One ordering the filing did not name is forced, and it is measured rather than chosen: the position outranks the value.** A delete drops a node's value and keeps its anchor, so the anchor is the only key of the rank a tombstoned id still carries — and ranking on it settles the whole difference, because the position is also all a tombstone encodes. With the value read first, the winner of a contest a delete landed in the middle of would have depended on which claim the delete buried; with the position first, all four orders of `[insert, insert, delete]` encode one snapshot. Spec `crates/core/tests/list_stamp_collision.rs` (11) folds every delivery order of each shape and compares snapshot bytes: two plain inserts, the winner's anchor, a delete between them, two `Text` runs at one base stamp (a run is one contest per codepoint, so the ids may split between the runs and converge doing it), a scalar against a child insert, the scalar against **both** child inserts in all six orders with a snapshot round-trip, a scalar against an `XmlMove` at one stamp, the merge seam, the tie, the replay, and the tag-before-value order. Residue filed as **C133**: a tombstone holds no kind, so a *mixed* pair a delete lands between is still ordered by arrival — narrowed by this unit (the split is no longer keyed on which claim arrived first) but not closed, and closing it needs the sequence to persist a rank per dead id, which §Tombstone GC's run compression forbids. ARCHITECTURE §List + DECISIONS updated. → *Core / List*. **C54 — a replicated room's op-version high-water is never carried, so the handshake range-check is inert on every replica (crates/server) — DONE (#396).** Found during C29 (#374), the sibling field of the same gap; filed as C34, renumbered because that id was taken. `Room::max_op_version` is the worst-case governing-app op version a joiner must down-reach, and `subscriber_reaches_governing` refuses an under-versioned joiner against it — but it was raised only by the version a client write carries into `Hub::ingest` (`schema_version`), which neither replication path supplied (`apply_replicate` ingests relay-style `None`; `install_snapshot` installs `Document` bytes carrying none). Measured against `main` on a two-node harness: a replica converged by ops and one installed from a state transfer both report `None`, a v1 joiner across a breaking rename is **admitted** on each, and admitted on the promoted leader after a failover. The admitted joiner is then served the room's ops **verbatim** — a replicated batch is untranslated (C71) and a replica's log untagged, so nothing down-translates; the harm is a client handed a state its own version cannot model, past the one check that exists to refuse it. `clone_room` reported `None` for the same reason. **The binding had to travel with it, and that is forced rather than adjacent:** a high-water is a number in the governing app's version space, `governing_target` abstains on an unbound room, and a pure-replication replica has neither a subscriber nor a store record to bind it — so carrying only the number would leave the *first* joiner at a replica admitted and every later one refused against a binding that first joiner invented at its own version. `persist_meta` already writes the two beside the creator as one `RoomMeta`; they now ride the wire as one record too. `Message::Replicate` and `Message::ReplicateSnapshot` gain `governing` and `max_op_version`, each composing against what the replica holds rather than replacing it — the root set-once (C29), the high-water as a **max** (the all-time worst case, so a re-sent or lower-naming frame never talks it down), the binding through `Registry::bind_room_app`, the same incumbent-app rule a subscribe and the durable load take — and the high-water **only under the app that won that composition**, since a frame whose app was refused names a number in a space the room is not read in, which the first cut adopted anyway and a cold review caught. **The filing's second half is settled explicitly, not incidentally: the follower's log stays a verbatim mirror.** `apply_replicate` still ingests with `None`; re-tagging is rejected because it cannot carry the fact at all on the ops-less state-transfer path, because one version per frame would mislabel a catch-up batch whose ops `Catchup::Ops` says may mix versions, and because **C71 owns it by name** including the mixed-version question. C54 does not close C71: a joiner the check now admits on a replica is still served the delta untranslated. The cost of that reading is stated — a replica's logged ops carry no versions, so it cannot rebuild its high-water from its own log, which widens **C55**'s failed-persist route to all three fields. This unit also closes the **replication half of C62** (the binding travels), leaving that unit its import case; and it closes the eviction hole it opens — a replicated lift now evicts a stranded follower-local subscriber on both seams, on the identical predicate the subscribe gate admits on, since before the change no replicated frame could lift a follower's high-water at all. The **ordering** is the load-bearing half and was measured wrong first: a leader's stranded peer receives nothing because the leader's fan-out translates and drops it, while a replica's fan-out is verbatim — so the eviction runs *before* the fan-out here, or the doomed peer is handed the op and told to update afterwards. **One resolution for the room's binding, which a falsification pass forced:** the replication record is read the way a write's own version tag reads it (the presence map, falling back to the hub), because a sweep prunes each source in a different case — the hub's for a room it does not yet hold, the map's for a room nobody subscribes. Reading the hub alone made a swept leader emit a bare high-water that every replica then discarded, re-opening this unit's own defect through its own gate; measured, and pinned. The clone seam keeps the same pair together, so no seam here mints a number with no app to read it in. **C55 (#397) built a metadata-only carrier after this was written** — `Message::ReplicateMeta`, the root with no ops beneath it — and the binding and high-water have exactly the shape that wants one: a replica logs its leader's batch untagged, so it cannot re-derive either from its own log. Widening that frame to the whole record is **C125**, deliberately left to it, because a root is set-once while these are not and a metadata-only assertion has to say whether it asserts the leader's current value or repairs a lost one. Residue filed as **C129** (a subscriber admitted against an unbound room's high-water is never re-checked once it binds the room itself — pre-existing, needs a ruling) and **C120**: the max-compose is unrepairable, so a cluster member asserting `u32::MAX` pins a replica's high-water there permanently — measured, every governing-app joiner is then refused and an honest later frame cannot bring it back down. Spec `crates/server/tests/replicated_op_version.rs` (32) plus the wire round-trips and truncation sweeps in `crates/core/tests/protocol_replicate.rs`. Design in DECISIONS (2026-08-09). → *Server / Replication*. @@ -561,7 +563,7 @@ scalar / counter / register / element / map (#22–#27), list Fugue (#24), text **C41 — a reveal shell does not survive a snapshot, so a restarted replica refuses the move that places it (crates/core) — READY, no dependencies. Found by cold review during C24, reproduced; pre-dates C24.** Filed during C24 (#371) and renumbered from C28, which C15 (#368) had already taken. `Document::revealed_pending` is the set of movable nodes materialised by an `XmlReveal` shell and still awaiting their first placement — the server injects those shells so a partial reader can hold a node born in a subtree it cannot read. Both the readiness gate and `apply_move` accept a move of such a node *because* it is in that set. Nothing encodes it: `encode_state` writes no section for it and `read_state` rebuilds it empty, so a replica that snapshots between the shell and the move that places it comes back with the shell materialised and unplaceable, and every later move of it buffers forever while a peer that stayed up applies it. Measured directly — reveal, snapshot, reload, move — one replica renders the node under its new parent and the other does not. Independent of C24 but sharpened by it, and **C24's shell case is conditional on this unit**: C24 makes a node left with no placement movable again, and a decode re-derives that from the parent link — which recovers a *born* loser but not a **shell**, since a shell has no such link to key on and C24 correctly removes the one the fold gave it. So `a_reveal_shell_that_loses_the_placement_stays_movable` holds live and across both arrival orders, but not across a snapshot: reveal a shell, let a smaller-id mover evict it, reload, and a later move of the shell buffers where the live replica applies it. That is this unit's hole reached by a new route, not a new hole — the plain shape (reveal, snapshot, reload, move) diverges on `main` too — but it is the reason C24 cannot be called complete for shells until this lands. The fix is a section in the state codec for the shells (and the projections' filters for it, since a shell's whole point is that its origin was denied), which is new persisted state and wants the decision made explicitly rather than inherited — the same bar C21's resolved-key set is held to. → *Core / XML*. -**C40 — two ops carrying one stamp into one plain list resolve by arrival order (crates/core) — READY, no dependencies. Found during C24, reproduced.** Filed during C24 (#371) and renumbered from C27, which C15 (#368) had already taken. C24 closed the *children-list* half: a placement key is owned by a birth over a move, then by the smaller element id, whatever the arrival order. One layer down the same collision is still first-wins. `List::insert_at` is idempotent on the id, so two `ListInsert` ops under one `ClientId` carrying the identical `Stamp` into one list — both admissible for the reason C24 gives, dedup being on `OpId` and the id-space record bounding only an honest mint — leave whichever landed first holding the slot, with its value *and* its Fugue anchor. Delivered the other way the other value renders, and the two replicas' `encode_state` bytes differ (measured: the encoded node carries `2` on one and `99` on the other). The C24 tiebreak does not carry over: a scalar node has no element id to order by, so the rule has to be a total order over the *values* (their encoded bytes, with the kind tag as discriminator), and it belongs in `List` rather than the document, which means it reaches `Text` runs on the same seam. Two shapes to settle first: whether the winner's *anchor* travels with its value (C24 says yes — the tombstone that a delete leaves must not remember the loser's position), and what a scalar contending with a composite node resolves to, since a plain `ListInsert` and an `XmlInsertChild` can name one stamp in one children list and only the latter passes through `placement_index`. #371 sharpened that half rather than closing it: a claim owns the key, not the id, so `Fresh` finds `insert_at` inert against a scalar already at the id while an eviction or a join re-seats over it — `[ListInsert, tagged, tagless]` replaces the scalar and `[ListInsert, tagless, tagged]` keeps it, one op set and two trees. The rule has to say what a composite claim does to an id a scalar holds, not only who owns the key. → *Core / List*. +**C133 — a delete landing between a scalar claim and a composite claim on one sequence id resolves by arrival order (crates/core) — READY, needs a ruling. Filed by C40 (#400), measured; the one edge C40's seat order leaves open.** C40 ranks every claim on a sequence id by `(kind tag, position, encoded value)` and every seat path runs it, which makes a scalar-against-scalar contest total *including* across a delete: the position is the ranking key a tombstone keeps, so a claim landing on a tombstoned id is ranked on the one key that survived. A **mixed** pair is not, because the key that decides it is the tag, and a tombstone holds no kind to read — a delete drops the value and keeps only the anchor. **Reproduction:** a fragment's children list, an `XmlInsertChild` at the front and a `ListInsert` at the back sharing one `Stamp`, plus a `ListDelete` of that id. The two delete-between orders agree with each other (both take the meet of the anchors, the only rank left) and the two delete-last orders agree with each other (the tag rules, so the scalar's anchor stands), and the two classes encode different bytes. C40 narrowed this rather than opening it — on `main` the same three ops fold four ways into two states keyed on *which claim arrived first*, and after C40 the split is only between "the delete landed between" and "the delete landed last" — but it is a divergence and it is reachable from three honest-shaped ops. `List::merge` carries the same hole by its own route: a replica holding a tombstone at the id drops the peer's live node while a replica holding the live node buries it at *its own* anchor, so the two keep different positions. **The ruling is what a sequence is allowed to remember about a dead id.** Ranking a mixed claim against a tombstone needs the winning rank to outlive the delete, and the sequence has nowhere to put it: a tombstone is a run record covering any number of ids and a rank per dead id is O(deletions) in memory and on the wire, which is exactly the compression §Tombstone GC exists for — a bulk text delete would carry one rank per codepoint. The document's `(list, stamp)` rank persists and is what makes the composite-against-composite case total across a delete (C24), so one direction is to give the scalar half a record of the same shape; the other is to say a delete is terminal for the position too and take the cost on the live side. Either way it is new persisted state or a changed convergence rule, which is why it is not folded into C40. → *Core / List*. **C47 — a minority `count` rewrite folds one op set to two states, because unanimity is judged over the members that have *arrived* (crates/core) — READY, no dependencies. Found by cold review during C21 (#372); pre-existing on `main` before it. Filed under a different id during that unit and renumbered into C21's reserved range, its first id having been claimed by a sibling unit in flight.** C3 bounds a rewrite with "a bucket whose members disagree names no group and is never complete", and C21 leaned on that. It bounds rather than closes, because a unanimous **subset** can reach its own declared count before the dissenting member lands. **Reproduction:** take an honest three-member group and rewrite **two** members to declare 2, leaving the third at 3 — every member legal on its own terms, `is_admissible` passes. Delivered `x,y,z` or `y,x,z` the pair completes at 2, commits, spends the key, and `z` lands as a stray of a resolved group: all three present. Delivered in any of the other four orders the dissenter is in the bucket from the start, `tx_declared_count` returns `None` forever, and **nothing lands at all**. Two distinct states over six orders, measured. **C21 (#372) widened it**, and this entry should not read as though it left it alone: spending the key on the minority's commit makes the stray land, which is *visible* on pools where the un-recorded replica's commit cancelled itself out and the stray stayed held. Measured over 389 byte-identical forged pools x 12 orders, the split rate goes 58 -> 85 — 28 pools read two ways here that read one way before, 1 the other way. Forged envelopes only, and eviction still collapses all 85 to a single reading. It is the cheapest of the four rewrite shapes for an attacker, since it rewrites a minority rather than every member. **The fix is one rule, and it is a reversal:** a bucket whose members disagree can never honestly complete, so it **spends its key** where a commit spends it — every order then lands all three and spends the key, and the no-duplicate convergence fuzz passes. What it costs is C3's decision that a disagreement means *hold*: `a_rewritten_first_member_count_does_not_commit_the_group_at_the_wrong_size`, `a_bucket_whose_members_disagree_on_the_size_never_completes` and `a_rewritten_count_holds_the_same_set_whatever_order_it_arrives_in` all pin the opposite and would invert, and ARCHITECTURE §Opt-In: Atomic's unanimity sentence changes with them. Distinct from **C46**, which needs per-op-id evidence; this one needs only the rule to change. ARCHITECTURE already carries the caveat that unanimity bounds rather than closes, so the docs do not overclaim in the meantime. → *Transactions*. diff --git a/crates/core/src/list.rs b/crates/core/src/list.rs index 427a18ef..ac476b50 100644 --- a/crates/core/src/list.rs +++ b/crates/core/src/list.rs @@ -118,6 +118,51 @@ pub struct Anchor { pub side: Side, } +/// How a claim on a sequence id is ranked against the claim already holding it: +/// the kind tag, then the position, then the encoded value. +/// +/// Two ops can carry one `Stamp` into one sequence — dedup is on `OpId`, and an +/// id-space record only bounds an *honest* mint — so which of them the id ends at +/// has to be a function of the ops alone. This is that function, and every seat +/// path runs it: a claim owns the key it named, not the id, so a path that +/// skipped it would keep the bug in that path only. +/// +/// The **tag leads** because it is the one key a scalar and a composite both +/// have, and it is read as the number it is rather than as a preference for +/// either: a numeric order is total, and it stays total when a kind is added, +/// where a semantic one would re-open the question on every new op. +/// +/// The **position outranks the value** because it is the only part of a claim a +/// delete leaves behind. A tombstone drops the value and keeps the anchor, so a +/// claim arriving at a tombstoned id can be ranked on the position and on nothing +/// else — and ranking it there settles everything, since the position is also all +/// a tombstone encodes. Were the value read first, the winner of a contest a +/// delete landed in the middle of would depend on which claim the delete buried. +/// +/// Two *composites* are not ranked here. The document ranks them at the +/// `(list, stamp)` placement key first — a birth over a move, then the smaller +/// element id — and hands the sequence its verdict, which is why the tag decides +/// only across the scalar/composite boundary and never inside it. +type Rank = (u8, Anchor, Vec); + +fn claim_rank(value: &Element, anchor: Anchor) -> Rank { + let mut encoded = Vec::new(); + put_node_value(&mut encoded, value); + (value.kind() as u8, anchor, encoded) +} + +/// What holds a sequence id, as far as a claim can rank it. +enum Seated { + /// Nothing holds it: the claim seats without a contest. + Vacant, + /// A live node, rankable in full. + Live(Rank), + /// A tombstone. The delete dropped the value, so only the position is left to + /// rank on — and only the position is encoded, so nothing a snapshot can show + /// goes unranked. + Dead(Anchor), +} + struct Node { id: Stamp, value: Element, @@ -505,35 +550,69 @@ impl List { Anchor { parent, side } } - /// Insert a node with an explicit id and placement. Idempotent on the id: - /// a replayed op leaves the sequence untouched, and an id already deleted - /// stays deleted. + /// Claim the id `id` for `value` at `anchor`, ranked against whatever holds + /// it — the seam every claim the document has *not* already ranked passes + /// through: a plain `ListInsert`, a `Text` run's codepoints, a birth whose + /// `(list, stamp)` key was free. + /// + /// Idempotent where it is a replay: a claim that ties the seated one ranks + /// equal and changes nothing. Where the two differ, the smaller [`Rank`] takes + /// the id with its value *and* its position, so the id ends at the same claim + /// whichever arrived first. pub fn insert_at(&mut self, id: Stamp, value: Element, anchor: Anchor) { - if self.contains(id) { - return; + let takes = match self.seated(id) { + Seated::Vacant => true, + Seated::Live(seated) => claim_rank(&value, anchor) < seated, + // A delete is terminal for the value but not for the position: the + // claims still have to agree on where the id sits, and the anchor is + // the one key of the rank that survived the delete to compare on. + Seated::Dead(seated) => anchor < seated, + }; + if takes { + self.seat(id, value, anchor); } - self.nodes.insert( - seq_key(&id), - Node { - id, - value, - parent: anchor.parent, - side: anchor.side, - moved_away: false, - }, - ); } - /// Hand the id `id` to `value` at `anchor`, whatever it currently holds. The - /// seam a children-list placement collision resolves through: two ops can carry - /// one id into one list, and which position it ends at is decided by the ops - /// alone, not by which arrived first — so the id has to be re-seated when the - /// deciding op lands second, where `insert_at` is idempotent on it. + /// Hand the id `id` to `value` at `anchor` on the document's ranking. The seam + /// a children-list placement collision resolves through: the document ranked + /// this claim against the composite holding the `(list, stamp)` key (a birth + /// over a move, then the smaller element id) and this claim won, so the id is + /// re-seated even though it is taken. + /// + /// It still yields to a *scalar* on the id, which no placement key ranks it + /// against — that half is the kind tag's, the first key of the one order every + /// seat path runs. /// /// A delete stays terminal. An id already tombstoned keeps its tombstone and /// takes only the new position, so the run remembers where the deciding op put /// it rather than where the other one did. pub(crate) fn reseat(&mut self, id: Stamp, value: Element, anchor: Anchor) { + if self.yields_to_seated(id, &value) { + return; + } + self.seat(id, value, anchor); + } + + /// Re-seat `id` at the lesser of where it already sits and `anchor`, under + /// `value`. Two ops can carry one id into one list naming the same node and + /// differ in nothing but the position — neither is the other's loser, so the + /// position is the meet of the two, which is the same wherever it is computed + /// and whichever arrived first. Yields to a scalar on the id for the reason + /// [`reseat`](Self::reseat) gives. + pub(crate) fn rejoin(&mut self, id: Stamp, value: Element, anchor: Anchor) { + if self.yields_to_seated(id, &value) { + return; + } + let anchor = self.anchor_of(id).map_or(anchor, |held| anchor.min(held)); + self.seat(id, value, anchor); + } + + /// Install `value` at `id` and `anchor`, whatever the id currently holds — the + /// one mutation behind every seat path, run once the claim has been ranked. + /// + /// A delete stays terminal: an id already tombstoned keeps its tombstone and + /// takes only the new position. + fn seat(&mut self, id: Stamp, value: Element, anchor: Anchor) { let dead = self.take_dead(id); self.nodes.insert( seq_key(&id), @@ -550,24 +629,39 @@ impl List { } } - /// Re-seat `id` at the lesser of where it already sits and `anchor`, under - /// `value`. Two ops can carry one id into one list naming the same node and - /// differ in nothing but the position — neither is the other's loser, so the - /// position is the meet of the two, which is the same wherever it is computed - /// and whichever arrived first. - pub(crate) fn rejoin(&mut self, id: Stamp, value: Element, anchor: Anchor) { - let anchor = self.anchor_of(id).map_or(anchor, |held| anchor.min(held)); - self.reseat(id, value, anchor); + /// What holds `id`, ranked as far as it can be — the reading every seat path + /// takes its verdict from. + fn seated(&self, id: Stamp) -> Seated { + if let Some(node) = self.nodes.get(&seq_key(&id)) { + let anchor = Anchor { + parent: node.parent, + side: node.side, + }; + return Seated::Live(claim_rank(&node.value, anchor)); + } + match self.dead_anchor_of(id) { + Some(anchor) => Seated::Dead(anchor), + None => Seated::Vacant, + } + } + + /// Whether a claim the document has already ranked yields to what holds `id`. + /// + /// Only a composite reaches a placement key, so such a claim carries a kind tag + /// above `Scalar`'s: a live scalar on the id outranks it on the tag, and a live + /// composite was ranked against it at the key, where the answer came from. A + /// tombstone holds no kind to read and keeps the document's verdict, which + /// outlives the delete where a sequence rank would not. + fn yields_to_seated(&self, id: Stamp, claim: &Element) -> bool { + let Some(seated) = self.nodes.get(&seq_key(&id)).map(|n| n.value.kind()) else { + return false; + }; + seated == ElementKind::Scalar && claim.kind() != ElementKind::Scalar } /// Where `id` currently sits, or `None` if the list does not hold it — read /// back off the sequence rather than remembered, so a reloaded replica joins a /// later claim exactly as the one that never restarted. - /// - /// A tombstoned id answers as faithfully as a live one. It heads the run the - /// delete built, which keeps the anchor it was buried with; and an interior id - /// only ever welds into a run by hanging to the right of its predecessor, so - /// that is the anchor it was buried with too. fn anchor_of(&self, id: Stamp) -> Option { if let Some(node) = self.nodes.get(&seq_key(&id)) { return Some(Anchor { @@ -575,6 +669,15 @@ impl List { side: node.side, }); } + self.dead_anchor_of(id) + } + + /// Where a tombstoned `id` sits, or `None` if no run covers it. A tombstone + /// answers as faithfully as a live node: it heads the run the delete built, + /// which keeps the anchor it was buried with; and an interior id only ever + /// welds into a run by hanging to the right of its predecessor, so that is the + /// anchor it was buried with too. + fn dead_anchor_of(&self, id: Stamp) -> Option { let (head, run) = self.dead_run(id)?; if head == id { return Some(Anchor { @@ -792,21 +895,38 @@ impl List { /// per-id registry, not in the node. pub fn merge(&mut self, other: &Self) { for (key, on) in &other.nodes { - if self.dead_run(on.id).is_some() { - continue; - } - match self.nodes.get_mut(key) { - // Same logical item: fold composite values together; scalars are - // immutable so their shared id already agrees. - Some(sn) => { - if sn.value.kind() != ElementKind::Scalar && sn.value.kind() == on.value.kind() - { - sn.value.merge(&on.value); - } - } - None => { + let anchor = Anchor { + parent: on.parent, + side: on.side, + }; + match self.seated(on.id) { + Seated::Vacant => { self.nodes.insert(*key, on.deep_clone()); } + // A delete wins and is terminal, so the peer's copy of a deleted + // node is dropped rather than folded. + Seated::Dead(_) => {} + Seated::Live(seated) => { + let claim = claim_rank(&on.value, anchor); + if (claim.0, &claim.2) == (seated.0, &seated.2) { + // The same value at one id, so nothing is contested: fold + // the composite halves together and take the meet of the + // two positions, which is the same on both replicas. + let sn = self.nodes.get_mut(key).expect("a live node was just read"); + if sn.value.kind() != ElementKind::Scalar { + sn.value.merge(&on.value); + } + if claim.1 < seated.1 { + sn.parent = anchor.parent; + sn.side = anchor.side; + } + } else if claim < seated { + // Two claims took one id on the two replicas — the same + // contest an op fold resolves, resolved by the same rank so + // a merge cannot answer it by which side received. + self.seat(on.id, on.value.deep_clone(), anchor); + } + } } } for (key, run) in &other.dead { diff --git a/crates/core/tests/list_stamp_collision.rs b/crates/core/tests/list_stamp_collision.rs new file mode 100644 index 00000000..03727a54 --- /dev/null +++ b/crates/core/tests/list_stamp_collision.rs @@ -0,0 +1,484 @@ +//! Two ops carrying one stamp into one plain sequence. +//! +//! `List::insert_at` is idempotent on the id, so two `ListInsert` ops under one +//! `ClientId` at the identical `Stamp` used to leave whichever landed first +//! holding the slot, with its value *and* its Fugue anchor — one op set, two +//! states, two snapshots. Both are admissible for the reason C24 gives: dedup is +//! on `OpId`, and the id-space record only bounds an *honest* mint. +//! +//! Which claim holds a sequence id therefore has to be a function of the ops +//! alone. The order is `(kind tag, encoded value)`: two scalars separate on their +//! bytes, a scalar and a composite on the tag, and two composites are already +//! ranked by the document's `(list, stamp)` rank (C24) before the sequence is +//! touched at all. Every shape is folded here in every delivery order and +//! compared byte-for-byte. + +use crdtsync_core::doc::Document; +use crdtsync_core::elementid::{ElementId, ElementKind}; +use crdtsync_core::list::{Anchor, List, Side}; +use crdtsync_core::op::{Op, OpKind}; +use crdtsync_core::xml::XmlFragment; +use crdtsync_core::{Element, Scalar}; + +mod common; +use common::{cid, eid, stmp}; + +/// A rendering of the live sequence in slot `l`: scalars as their debug form, a +/// composite as its kind tag and id, so a divergence in *what* holds a slot +/// shows up as plainly as a divergence in order. +fn seq(d: &Document) -> String { + match d.get(b"l") { + Some(Element::List(l)) => render_all(&l.borrow().values()), + _ => "∅".to_string(), + } +} + +/// The same rendering for the `doc` fragment's children sequence. +fn kids(d: &Document) -> String { + match d.get(b"doc") { + Some(Element::XmlFragment(f)) => render_all(&f.borrow().children().borrow().values()), + _ => "∅".to_string(), + } +} + +fn render_all(values: &[Element]) -> String { + let parts: Vec = values.iter().map(render).collect(); + format!("[{}]", parts.join(",")) +} + +fn render(e: &Element) -> String { + match e { + Element::Scalar(s) => format!("{s:?}"), + Element::XmlElement(x) => format!("elem({})", String::from_utf8_lossy(x.borrow().tag())), + Element::Text(t) => format!("text({:?})", t.borrow().as_string()), + other => format!("?{}", other.kind() as u8), + } +} + +/// The lone op of a given shape in a batch. +fn only_kind(batch: Vec, is: impl Fn(&OpKind) -> bool) -> Op { + batch + .into_iter() + .find(|op| is(&op.kind)) + .expect("the op of that shape") +} + +fn only_insert(batch: Vec) -> Op { + only_kind(batch, |k| matches!(k, OpKind::ListInsert { .. })) +} + +/// Fold `build` then `ops` into a fresh replica and return `(rendered sequence, +/// snapshot bytes)`. The replica identity is fixed so two orders differ in +/// nothing but arrival. +fn fold(build: &[Op], ops: &[&Op], render: fn(&Document) -> String) -> (String, Vec) { + let mut d = Document::new(cid(9)); + for op in build.iter().chain(ops.iter().copied()) { + d.apply(op); + } + (render(&d), d.encode_state()) +} + +/// Fold every delivery order of `ops` (after `build`) and assert they agree on +/// the rendered state and byte-for-byte on the snapshot. Returns the one state. +#[track_caller] +fn converges(build: &[Op], ops: &[&Op], render: fn(&Document) -> String) -> (String, Vec) { + let mut folded: Vec<(Vec, String, Vec)> = Vec::new(); + for order in permutations(ops.len()) { + let picked: Vec<&Op> = order.iter().map(|i| ops[*i]).collect(); + let (state, bytes) = fold(build, &picked, render); + folded.push((order, state, bytes)); + } + let (first_order, first_state, first_bytes) = folded[0].clone(); + for (order, state, bytes) in folded.iter().skip(1) { + assert_eq!( + *state, first_state, + "order {order:?} folded differently from {first_order:?}" + ); + assert_eq!( + *bytes, first_bytes, + "order {order:?} encoded a different snapshot from {first_order:?}" + ); + } + (first_state, first_bytes) +} + +/// Every permutation of `0..n` (Heap's algorithm, iterative on a small n). +fn permutations(n: usize) -> Vec> { + let mut out = Vec::new(); + let mut items: Vec = (0..n).collect(); + permute(&mut items, 0, &mut out); + out +} + +fn permute(items: &mut Vec, at: usize, out: &mut Vec>) { + if at == items.len() { + out.push(items.clone()); + return; + } + for i in at..items.len() { + items.swap(at, i); + permute(items, at + 1, out); + items.swap(at, i); + } +} + +/// A list `l` holding two items, so two colliding inserts can name different +/// Fugue anchors. +fn list_with_two(d: &mut Document) -> Vec { + d.transact(|tx| { + let mut l = tx.list(b"l"); + l.insert(0, Scalar::Int(1)); + l.insert(1, Scalar::Int(7)); + }) +} + +/// The `doc` fragment with two children, so a colliding claim into its children +/// list has somewhere to differ. +fn fragment_with_two(d: &mut Document) -> Vec { + d.transact(|tx| { + let mut frag = tx.xml_fragment(b"doc"); + let mut kids = frag.children(); + kids.insert_element(0, b"p"); + kids.insert_element(1, b"q"); + }) +} + +#[test] +fn two_inserts_at_one_stamp_into_one_list_converge_in_either_order() { + // The measured bug: the encoded node carried `2` on one replica and `99` on + // the other, because `insert_at` is idempotent on the id and the first to + // land kept it. + let mut author = Document::new(cid(1)); + let build = list_with_two(&mut author); + + let low = only_insert(author.transact(|tx| tx.list(b"l").insert(0, Scalar::Int(2)))); + let mut high = only_insert(author.transact(|tx| tx.list(b"l").insert(3, Scalar::Int(99)))); + high.stamp = low.stamp; + assert_ne!( + low.kind, high.kind, + "the twins must differ in value and anchor" + ); + + let (state, _) = converges(&build, &[&low, &high], seq); + // Exactly one of the two values holds the slot — a rule that dropped both + // would converge while losing an admissible op. + assert!( + state.contains("Int(2)") ^ state.contains("Int(99)"), + "exactly one value must hold the slot: {state}" + ); +} + +#[test] +fn the_winners_anchor_travels_with_its_value() { + // Anchor and value are one unit. A rule that seated the winner's value at the + // incumbent's position would render the same sequence only until a delete + // froze the position into a tombstone. + let mut author = Document::new(cid(1)); + let build = list_with_two(&mut author); + + let front = only_insert(author.transact(|tx| tx.list(b"l").insert(0, Scalar::Int(2)))); + let mut back = only_insert(author.transact(|tx| tx.list(b"l").insert(3, Scalar::Int(99)))); + back.stamp = front.stamp; + + let (state, _) = converges(&build, &[&front, &back], seq); + // `Int(2)` wins on encoded bytes, and it was placed at the front — so the + // winner's own anchor is what the sequence must show. + assert_eq!( + state, "[Int(2),Int(1),Int(7)]", + "the winner sits at the loser's position" + ); +} + +#[test] +fn a_delete_between_the_two_colliding_inserts_converges_in_every_order() { + // A tombstone keeps the position of whatever it took out, so the two claims + // must agree on *where* the id sits and not only on what holds it: a delete + // landing between them would otherwise freeze the loser's anchor into the dead + // run on one replica and the winner's on the other, and the two encode + // different bytes while rendering the same sequence. + let mut author = Document::new(cid(1)); + let build = list_with_two(&mut author); + + let front = only_insert(author.transact(|tx| tx.list(b"l").insert(0, Scalar::Int(2)))); + let mut back = only_insert(author.transact(|tx| tx.list(b"l").insert(3, Scalar::Int(99)))); + back.stamp = front.stamp; + + let mut delete = front.clone(); + delete.id.seq = 9_100; + delete.stamp.lamport = front.stamp.lamport + 1; + delete.kind = OpKind::ListDelete { id: front.stamp }; + + // The delete lands after at least one insert in every order tried: it is + // inert against an id no insert has installed, which is a delete-of-an-unseen + // -id question, not a collision one. + let orders: [[&Op; 3]; 4] = [ + [&front, &delete, &back], + [&back, &delete, &front], + [&front, &back, &delete], + [&back, &front, &delete], + ]; + let mut folded: Vec<(String, Vec)> = Vec::new(); + for order in orders { + folded.push(fold(&build, &order, seq)); + } + for (i, got) in folded.iter().enumerate().skip(1) { + assert_eq!( + got.0, folded[0].0, + "order {i} folded to a different sequence" + ); + assert_eq!(got.1, folded[0].1, "order {i} encoded a different snapshot"); + } + assert_eq!(folded[0].0, "[Int(1),Int(7)]", "the delete must win"); +} + +#[test] +fn two_text_runs_at_one_stamp_converge_in_either_order() { + // Text runs ride the same seam: `insert_run` derives each codepoint's id from + // one base stamp, so two runs at one stamp collide id-for-id. + let mut author = Document::new(cid(1)); + let build = author.transact(|tx| { + let mut t = tx.text(b"t"); + t.insert(0, "ab"); + }); + + let first = only_kind(author.transact(|tx| tx.text(b"t").insert(0, "xy")), |k| { + matches!(k, OpKind::TextInsert { .. }) + }); + let mut second = only_kind(author.transact(|tx| tx.text(b"t").insert(4, "PQ")), |k| { + matches!(k, OpKind::TextInsert { .. }) + }); + second.stamp = first.stamp; + + fn text(d: &Document) -> String { + match d.get(b"t") { + Some(Element::Text(t)) => t.borrow().as_string(), + _ => "∅".to_string(), + } + } + // A run is one contest per codepoint, not one for the run: `x` takes the base + // id on its anchor, and `Q` takes the next — where both runs chain to the right + // of the base id, so the anchors tie and the encoded codepoints separate them. + let (state, _) = converges(&build, &[&first, &second], text); + assert_eq!(state, "xQab", "the codepoint ids resolved somewhere else"); +} + +/// A `ListInsert` addressed straight at the `doc` fragment's children list — a +/// scalar claim on a key an `XmlInsertChild` also derives a child for. It carries +/// the template's stamp, target and anchor, so the two meet at the sequence id. +fn scalar_into_children(template: &Op, value: Scalar) -> Op { + let mut op = template.clone(); + op.id.seq = 9_500; + let OpKind::XmlInsertChild { anchor, .. } = template.kind else { + panic!("the template must be a child insert") + }; + op.kind = OpKind::ListInsert { value, anchor }; + op +} + +#[test] +fn a_scalar_and_a_child_insert_at_one_stamp_converge_in_either_order() { + // A plain `ListInsert` reaches a children list without passing through the + // placement index at all, so the two claims meet only at the sequence id. The + // kind tag is the first key of the one order, so `Scalar` (tag 0) takes it. + let mut author = Document::new(cid(1)); + let build = fragment_with_two(&mut author); + + let child = only_kind( + author.transact(|tx| { + tx.xml_fragment(b"doc").children().insert_element(2, b"c"); + }), + |k| matches!(k, OpKind::XmlInsertChild { .. }), + ); + let scalar = scalar_into_children(&child, Scalar::Int(5)); + + let (state, _) = converges(&build, &[&child, &scalar], kids); + assert_eq!( + state, "[elem(p),elem(q),Int(5)]", + "the scalar's kind tag orders first, so it holds the slot: {state}" + ); +} + +#[test] +fn a_scalar_and_both_child_inserts_at_one_stamp_converge_in_every_order() { + // The shape #371 sharpened rather than closed: `[ListInsert, tagged, + // tagless]` replaced the scalar (an eviction re-seats over it) while + // `[ListInsert, tagless, tagged]` kept it (a refusal never reaches the + // sequence) — one op set and two trees. Every one of the six orders is folded. + let mut author = Document::new(cid(1)); + let build = fragment_with_two(&mut author); + + let tagged = only_kind( + author.transact(|tx| { + tx.xml_fragment(b"doc").children().insert_element(2, b"c"); + }), + |k| matches!(k, OpKind::XmlInsertChild { .. }), + ); + let mut tagless = tagged.clone(); + tagless.id.seq = 9_400; + if let OpKind::XmlInsertChild { tag, .. } = &mut tagless.kind { + *tag = None; + } + let scalar = scalar_into_children(&tagged, Scalar::Int(5)); + assert_eq!(tagless.stamp, tagged.stamp); + assert_eq!(scalar.stamp, tagged.stamp); + + let (state, bytes) = converges(&build, &[&scalar, &tagged, &tagless], kids); + assert_eq!( + state, "[elem(p),elem(q),Int(5)]", + "the scalar's kind tag orders first, so it holds the slot: {state}" + ); + + // And the replica still encodes a snapshot its own decoder accepts — the + // losing children are materialised, parented and placeless. + let back = Document::decode_state(&bytes).expect("a replica could not load its own snapshot"); + assert_eq!(back.encode_state(), bytes, "the re-encode is not canonical"); +} + +#[test] +fn a_composite_claim_does_not_take_an_id_a_scalar_holds_through_a_move() { + // The other way a composite reaches an id a scalar holds: an `XmlMove` whose + // stamp a `ListInsert` also carries. A move that lost the sequence id still + // holds its `(list, stamp)` placement — the two are separate contests — so the + // snapshot has to load back. + let mut author = Document::new(cid(1)); + let build = author.transact(|tx| { + let mut frag = tx.xml_fragment(b"doc"); + let mut kids = frag.children(); + kids.insert_element(0, b"p"); + kids.insert_element(1, b"q"); + }); + let node = match author.get(b"doc") { + Some(Element::XmlFragment(f)) => { + let kids = f.borrow().children(); + let first = kids.borrow().get(0).expect("a first child"); + first.id() + } + _ => panic!("doc is not a fragment"), + }; + let frag_id = XmlFragment::node_id(author.root_id(), b"doc"); + let children = XmlFragment::children_id(frag_id); + + let mv = only_kind(author.transact(|tx| tx.move_xml(node, frag_id, 2)), |k| { + matches!(k, OpKind::XmlMove { .. }) + }); + let OpKind::XmlMove { anchor, .. } = mv.kind else { + panic!("the move op") + }; + let mut scalar = mv.clone(); + scalar.id.seq = 9_600; + scalar.target = children; + scalar.kind = OpKind::ListInsert { + value: Scalar::Int(5), + anchor, + }; + + let (state, bytes) = converges(&build, &[&mv, &scalar], kids); + assert!( + state.contains("Int(5)"), + "the scalar must hold the sequence id: {state}" + ); + let back = Document::decode_state(&bytes).expect("a replica could not load its own snapshot"); + assert_eq!(back.encode_state(), bytes, "the re-encode is not canonical"); +} + +// --- the seam itself --- + +#[test] +fn merging_two_sequences_that_disagree_at_one_id_converges_either_way() { + // A merge is a seat path like any other: two replicas that folded one of the + // colliding ops each must not converge on whichever list was the receiver. + let id = eid(1, 1); + let anchor_a = Anchor { + parent: None, + side: Side::Right, + }; + let anchor_b = Anchor { + parent: None, + side: Side::Left, + }; + let mk = |value: i64, anchor: Anchor| { + let mut l = List::new(id); + l.insert_at(stmp(5, 1), Element::Scalar(Scalar::Int(value)), anchor); + l + }; + let mut ab = mk(2, anchor_a); + ab.merge(&mk(99, anchor_b)); + let mut ba = mk(99, anchor_b); + ba.merge(&mk(2, anchor_a)); + assert_eq!( + ab.encode_state(), + ba.encode_state(), + "a merge resolved by which side received" + ); +} + +#[test] +fn a_claim_that_ties_takes_the_meet_of_the_two_positions() { + // Two claims that carry the same value are not a contest — nothing separates + // them — so the position is the meet, which is the same whichever arrived + // first. This is the scalar image of `rejoin`. + let id = eid(1, 1); + let low = Anchor { + parent: None, + side: Side::Left, + }; + let high = Anchor { + parent: None, + side: Side::Right, + }; + let mk = |first: Anchor, second: Anchor| { + let mut l = List::new(id); + l.insert_at(stmp(5, 1), Element::Scalar(Scalar::Int(4)), first); + l.insert_at(stmp(5, 1), Element::Scalar(Scalar::Int(4)), second); + l + }; + assert_eq!( + mk(low, high).encode_state(), + mk(high, low).encode_state(), + "two equal claims resolved by arrival order" + ); +} + +#[test] +fn a_replayed_insert_is_still_inert() { + // The idempotence `insert_at` had on the id is preserved where it is a + // replay: the same op twice must leave one node at one position. + let mut l = List::new(eid(1, 1)); + let anchor = Anchor { + parent: None, + side: Side::Right, + }; + l.insert_at(stmp(5, 1), Element::Scalar(Scalar::Int(4)), anchor); + let once = l.encode_state(); + l.insert_at(stmp(5, 1), Element::Scalar(Scalar::Int(4)), anchor); + assert_eq!(l.encode_state(), once, "a replay changed the sequence"); + assert_eq!(l.len(), 1); +} + +#[test] +fn the_order_ranks_the_kind_tag_before_the_value() { + // A composite's encoded value is its element id, which is uncorrelated with + // any scalar's bytes — so the tag has to be read first or the two would + // interleave by content. + let anchor = Anchor { + parent: None, + side: Side::Right, + }; + let composite = Element::List(std::rc::Rc::new(std::cell::RefCell::new(List::new( + ElementId::from_bytes([0u8; 16]), + )))); + assert_eq!(composite.kind(), ElementKind::List); + // The all-zero element id is the smallest possible composite payload; a + // scalar still outranks it, because tag 0 is read before either payload. + let mut l = List::new(eid(1, 1)); + l.insert_at(stmp(5, 1), composite.deep_clone(), anchor); + l.insert_at( + stmp(5, 1), + Element::Scalar(Scalar::Bytes(vec![0xff; 8])), + anchor, + ); + assert!( + matches!(l.get(0), Some(Element::Scalar(_))), + "the composite kept an id the scalar's tag outranks" + ); +} From 79594015646e5f60d2f2dd2dc988897a1fb45b70 Mon Sep 17 00:00:00 2001 From: Lucas Vieira Date: Sun, 9 Aug 2026 08:47:10 -0300 Subject: [PATCH 2/4] test(list): pin the tag ahead of the position, the join's yield and the merge meet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A mutation sweep left three behaviours unpinned: the kind tag leading the position (every claim in the spec shared one anchor), a join yielding to a scalar on the id, and a merge taking the meet of two positions for one value. Each now folds every delivery order and compares snapshot bytes. `yields_to_seated` drops its redundant claim-kind test — only a composite reaches a placement key — and states the precondition instead. --- crates/core/src/list.rs | 10 +- crates/core/tests/list_stamp_collision.rs | 160 ++++++++++++++++++++++ 2 files changed, 166 insertions(+), 4 deletions(-) diff --git a/crates/core/src/list.rs b/crates/core/src/list.rs index ac476b50..a274fb7b 100644 --- a/crates/core/src/list.rs +++ b/crates/core/src/list.rs @@ -653,10 +653,12 @@ impl List { /// tombstone holds no kind to read and keeps the document's verdict, which /// outlives the delete where a sequence rank would not. fn yields_to_seated(&self, id: Stamp, claim: &Element) -> bool { - let Some(seated) = self.nodes.get(&seq_key(&id)).map(|n| n.value.kind()) else { - return false; - }; - seated == ElementKind::Scalar && claim.kind() != ElementKind::Scalar + debug_assert_ne!( + claim.kind(), + ElementKind::Scalar, + "a scalar reaches no placement key, so nothing ranks one here" + ); + self.nodes.get(&seq_key(&id)).map(|n| n.value.kind()) == Some(ElementKind::Scalar) } /// Where `id` currently sits, or `None` if the list does not hold it — read diff --git a/crates/core/tests/list_stamp_collision.rs b/crates/core/tests/list_stamp_collision.rs index 03727a54..36464b40 100644 --- a/crates/core/tests/list_stamp_collision.rs +++ b/crates/core/tests/list_stamp_collision.rs @@ -381,6 +381,131 @@ fn a_composite_claim_does_not_take_an_id_a_scalar_holds_through_a_move() { assert_eq!(back.encode_state(), bytes, "the re-encode is not canonical"); } +#[test] +fn the_kind_tag_leads_the_position_across_the_scalar_boundary() { + // The three claims carry *different* anchors, so the order of the first two + // keys is observable — and it is not a preference: ranking the position above + // the tag makes this op set fold two ways. A composite that took the id on a + // smaller anchor would be evicted by the document's own `(list, stamp)` rank in + // one order and refused before it ever reached the sequence in another, because + // the eviction carries a verdict the refusal never asks for. + let mut author = Document::new(cid(1)); + let build = author.transact(|tx| { + let mut frag = tx.xml_fragment(b"doc"); + let mut kids = frag.children(); + for tag in [b"p", b"q", b"r"] { + let at = kids.len(); + kids.insert_element(at, tag); + } + }); + + let tagged = only_kind( + author.transact(|tx| { + let mut frag = tx.xml_fragment(b"doc"); + frag.children().insert_element(0, b"c"); + }), + |k| matches!(k, OpKind::XmlInsertChild { .. }), + ); + let mut tagless = only_kind( + author.transact(|tx| { + let mut frag = tx.xml_fragment(b"doc"); + frag.children().insert_text(1); + }), + |k| matches!(k, OpKind::XmlInsertChild { .. }), + ); + let back = only_kind( + author.transact(|tx| { + let mut frag = tx.xml_fragment(b"doc"); + frag.children().insert_element(5, b"z"); + }), + |k| matches!(k, OpKind::XmlInsertChild { .. }), + ); + let mut scalar = scalar_into_children(&back, Scalar::Int(5)); + tagless.stamp = tagged.stamp; + scalar.stamp = tagged.stamp; + let anchors: Vec = [&tagged, &tagless, &scalar] + .iter() + .map(|op| match op.kind { + OpKind::XmlInsertChild { anchor, .. } | OpKind::ListInsert { anchor, .. } => anchor, + _ => panic!("a claim op"), + }) + .collect(); + assert_eq!( + anchors + .iter() + .collect::>() + .len(), + 3, + "the three claims must name three positions" + ); + + let (state, _) = converges(&build, &[&scalar, &tagged, &tagless], kids); + assert!( + state.ends_with(",Int(5)]"), + "the scalar lost its own position to a composite: {state}" + ); +} + +#[test] +fn a_join_at_an_id_a_scalar_holds_converges_in_every_order() { + // Two moves of one node at one stamp into one list are not a contest — the key + // is already the claimant's own — so the document answers `Joined` and the + // sequence takes the meet of the two positions. A scalar on the id is a contest + // all the same, and the join has to yield to it: the placement key never ranked + // the mover against a scalar, so if the join skipped the order the same three + // ops would fold two ways. + let mut author = Document::new(cid(1)); + let build = author.transact(|tx| { + let mut frag = tx.xml_fragment(b"doc"); + let mut kids = frag.children(); + for tag in [b"p", b"q", b"r"] { + let at = kids.len(); + kids.insert_element(at, tag); + } + }); + let frag_id = XmlFragment::node_id(author.root_id(), b"doc"); + let children = XmlFragment::children_id(frag_id); + let node = match author.get(b"doc") { + Some(Element::XmlFragment(f)) => f + .borrow() + .children() + .borrow() + .get(0) + .expect("a first child") + .id(), + _ => panic!("doc is not a fragment"), + }; + + let first = only_kind(author.transact(|tx| tx.move_xml(node, frag_id, 2)), |k| { + matches!(k, OpKind::XmlMove { .. }) + }); + let mut second = only_kind(author.transact(|tx| tx.move_xml(node, frag_id, 0)), |k| { + matches!(k, OpKind::XmlMove { .. }) + }); + second.stamp = first.stamp; + assert_ne!( + second.kind, first.kind, + "the two moves must differ in anchor" + ); + + let mut scalar = first.clone(); + scalar.id.seq = 9_700; + scalar.target = children; + let OpKind::XmlMove { anchor, .. } = second.kind else { + panic!("the move op") + }; + scalar.kind = OpKind::ListInsert { + value: Scalar::Int(5), + anchor, + }; + + let (state, _) = converges(&build, &[&first, &second, &scalar], kids); + assert!( + state.contains("Int(5)"), + "the join took an id the scalar's tag outranks: {state}" + ); +} + // --- the seam itself --- #[test] @@ -412,6 +537,41 @@ fn merging_two_sequences_that_disagree_at_one_id_converges_either_way() { ); } +#[test] +fn merging_one_value_seated_at_two_positions_takes_the_meet() { + // The two replicas hold the same value at one id and disagree only on where it + // sits — each folded one of two claims that tie on the value. Nothing is + // contested, so the position is the meet, which is the same on both. + let id = eid(1, 1); + let low = Anchor { + parent: None, + side: Side::Left, + }; + let high = Anchor { + parent: None, + side: Side::Right, + }; + let mk = |anchor: Anchor| { + let mut l = List::new(id); + l.insert_at(stmp(5, 1), Element::Scalar(Scalar::Int(4)), anchor); + l + }; + let mut ab = mk(low); + ab.merge(&mk(high)); + let mut ba = mk(high); + ba.merge(&mk(low)); + assert_eq!( + ab.encode_state(), + ba.encode_state(), + "a merge kept the position of whichever side received" + ); + assert_eq!( + ab.encode_state(), + mk(low).encode_state(), + "the meet is not the lesser position" + ); +} + #[test] fn a_claim_that_ties_takes_the_meet_of_the_two_positions() { // Two claims that carry the same value are not a contest — nothing separates From b1786171d077605baf08674da9e6a4cc481f15f7 Mon Sep 17 00:00:00 2001 From: Lucas Vieira Date: Sun, 9 Aug 2026 09:02:34 -0300 Subject: [PATCH 3/4] docs(doc): restate the claim answers in terms of the sequence's own order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Claim::Fresh` no longer means the insert lands unconditionally and `Claim::Evicted` no longer means it overwrites whatever is there — both go through the seat order, which yields to a scalar on the id. --- crates/core/src/doc.rs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/crates/core/src/doc.rs b/crates/core/src/doc.rs index ab6b7b7f..c44964fb 100644 --- a/crates/core/src/doc.rs +++ b/crates/core/src/doc.rs @@ -59,15 +59,18 @@ use std::rc::Rc; enum Claim { /// Nothing held the key. A claim owns the key, not the sequence id: a plain /// `ListInsert` reaches a children list without passing through the placement - /// index, so the id may already be taken, in which case this answer's insert - /// does not land and the eviction and join below overwrite what is there. + /// index at all, so the id may still be taken — by a scalar, which outranks + /// every composite on the kind tag, the first key of the sequence's own order. + /// All four answers below therefore go through that order too; only the + /// question of who holds the *key* is settled here. Fresh, /// The claimant's own node already held it, so nothing changes hands — the /// sequence slot takes the meet of the two positions and the placement record /// is already there. Joined, /// A node this one outranks held it and now holds nothing at it, so the - /// sequence slot is re-seated rather than inserted into. + /// sequence slot is re-seated rather than inserted into — over the composite + /// this claim was ranked against, never over a scalar it was not. Evicted, /// A node this one does not outrank holds the key. Refused, @@ -4297,7 +4300,7 @@ impl Document { /// (an `XmlElement` when `tag` is present, else a `Text` run), register it, /// and insert its handle as a sequence node keyed by the op's stamp. The /// child's element id derives from that stamp, so every replica builds the - /// same child; `insert_at` is idempotent on the stamp, so a replay is inert. + /// same child; a replay ties its own claim on the sequence id, so it is inert. /// /// Inserts into the sequence even when its holding element is displaced: a /// displaced parent retains its children, so the child materialises hidden and From d69349fc1019237c90f93946fb8401dab3dbb474 Mon Sep 17 00:00:00 2001 From: Lucas Vieira Date: Sun, 9 Aug 2026 09:23:51 -0300 Subject: [PATCH 4/4] docs: state which part of the seat order each seam reads, and file the merge residue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An adversarial falsification pass measured that "every seat path runs the one rank" is overstated: a document-ranked eviction or join reads only the leading tag, and a claim meeting a tombstone reads only the position. ARCHITECTURE, DECISIONS and the spec's own module doc now say which seam reads what, and the spec no longer attributes a win to the encoded bytes where the position decides. The same pass measured `List::merge` non-associative on a collision plus a delete (104/3000 pools, 0 without a delete) where C40 made it commutative (2638/3000 before, 0 after) — the tombstone arm can rank nothing. Recorded on C133 with the mixed pair it shares a cause with. C134 files the detached deep clone the winning merge arm installs. `claim_placement` said two inserts "carrying one tag" derive one child; it is one *kind*, and two tags of that kind are left unsettled — C44's shape. --- ARCHITECTURE.md | 2 +- DECISIONS.md | 6 ++++-- KANBAN.md | 6 ++++-- crates/core/src/doc.rs | 10 ++++++---- crates/core/tests/list_stamp_collision.rs | 23 +++++++++++++---------- 5 files changed, 28 insertions(+), 19 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 270bd027..43911afd 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -325,7 +325,7 @@ The fold is a pure function of the move-set + tombstone-set, **independent of th **Fugue** (Weidner & Kleppmann 2023, "The Art of the Fugue"). Tree-based, formally proven no-interleaving on concurrent inserts at the same point. Same algorithm reused for Text. -**A sequence id is owned by the kind tag, then the position, then the encoded value.** Two ops can carry one `Stamp` into one sequence and both be admissible — op dedup is by `OpId`, and the id-space record bounds only an *honest* mint — so which of them the id ends at is decided by that rank, never by which arrived first, and the winner takes the id with its value **and** its anchor. Every seat path runs the one rank: a plain `ListInsert`, a `Text` run's codepoints, a children-list birth whose placement key was free, an eviction or a join the document ranked, and a sequence merge. A claim owns the key it named and not the id, so a path that skipped the rank would keep arrival-order ownership in that path alone — which is how this class hides. The **tag leads** because it is the one key a scalar and a composite both have, and it is read as the number it is rather than as a preference for either: a numeric order stays total when a kind is added, where a semantic one re-opens the question on every new op. The **position outranks the value** because it is the only part of a claim a delete leaves behind — a tombstone drops the value and keeps the anchor, so a claim landing on a tombstoned id is ranked on the position alone, and that settles the whole difference, since the position is also all a tombstone encodes. Two *composites* are ranked one layer up instead: the document's `(list, stamp)` rank (§Tree Moves) decides them before the sequence is touched, which is why the tag decides across the scalar/composite boundary and never inside it. That layering is also its one open edge — a tombstone holds no kind to read, so a scalar and a composite claim that a delete lands between are still ordered by arrival (C133). +**A sequence id is owned by the kind tag, then the position, then the encoded value.** Two ops can carry one `Stamp` into one sequence and both be admissible — op dedup is by `OpId`, and the id-space record bounds only an *honest* mint — so which of them the id ends at is decided by that rank, never by which arrived first, and the winner takes the id with its value **and** its anchor. Every seat path runs the rank, each reading as much of it as is still open to that seam — and a claim owns the key it named and not the id, so a seam that read none of it would keep arrival-order ownership there alone, which is how this class hides. A claim nothing has ranked reads the whole order: a plain `ListInsert`, a `Text` run's codepoints, a children-list birth whose placement key was free, and a sequence merge against a live node. A claim the document has *already* ranked — an eviction or a join at the `(list, stamp)` key — has its composite half answered and reads only the leading tag here, which is the half no placement key covers. A claim meeting a **tombstone** can read only the position, since a delete leaves nothing else. The **tag leads** because it is the one key a scalar and a composite both have, and it is read as the number it is rather than as a preference for either: a numeric order stays total when a kind is added, where a semantic one re-opens the question on every new op. The **position outranks the value** because it is the only part of a claim a delete leaves behind — a tombstone drops the value and keeps the anchor, so a claim landing on a tombstoned id is ranked on the position alone, and where the two claims are of one class that settles the whole difference, since the position is also all a tombstone encodes. Two *composites* are ranked one layer up instead: the document's `(list, stamp)` rank (§Tree Moves) decides them before the sequence is touched, which is why the tag decides across the scalar/composite boundary and never inside it. That layering is also its one open edge, and a tombstone is where it shows: a delete leaves no kind to read, so a scalar and a composite claim that one lands between are still ordered by arrival, and a sequence *merge* — whose tombstone arm can rank nothing at all — loses associativity on the same shape (C133). ## LWW diff --git a/DECISIONS.md b/DECISIONS.md index 9de22b0c..53382571 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -44,9 +44,11 @@ The entries below (2026-07-02) are a backfill: design changes made during the v0 **The position outranks the value, and that ordering is forced by a measurement rather than chosen.** A delete drops a node's value and keeps its anchor — §Tombstone GC, a tombstone holds a position and nothing else. So the anchor is the only key of the rank that survives a delete, and it is also the only thing a tombstone encodes. Read the value first and a contest a delete lands in the middle of is decided by *which claim the delete buried*: `[insert 2, delete, insert 99]` freezes one anchor and `[insert 99, delete, insert 2]` the other, and the two encode different bytes while rendering the same sequence. Read the position first and the winner is the claim with the smaller anchor whether or not its value is still there to read, so all four orders of `[insert, insert, delete]` agree. The value stays in the rank below it, so two claims at one position still separate — and where a claim ties outright, nothing is contested and the id is left alone, which is what keeps a replay inert. -**The fix is that every seat path runs it, which is the half the filing said would hide.** A claim owns the key it named, not the id. `Claim::Fresh` therefore found `insert_at` inert against a scalar already sitting at the id, while an eviction or a join re-seated over it — so `[ListInsert, tagged, tagless]` replaced the scalar and `[ListInsert, tagless, tagged]` kept it, one op set folding to two trees. Stating the rule in terms of the *key* means five paths take it: the plain `ListInsert`, every codepoint of a `Text` run, a children-list birth whose key was free, the document-ranked eviction and join (which now yield to a live scalar on the id — the half no placement key ranks a composite against), and `List::merge`, which was the fourth route and had been answering by which side received. A path that skipped it would have kept arrival-order ownership in that path alone, which is exactly how this class hides. +**The fix is that every seat path runs it, each reading as much of the order as is still open to that seam — the half the filing said would hide.** A claim owns the key it named, not the id. `Claim::Fresh` therefore found `insert_at` inert against a scalar already sitting at the id, while an eviction or a join re-seated over it — so `[ListInsert, tagged, tagless]` replaced the scalar and `[ListInsert, tagless, tagged]` kept it, one op set folding to two trees. Stating the rule in terms of the *key* means five paths take it, and what each reads is worth being exact about, because they do not all read the same thing. The plain `ListInsert`, every codepoint of a `Text` run, a children-list birth whose key was free, and `List::merge` against a live node read the **whole** order. The document-ranked eviction and join read only the **leading tag**: their composite half was answered at the placement key, and what is left is the scalar a placement key never ranks them against. A claim meeting a **tombstone** reads only the **position**, because a delete leaves nothing else — which is the whole of the edge below. A seam that read none of it would have kept arrival-order ownership in that seam alone, which is exactly how this class hides. -**What is left open, and why it is a separate ruling.** A tombstone holds no kind to read, so a *mixed* pair that a delete lands between is ranked on the anchor alone and lands somewhere the tag would not have put it: the two delete-between orders agree with each other, the two delete-last orders agree with each other, and the classes differ. Measured, and filed as C133. This unit narrows it — on `main` the same three ops split on *which claim arrived first* — but does not close it, because closing it needs the winning rank to outlive the delete, and a sequence has nowhere to put one: a tombstone is a run record covering any number of ids, so a rank per dead id is O(deletions) in memory and on the wire and defeats the compression §Tombstone GC exists for. The composite half is total across a delete only because the *document* persists that rank, which is a record the scalar half does not have and cannot be given without deciding what a sequence may remember about a dead id. +**`List::merge` was the fourth route and had been answering by which side received; it is fixed for commutativity and left short of associativity.** Two replicas that each folded one of two colliding claims used to converge on whichever list was the receiver — measured non-commutative on 2638 of 3000 random collision pools, and commutative on all 3000 after. Associativity is a strictly narrower property here and it does not survive: a merge whose receiver holds a *tombstone* at the contested id can rank nothing (the peer's live node is dropped and the run keeps the receiver's own anchor), so `A·(B·C)` and `(A·B)·C` can bury the id at different positions — 104 of 3000 pools, and none without a delete. It is the same root cause as the edge below reached by a second route, and it is filed with it rather than claimed closed. + +**What is left open, and why it is a separate ruling.** A tombstone holds no kind to read, so a *mixed* pair that a delete lands between is ranked on the anchor alone and lands somewhere the tag would not have put it: the two delete-between orders agree with each other, the two delete-last orders agree with each other, and the classes differ. Measured, and filed as C133 together with the merge associativity loss above, which is the same cause on a seam that can rank even less. This unit narrows it — on `main` the same three ops split on *which claim arrived first* — but does not close it, because closing it needs the winning rank to outlive the delete, and a sequence has nowhere to put one: a tombstone is a run record covering any number of ids, so a rank per dead id is O(deletions) in memory and on the wire and defeats the compression §Tombstone GC exists for. The composite half is total across a delete only because the *document* persists that rank, which is a record the scalar half does not have and cannot be given without deciding what a sequence may remember about a dead id. ## 2026-08-09 · C53 branch catch-up floor · a shared base is only as durable as `main`'s retained log, and a catch-up that cannot serve it refuses rather than serving what is left diff --git a/KANBAN.md b/KANBAN.md index b7c89ac0..4375e6f4 100644 --- a/KANBAN.md +++ b/KANBAN.md @@ -34,7 +34,7 @@ _Derived from code + git; a convenience view, not the source of truth._ **C53 — a log-shared branch materializes truncated once `main` compacts, and says nothing (crates/server) — DONE (#399).** Filed by review during C27 (#375) as reasoned-not-reproduced; **reproduced first**, five shapes, each a failing test before a line of the fix. `catch_up_branch`'s shared-base slice clamped its low end up to the compaction floor (`last_seen_seq.max(r.base_seq) - r.base_seq`), so every record at or below the floor was dropped from the served base and the answer was still `Catchup::Ops`. Measured on `main`: a fork at 4 in a room compacted to 2 folds, for a fresh subscriber, to `one=None two=None three=3 four=4 tail=9`; a floor raised to the fork point folds to the divergent tail over nothing, with the same fork serving its whole base one statement earlier as the control; `base_seq >= fork_point` reaches the same place by the other route, an empty retained slice rather than a clipped one; `install_snapshot` reaches it with no compaction configured anywhere, which is the ordinary below-floor follower state-transfer; and **at the wire**, on a room holding no doc-ACL tuples so that C60's `no_tree` refusal is skipped, a `Subscribe` at `last_seen_seq = 0` is answered with one `Ops` frame carrying the branch's tail op and nothing else. That last is the reachability claim the filing rested on, and it holds. The read was the only consequence left: C60 (#386) made a clipped shared base yield no redaction tree and C51 (#393) split `materialize_branch` four ways, so a publish of such a stream already froze nothing and a diff already answered `UnreadableBranch` — the catch-up was the seam still answering `Some`, to the one caller that acts on it by *becoming* the stream. **Ruling: the refusal is bounded by what the subscriber needs, not by what the branch is.** `catch_up_branch` answers `Catchup::Unavailable` when `min(base_end, base_seq) > last_seen_seq`. Asked of a fold from zero that condition is *identical* to `stream_doc`'s own clipped-base check, so the tree a read is redacted against and the stream that read is served cannot describe different branches — a second, differently-shaped notion of "clipped" at the serve seam is exactly the drift C60's lesson warns about. The refusal takes **both** ends of the window, and standing above either is enough to be served — at or above the floor nothing the subscriber still needs was dropped, at or above its own fork point it needs none of the base whatever the floor has since done. Stating one end as *the* bound is wrong in both directions, which a falsification pass measured (a subscriber three records below the floor, served on its fork point) after a first correction over-corrected the other way. That a subscriber past the fork point keeps its tail is `Catchup::Unavailable`'s own standing ruling, which C60 relies on, pinned at the hub and at the wire rather than left to the enum. **The filing's alternative — a compaction floor that will not advance past a live fork point — is rejected, on two counts.** It cannot be complete: `install_room_state` lands an empty log at a raised floor with no `compact` call to guard, so the same wrong `Some` would survive on the path needing no operator action at all. And it converts a correctness bug into an availability one: a branch is long-lived by design (§Per-User Branches), so one forgotten per-user fork would hold a room's log unreclaimable forever, and `set_compaction_threshold` — which compacts inline from `ingest` — would then never reclaim on exactly the rooms that grow. **What this does not fix, stated because three filings share the signature.** The refusal is not a repair: the pre-floor content is still gone and the branch is now unservable at every seam rather than wrong at one. **C88** owns the repair (an owned base materialized for every registered fork whose fork point sits above a floor about to rise, at both compaction and the snapshot install) and its cost — one whole-replica copy per live-log fork, paid where the reclaim was — is the ruling this unit deliberately does not pre-empt. **C104** is untouched and unreachable by this guard: an orphaned base reclassified as a live-log fork produces wrong bytes only at floor **0**, which is precisely where `min(base_end, base_seq) > last_seen_seq` never fires. **The cost of the refusal, measured rather than left to be discovered.** The filing's framing is an aging one — a floor rising past a fork that already existed — and the dominant case is the opposite: a fork taken on an **already-compacted** room is unservable from birth. `fork_branch` clamps to `main`'s head, which is the only point the wire's own fork command names, the floor is already there, and the first fresh subscriber is refused; nothing repairs it, and deleting the name and re-forking reaches the same missing log. Pinned as a test rather than described — on a room whose floor has left zero, which is the condition, a fork on a room still at floor 0 being a pinned control. **And compaction is not the only way a room is born at its floor**: `install_room_state` lands an empty log at a floor equal to the op count the installed state carries, so a **cloned or imported** room has `floor == head` from birth — and `clone_room` is the "duplicate this doc as a template" primitive, which plainly fits the per-user-fork workflow though ARCHITECTURE §Per-User Branches names the room's own `main` rather than a clone. The mechanical fact stands without the linkage and is pinned: a fork on a cloned room is refused from birth on a node that has compacted nothing, and a later `main` write does not rescue it: the fork point stays pinned at the old head, which is the floor. Pinned too. It is the right trade anyway, for two reasons that are not shrugs: it replaces *silent divergence*, and it is the last seam to take a refusal the branch already took everywhere else, so it makes the set consistent rather than inventing a policy. **The cheap repair the reach points at is recorded on C88 rather than taken here** — where a fork point sits at `main`'s head, `main`'s replica *is* the branch's base byte for byte, so a base could be written at the fork seam for one copy at fork time; doing it here would flip the publish and diff answers `branch_state_taxonomy` pins for exactly that fixture, which is re-litigating two merged rulings inside a third. **Two residuals the falsification passes measured, stated rather than closed.** A subscriber's position is what it *claims* — `last_seen_seq` is a client-supplied wire field and nothing verifies it — and the general statement is stronger than the fork-point case: a client asserting **the room's floor** computes the identical `lo = 0` the pre-fix code computed, so wherever the floor sits at or below the fork point it receives byte-identical pre-fix output — measured on four of the spec's five clipped fixtures. It is not universal, and the exception is instructive: where the floor has run *past* the fork point, claiming it also skips past the fork point and so truncates the branch's own tail, leaving the liar with less than the pre-fix answer. The refusal's threshold is discoverable by binary search over subscribes, which recovers `min(fork_point, head, floor)` rather than the floor itself, and only on a room holding no doc-ACL tuples — with tuples, C60's refusal is cursor-independent and every probe returns the same frame. The two flagship cases in the file share a fixture and differ only in that integer. That is the protocol's standing cursor model — the read-your-writes floor is the same field — and not this seam's to close; a server-side per-channel cursor would. It also means a client caught up short by the *pre-fix* server is not healed either. And the guard tests the **floor** end of the window only, so a `main` whose head has *regressed* below a live fork point — reachable through `install_snapshot` at a sequence below one, whose `seq` is peer-supplied — still serves a base short of the fork point with no signal; filed as **C135**, a head regression being a different cause and a different fix. One pre-existing clause is now implied rather than load-bearing and **stays** with a comment saying so — past the refusal the window sits wholly inside the retained log, so `base_end > r.base_seq` and the `.max(r.base_seq)` clamp narrow nothing, but they are what makes the slice arithmetic total on its own inputs rather than on the guard above holding. Spec `crates/server/tests/branch_catchup_floor.rs` (15), with an oracle indifferent to *how* a stream is served — a delta folds from the empty document exactly as a subscriber does, a snapshot decodes — so every case reads "the pre-floor content is there, or the catch-up said no" and stays true under C88's repair as well as under this refusal. Seven reproduce (the straddled floor, the floor at the fork point, the floor past it, the state-transfer route, the wire subscribe, the fork born on an already-compacted room — which also pins that re-forking the name recovers nothing — and the fork born on a *cloned* room, with no compaction anywhere), each reddened by deleting the guard; seven are the controls the refusal must not swallow (a subscriber at the fork point and at the branch head, an uncompacted fork, a floor of 0, a fork at 0, `main`'s compacted catch-up, a snapshot fork, the two boundaries again at the wire, and the one arm where the slice arithmetic still does real work — forked at 4 over a floor of 2, a subscriber at the floor is owed records 3 and 4 and its tail, asserted by identity rather than by count, while one record lower is refused, the `>` boundary itself); the fifteenth measures the identity claim instead of arguing it, across a grid of pre/post-compaction op counts and every fork point from 0 to one past the head, reaching **one** direction and saying so, since `stream_doc` answers the clipped case before it folds but past that check the diff seam *is* this catch-up and an over-refusal propagates into both answers. Mutation-checked against the committed tree: deleting the guard reddens nine, `min(base_end, base_seq)` → `base_seq` alone reddens two, → `base_end` alone reddens three, `min` → `max` reddens five, `>` → `>=` reddens seven; two cases are reddened by no mutation and are named rather than counted as coverage, sitting above the guard's own early returns. ARCHITECTURE §Branches + DECISIONS 2026-08-09. → *Server / Branches*. **C14 — a redacted op delta left the same re-mint hole, and no frame could carry the ids it withheld (crates/server + crates/core) — DONE (#PR).** C9 (#357) closed the *snapshot* seam by keeping the recipient's own ids in a projected snapshot's frontier; an uncompacted room serves an op delta, and the per-op read filter is authorship-blind, so it withheld the recipient's own ops on paths it may no longer read. Reproduced on C9's own fixture before anything was written: a reader with write at `/` and read on `/a` writes into `/b` then `/a`, restarts, `next_seq` reports **0**, and the next write mints straight onto its own withheld id and dedups away at ingest, silently. **The state encoding was the carrier for the snapshot; an `Ops` frame has none, so one is added.** `Message::Frontier { channel, seqs, reach }` (tag 54, server→client) leads a redacted catch-up delta; `Document::note_published` folds what it names into a `reserved` set the mint searches. **C9 explicitly refused a wire frontier and every one of its four reasons is seam-specific**, which is why the answer flips here: it supplies no number to the *sequence* counter (one named sequence is one held id and one search step — a frame naming `u64::MAX` leaves a fresh replica minting `0`), and the `reach` half, which is a wire number reaching the id-space floor, buys nothing an op carrying this replica's id does not already buy under the same ceiling; it carries the codebase's own decode bound; "a scalar cannot express a hole" is exactly why this is a **set**; and "`seen` rides `encode_state`" was the case *for* `seen` on the snapshot seam, while the delta frame has no state to ride — and what it installs rides `encode_state` anyway (`STATE_VERSION` 15). **Sequences, not `OpId`s**, so no other replica's id space is representable and the "only the recipient's own" rule is a property of the frame rather than a check on it — and the frame carries none of the targets or content that made the filing's other candidate shape (stop scrubbing the reader's own ops) unshippable. **A falsification pass broke the first cut twice and both are the substance.** *(1) A mint reads two records and the first cut carried one.* The lamport position comes from `mint_floor`, and the snapshot seam keeps it (`scrub_high_water_to`); the first frame carried only sequences, so every id derived from a stamp **alone** — an ACL tuple's, a ranged element's, an XML sequence child's — re-derived across a redacted delta. Measured: a restarted reader's next mark took the byte-identical `ElementId` of the mark its delta withheld, and the room dropped it at ingest. The frame now carries `reach`, taken from the withheld ops themselves so a branch stream reports its own and the figure is the tighter of the two available, clamped exactly as `record_stamp` clamps a folded stamp. *(2) Naming a sequence must not be refusing its op.* The first cut folded the run into the dedup set following C9, whose justification — "the ops sit below the snapshot's sequence, so no later delivery meets them" — a delta does not have: the client's cursor advances by the *delivered* batch length (C117), so a reader whose run ends in the withheld subtree resumes from below its own last ops and a widened grant re-serves exactly them. Measured: both dropped as replays while another author's write into the same subtree folded normally. The ids now go to `reserved`, cleared by `apply` when the op arrives. **The frontier leads the delta** because a provider opens the socket to app traffic *on a frame* and an app can author between two. Both the Python and JS providers completed the initial sync on the wrong frame (JS's gate read `lastSeenSeq(channel) !== undefined`, satisfied from the moment the channel is held, so *any* frame completed it); both now match the catch-up reply by tag and channel — an allowlist, Go's shape, chosen over excluding the new tag because a denylist is re-broken by the next server-directed frame. **The live fan-out is deliberately given nothing**, on the invariant that a replica applies its own op at authoring time — which the falsification pass broke as stated (two connections declaring one `ClientId` under one actor), so it is restated as a property of one replica per identity and filed as **C130**. Spec `crates/server/tests/delta_frontier.rs` (12), `crates/core/tests/op_seq_high_water.rs` (+20), `crates/core/tests/state_project_seen.rs` (+2), `crates/core/tests/protocol.rs` (+4), `sdks/python/tests/test_provider_lifecycle.py` (+1), `sdks/js/test/provider.lifecycle.test.ts` (+1). **A seventeen-branch statement-deletion sweep against the shipped code left four mutants green and none was redundant code** — each was a hole in the suite, all four in the new reservation set (`apply`'s clear, `adopt_as`'s clear, the decoder's reserved-over-applied check, both projections' clear), and the last exposed a second vacuous test since `adopt_as` masks the projection rule from anything read back through `decode_state_as`. Pinned, and the sweep now leaves no survivor. ARCHITECTURE §Wire-Level Redaction + DECISIONS 2026-08-09. Three residues filed: **C130** (the live seam's exemption rests on one replica per identity), **C131** (the read half of the identity boundary C23 closed for writes), **C132** (the frame buys durably-retained ids at 8 wire bytes each, bounded only by the frame cap). → *Server / Fan-out*. -**C40 — two ops carrying one stamp into one plain list resolved by arrival order (crates/core) — DONE (#400).** Filed during C24 (#371) and renumbered from C27, which C15 (#368) had already taken. C24 closed the *children-list* half — a placement key is owned by a birth over a move, then by the smaller element id, whatever the arrival order — and one layer down the same collision was still first-wins: `List::insert_at` was idempotent on the id, so two `ListInsert` ops under one `ClientId` at the identical `Stamp` left whichever landed first holding the slot, with its value *and* its Fugue anchor (measured: the encoded node carried `2` on one replica and `99` on the other). Both are admissible for the reason C24 gives — dedup is on `OpId`, and the id-space record bounds only an *honest* mint. **Fixed by one rank over `(kind tag, position, encoded value)`, in `List` rather than the document, so it reaches `Text` runs on the same seam.** The smaller rank takes the id with its value **and** its anchor, and a claim that ties changes nothing, which keeps a replay inert and makes two claims on one node the meet of their positions — the scalar image of `rejoin`. The two shapes the filing left open are settled: **the anchor travels with the value**, since a tombstone must not remember the loser's position, and **mixed scalar-against-composite is not a special case** — the tag is the first key and is read as the number it is, not as a semantic "composite beats scalar", because a numeric order stays total when a kind is added where a semantic one re-opens the question on every new op. **The load-bearing half is that every seat path runs the one rank**, since a claim owns the key it named and not the id: `Claim::Fresh` found `insert_at` inert against a scalar already at the id while an eviction or a join re-seated over it, so `[ListInsert, tagged, tagless]` replaced the scalar and `[ListInsert, tagless, tagged]` kept it — one op set, two trees. The rank now runs at the plain `ListInsert`, at every codepoint of a `Text` run, at the birth whose key was free, at the eviction and the join (which yield to a *scalar* on the id, the half no placement key ranks them against), and at `List::merge`, which was the fourth path and answered by which side received. **One ordering the filing did not name is forced, and it is measured rather than chosen: the position outranks the value.** A delete drops a node's value and keeps its anchor, so the anchor is the only key of the rank a tombstoned id still carries — and ranking on it settles the whole difference, because the position is also all a tombstone encodes. With the value read first, the winner of a contest a delete landed in the middle of would have depended on which claim the delete buried; with the position first, all four orders of `[insert, insert, delete]` encode one snapshot. Spec `crates/core/tests/list_stamp_collision.rs` (11) folds every delivery order of each shape and compares snapshot bytes: two plain inserts, the winner's anchor, a delete between them, two `Text` runs at one base stamp (a run is one contest per codepoint, so the ids may split between the runs and converge doing it), a scalar against a child insert, the scalar against **both** child inserts in all six orders with a snapshot round-trip, a scalar against an `XmlMove` at one stamp, the merge seam, the tie, the replay, and the tag-before-value order. Residue filed as **C133**: a tombstone holds no kind, so a *mixed* pair a delete lands between is still ordered by arrival — narrowed by this unit (the split is no longer keyed on which claim arrived first) but not closed, and closing it needs the sequence to persist a rank per dead id, which §Tombstone GC's run compression forbids. ARCHITECTURE §List + DECISIONS updated. → *Core / List*. +**C40 — two ops carrying one stamp into one plain list resolved by arrival order (crates/core) — DONE (#400).** Filed during C24 (#371) and renumbered from C27, which C15 (#368) had already taken. C24 closed the *children-list* half — a placement key is owned by a birth over a move, then by the smaller element id, whatever the arrival order — and one layer down the same collision was still first-wins: `List::insert_at` was idempotent on the id, so two `ListInsert` ops under one `ClientId` at the identical `Stamp` left whichever landed first holding the slot, with its value *and* its Fugue anchor (measured: the encoded node carried `2` on one replica and `99` on the other). Both are admissible for the reason C24 gives — dedup is on `OpId`, and the id-space record bounds only an *honest* mint. **Fixed by one rank over `(kind tag, position, encoded value)`, in `List` rather than the document, so it reaches `Text` runs on the same seam.** The smaller rank takes the id with its value **and** its anchor, and a claim that ties changes nothing, which keeps a replay inert and makes two claims on one node the meet of their positions — the scalar image of `rejoin`. The two shapes the filing left open are settled: **the anchor travels with the value**, since a tombstone must not remember the loser's position, and **mixed scalar-against-composite is not a special case** — the tag is the first key and is read as the number it is, not as a semantic "composite beats scalar", because a numeric order stays total when a kind is added where a semantic one re-opens the question on every new op. **The load-bearing half is that every seat path runs the one rank**, since a claim owns the key it named and not the id: `Claim::Fresh` found `insert_at` inert against a scalar already at the id while an eviction or a join re-seated over it, so `[ListInsert, tagged, tagless]` replaced the scalar and `[ListInsert, tagless, tagged]` kept it — one op set, two trees. The rank now runs at the plain `ListInsert`, at every codepoint of a `Text` run, at the birth whose key was free, at the eviction and the join (which yield to a *scalar* on the id, the half no placement key ranks them against), and at `List::merge`, which was the fourth path and answered by which side received. **One ordering the filing did not name is forced, and it is measured rather than chosen: the position outranks the value.** A delete drops a node's value and keeps its anchor, so the anchor is the only key of the rank a tombstoned id still carries — and ranking on it settles the whole difference, because the position is also all a tombstone encodes. With the value read first, the winner of a contest a delete landed in the middle of would have depended on which claim the delete buried; with the position first, all four orders of `[insert, insert, delete]` encode one snapshot. Spec `crates/core/tests/list_stamp_collision.rs` (11) folds every delivery order of each shape and compares snapshot bytes: two plain inserts, the winner's anchor, a delete between them, two `Text` runs at one base stamp (a run is one contest per codepoint, so the ids may split between the runs and converge doing it), a scalar against a child insert, the scalar against **both** child inserts in all six orders with a snapshot round-trip, a scalar against an `XmlMove` at one stamp, the merge seam, the tie, the replay, and the tag-before-value order. **The placement key and the sequence id are two contests, and the unit states that rather than reconciling them:** a composite that holds `(list, stamp)` can lose the sequence id to a scalar and keep its placement record, so the document names a slot the sequence does not hold. That is deliberate and it is what a *refusal* already left behind before this unit — it is the state that keeps the loser's later moves landing — and a falsification pass folded 300 collision pools in every order with a decode-and-re-encode check without reaching a divergence or a refused snapshot from it. Residue filed as **C133**: a tombstone holds no kind, so a *mixed* pair a delete lands between is still ordered by arrival, and `List::merge`, whose tombstone arm can rank nothing at all, is left non-associative on the same shape (104 of 3000 random collision pools, none without a delete — against 2638 of 3000 **non-commutative** before this unit, which it closes). Narrowed by this unit (the mixed split is no longer keyed on which claim arrived first) but not closed, and closing it needs the sequence to persist a rank per dead id, which §Tombstone GC's run compression forbids. **C134** files the second, smaller thing the merge seam does: the winner is installed as a detached deep clone. ARCHITECTURE §List + DECISIONS updated. → *Core / List*. **C54 — a replicated room's op-version high-water is never carried, so the handshake range-check is inert on every replica (crates/server) — DONE (#396).** Found during C29 (#374), the sibling field of the same gap; filed as C34, renumbered because that id was taken. `Room::max_op_version` is the worst-case governing-app op version a joiner must down-reach, and `subscriber_reaches_governing` refuses an under-versioned joiner against it — but it was raised only by the version a client write carries into `Hub::ingest` (`schema_version`), which neither replication path supplied (`apply_replicate` ingests relay-style `None`; `install_snapshot` installs `Document` bytes carrying none). Measured against `main` on a two-node harness: a replica converged by ops and one installed from a state transfer both report `None`, a v1 joiner across a breaking rename is **admitted** on each, and admitted on the promoted leader after a failover. The admitted joiner is then served the room's ops **verbatim** — a replicated batch is untranslated (C71) and a replica's log untagged, so nothing down-translates; the harm is a client handed a state its own version cannot model, past the one check that exists to refuse it. `clone_room` reported `None` for the same reason. **The binding had to travel with it, and that is forced rather than adjacent:** a high-water is a number in the governing app's version space, `governing_target` abstains on an unbound room, and a pure-replication replica has neither a subscriber nor a store record to bind it — so carrying only the number would leave the *first* joiner at a replica admitted and every later one refused against a binding that first joiner invented at its own version. `persist_meta` already writes the two beside the creator as one `RoomMeta`; they now ride the wire as one record too. `Message::Replicate` and `Message::ReplicateSnapshot` gain `governing` and `max_op_version`, each composing against what the replica holds rather than replacing it — the root set-once (C29), the high-water as a **max** (the all-time worst case, so a re-sent or lower-naming frame never talks it down), the binding through `Registry::bind_room_app`, the same incumbent-app rule a subscribe and the durable load take — and the high-water **only under the app that won that composition**, since a frame whose app was refused names a number in a space the room is not read in, which the first cut adopted anyway and a cold review caught. **The filing's second half is settled explicitly, not incidentally: the follower's log stays a verbatim mirror.** `apply_replicate` still ingests with `None`; re-tagging is rejected because it cannot carry the fact at all on the ops-less state-transfer path, because one version per frame would mislabel a catch-up batch whose ops `Catchup::Ops` says may mix versions, and because **C71 owns it by name** including the mixed-version question. C54 does not close C71: a joiner the check now admits on a replica is still served the delta untranslated. The cost of that reading is stated — a replica's logged ops carry no versions, so it cannot rebuild its high-water from its own log, which widens **C55**'s failed-persist route to all three fields. This unit also closes the **replication half of C62** (the binding travels), leaving that unit its import case; and it closes the eviction hole it opens — a replicated lift now evicts a stranded follower-local subscriber on both seams, on the identical predicate the subscribe gate admits on, since before the change no replicated frame could lift a follower's high-water at all. The **ordering** is the load-bearing half and was measured wrong first: a leader's stranded peer receives nothing because the leader's fan-out translates and drops it, while a replica's fan-out is verbatim — so the eviction runs *before* the fan-out here, or the doomed peer is handed the op and told to update afterwards. **One resolution for the room's binding, which a falsification pass forced:** the replication record is read the way a write's own version tag reads it (the presence map, falling back to the hub), because a sweep prunes each source in a different case — the hub's for a room it does not yet hold, the map's for a room nobody subscribes. Reading the hub alone made a swept leader emit a bare high-water that every replica then discarded, re-opening this unit's own defect through its own gate; measured, and pinned. The clone seam keeps the same pair together, so no seam here mints a number with no app to read it in. **C55 (#397) built a metadata-only carrier after this was written** — `Message::ReplicateMeta`, the root with no ops beneath it — and the binding and high-water have exactly the shape that wants one: a replica logs its leader's batch untagged, so it cannot re-derive either from its own log. Widening that frame to the whole record is **C125**, deliberately left to it, because a root is set-once while these are not and a metadata-only assertion has to say whether it asserts the leader's current value or repairs a lost one. Residue filed as **C129** (a subscriber admitted against an unbound room's high-water is never re-checked once it binds the room itself — pre-existing, needs a ruling) and **C120**: the max-compose is unrepairable, so a cluster member asserting `u32::MAX` pins a replica's high-water there permanently — measured, every governing-app joiner is then refused and an honest later frame cannot bring it back down. Spec `crates/server/tests/replicated_op_version.rs` (32) plus the wire round-trips and truncation sweeps in `crates/core/tests/protocol_replicate.rs`. Design in DECISIONS (2026-08-09). → *Server / Replication*. @@ -563,7 +563,9 @@ scalar / counter / register / element / map (#22–#27), list Fugue (#24), text **C41 — a reveal shell does not survive a snapshot, so a restarted replica refuses the move that places it (crates/core) — READY, no dependencies. Found by cold review during C24, reproduced; pre-dates C24.** Filed during C24 (#371) and renumbered from C28, which C15 (#368) had already taken. `Document::revealed_pending` is the set of movable nodes materialised by an `XmlReveal` shell and still awaiting their first placement — the server injects those shells so a partial reader can hold a node born in a subtree it cannot read. Both the readiness gate and `apply_move` accept a move of such a node *because* it is in that set. Nothing encodes it: `encode_state` writes no section for it and `read_state` rebuilds it empty, so a replica that snapshots between the shell and the move that places it comes back with the shell materialised and unplaceable, and every later move of it buffers forever while a peer that stayed up applies it. Measured directly — reveal, snapshot, reload, move — one replica renders the node under its new parent and the other does not. Independent of C24 but sharpened by it, and **C24's shell case is conditional on this unit**: C24 makes a node left with no placement movable again, and a decode re-derives that from the parent link — which recovers a *born* loser but not a **shell**, since a shell has no such link to key on and C24 correctly removes the one the fold gave it. So `a_reveal_shell_that_loses_the_placement_stays_movable` holds live and across both arrival orders, but not across a snapshot: reveal a shell, let a smaller-id mover evict it, reload, and a later move of the shell buffers where the live replica applies it. That is this unit's hole reached by a new route, not a new hole — the plain shape (reveal, snapshot, reload, move) diverges on `main` too — but it is the reason C24 cannot be called complete for shells until this lands. The fix is a section in the state codec for the shells (and the projections' filters for it, since a shell's whole point is that its origin was denied), which is new persisted state and wants the decision made explicitly rather than inherited — the same bar C21's resolved-key set is held to. → *Core / XML*. -**C133 — a delete landing between a scalar claim and a composite claim on one sequence id resolves by arrival order (crates/core) — READY, needs a ruling. Filed by C40 (#400), measured; the one edge C40's seat order leaves open.** C40 ranks every claim on a sequence id by `(kind tag, position, encoded value)` and every seat path runs it, which makes a scalar-against-scalar contest total *including* across a delete: the position is the ranking key a tombstone keeps, so a claim landing on a tombstoned id is ranked on the one key that survived. A **mixed** pair is not, because the key that decides it is the tag, and a tombstone holds no kind to read — a delete drops the value and keeps only the anchor. **Reproduction:** a fragment's children list, an `XmlInsertChild` at the front and a `ListInsert` at the back sharing one `Stamp`, plus a `ListDelete` of that id. The two delete-between orders agree with each other (both take the meet of the anchors, the only rank left) and the two delete-last orders agree with each other (the tag rules, so the scalar's anchor stands), and the two classes encode different bytes. C40 narrowed this rather than opening it — on `main` the same three ops fold four ways into two states keyed on *which claim arrived first*, and after C40 the split is only between "the delete landed between" and "the delete landed last" — but it is a divergence and it is reachable from three honest-shaped ops. `List::merge` carries the same hole by its own route: a replica holding a tombstone at the id drops the peer's live node while a replica holding the live node buries it at *its own* anchor, so the two keep different positions. **The ruling is what a sequence is allowed to remember about a dead id.** Ranking a mixed claim against a tombstone needs the winning rank to outlive the delete, and the sequence has nowhere to put it: a tombstone is a run record covering any number of ids and a rank per dead id is O(deletions) in memory and on the wire, which is exactly the compression §Tombstone GC exists for — a bulk text delete would carry one rank per codepoint. The document's `(list, stamp)` rank persists and is what makes the composite-against-composite case total across a delete (C24), so one direction is to give the scalar half a record of the same shape; the other is to say a delete is terminal for the position too and take the cost on the live side. Either way it is new persisted state or a changed convergence rule, which is why it is not folded into C40. → *Core / List*. +**C133 — a delete landing between a scalar claim and a composite claim on one sequence id resolves by arrival order (crates/core) — READY, needs a ruling. Filed by C40 (#400), measured; the one edge C40's seat order leaves open.** C40 ranks every claim on a sequence id by `(kind tag, position, encoded value)` and every seat path runs it, which makes a scalar-against-scalar contest total *including* across a delete: the position is the ranking key a tombstone keeps, so a claim landing on a tombstoned id is ranked on the one key that survived. A **mixed** pair is not, because the key that decides it is the tag, and a tombstone holds no kind to read — a delete drops the value and keeps only the anchor. **Reproduction:** a fragment's children list, an `XmlInsertChild` at the front and a `ListInsert` at the back sharing one `Stamp`, plus a `ListDelete` of that id. The two delete-between orders agree with each other (both take the meet of the anchors, the only rank left) and the two delete-last orders agree with each other (the tag rules, so the scalar's anchor stands), and the two classes encode different bytes. C40 narrowed this rather than opening it — on `main` the same three ops fold four ways into two states keyed on *which claim arrived first*, and after C40 the split is only between "the delete landed between" and "the delete landed last" — but it is a divergence and it is reachable from three honest-shaped ops. `List::merge` carries the same hole by its own route, and there it costs a law: its tombstone arm ranks nothing at all — a replica holding a tombstone at the id drops the peer's live node, while a replica holding the live node buries it at *its own* anchor — so `A·(B·C)` and `(A·B)·C` bury the id at different positions. Measured at 104 of 3000 random collision pools and 0 of 3000 without a delete, so it needs a stamp collision *and* a delete; C40 closed the commutativity half of the same seam (2638 of 3000 before, 0 after) and this is what it leaves. Unlike the mixed pair above, this route is reachable with scalars only. **The ruling is what a sequence is allowed to remember about a dead id.** Ranking a mixed claim against a tombstone needs the winning rank to outlive the delete, and the sequence has nowhere to put it: a tombstone is a run record covering any number of ids and a rank per dead id is O(deletions) in memory and on the wire, which is exactly the compression §Tombstone GC exists for — a bulk text delete would carry one rank per codepoint. The document's `(list, stamp)` rank persists and is what makes the composite-against-composite case total across a delete (C24), so one direction is to give the scalar half a record of the same shape; the other is to say a delete is terminal for the position too and take the cost on the live side. Either way it is new persisted state or a changed convergence rule, which is why it is not folded into C40. → *Core / List*. + +**C134 — a sequence merge installs the winning claim as a detached deep clone (crates/core) — READY, no dependencies. Filed by C40 (#400), which widened it; the shape pre-dates it.** `List::merge` folds a peer's sequence in, and where the peer's node carries a composite the merge stores `on.value.deep_clone()` — a fresh handle, not the `Rc` the document's per-id registry holds for that element. An op addressed to that id afterwards applies to the registry's handle while the sequence renders the clone, so the two drift. The `Seated::Vacant` arm has done this since before C40 (a node the receiver has never seen is cloned in whole); C40 adds a second arm that does it, since a claim that outranks the incumbent now replaces it rather than folding the two together — which is the right *semantics* (two composites contending for one id are different elements, and folding their content together cross-contaminated them) but keeps the detachment. It is not reachable from `Document` today: there is no `Document::merge`, and the `Element::merge` cluster is entered only from the public API and `XmlElement::merge`, so nothing in the op-fold or snapshot path takes it. That is why it is filed rather than fixed here, and it is also the reason the fix is not local: `List` has no registry to resolve an id against, so either the caller supplies one or `merge` stops being a `List`-level operation. → *Core / List*. **C47 — a minority `count` rewrite folds one op set to two states, because unanimity is judged over the members that have *arrived* (crates/core) — READY, no dependencies. Found by cold review during C21 (#372); pre-existing on `main` before it. Filed under a different id during that unit and renumbered into C21's reserved range, its first id having been claimed by a sibling unit in flight.** C3 bounds a rewrite with "a bucket whose members disagree names no group and is never complete", and C21 leaned on that. It bounds rather than closes, because a unanimous **subset** can reach its own declared count before the dissenting member lands. **Reproduction:** take an honest three-member group and rewrite **two** members to declare 2, leaving the third at 3 — every member legal on its own terms, `is_admissible` passes. Delivered `x,y,z` or `y,x,z` the pair completes at 2, commits, spends the key, and `z` lands as a stray of a resolved group: all three present. Delivered in any of the other four orders the dissenter is in the bucket from the start, `tx_declared_count` returns `None` forever, and **nothing lands at all**. Two distinct states over six orders, measured. **C21 (#372) widened it**, and this entry should not read as though it left it alone: spending the key on the minority's commit makes the stray land, which is *visible* on pools where the un-recorded replica's commit cancelled itself out and the stray stayed held. Measured over 389 byte-identical forged pools x 12 orders, the split rate goes 58 -> 85 — 28 pools read two ways here that read one way before, 1 the other way. Forged envelopes only, and eviction still collapses all 85 to a single reading. It is the cheapest of the four rewrite shapes for an attacker, since it rewrites a minority rather than every member. **The fix is one rule, and it is a reversal:** a bucket whose members disagree can never honestly complete, so it **spends its key** where a commit spends it — every order then lands all three and spends the key, and the no-duplicate convergence fuzz passes. What it costs is C3's decision that a disagreement means *hold*: `a_rewritten_first_member_count_does_not_commit_the_group_at_the_wrong_size`, `a_bucket_whose_members_disagree_on_the_size_never_completes` and `a_rewritten_count_holds_the_same_set_whatever_order_it_arrives_in` all pin the opposite and would invert, and ARCHITECTURE §Opt-In: Atomic's unanimity sentence changes with them. Distinct from **C46**, which needs per-op-id evidence; this one needs only the rule to change. ARCHITECTURE already carries the caveat that unanimity bounds rather than closes, so the docs do not overclaim in the meantime. → *Transactions*. diff --git a/crates/core/src/doc.rs b/crates/core/src/doc.rs index c44964fb..213b8e3b 100644 --- a/crates/core/src/doc.rs +++ b/crates/core/src/doc.rs @@ -4401,10 +4401,12 @@ impl Document { /// /// The rank orders the *nodes* two claims name, which is the whole question /// only while they name two. A move can name exactly the child a birth at the - /// key derives, and two inserts carrying one tag derive one child between them - /// — there the key is already the claimant's own, nothing changes hands, and - /// what is left to settle is the position, which the sequence takes as the meet - /// of the two ([`List::rejoin`]). A meet is the same whichever arrived first, + /// key derives, and two inserts of one *kind* derive one child between them — + /// there the key is already the claimant's own, nothing changes hands, and what + /// is left to settle is the position, which the sequence takes as the meet of + /// the two ([`List::rejoin`]). Their two *tags* are left to settle with it when + /// the kind is `XmlElement` and the tags differ, which this rank does not reach + /// and C44 is filed for. A meet is the same whichever arrived first, /// where a contest between two claims on one node would have needed to know /// what put the incumbent there, and nothing answers that: the move log dedups /// on the stamp alone, so a move can hold the key having recorded no edge. diff --git a/crates/core/tests/list_stamp_collision.rs b/crates/core/tests/list_stamp_collision.rs index 36464b40..0be2cc57 100644 --- a/crates/core/tests/list_stamp_collision.rs +++ b/crates/core/tests/list_stamp_collision.rs @@ -7,11 +7,12 @@ //! on `OpId`, and the id-space record only bounds an *honest* mint. //! //! Which claim holds a sequence id therefore has to be a function of the ops -//! alone. The order is `(kind tag, encoded value)`: two scalars separate on their -//! bytes, a scalar and a composite on the tag, and two composites are already -//! ranked by the document's `(list, stamp)` rank (C24) before the sequence is -//! touched at all. Every shape is folded here in every delivery order and -//! compared byte-for-byte. +//! alone. The order is `(kind tag, position, encoded value)`: a scalar and a +//! composite separate on the tag, two of a class on the position and then on +//! their encoded bytes, and two composites are already ranked by the document's +//! `(list, stamp)` rank (C24) before the sequence is touched at all. The position +//! sits above the value because it is the only key a delete leaves behind. Every +//! shape is folded here in every delivery order and compared byte-for-byte. use crdtsync_core::doc::Document; use crdtsync_core::elementid::{ElementId, ElementKind}; @@ -180,9 +181,10 @@ fn the_winners_anchor_travels_with_its_value() { let mut back = only_insert(author.transact(|tx| tx.list(b"l").insert(3, Scalar::Int(99)))); back.stamp = front.stamp; + // The front claim wins on the position, which is the key that decides between + // two scalars — so what the sequence must show is *its* value at *its* anchor, + // never one claim's value frozen at the other's position. let (state, _) = converges(&build, &[&front, &back], seq); - // `Int(2)` wins on encoded bytes, and it was placed at the front — so the - // winner's own anchor is what the sequence must show. assert_eq!( state, "[Int(2),Int(1),Int(7)]", "the winner sits at the loser's position" @@ -574,9 +576,10 @@ fn merging_one_value_seated_at_two_positions_takes_the_meet() { #[test] fn a_claim_that_ties_takes_the_meet_of_the_two_positions() { - // Two claims that carry the same value are not a contest — nothing separates - // them — so the position is the meet, which is the same whichever arrived - // first. This is the scalar image of `rejoin`. + // Two claims that carry the same value separate on the position alone, so the + // id ends at the lesser of the two whichever arrived first — the same place + // `rejoin` reaches by taking the meet, falling out of the one order rather + // than being a rule of its own. let id = eid(1, 1); let low = Anchor { parent: None,