From cfed597ff7e1c45bea74000b5f8b541d2fc34a39 Mon Sep 17 00:00:00 2001 From: Lucas Vieira Date: Sun, 9 Aug 2026 11:00:34 -0300 Subject: [PATCH 1/7] fix(core): a node's tag is a meet over the claims that named it, not the first arrival's (C44) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `xml_child_id` mixes the children list, the stamp and the kind into the derivation but never the tag, so two `XmlInsertChild`s at one `Stamp` with different tags derive one `XmlElement` id; `registered_handle`'s `or_insert_with` seated the first arrival's and no op ever rewrote it. The two orders folded to `frag(a(),div())` and `frag(a(),span())` and encoded different bytes — the XML registry writes each node as its id plus its tag. The smaller tag bytes now take the node (`XmlElement::claim_tag`), the same intrinsic rank C40 landed one layer down. It is a meet, so it is idempotent, commutative and associative: a replay ties, a third claim only lowers, a merge cannot answer by which side received, and a decoded tag is a bound later ops can only tighten — recoverable on reload with no new persisted state. Four seats run it, not the one the filing named. The birth; `apply_reveal`, which names an arbitrary id with arbitrary tag bytes and returned early on an already-materialised node, so fixing the birth alone leaves reveal-then-birth diverging from birth-then-reveal; `XmlElement::merge`, whose tag was left untouched as identity; and `create_container`, safe against itself but collidable by a reveal. The rank runs on every claim, including at an id that already determines its tag, because no id answers whether it does. --- ARCHITECTURE.md | 2 + DECISIONS.md | 17 ++ KANBAN.md | 4 +- crates/core/src/doc.rs | 37 ++- crates/core/src/xml.rs | 38 ++- crates/core/tests/xml.rs | 14 +- crates/core/tests/xml_tag_collision.rs | 395 +++++++++++++++++++++++++ 7 files changed, 487 insertions(+), 20 deletions(-) create mode 100644 crates/core/tests/xml_tag_collision.rs diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e1076342..f910a828 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -319,6 +319,8 @@ The fold is a pure function of the move-set + tombstone-set, **independent of th **A children-list position is owned by a birth over a move, then by the smaller element id.** A node's position in a children sequence is keyed `(list, stamp)` and a document holds at most one per key, since the snapshot codec refuses a duplicate. Two ops can nevertheless carry one stamp into one list and both be admissible — op dedup is by `OpId`, and the id-space record bounds only an *honest* mint — so the key is contended, and which op takes it is decided by that rank, never by which arrived first. A birth outranks because the key is where the born node's element id comes from: a birth that lost would leave a node whose id names a position it does not hold and which nothing can re-derive, while a move brings an id of its own and survives losing. The birth test is a pure function of the key — a stamp derives exactly two children, the tagged and the tagless — so nothing a reveal registers can invert the rank, and a move naming either of them is ranked as that child rather than as a move. A later-arriving winner evicts the incumbent from the key, from the move edge that came with it, and from the reachability edge the fold derived. A losing *move* is refused whole; a losing *birth* still materialises its child, which is then left with no position. **A node left with no position is left movable**, whether it was refused or evicted, because that is the state the opposite arrival order leaves it in and a move naming it must land the same way either way — and a snapshot carries that for a node created under a parent, which is what a reload re-derives it from. Arrival-order ownership folded one op set into two states, so two replicas that saw the same ops disagreed and their snapshots differed byte for byte. That 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 at one stamp derive one child between them whenever their kinds agree: there the key is already the claimant's own, nothing changes hands, and the position is the **meet** of the two anchors under a total order that is arbitrary but the same everywhere. A *contest* is what takes the winner's position outright, the loser's being nothing to it. A meet cannot show which arrived first, where a contest between two claims on one node would have had to know what put the incumbent at the key — and nothing answers that, since the move log dedups on the stamp alone and a move can hold a key having recorded no edge. +**A node's tag is owned by the smaller tag bytes.** A children-list node's element id derives from `(list, stamp, kind)` and carries no tag — deliberately, since a stamp derives exactly two children (the tagged and the tagless) and only that purity makes "is this node the child this key derives" answerable without guessing every tag. So two `XmlInsertChild`s at one stamp with different tags name **one** node, both admissible, and the tag it ends at is decided by a lexicographic order over the tags, never by which arrived first. That order is total, intrinsic, and held by every replica that has either op; there is nothing else two such claims differ in to read. Because it is a **meet**, it is idempotent and associative as well as commutative — a replay ties, a third claim lowers or does nothing, and a snapshot's tag is a bound later ops can only tighten, which is what makes the tag recoverable on reload with no new persisted state (it rides `encode_state` already). Every seam that installs or restates a tag runs it — the birth, an `XmlReveal` shell (including one meeting a node already materialised, where returning early settled the two tags by arrival), and an element merge, whose sequence layer is structurally blind to a tag and so can never be where it is decided. It runs even on a node whose id *does* determine its tag (a map slot's, which derives from `(map, key, tag)`): whether an id determines its tag is not a question an id answers, and where it does, every honest claim carries that same tag and the rank is a tie. A live handle stays valid across a restatement and observes it — a handle is a view onto convergent state, not a snapshot, and remote ops mutating state underneath one is the normal case. + **The created-under relation spans the whole tree, the map half included.** A node's position among its siblings is a children-sequence concern, but the relation the move log's cycle check walks is not: a container keyed into a map is created under that map, and the map under the element that owns it. Without those edges the walk stops where the children lists stop, and a move under a node reachable only *through* a map — an element in the moved node's own attrs — reads as acyclic, closes a loop, and leaves the replica holding a document that is no longer a tree. Every rebuild of the relation re-seeds it: a movable node's edge from its birth placement, or from its parent link when it holds no placement at all, and every other container's one hop up that link. ## List diff --git a/DECISIONS.md b/DECISIONS.md index 5d2dc7f2..c05bf894 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -28,6 +28,23 @@ The entries below (2026-07-02) are a backfill: design changes made during the v0 **And it widens C48, which this entry should not read as though it left alone.** The record is per-replica *evidence*, and both projections (`project_zones`, `project_read_paths`) clear it whole — a key names an author and a group and never a partition, so a kept one would count the groups a withheld partition resolved. Under the old rule a disagreement produced no record, so a projection had nothing to lose there; it produces one now, so a projected recipient re-buckets the member still to come while a verbatim recipient merges it as a stray. Measured on an all-one-zone group, so nothing straddles and no destranding is involved: a verbatim and a projected recipient of the same bytes read the same on the base commit (both holding) and differently after it. It is C48's shape reached by a second door rather than a new one, eviction still collapses it, and the fix is C48's — spending the keys a projection cuts, which has to answer what re-adding them reveals about a withheld partition. Filed onto **C48** rather than settled here. **And it widens C46, through a door the enumeration of that shape did not have.** A second envelope of one op id is answerable only where the buffer is still *holding* the first copy — `apply` reads `buffered_tx`, which answers `None` for an id the buffer does not hold under a group. C46 enumerates two ways that happens: the other copy committed out of the buffer under a different group, or it carries no tag at all. A disagreement now takes a member out of the buffer too, so there is a third, and whether it fires is the delivery order's. Measured on three admissible envelopes — `x` under `(T1,2)`, its group-mate `y` under `(T1,3)`, and `x` again under a second group `(T2,2)`: the base commit reads **one** state over all six orders, this one reads **two**, split 2/4, because the two orders that contradict before the second envelope arrives release `x` and never spend `T2`. Add a stray under `T2` and it is content-visible — present in all 24 orders on the base commit, absent in 8 of 24 here. Eviction collapses the document every order reads; the spent-key sets stay split, which is exactly the state C46 already describes for its other two doors ("the members still converge on eviction, and which keys each replica has spent does not"). Closing it needs the same per-op-id evidence C46 is filed for — which group a member was released *under* — so it goes onto **C46**, pinned as `a_copy_released_by_a_disagreement_leaves_the_second_envelope_nothing_to_contradict`. C21 accepted the same trade on this shape explicitly ("better on 156 and worse on 4 over 392 forged pools"); this is another 4. One smaller cost with it, stated precisely because a first draft overstated it: `apply` gains **no** new branch — the contradicting arrival takes the existing tagged path and normally answers `true` for itself. What is new is that the ops it *releases* are reported nowhere, because the FFI and wasm folds count `apply`'s `true`s one op at a time: a two-op disagreeing batch folded that way reports 1 where 2 applied. Filed as **C147**. +## 2026-08-09 · C44 XML tag derivation collision · a node's tag is a **meet** over the claims that named it, and the reveal seam the filing did not name is where the bug survives a fix to the birth + +**Changed:** ARCHITECTURE §Tree Moves gains a paragraph ruling how a node's tag resolves. No wire change, no state-version change — the tag already rides `encode_state`. + +**Reproduced first.** The filing said "reproduced by inspection", which is not reproduced. Two `XmlInsertChild`s under one `ClientId` at one `Stamp` into one children list, tags `div` and `span`: `xml_child_id` mixes `(list, stamp, kind)` and never the tag, so both derive the same `XmlElement` id, `registered_handle`'s `or_insert_with` seats the first arrival's tag and no op ever rewrote it. Measured: `frag(a(),div())` one order, `frag(a(),span())` the other, and `encode_state` differing byte for byte — the XML registry writes each node as its id plus its tag. + +**Mixing the tag into the derivation stays rejected, for the reason the filing gives.** C24's placement rank leans on `born_at` being pure in the key: a stamp derives exactly two children and both are tried, so nothing a reveal registers can invert the rank. A tag in the derivation makes that test unanswerable without enumerating every tag. + +**The rank is the smaller tag bytes**, the same principle C40 landed one layer down: an intrinsic total order over the two ops, never arrival order. Lexicographic over the tags is total, held by every replica that has either op, and the only thing two such claims differ in. It is a **meet**, which is what makes it work at every seam without a per-seam argument — idempotent, commutative and associative by construction, so a replay ties, a third claim only lowers, and a merge cannot answer by which side received. It is also what makes the snapshot sound: a decoded tag is unranked (one tag per id in the registry, no contest at decode) and later ops can only tighten it, so no new persisted state is needed to recover the rule. Pinned by a three-tag six-order fold, a round-trip that re-encodes canonically, and a delete landing between the two claims — the shape C40 measured, where the tag has to answer the same with a delete in the middle as without one, since a `ListDelete` tombstones the sequence slot and never the node registry. + +**C40's warning paid off: the filing named one seat and there are four.** Enumerated rather than assumed. (1) the birth, `insert_xml_child` → `registered_handle`. (2) **`apply_reveal`**, which the filing does not mention and which is the *widest* seat — an `XmlReveal` names an arbitrary `ElementId` with arbitrary tag bytes, so it can name exactly the node a birth derives, and it returned early on an already-materialised node. Fixing only the birth leaves it: reveal(`aa`) then birth(`div`) folds to `aa`, birth then reveal folds to `div`. It now claims the tag before returning, against an element and from a tagged reveal only — a tagless reveal names a text run, and the two kinds are never one node since `xml_child_id` mixes the kind in. (3) **`XmlElement::merge`**, whose doc said "the `tag` is identity, not state, so it is left untouched" — that made a merge resolve the tag by which side received. It now claims. (4) `create_container`, safe against itself because `XmlElement::node_id` folds the tag into the id, but collidable *by a reveal*, and it runs the rank through the same `registered_handle` seam. + +**The rank runs on every claim, including one whose id already determines its tag.** The narrower rule — rank only where the id does not carry the tag — is not implementable: whether an id determines its tag is not a question an id answers, and the reveal, which is the seat that reaches a map-slot node, holds neither the map nor the key to check it against. So the uniform rule is the only answerable one, and where the id does determine the tag every honest claim carries that same tag and the rank is a tie. The cost is stated rather than argued away: a *dishonest* reveal can lower a map-slot node's tag below the one its id derives from, which then makes `slot_inverse` re-emit an `XmlElementCreate` at a different `node_id` on undo. It converges (both orders agree), and it is not client-reachable — the server refuses client-authored reveals — so it requires a compromised server, which is outside this rank's reach either way. Filed as **C154**. + +**A live handle stays valid and observes the restatement.** Invalidation was considered and rejected: a CRDT handle is a view onto convergent state, not a snapshot, and remote ops mutating state underneath one is the normal case rather than the exception. Invalidation would be an API contract change propagating through FFI, Python, Go and JS for no correctness gain. Pinned by a test that holds the node's `Rc` across the second claim's arrival and reads the settled tag. + +**Two things the sequence layer structurally cannot do, measured rather than assumed.** `List::merge` ranks by `claim_rank`, built from `put_node_value`, which writes a composite as its kind and id and **never** its tag — so two same-id different-tag elements always compare equal and take the fold arm. The sequence engine therefore can never be where a tag contest is decided, and the `Seated::Vacant` arm's `deep_clone` is uncontested by definition. `Map::merge`'s LWW arm likewise installs a detached deep clone by slot stamp; the tag converges there because the slot LWW is symmetric, and the detachment is C134's already-filed shape, not this unit's. ## 2026-08-09 · C14 redacted-delta frontier (#398) · the carrier C9 refused for the snapshot seam is the right answer one seam over — and a redaction owes the recipient **both** records its mint reads, without burying the ops it names diff --git a/KANBAN.md b/KANBAN.md index c39e41ac..944a5aaa 100644 --- a/KANBAN.md +++ b/KANBAN.md @@ -30,6 +30,8 @@ Element + Map are one coupled unit (Map slots hold Elements; Element forwards li _Derived from code + git; a convenience view, not the source of truth._ +**C44 — two inserts at one stamp carrying *different* tags derive one child, whose tag was the first arrival's (crates/core) — DONE (#PR).** `xml_child_id` mixes the children list, the stamp and the *kind* into the derivation but never the tag, so `XmlInsertChild { tag: Some(b"div") }` and `XmlInsertChild { tag: Some(b"span") }` at one stamp into one list derive the **same** `XmlElement` id; `registered_handle`'s `or_insert_with` seated the first arrival's tag and no op ever rewrote it. Reproduced before fixing (the entry had said "by inspection"): `frag(a(),div())` one order, `frag(a(),span())` the other, and `encode_state` differing byte for byte, since the XML registry writes each node as its id plus its tag. Mixing the tag into the derivation stays rejected for the reason the filing gives — C24's rank leans on `born_at` being pure in the key. **Fixed by an intrinsic rank: the smaller tag bytes take the node** (`XmlElement::claim_tag`), the same principle C40 (#400) landed one layer down. It is a **meet**, which is what makes it hold at every seam without a per-seam argument — idempotent, commutative and associative, so a replay ties, a third claim only lowers, and a merge cannot answer by which side received; and a decoded tag (unranked — one tag per id in the registry, no contest at decode) is a bound later ops can only tighten, so the rule is recoverable on reload with **no new persisted state**. C40's warning about a missed seat path paid off: the filing named one and there are **four**. The birth (`insert_xml_child`); **`apply_reveal`**, which the filing does not mention and is the widest — an `XmlReveal` names an arbitrary `ElementId` with arbitrary tag bytes, so it can name exactly the node a birth derives, and it returned early on an already-materialised node, so fixing the birth alone leaves reveal(`aa`)→birth(`div`) folding to `aa` and the reverse to `div`; **`XmlElement::merge`**, whose doc said the tag "is identity, not state, so it is left untouched" — that resolved it by which side received; and `create_container`, safe against itself (`XmlElement::node_id` folds the tag into the id) but collidable by a reveal, running the rank through the same `registered_handle` seam. The rank runs on **every** claim, including one whose id already determines its tag, because whether an id determines its tag is not a question an id answers and the reveal holds neither the map nor the key to check against — where it does determine it, every honest claim carries that tag and the rank is a tie. A **live handle stays valid** and observes the restatement: a handle is a view onto convergent state, not a snapshot. Two things measured rather than assumed: `List::merge`'s `claim_rank` is built from `put_node_value`, which writes a composite as its kind and id and never its tag, so two same-id different-tag elements always rank equal and take the fold arm — the sequence engine structurally cannot be where a tag contest is decided; and `Map::merge`'s LWW arm converges on the tag because the slot LWW is symmetric, its detached deep clone being C134's shape, not this one's. Spec `crates/core/tests/xml_tag_collision.rs` folds every shape in both orders and compares snapshot bytes. ARCHITECTURE §Tree Moves + DECISIONS updated. Residue: C154. → *Core / XML*. + **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*. @@ -555,7 +557,7 @@ scalar / counter / register / element / map (#22–#27), list Fugue (#24), text **C22 — a global mint floor leaks cross-zone activity through a zone's lamport numbering (crates/core) — READY, needs a human ruling. Raised by C17 (#366).** A stamp is a document-global id, so the mint has to clear the replica's whole id space; every partition-scoped rule C17 tried was defeated by an op's envelope naming its own partition while its ids land in its target's, or by a snapshot declaring any clock it likes. The consequence is that a zone's lamports count on from the author's whole stamp position, so the gaps between one author's stamps inside a zone reveal how much it minted outside — the activity-inference channel ARCHITECTURE (§Isolation) says per-zone clocks close. Folding is untouched, so the streams stay causally independent and separately replicable, and the leak is *numbering*, not content or structure. **The ruling is which guarantee wins**, since correctness holds either way: keep the leak; or give the decoder a trustworthy way to attribute a decoded stamp to a partition (the only label that arrives with one today is the attacker-supplied envelope, and the honest one — `zone_of_op` over the target's path — needs a schema a decode does not have); or scope the mint per partition and accept that a peer can aim it at a victim's live ids. Whichever wins, ARCHITECTURE's zone-isolation wording needs to match it. → *Core / Zones*. -**C44 — two inserts at one stamp carrying *different* tags derive one child, whose tag is the first arrival's (crates/core) — READY, no dependencies. Found by cold review during C24 (#371), reproduced by inspection; pre-dates C24.** `xml_child_id` mixes the children list, the stamp and the *kind* into the derivation, but not the tag — so `XmlInsertChild { tag: Some(b"div") }` and `XmlInsertChild { tag: Some(b"span") }` at one stamp into one list derive the **same** `XmlElement` id. `registered_handle` materialises it with `entry(id).or_insert_with(|| XmlElement::new(id, tag))` and no op ever rewrites a tag, so the element renders `div` delivered one way and `span` the other, and the two replicas encode different bytes. C24 makes the two claims a join rather than a contest — correctly, since they name one node — and converges the *position*; the identity content it does not reach. **Mixing the tag into the derivation is not the fix**: C24's rank leans on `born_at` being pure in the key, and a stamp derives exactly two children (the tagged and the tagless) precisely because the derivation carries no payload — a tag in it would make "is this node the child this key derives" unanswerable without guessing every tag. So the tag has to resolve by an intrinsic order over the two ops (the smaller tag bytes, say), which means a seam that can restate a materialised element's tag and a decision about what that does to a live handle an app already holds. The tag rides the snapshot, so whichever rule is chosen is recoverable on reload without new persisted state. → *Core / XML*. +**C154 — a dishonest `XmlReveal` can lower a map-slot node's tag below the one its own id derives from (crates/core) — READY, no dependencies. Found by C44 while enumerating the tag seats, reproduced by inspection.** A map-slot `XmlElement`'s id is `XmlElement::node_id(map, key, tag)` — the tag *is* in the derivation, so create-vs-create at one key with two tags is two ids and there is no contest. C44's tag rank nonetheless runs on every claim, including one at such an id, because whether an id determines its tag is not a question an id answers and the seat that reaches a map-slot node — `apply_reveal`, which names an arbitrary `ElementId` with arbitrary tag bytes — holds neither the map nor the key to check it against. A narrower rule is therefore not implementable at that seam. The consequence: a reveal carrying tag bytes smaller than a map-slot node's takes the node's tag, leaving it disagreeing with its own id, and `slot_inverse` (doc.rs) then re-emits an `XmlElementCreate` carrying the *live* tag, which derives a **different** `node_id` — so an undo of that slot re-creates a different element. It **converges** (both arrival orders agree, which is what C44 was for), and it is not client-reachable: `crates/server/src/session.rs` refuses a client-authored `XmlReveal`, and the node-to-node `Replicate` seam never carries one because reveals are never logged. So it requires a compromised server, and a compromised server can seat a wrong tag at an unheld id regardless of the rank. What is open is whether `registered_handle` should be handed the fact that a caller derived the id *from* the tag — `create_container` knows it and the reveal does not — which would let a self-consistent claim outrank an inconsistent one without asking an id a question it cannot answer. → *Core / XML*. **C45 — a birth that loses its key while holding a move placement loses its created-under edge on reload (crates/core) — READY, no dependencies. Found by cold review during C24 (#371).** C24 re-seeds the created-under relation from three sources — a node's birth placement, its parent link when it holds no placement at all, and one hop up `parents` for everything else — which covers a losing birth that was left placeless. A node born at `(Lb, sb)`, *then moved* to `(L2, s2)`, and only afterwards evicted from `(Lb, sb)` by a smaller-id twin falls through all three: `birth_placement` finds nothing, `placements` is non-empty so the placeless source skips it, and its `parents` entry is the move destination (a children list) so the one-hop source skips it too. `evict_placement` returns early without re-pointing anything, because the node still holds a placement. The live replica keeps `base[node] = owner(Lb)`; a replica reloaded from its snapshot has no entry at all. While the move edge governs, `parent_of` agrees and nothing shows; withdraw that edge — a later eviction at `(L2, s2)`, or a projection that filters it — and the live replica falls back to the birth owner while the reloaded one reads the node as a root, `refold_moves` skipping it entirely and leaving its suppression flags at whatever the last fold wrote. It is also a hole in the tree the move log's cycle check walks. Nothing in the snapshot still names the birth owner, so the fix is to persist the edge (a base-edge section in the state codec, or the birth list beside each placement record) — new persisted state, which wants the decision made explicitly rather than inherited, the same bar C21's resolved-key set is held to. → *Core / XML*. diff --git a/crates/core/src/doc.rs b/crates/core/src/doc.rs index 3c80f14e..ac667e3f 100644 --- a/crates/core/src/doc.rs +++ b/crates/core/src/doc.rs @@ -51,6 +51,7 @@ struct Placement { stamp: Stamp, } use std::cell::RefCell; +use std::collections::hash_map::Entry; use std::collections::{BTreeSet, HashMap, HashSet}; use std::rc::Rc; @@ -4625,10 +4626,20 @@ impl Document { /// the snapshot projection keeping a born-denied node at its readable current /// position: it registers the node's attrs Map and children List (by derived id) /// so the node's readable content ops resolve and drain onto it, then the readable - /// move lands its first placement. Idempotent — a node already materialized (its - /// real create arrived, or a duplicate reveal) is left as it is. + /// move lands its first placement. Idempotent in everything but the tag — a node + /// already materialized (its real create arrived, or a duplicate reveal) is left + /// as it is, except that the reveal's tag still runs the rank: a children-list + /// node's id carries no tag, so a reveal and a birth can name one node with two + /// tags, and returning early here would settle that by which arrived first. fn apply_reveal(&mut self, node: ElementId, tag: Option>) { - if self.node_element(node).is_some() { + if let Some(element) = self.node_element(node) { + // Only against an element, and only from a tagged reveal: a tagless + // reveal names a text run, which holds no tag, and the two kinds are + // never one node — `xml_child_id` mixes the kind in, so a claim under + // the other kind names the other child of the stamp. + if let (Element::XmlElement(x), Some(t)) = (&element, &tag) { + x.borrow_mut().claim_tag(t); + } return; } let container = match tag { @@ -4805,11 +4816,21 @@ impl Document { .or_insert_with(|| Rc::new(RefCell::new(Text::new(id)))), )), Container::XmlElement(tag) => { - let handle = Rc::clone( - self.xml_elements - .entry(id) - .or_insert_with(|| Rc::new(RefCell::new(XmlElement::new(id, tag)))), - ); + let handle = match self.xml_elements.entry(id) { + // Nothing held the id: the claim takes the tag uncontested. + Entry::Vacant(slot) => { + Rc::clone(slot.insert(Rc::new(RefCell::new(XmlElement::new(id, tag))))) + } + // A node already materialised meets this claim at the tag + // rank. A children-list node's id carries no tag, so two + // claims can name one node with two tags, and the smaller + // bytes take it whichever arrived first. + Entry::Occupied(held) => { + let handle = Rc::clone(held.get()); + handle.borrow_mut().claim_tag(&tag); + handle + } + }; // The node's attrs Map and children List are containers in their // own right — register them so ops targeting them resolve, and // link them to the node so reachability walks up through it. diff --git a/crates/core/src/xml.rs b/crates/core/src/xml.rs index 96075e66..2bb6021d 100644 --- a/crates/core/src/xml.rs +++ b/crates/core/src/xml.rs @@ -92,11 +92,38 @@ impl XmlElement { self.id } - /// The node's tag — fixed at creation. + /// The node's tag — the rank over every claim that named it. pub fn tag(&self) -> &[u8] { &self.tag } + /// Claim this node's tag for `tag`, which takes it when its bytes are the + /// smaller — the seam every path that names a node's tag runs through. + /// + /// A children-list node's id derives from `(list, stamp, kind)` and carries no + /// tag, deliberately: a stamp derives exactly two children, the tagged and the + /// tagless, and mixing the tag in would make "is this node the child this key + /// derives" unanswerable without guessing every tag. So two ops can name one + /// node with two tags and both pass every gate — dedup is on `OpId`, and an + /// id-space record only bounds an *honest* mint — and which tag the node ends + /// at has to be a function of the ops alone, never of which arrived first. + /// + /// **The smaller bytes take it.** A lexicographic order over the tags is + /// total, intrinsic, and held by every replica that has either op; there is + /// nothing else two such claims differ in to read. It is idempotent and + /// commutative by construction — the meet of a set of byte strings is the same + /// whatever order they arrive in — so a replay ties and changes nothing. + /// + /// Every claim runs it, including one on a node whose id *does* determine its + /// tag (a map slot's, which derives from `(map, key, tag)`): whether an id + /// determines its tag is not a question an id answers, and where it does, every + /// honest claim carries that same tag and the rank is a tie. + pub(crate) fn claim_tag(&mut self, tag: &[u8]) { + if tag < &self.tag[..] { + self.tag = tag.to_vec(); + } + } + /// The attrs Map handle, shared with the document registry. pub fn attrs(&self) -> Rc> { Rc::clone(&self.attrs) @@ -108,9 +135,12 @@ impl XmlElement { } /// Merge `src` (the same element) into this one — attrs and children each - /// reconcile through their own engine. The `tag` is identity, not state, so - /// it is left untouched. - pub fn merge(&self, src: &Self) { + /// reconcile through their own engine, and the tag through its own rank + /// ([`claim_tag`](Self::claim_tag)). Two replicas can hold one node under two + /// tags, so leaving the tag untouched here would make a merge resolve it by + /// which side received. + pub fn merge(&mut self, src: &Self) { + self.claim_tag(&src.tag); self.attrs.borrow_mut().merge(&src.attrs.borrow()); self.children.borrow_mut().merge(&src.children.borrow()); } diff --git a/crates/core/tests/xml.rs b/crates/core/tests/xml.rs index a73006c9..20810d3e 100644 --- a/crates/core/tests/xml.rs +++ b/crates/core/tests/xml.rs @@ -164,7 +164,7 @@ fn twins(tag: &str) -> (XmlElement, XmlElement) { #[test] fn merge_is_idempotent() { - let x = xml("ul"); + let mut x = xml("ul"); insert_child(&x, 0, child(eid(2, 1), "li"), 1, 1); let twin = x.deep_clone(); x.merge(&twin); @@ -173,7 +173,7 @@ fn merge_is_idempotent() { #[test] fn merge_absorbs_disjoint_children() { - let (a, b) = twins("ul"); + let (mut a, mut b) = twins("ul"); insert_child(&a, 0, child(eid(2, 1), "a"), 1, 1); insert_child(&b, 0, child(eid(2, 2), "b"), 1, 2); a.merge(&b); @@ -189,10 +189,10 @@ fn merge_is_commutative() { insert_child(&a, 0, child(eid(2, 1), "a"), 1, 1); insert_child(&b, 0, child(eid(2, 2), "b"), 1, 2); - let (ab, _) = twins("ul"); + let (mut ab, _) = twins("ul"); ab.merge(&a); ab.merge(&b); - let (ba, _) = twins("ul"); + let (mut ba, _) = twins("ul"); ba.merge(&b); ba.merge(&a); assert_eq!(child_tags(&ab), child_tags(&ba)); @@ -200,7 +200,7 @@ fn merge_is_commutative() { #[test] fn merge_carries_child_tombstones() { - let (a, b) = twins("ul"); + let (a, mut b) = twins("ul"); insert_child(&a, 0, child(eid(2, 1), "gone"), 1, 1); // b learns of the child, then a deletes it; the tombstone must ride the merge. b.merge(&a); @@ -211,7 +211,7 @@ fn merge_carries_child_tombstones() { #[test] fn merge_reconciles_attrs_by_lww() { - let (a, b) = twins("a"); + let (mut a, b) = twins("a"); a.attrs() .borrow_mut() .set(b"k", Element::Scalar(Scalar::Int(1)), stmp(1, 1)); @@ -228,7 +228,7 @@ fn merge_reconciles_attrs_by_lww() { #[test] fn merge_recurses_into_a_shared_child() { // The same child element edited on both sides folds together, not replaced. - let (a, b) = twins("ul"); + let (mut a, mut b) = twins("ul"); let child_id = eid(2, 7); insert_child(&a, 0, child(child_id, "li"), 1, 1); b.merge(&a); // b now holds the same child (same id) diff --git a/crates/core/tests/xml_tag_collision.rs b/crates/core/tests/xml_tag_collision.rs new file mode 100644 index 00000000..e8d67ff5 --- /dev/null +++ b/crates/core/tests/xml_tag_collision.rs @@ -0,0 +1,395 @@ +//! Two claims naming one XML node with two different tags. +//! +//! `xml_child_id` mixes the children list, the stamp and the *kind* into the +//! derivation, but never the tag — so `XmlInsertChild { tag: Some(b"div") }` and +//! `XmlInsertChild { tag: Some(b"span") }` at one stamp into one list derive the +//! **same** `XmlElement` id. Both ops pass every gate: dedup is on `OpId`, and +//! the id-space record only bounds an *honest* mint. C24 makes the two a join +//! rather than a contest — correctly, since they name one node — and settles the +//! position at the meet of the two anchors; the *tag* the node ends at is what is +//! left, and it has to be a function of the ops alone. +//! +//! The tag rides `encode_state` (the XML registry writes each node as its id plus +//! its tag), so a replica that took the first arrival's tag encodes different +//! bytes from one that saw the other order. Every shape that reaches the question +//! is folded here in both orders and compared byte-for-byte. + +use crdtsync_core::doc::Document; +use crdtsync_core::elementid::ElementId; +use crdtsync_core::list::{Anchor, List, Side}; +use crdtsync_core::op::{Op, OpKind}; +use crdtsync_core::xml::XmlElement; +use crdtsync_core::Element; +use std::cell::RefCell; +use std::rc::Rc; + +mod common; +use common::{cid, stmp}; + +/// A parenthesised rendering of the fragment in slot `doc` — an element as +/// `tag(children)`, a text run quoted. +fn tree(d: &Document) -> String { + match d.get(b"doc") { + Some(Element::XmlFragment(f)) => { + let kids: Vec = f + .borrow() + .children() + .borrow() + .values() + .iter() + .map(render) + .collect(); + format!("frag({})", kids.join(",")) + } + _ => "∅".to_string(), + } +} + +fn render(e: &Element) -> String { + match e { + Element::XmlElement(x) => { + let x = x.borrow(); + let kids: Vec = x.children().borrow().values().iter().map(render).collect(); + format!("{}({})", String::from_utf8_lossy(x.tag()), kids.join(",")) + } + Element::Text(t) => format!("{:?}", t.borrow().as_string()), + other => format!("?{}", other.kind() as u8), + } +} + +/// Build `doc` = frag(a()); return the ops. +fn frag_with_a(d: &mut Document) -> Vec { + d.transact(|tx| { + tx.xml_fragment(b"doc").children().insert_element(0, b"a"); + }) +} + +/// The lone `XmlInsertChild` in a batch. +fn only_insert(batch: Vec) -> Op { + batch + .into_iter() + .find(|op| matches!(op.kind, OpKind::XmlInsertChild { .. })) + .expect("the child insert") +} + +/// A twin of `op` carrying the identical stamp under a distinct `OpId`, with its +/// insert tag replaced. +fn twin_tagged(op: &Op, seq: u64, tag: &[u8]) -> Op { + let mut twin = op.clone(); + twin.id.seq = seq; + if let OpKind::XmlInsertChild { tag: t, .. } = &mut twin.kind { + *t = Some(tag.to_vec()); + } + assert_eq!(twin.stamp, op.stamp, "the twin must carry one stamp"); + twin +} + +/// Fold `build` then `ops` into a fresh replica and return `(rendered tree, +/// snapshot bytes)`. The replica identity is fixed so the orders differ in +/// nothing but arrival. +fn fold(build: &[Op], ops: &[&Op]) -> (String, Vec) { + let mut d = Document::new(cid(9)); + for op in build.iter().chain(ops.iter().copied()) { + d.apply(op); + } + (tree(&d), d.encode_state()) +} + +/// The tag of the sole `XmlElement` child of the `doc` fragment other than `a`. +fn contested_tag(d: &Document) -> Option> { + contested(d).map(|x| x.borrow().tag().to_vec()) +} + +/// The id of that child. +fn contested_id(d: &Document) -> Option { + contested(d).map(|x| x.borrow().id()) +} + +fn contested(d: &Document) -> Option>> { + let Some(Element::XmlFragment(f)) = d.get(b"doc") else { + return None; + }; + let kids = f.borrow().children(); + let values = kids.borrow().values(); + values.iter().find_map(|value| match value { + Element::XmlElement(x) if x.borrow().tag() != b"a" => Some(Rc::clone(x)), + _ => None, + }) +} + +#[test] +fn two_tags_at_one_stamp_converge_in_either_order() { + let mut author = Document::new(cid(1)); + let build = frag_with_a(&mut author); + + let base = only_insert(author.transact(|tx| { + tx.xml_fragment(b"doc").children().insert_element(1, b"div"); + })); + let div = twin_tagged(&base, 9_000, b"div"); + let span = twin_tagged(&base, 9_001, b"span"); + + let (tree_a, bytes_a) = fold(&build, &[&div, &span]); + let (tree_b, bytes_b) = fold(&build, &[&span, &div]); + assert_eq!(tree_a, tree_b, "the two orders folded to different trees"); + assert_eq!( + bytes_a, bytes_b, + "the two orders encode different snapshots" + ); +} + +#[test] +fn the_smaller_tag_bytes_take_the_node() { + // The rank is an intrinsic total order over the two ops, so the answer is + // stated, not merely agreed on: `div` < `span`. + let mut author = Document::new(cid(1)); + let build = frag_with_a(&mut author); + let base = only_insert(author.transact(|tx| { + tx.xml_fragment(b"doc").children().insert_element(1, b"div"); + })); + let div = twin_tagged(&base, 9_000, b"div"); + let span = twin_tagged(&base, 9_001, b"span"); + + for order in [[&div, &span], [&span, &div]] { + let mut d = Document::new(cid(9)); + for op in build.iter().chain(order) { + d.apply(op); + } + assert_eq!( + contested_tag(&d).as_deref(), + Some(&b"div"[..]), + "the smaller tag bytes must take the node" + ); + } +} + +#[test] +fn a_restated_tag_survives_a_snapshot_round_trip() { + // The tag rides the snapshot, so the rule has to be recoverable on reload + // with no new persisted state. + let mut author = Document::new(cid(1)); + let build = frag_with_a(&mut author); + let base = only_insert(author.transact(|tx| { + tx.xml_fragment(b"doc").children().insert_element(1, b"div"); + })); + let div = twin_tagged(&base, 9_000, b"div"); + let span = twin_tagged(&base, 9_001, b"span"); + + for order in [[&span, &div], [&div, &span]] { + let mut d = Document::new(cid(9)); + for op in build.iter().chain(order) { + d.apply(op); + } + let bytes = d.encode_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"); + assert_eq!( + contested_tag(&back).as_deref(), + Some(&b"div"[..]), + "the reload forgot which tag won" + ); + } +} + +#[test] +fn three_tags_at_one_stamp_converge_in_every_order() { + // The rank is a meet over the tags, so it is idempotent and associative as + // well as commutative: any number of claims, in any order, land on the least. + let mut author = Document::new(cid(1)); + let build = frag_with_a(&mut author); + let base = only_insert(author.transact(|tx| { + tx.xml_fragment(b"doc").children().insert_element(1, b"div"); + })); + let p = twin_tagged(&base, 9_000, b"p"); + let div = twin_tagged(&base, 9_001, b"div"); + let span = twin_tagged(&base, 9_002, b"span"); + + let orders: [[&Op; 3]; 6] = [ + [&p, &div, &span], + [&p, &span, &div], + [&div, &p, &span], + [&div, &span, &p], + [&span, &p, &div], + [&span, &div, &p], + ]; + let expect = fold(&build, &orders[0]); + for (i, order) in orders.iter().enumerate().skip(1) { + assert_eq!(fold(&build, order), expect, "order {i} diverged"); + } + assert!( + expect.0.contains("div()"), + "the least tag must win: {}", + expect.0 + ); +} + +#[test] +fn a_delete_between_the_two_claims_does_not_decide_the_tag() { + // A delete is terminal for the sequence slot and takes the loser's or the + // winner's position depending on where it lands — the shape C40 measured. The + // tag lives in the node registry, which no delete tombstones, so the rank has + // to answer the same with a delete in the middle as without one. + let mut author = Document::new(cid(1)); + let build = frag_with_a(&mut author); + let base = only_insert(author.transact(|tx| { + tx.xml_fragment(b"doc").children().insert_element(1, b"div"); + })); + let div = twin_tagged(&base, 9_000, b"div"); + let span = twin_tagged(&base, 9_001, b"span"); + let mut delete = base.clone(); + delete.id.seq = 9_100; + delete.stamp.lamport = base.stamp.lamport + 1; + delete.kind = OpKind::ListDelete { id: base.stamp }; + + let orders: [[&Op; 3]; 4] = [ + [&div, &delete, &span], + [&span, &delete, &div], + [&div, &span, &delete], + [&span, &div, &delete], + ]; + let expect = fold(&build, &orders[0]); + for (i, order) in orders.iter().enumerate().skip(1) { + assert_eq!(fold(&build, order), expect, "order {i} diverged"); + } +} + +#[test] +fn a_reveal_and_a_birth_naming_one_node_converge_in_either_order() { + // An `XmlReveal` names an arbitrary element id with an arbitrary tag, so it + // can name exactly the node a birth derives. A reveal that met a materialised + // node and returned without claiming would settle the two tags by which + // arrived first — the same bug in a second seat. + let mut author = Document::new(cid(1)); + let build = frag_with_a(&mut author); + let birth = only_insert(author.transact(|tx| { + tx.xml_fragment(b"doc").children().insert_element(1, b"div"); + })); + + // The node the birth derives, read off a replica that folded it alone. + let node = { + let mut d = Document::new(cid(9)); + for op in build.iter().chain([&birth]) { + d.apply(op); + } + contested_id(&d).expect("the born child") + }; + + // The reveal carries the *smaller* tag, so it must win from either side — + // a reveal that only ever seated an unheld id would win from one. + let mut reveal = birth.clone(); + reveal.id.seq = 9_000; + reveal.kind = OpKind::XmlReveal { + node, + tag: Some(b"aa".to_vec()), + }; + + let (tree_a, bytes_a) = fold(&build, &[&birth, &reveal]); + let (tree_b, bytes_b) = fold(&build, &[&reveal, &birth]); + assert_eq!(tree_a, tree_b, "the two orders folded to different trees"); + assert_eq!( + bytes_a, bytes_b, + "the two orders encode different snapshots" + ); + assert!( + tree_a.contains("aa()"), + "the smaller tag must win: {tree_a}" + ); +} + +#[test] +fn a_live_handle_observes_the_restated_tag() { + // A handle is a view onto convergent state, not a snapshot: the app holds the + // node's `Rc` across the second claim's arrival and reads the tag the rank + // settled on, rather than a stale one or an invalidated handle. + let mut author = Document::new(cid(1)); + let build = frag_with_a(&mut author); + let base = only_insert(author.transact(|tx| { + tx.xml_fragment(b"doc") + .children() + .insert_element(1, b"span"); + })); + let span = twin_tagged(&base, 9_000, b"span"); + let div = twin_tagged(&base, 9_001, b"div"); + + let mut d = Document::new(cid(9)); + for op in build.iter().chain([&span]) { + d.apply(op); + } + let held = match d.get(b"doc") { + Some(Element::XmlFragment(f)) => { + let kids = f.borrow().children(); + let values = kids.borrow().values(); + values + .iter() + .find_map(|v| match v { + Element::XmlElement(x) if x.borrow().tag() == b"span" => Some(Rc::clone(x)), + _ => None, + }) + .expect("the born child") + } + _ => panic!("doc is not a fragment"), + }; + assert_eq!(held.borrow().tag(), b"span"); + + d.apply(&div); + assert_eq!( + held.borrow().tag(), + b"div", + "the handle the app already held did not observe the restated tag" + ); +} + +/// Two independent replicas of one node under two tags. +fn twin_nodes(id: ElementId) -> (XmlElement, XmlElement) { + ( + XmlElement::new(id, b"div".to_vec()), + XmlElement::new(id, b"span".to_vec()), + ) +} + +#[test] +fn an_element_merge_ranks_the_tag_rather_than_taking_the_receiver_s() { + // `XmlElement::merge` folds two replicas of one node. Leaving the tag alone + // resolved it by which side received — the seam the op fold does not reach, + // and the one C40's sweep found a layer down in `List::merge`. + let id = ElementId::from_bytes([7u8; 16]); + let (mut a, b) = twin_nodes(id); + a.merge(&b); + let (mut b, a2) = twin_nodes(id); + b.merge(&a2); + assert_eq!(a.tag(), b"div"); + assert_eq!(b.tag(), b"div", "the merge took the receiver's tag"); +} + +#[test] +fn a_list_merge_ranks_a_child_s_tag() { + // The sequence rank is blind to a tag — `put_node_value` writes a composite as + // its kind and id — so two same-id nodes always rank equal and fold through + // `Element::merge`. That fold is where the tag is settled. + let id = ElementId::from_bytes([7u8; 16]); + let stamp = stmp(1, 1); + let anchor = Anchor { + parent: None, + side: Side::Right, + }; + let seat = |tag: &[u8]| { + let mut l = List::new(ElementId::from_bytes([1u8; 16])); + l.insert_at( + stamp, + Element::XmlElement(Rc::new(RefCell::new(XmlElement::new(id, tag.to_vec())))), + anchor, + ); + l + }; + let tag_of = |l: &List| match l.get(0) { + Some(Element::XmlElement(x)) => x.borrow().tag().to_vec(), + _ => panic!("a child element"), + }; + + let (mut a, b) = (seat(b"div"), seat(b"span")); + a.merge(&b); + let (mut b, a2) = (seat(b"span"), seat(b"div")); + b.merge(&a2); + assert_eq!(tag_of(&a), b"div"); + assert_eq!(tag_of(&b), b"div", "the merge took the receiver's tag"); +} From eb32ece2f7367544c386a11b4384167097d42e81 Mon Sep 17 00:00:00 2001 From: Lucas Vieira Date: Sun, 9 Aug 2026 11:30:06 -0300 Subject: [PATCH 2/7] test(xml): pin the meet's three properties and correct the module doc's tag claim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mutation sweep left the rank itself and all four seats killed, but two claims the record leans on were argued rather than measured: that a reload needs no state the snapshot lacks, and that the rank holds under an interaction the isolated shapes do not reach. A claim arriving at a *reloaded* replica is now ranked against a decoded tag in both directions and compared against a replica that never restarted. A randomized oracle shuffles pools mixing several tag claims at two stamps with a reveal and a delete; it reddens with the rank removed. The module doc called the tag immutable and said a merge "never reconciles it", which the merge seat makes false. It now states what the tag is — identity, not editable state — and that two claims can still name one node under two tags, which is what the rank answers. --- KANBAN.md | 2 +- crates/core/src/xml.rs | 10 +- crates/core/tests/xml_tag_collision.rs | 153 +++++++++++++++++++++++++ 3 files changed, 161 insertions(+), 4 deletions(-) diff --git a/KANBAN.md b/KANBAN.md index 944a5aaa..48b8e6a0 100644 --- a/KANBAN.md +++ b/KANBAN.md @@ -30,7 +30,7 @@ Element + Map are one coupled unit (Map slots hold Elements; Element forwards li _Derived from code + git; a convenience view, not the source of truth._ -**C44 — two inserts at one stamp carrying *different* tags derive one child, whose tag was the first arrival's (crates/core) — DONE (#PR).** `xml_child_id` mixes the children list, the stamp and the *kind* into the derivation but never the tag, so `XmlInsertChild { tag: Some(b"div") }` and `XmlInsertChild { tag: Some(b"span") }` at one stamp into one list derive the **same** `XmlElement` id; `registered_handle`'s `or_insert_with` seated the first arrival's tag and no op ever rewrote it. Reproduced before fixing (the entry had said "by inspection"): `frag(a(),div())` one order, `frag(a(),span())` the other, and `encode_state` differing byte for byte, since the XML registry writes each node as its id plus its tag. Mixing the tag into the derivation stays rejected for the reason the filing gives — C24's rank leans on `born_at` being pure in the key. **Fixed by an intrinsic rank: the smaller tag bytes take the node** (`XmlElement::claim_tag`), the same principle C40 (#400) landed one layer down. It is a **meet**, which is what makes it hold at every seam without a per-seam argument — idempotent, commutative and associative, so a replay ties, a third claim only lowers, and a merge cannot answer by which side received; and a decoded tag (unranked — one tag per id in the registry, no contest at decode) is a bound later ops can only tighten, so the rule is recoverable on reload with **no new persisted state**. C40's warning about a missed seat path paid off: the filing named one and there are **four**. The birth (`insert_xml_child`); **`apply_reveal`**, which the filing does not mention and is the widest — an `XmlReveal` names an arbitrary `ElementId` with arbitrary tag bytes, so it can name exactly the node a birth derives, and it returned early on an already-materialised node, so fixing the birth alone leaves reveal(`aa`)→birth(`div`) folding to `aa` and the reverse to `div`; **`XmlElement::merge`**, whose doc said the tag "is identity, not state, so it is left untouched" — that resolved it by which side received; and `create_container`, safe against itself (`XmlElement::node_id` folds the tag into the id) but collidable by a reveal, running the rank through the same `registered_handle` seam. The rank runs on **every** claim, including one whose id already determines its tag, because whether an id determines its tag is not a question an id answers and the reveal holds neither the map nor the key to check against — where it does determine it, every honest claim carries that tag and the rank is a tie. A **live handle stays valid** and observes the restatement: a handle is a view onto convergent state, not a snapshot. Two things measured rather than assumed: `List::merge`'s `claim_rank` is built from `put_node_value`, which writes a composite as its kind and id and never its tag, so two same-id different-tag elements always rank equal and take the fold arm — the sequence engine structurally cannot be where a tag contest is decided; and `Map::merge`'s LWW arm converges on the tag because the slot LWW is symmetric, its detached deep clone being C134's shape, not this one's. Spec `crates/core/tests/xml_tag_collision.rs` folds every shape in both orders and compares snapshot bytes. ARCHITECTURE §Tree Moves + DECISIONS updated. Residue: C154. → *Core / XML*. +**C44 — two inserts at one stamp carrying *different* tags derive one child, whose tag was the first arrival's (crates/core) — DONE (#PR).** `xml_child_id` mixes the children list, the stamp and the *kind* into the derivation but never the tag, so `XmlInsertChild { tag: Some(b"div") }` and `XmlInsertChild { tag: Some(b"span") }` at one stamp into one list derive the **same** `XmlElement` id; `registered_handle`'s `or_insert_with` seated the first arrival's tag and no op ever rewrote it. Reproduced before fixing (the entry had said "by inspection"): `frag(a(),div())` one order, `frag(a(),span())` the other, and `encode_state` differing byte for byte, since the XML registry writes each node as its id plus its tag. Mixing the tag into the derivation stays rejected for the reason the filing gives — C24's rank leans on `born_at` being pure in the key. **Fixed by an intrinsic rank: the smaller tag bytes take the node** (`XmlElement::claim_tag`), the same principle C40 (#400) landed one layer down. It is a **meet**, which is what makes it hold at every seam without a per-seam argument — idempotent, commutative and associative, so a replay ties, a third claim only lowers, and a merge cannot answer by which side received; and a decoded tag (unranked — one tag per id in the registry, no contest at decode) is a bound later ops can only tighten, so the rule is recoverable on reload with **no new persisted state**. C40's warning about a missed seat path paid off: the filing named one and there are **four**. The birth (`insert_xml_child`); **`apply_reveal`**, which the filing does not mention and is the widest — an `XmlReveal` names an arbitrary `ElementId` with arbitrary tag bytes, so it can name exactly the node a birth derives, and it returned early on an already-materialised node, so fixing the birth alone leaves reveal(`aa`)→birth(`div`) folding to `aa` and the reverse to `div`; **`XmlElement::merge`**, whose doc said the tag "is identity, not state, so it is left untouched" — that resolved it by which side received; and `create_container`, safe against itself (`XmlElement::node_id` folds the tag into the id) but collidable by a reveal, running the rank through the same `registered_handle` seam. The rank runs on **every** claim, including one whose id already determines its tag, because whether an id determines its tag is not a question an id answers and the reveal holds neither the map nor the key to check against — where it does determine it, every honest claim carries that tag and the rank is a tie. A **live handle stays valid** and observes the restatement: a handle is a view onto convergent state, not a snapshot. Two things measured rather than assumed: `List::merge`'s `claim_rank` is built from `put_node_value`, which writes a composite as its kind and id and never its tag, so two same-id different-tag elements always rank equal and take the fold arm — the sequence engine structurally cannot be where a tag contest is decided; and `Map::merge`'s LWW arm converges on the tag because the slot LWW is symmetric, its detached deep clone being C134's shape, not this one's. Spec `crates/core/tests/xml_tag_collision.rs` folds every shape in both orders and compares snapshot bytes, and pins the three properties the meet buys rather than arguing them: commutativity (two claims), associativity + idempotence (three claims, all six orders), and needing no state the snapshot lacks — a claim arriving at a **reloaded** replica is ranked against a decoded tag and lands where no restart does. A randomized permutation oracle shuffles pools mixing several tag claims at two stamps with a reveal and a delete. Mutation sweep under statement deletion: 6 mutants over the rank and every seat, **0 survivors**, with the reveal seat and the merge seat each killed by exactly one test. ARCHITECTURE §Tree Moves + DECISIONS updated. Residue: C154. → *Core / XML*. **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*. diff --git a/crates/core/src/xml.rs b/crates/core/src/xml.rs index 2bb6021d..48a8726c 100644 --- a/crates/core/src/xml.rs +++ b/crates/core/src/xml.rs @@ -1,6 +1,6 @@ //! XmlElement / XmlFragment — the tree primitives. //! -//! An [`XmlElement`] is a tagged tree node: an immutable `tag`, an `attrs` [`Map`] +//! An [`XmlElement`] is a tagged tree node: a `tag`, an `attrs` [`Map`] //! (holding any CRDT values), and an ordered `children` sequence of nested //! `XmlElement`s and `Text` runs. An [`XmlFragment`] is the same children sequence //! without a tag or attrs — a bare document body. @@ -12,8 +12,12 @@ //! engines unchanged — this type only pairs them under one id and a tag. The //! attrs Map and children List take ids derived from the element's own id, so //! every replica agrees on them (the same convergence the [`ElementId`] derivation -//! gives Map slots). The `tag` is fixed at creation — retagging a node is a -//! replace, not a mutation — so a merge of the same element never reconciles it. +//! gives Map slots). The `tag` is identity rather than editable state — no op +//! retags a node, and retagging in the API is a replace — but two claims can +//! still *name* one node under two tags, since a children-list node's id derives +//! from `(list, stamp, kind)` and carries no tag. So the tag is reconciled by its +//! own rank ([`XmlElement::claim_tag`]) wherever one is installed, a merge +//! included, rather than left at whichever claim landed first. use std::cell::{Cell, RefCell}; use std::rc::Rc; diff --git a/crates/core/tests/xml_tag_collision.rs b/crates/core/tests/xml_tag_collision.rs index e8d67ff5..beeadec5 100644 --- a/crates/core/tests/xml_tag_collision.rs +++ b/crates/core/tests/xml_tag_collision.rs @@ -13,6 +13,15 @@ //! its tag), so a replica that took the first arrival's tag encodes different //! bytes from one that saw the other order. Every shape that reaches the question //! is folded here in both orders and compared byte-for-byte. +//! +//! The rank is a **meet** over the tags, and the three properties that buys are +//! each pinned rather than argued: it is commutative (two claims, both orders), +//! associative and idempotent (three claims, all six orders), and it needs no +//! state a snapshot does not carry — a claim arriving at a *reloaded* replica is +//! ranked against a decoded tag and lands where no restart does. The last shape +//! is the randomized one: pools mixing several tag claims at two stamps with a +//! reveal and a delete, shuffled, where an interaction the reasoning missed shows +//! up and an isolated shape would not reach it. use crdtsync_core::doc::Document; use crdtsync_core::elementid::ElementId; @@ -191,6 +200,41 @@ fn a_restated_tag_survives_a_snapshot_round_trip() { } } +#[test] +fn a_reload_between_the_two_claims_lands_where_no_restart_does() { + // The sharper form of the round-trip: the *second* claim arrives at a replica + // that has restarted, so it is ranked against a **decoded** tag rather than a + // live one. Both directions matter — the reload can carry the winner (the + // later claim must lose to it) or the loser (the later claim must take it) — + // and a rule needing state the snapshot does not carry would break on one of + // them. + let mut author = Document::new(cid(1)); + let build = frag_with_a(&mut author); + let base = only_insert(author.transact(|tx| { + tx.xml_fragment(b"doc").children().insert_element(1, b"div"); + })); + let div = twin_tagged(&base, 9_000, b"div"); + let span = twin_tagged(&base, 9_001, b"span"); + + for order in [[&div, &span], [&span, &div]] { + let mut d = Document::new(cid(9)); + for op in build.iter().chain([order[0]]) { + d.apply(op); + } + let mut back = Document::decode_state(&d.encode_state()).expect("a reloadable snapshot"); + back.apply(order[1]); + + // The replica that never restarted, fed the same two ops. + let straight = fold(&build, &order); + assert_eq!( + (tree(&back), back.encode_state()), + straight, + "a reload between the two claims landed somewhere a restart-free replica does not" + ); + assert_eq!(contested_tag(&back).as_deref(), Some(&b"div"[..])); + } +} + #[test] fn three_tags_at_one_stamp_converge_in_every_order() { // The rank is a meet over the tags, so it is idempotent and associative as @@ -393,3 +437,112 @@ fn a_list_merge_ranks_a_child_s_tag() { assert_eq!(tag_of(&a), b"div"); assert_eq!(tag_of(&b), b"div", "the merge took the receiver's tag"); } +/// A small linear-congruential PRNG — deterministic, seedable, reproducible. +struct Rng(u64); + +impl Rng { + fn new(seed: u64) -> Self { + Rng(seed ^ 0x9E37_79B9_7F4A_7C15) + } + fn next(&mut self) -> u64 { + self.0 = self + .0 + .wrapping_mul(6364136223846793005) + .wrapping_add(1442695040888963407); + self.0 >> 17 + } + fn below(&mut self, n: usize) -> usize { + (self.next() as usize) % n + } +} + +const TAGS: &[&[u8]] = &[b"p", b"div", b"span", b"b", b"section", b"a"]; + +#[test] +fn a_shuffled_pool_of_tag_claims_converges_on_every_permutation() { + // The deterministic tests fold the shapes the rank was reasoned about. This + // pools them — several inserts at one stamp under different tags, a reveal + // naming the node they derive, a delete of the contested slot, and a second + // stamp's claims — and folds every pool in many permutations, comparing + // snapshot bytes. An interaction the reasoning missed shows up here, where an + // isolated shape would not reach it. + for seed in 0..40u64 { + let mut rng = Rng::new(seed); + let mut author = Document::new(cid(1)); + let build = frag_with_a(&mut author); + let base = only_insert(author.transact(|tx| { + tx.xml_fragment(b"doc").children().insert_element(1, b"z"); + })); + let other = only_insert(author.transact(|tx| { + tx.xml_fragment(b"doc").children().insert_element(2, b"z"); + })); + + // The node the first stamp's tagged child derives, read off a replica + // that folded one claim alone. + let node = { + let mut d = Document::new(cid(9)); + for op in build.iter().chain([&base]) { + d.apply(op); + } + contested_id(&d).expect("the born child") + }; + + let mut pool: Vec = Vec::new(); + let mut seq = 9_000u64; + for _ in 0..3 { + pool.push(twin_tagged(&base, seq, TAGS[rng.below(TAGS.len())])); + seq += 1; + } + for _ in 0..2 { + pool.push(twin_tagged(&other, seq, TAGS[rng.below(TAGS.len())])); + seq += 1; + } + if rng.below(2) == 0 { + let mut reveal = base.clone(); + reveal.id.seq = seq; + seq += 1; + reveal.kind = OpKind::XmlReveal { + node, + tag: Some(TAGS[rng.below(TAGS.len())].to_vec()), + }; + pool.push(reveal); + } + if rng.below(2) == 0 { + let mut delete = base.clone(); + delete.id.seq = seq; + delete.stamp.lamport = base.stamp.lamport + 2; + delete.kind = OpKind::ListDelete { id: base.stamp }; + pool.push(delete); + } + + let expect = { + let refs: Vec<&Op> = pool.iter().collect(); + fold(&build, &refs) + }; + for round in 0..12 { + let mut shuffled = pool.clone(); + for i in (1..shuffled.len()).rev() { + shuffled.swap(i, rng.below(i + 1)); + } + let refs: Vec<&Op> = shuffled.iter().collect(); + assert_eq!( + fold(&build, &refs), + expect, + "seed {seed} round {round} diverged" + ); + } + + // And the pool's verdict survives a reload. + let mut d = Document::new(cid(9)); + for op in build.iter().chain(pool.iter()) { + d.apply(op); + } + let bytes = d.encode_state(); + let back = Document::decode_state(&bytes).expect("a replica could not load its snapshot"); + assert_eq!( + back.encode_state(), + bytes, + "seed {seed}: the re-encode is not canonical" + ); + } +} From c7bc542054da569b2dd8aa3228a3930c57d4d4c5 Mon Sep 17 00:00:00 2001 From: Lucas Vieira Date: Sun, 9 Aug 2026 11:32:25 -0300 Subject: [PATCH 3/7] docs: state which seams the tag rank reaches, rather than claiming every one does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `claim_tag`'s own doc said it was "the seam every path that names a node's tag runs through", and ARCHITECTURE said every seam that installs or restates a tag runs it. Both are broader than the code: a path with no incumbent — a first materialisation, a decode, a detached deep clone — names a tag and has nothing to rank. The rank is what a claim meeting a tag already held goes through. DECISIONS also stated the reload soundness as reasoning; it is now measured, and says so. --- ARCHITECTURE.md | 2 +- DECISIONS.md | 2 +- KANBAN.md | 2 +- crates/core/src/xml.rs | 6 +++++- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index f910a828..9733b001 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -319,7 +319,7 @@ The fold is a pure function of the move-set + tombstone-set, **independent of th **A children-list position is owned by a birth over a move, then by the smaller element id.** A node's position in a children sequence is keyed `(list, stamp)` and a document holds at most one per key, since the snapshot codec refuses a duplicate. Two ops can nevertheless carry one stamp into one list and both be admissible — op dedup is by `OpId`, and the id-space record bounds only an *honest* mint — so the key is contended, and which op takes it is decided by that rank, never by which arrived first. A birth outranks because the key is where the born node's element id comes from: a birth that lost would leave a node whose id names a position it does not hold and which nothing can re-derive, while a move brings an id of its own and survives losing. The birth test is a pure function of the key — a stamp derives exactly two children, the tagged and the tagless — so nothing a reveal registers can invert the rank, and a move naming either of them is ranked as that child rather than as a move. A later-arriving winner evicts the incumbent from the key, from the move edge that came with it, and from the reachability edge the fold derived. A losing *move* is refused whole; a losing *birth* still materialises its child, which is then left with no position. **A node left with no position is left movable**, whether it was refused or evicted, because that is the state the opposite arrival order leaves it in and a move naming it must land the same way either way — and a snapshot carries that for a node created under a parent, which is what a reload re-derives it from. Arrival-order ownership folded one op set into two states, so two replicas that saw the same ops disagreed and their snapshots differed byte for byte. That 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 at one stamp derive one child between them whenever their kinds agree: there the key is already the claimant's own, nothing changes hands, and the position is the **meet** of the two anchors under a total order that is arbitrary but the same everywhere. A *contest* is what takes the winner's position outright, the loser's being nothing to it. A meet cannot show which arrived first, where a contest between two claims on one node would have had to know what put the incumbent at the key — and nothing answers that, since the move log dedups on the stamp alone and a move can hold a key having recorded no edge. -**A node's tag is owned by the smaller tag bytes.** A children-list node's element id derives from `(list, stamp, kind)` and carries no tag — deliberately, since a stamp derives exactly two children (the tagged and the tagless) and only that purity makes "is this node the child this key derives" answerable without guessing every tag. So two `XmlInsertChild`s at one stamp with different tags name **one** node, both admissible, and the tag it ends at is decided by a lexicographic order over the tags, never by which arrived first. That order is total, intrinsic, and held by every replica that has either op; there is nothing else two such claims differ in to read. Because it is a **meet**, it is idempotent and associative as well as commutative — a replay ties, a third claim lowers or does nothing, and a snapshot's tag is a bound later ops can only tighten, which is what makes the tag recoverable on reload with no new persisted state (it rides `encode_state` already). Every seam that installs or restates a tag runs it — the birth, an `XmlReveal` shell (including one meeting a node already materialised, where returning early settled the two tags by arrival), and an element merge, whose sequence layer is structurally blind to a tag and so can never be where it is decided. It runs even on a node whose id *does* determine its tag (a map slot's, which derives from `(map, key, tag)`): whether an id determines its tag is not a question an id answers, and where it does, every honest claim carries that same tag and the rank is a tie. A live handle stays valid across a restatement and observes it — a handle is a view onto convergent state, not a snapshot, and remote ops mutating state underneath one is the normal case. +**A node's tag is owned by the smaller tag bytes.** A children-list node's element id derives from `(list, stamp, kind)` and carries no tag — deliberately, since a stamp derives exactly two children (the tagged and the tagless) and only that purity makes "is this node the child this key derives" answerable without guessing every tag. So two `XmlInsertChild`s at one stamp with different tags name **one** node, both admissible, and the tag it ends at is decided by a lexicographic order over the tags, never by which arrived first. That order is total, intrinsic, and held by every replica that has either op; there is nothing else two such claims differ in to read. Because it is a **meet**, it is idempotent and associative as well as commutative — a replay ties, a third claim lowers or does nothing, and a snapshot's tag is a bound later ops can only tighten, which is what makes the tag recoverable on reload with no new persisted state (it rides `encode_state` already). Every seam where a claim can meet a tag **already held** runs it — the birth, an `XmlReveal` shell (including one meeting a node already materialised, where returning early settled the two tags by arrival), and an element merge, whose sequence layer is structurally blind to a tag and so can never be where it is decided. A seam with no incumbent has nothing to rank and seats its tag directly: a first materialisation, a decode, and a detached deep clone. It runs even on a node whose id *does* determine its tag (a map slot's, which derives from `(map, key, tag)`): whether an id determines its tag is not a question an id answers, and where it does, every honest claim carries that same tag and the rank is a tie. A live handle stays valid across a restatement and observes it — a handle is a view onto convergent state, not a snapshot, and remote ops mutating state underneath one is the normal case. **The created-under relation spans the whole tree, the map half included.** A node's position among its siblings is a children-sequence concern, but the relation the move log's cycle check walks is not: a container keyed into a map is created under that map, and the map under the element that owns it. Without those edges the walk stops where the children lists stop, and a move under a node reachable only *through* a map — an element in the moved node's own attrs — reads as acyclic, closes a loop, and leaves the replica holding a document that is no longer a tree. Every rebuild of the relation re-seeds it: a movable node's edge from its birth placement, or from its parent link when it holds no placement at all, and every other container's one hop up that link. diff --git a/DECISIONS.md b/DECISIONS.md index c05bf894..ca2285cf 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -36,7 +36,7 @@ The entries below (2026-07-02) are a backfill: design changes made during the v0 **Mixing the tag into the derivation stays rejected, for the reason the filing gives.** C24's placement rank leans on `born_at` being pure in the key: a stamp derives exactly two children and both are tried, so nothing a reveal registers can invert the rank. A tag in the derivation makes that test unanswerable without enumerating every tag. -**The rank is the smaller tag bytes**, the same principle C40 landed one layer down: an intrinsic total order over the two ops, never arrival order. Lexicographic over the tags is total, held by every replica that has either op, and the only thing two such claims differ in. It is a **meet**, which is what makes it work at every seam without a per-seam argument — idempotent, commutative and associative by construction, so a replay ties, a third claim only lowers, and a merge cannot answer by which side received. It is also what makes the snapshot sound: a decoded tag is unranked (one tag per id in the registry, no contest at decode) and later ops can only tighten it, so no new persisted state is needed to recover the rule. Pinned by a three-tag six-order fold, a round-trip that re-encodes canonically, and a delete landing between the two claims — the shape C40 measured, where the tag has to answer the same with a delete in the middle as without one, since a `ListDelete` tombstones the sequence slot and never the node registry. +**The rank is the smaller tag bytes**, the same principle C40 landed one layer down: an intrinsic total order over the two ops, never arrival order. Lexicographic over the tags is total, held by every replica that has either op, and the only thing two such claims differ in. It is a **meet**, which is what makes it work at every seam without a per-seam argument — idempotent, commutative and associative by construction, so a replay ties, a third claim only lowers, and a merge cannot answer by which side received. It is also what makes the snapshot sound: a decoded tag is unranked (one tag per id in the registry, no contest at decode) and later ops can only tighten it, so no new persisted state is needed to recover the rule — measured, not reasoned, by folding one claim, encoding, decoding, and applying the other to the reloaded replica in both directions, which lands where a replica that never restarted does. Pinned by a three-tag six-order fold, a round-trip that re-encodes canonically, and a delete landing between the two claims — the shape C40 measured, where the tag has to answer the same with a delete in the middle as without one, since a `ListDelete` tombstones the sequence slot and never the node registry. **C40's warning paid off: the filing named one seat and there are four.** Enumerated rather than assumed. (1) the birth, `insert_xml_child` → `registered_handle`. (2) **`apply_reveal`**, which the filing does not mention and which is the *widest* seat — an `XmlReveal` names an arbitrary `ElementId` with arbitrary tag bytes, so it can name exactly the node a birth derives, and it returned early on an already-materialised node. Fixing only the birth leaves it: reveal(`aa`) then birth(`div`) folds to `aa`, birth then reveal folds to `div`. It now claims the tag before returning, against an element and from a tagged reveal only — a tagless reveal names a text run, and the two kinds are never one node since `xml_child_id` mixes the kind in. (3) **`XmlElement::merge`**, whose doc said "the `tag` is identity, not state, so it is left untouched" — that made a merge resolve the tag by which side received. It now claims. (4) `create_container`, safe against itself because `XmlElement::node_id` folds the tag into the id, but collidable *by a reveal*, and it runs the rank through the same `registered_handle` seam. diff --git a/KANBAN.md b/KANBAN.md index 48b8e6a0..627a81f0 100644 --- a/KANBAN.md +++ b/KANBAN.md @@ -30,7 +30,7 @@ Element + Map are one coupled unit (Map slots hold Elements; Element forwards li _Derived from code + git; a convenience view, not the source of truth._ -**C44 — two inserts at one stamp carrying *different* tags derive one child, whose tag was the first arrival's (crates/core) — DONE (#PR).** `xml_child_id` mixes the children list, the stamp and the *kind* into the derivation but never the tag, so `XmlInsertChild { tag: Some(b"div") }` and `XmlInsertChild { tag: Some(b"span") }` at one stamp into one list derive the **same** `XmlElement` id; `registered_handle`'s `or_insert_with` seated the first arrival's tag and no op ever rewrote it. Reproduced before fixing (the entry had said "by inspection"): `frag(a(),div())` one order, `frag(a(),span())` the other, and `encode_state` differing byte for byte, since the XML registry writes each node as its id plus its tag. Mixing the tag into the derivation stays rejected for the reason the filing gives — C24's rank leans on `born_at` being pure in the key. **Fixed by an intrinsic rank: the smaller tag bytes take the node** (`XmlElement::claim_tag`), the same principle C40 (#400) landed one layer down. It is a **meet**, which is what makes it hold at every seam without a per-seam argument — idempotent, commutative and associative, so a replay ties, a third claim only lowers, and a merge cannot answer by which side received; and a decoded tag (unranked — one tag per id in the registry, no contest at decode) is a bound later ops can only tighten, so the rule is recoverable on reload with **no new persisted state**. C40's warning about a missed seat path paid off: the filing named one and there are **four**. The birth (`insert_xml_child`); **`apply_reveal`**, which the filing does not mention and is the widest — an `XmlReveal` names an arbitrary `ElementId` with arbitrary tag bytes, so it can name exactly the node a birth derives, and it returned early on an already-materialised node, so fixing the birth alone leaves reveal(`aa`)→birth(`div`) folding to `aa` and the reverse to `div`; **`XmlElement::merge`**, whose doc said the tag "is identity, not state, so it is left untouched" — that resolved it by which side received; and `create_container`, safe against itself (`XmlElement::node_id` folds the tag into the id) but collidable by a reveal, running the rank through the same `registered_handle` seam. The rank runs on **every** claim, including one whose id already determines its tag, because whether an id determines its tag is not a question an id answers and the reveal holds neither the map nor the key to check against — where it does determine it, every honest claim carries that tag and the rank is a tie. A **live handle stays valid** and observes the restatement: a handle is a view onto convergent state, not a snapshot. Two things measured rather than assumed: `List::merge`'s `claim_rank` is built from `put_node_value`, which writes a composite as its kind and id and never its tag, so two same-id different-tag elements always rank equal and take the fold arm — the sequence engine structurally cannot be where a tag contest is decided; and `Map::merge`'s LWW arm converges on the tag because the slot LWW is symmetric, its detached deep clone being C134's shape, not this one's. Spec `crates/core/tests/xml_tag_collision.rs` folds every shape in both orders and compares snapshot bytes, and pins the three properties the meet buys rather than arguing them: commutativity (two claims), associativity + idempotence (three claims, all six orders), and needing no state the snapshot lacks — a claim arriving at a **reloaded** replica is ranked against a decoded tag and lands where no restart does. A randomized permutation oracle shuffles pools mixing several tag claims at two stamps with a reveal and a delete. Mutation sweep under statement deletion: 6 mutants over the rank and every seat, **0 survivors**, with the reveal seat and the merge seat each killed by exactly one test. ARCHITECTURE §Tree Moves + DECISIONS updated. Residue: C154. → *Core / XML*. +**C44 — two inserts at one stamp carrying *different* tags derive one child, whose tag was the first arrival's (crates/core) — DONE (#PR).** `xml_child_id` mixes the children list, the stamp and the *kind* into the derivation but never the tag, so `XmlInsertChild { tag: Some(b"div") }` and `XmlInsertChild { tag: Some(b"span") }` at one stamp into one list derive the **same** `XmlElement` id; `registered_handle`'s `or_insert_with` seated the first arrival's tag and no op ever rewrote it. Reproduced before fixing (the entry had said "by inspection"): `frag(a(),div())` one order, `frag(a(),span())` the other, and `encode_state` differing byte for byte, since the XML registry writes each node as its id plus its tag. Mixing the tag into the derivation stays rejected for the reason the filing gives — C24's rank leans on `born_at` being pure in the key. **Fixed by an intrinsic rank: the smaller tag bytes take the node** (`XmlElement::claim_tag`), the same principle C40 (#400) landed one layer down. It is a **meet**, which is what makes it hold at every seam without a per-seam argument — idempotent, commutative and associative, so a replay ties, a third claim only lowers, and a merge cannot answer by which side received; and a decoded tag (unranked — one tag per id in the registry, no contest at decode) is a bound later ops can only tighten, so the rule is recoverable on reload with **no new persisted state**. C40's warning about a missed seat path paid off: the filing named one and there are **four**. The birth (`insert_xml_child`); **`apply_reveal`**, which the filing does not mention and is the widest — an `XmlReveal` names an arbitrary `ElementId` with arbitrary tag bytes, so it can name exactly the node a birth derives, and it returned early on an already-materialised node, so fixing the birth alone leaves reveal(`aa`)→birth(`div`) folding to `aa` and the reverse to `div`; **`XmlElement::merge`**, whose doc said the tag "is identity, not state, so it is left untouched" — that resolved it by which side received; and `create_container`, safe against itself (`XmlElement::node_id` folds the tag into the id) but collidable by a reveal, running the rank through the same `registered_handle` seam. The rank runs wherever a claim can meet a tag **already held** (a seam with no incumbent — a first materialisation, a decode, a detached clone — has nothing to rank and seats directly), and on **every** such claim, including one whose id already determines its tag, because whether an id determines its tag is not a question an id answers and the reveal holds neither the map nor the key to check against — where it does determine it, every honest claim carries that tag and the rank is a tie. A **live handle stays valid** and observes the restatement: a handle is a view onto convergent state, not a snapshot. Two things measured rather than assumed: `List::merge`'s `claim_rank` is built from `put_node_value`, which writes a composite as its kind and id and never its tag, so two same-id different-tag elements always rank equal and take the fold arm — the sequence engine structurally cannot be where a tag contest is decided; and `Map::merge`'s LWW arm converges on the tag because the slot LWW is symmetric, its detached deep clone being C134's shape, not this one's. Spec `crates/core/tests/xml_tag_collision.rs` folds every shape in both orders and compares snapshot bytes, and pins the three properties the meet buys rather than arguing them: commutativity (two claims), associativity + idempotence (three claims, all six orders), and needing no state the snapshot lacks — a claim arriving at a **reloaded** replica is ranked against a decoded tag and lands where no restart does. A randomized permutation oracle shuffles pools mixing several tag claims at two stamps with a reveal and a delete. Mutation sweep under statement deletion: 6 mutants over the rank and every seat, **0 survivors**, with the reveal seat and the merge seat each killed by exactly one test. ARCHITECTURE §Tree Moves + DECISIONS updated. Residue: C154. → *Core / XML*. **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*. diff --git a/crates/core/src/xml.rs b/crates/core/src/xml.rs index 48a8726c..34ff241d 100644 --- a/crates/core/src/xml.rs +++ b/crates/core/src/xml.rs @@ -102,7 +102,11 @@ impl XmlElement { } /// Claim this node's tag for `tag`, which takes it when its bytes are the - /// smaller — the seam every path that names a node's tag runs through. + /// smaller — the seam every path that can meet a tag *already held* runs + /// through. A path with no incumbent has nothing to rank and seats its tag + /// directly: a first materialisation, a decode (the registry holds one tag per + /// id, so a snapshot restores the ranked winner rather than re-deciding it), + /// and a detached deep clone. /// /// A children-list node's id derives from `(list, stamp, kind)` and carries no /// tag, deliberately: a stamp derives exactly two children, the tagged and the From 90039b5833c883954de12b355f3391c7a6c69d77 Mon Sep 17 00:00:00 2001 From: Lucas Vieira Date: Sun, 9 Aug 2026 13:07:05 -0300 Subject: [PATCH 4/7] fix(core): key a reveal shell by the tag it carries, so a lowered tag reaches its readers A falsification pass found a fifth seat this unit itself opened. `reveal_ops` reads a node's tag off live state into a synthetic shell, and `reveal_op_id` derived that shell's `OpId` from the node alone. That was harmless while a tag was immutable once materialised; making the tag a meet made the payload time-dependent while its identity stayed constant, so a shell emitted before a smaller claim landed and the corrected shell after it were one op, and the correction deduped away. A reader served the earlier shell was pinned at a tag its own document could never revise, falsifying `reveal_ops`' own claim that an op-served reader converges with a snapshot-served one. There is no recovery path: `reveal_backfill` never carries the parent list the tag-bearing insert targets, so the shell is the reader's only source of the tag. The id now derives from `(node, tag)`, a presence byte leading so a tagless shell and an empty-tagged one stay distinct. The corrected shell is a distinct op that reaches the rank, which is idempotent, so shells in any order meet. Two more the pass broke. A mutant guarding the rank with `!tag.is_empty()` survived the whole workspace: `Some(vec![])` round-trips the wire and nothing validates op-level tag bytes, so the empty tag is admissible and is the rank's bottom. And the test named for the merge's two directions ran one direction twice, so it stayed green with the rank removed. Both are pinned now, with a non-UTF8 tag beside them. Prose corrected where it was broader than the code: `Map::merge` does not converge on the tag (its LWW is symmetric only at distinct stamps); the sequence engine cannot decide a tag, but because the fold-arm gate excludes the anchor, not because two same-id claims "always rank equal"; and a diff reads a changed tag as a replace, which is conservative rather than exact once a tag can be lowered. C155 files the map-slot equal-stamp arrival order, C156 the diff. --- ARCHITECTURE.md | 4 +- DECISIONS.md | 8 ++- KANBAN.md | 6 +- crates/core/src/diff.rs | 13 ++-- crates/core/src/doc.rs | 54 +++++++++++---- crates/core/src/xml.rs | 14 ++-- crates/core/tests/state_project_read.rs | 91 +++++++++++++++++++++++++ crates/core/tests/xml_tag_collision.rs | 91 +++++++++++++++++++++---- 8 files changed, 239 insertions(+), 42 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 9733b001..85bab052 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -319,7 +319,9 @@ The fold is a pure function of the move-set + tombstone-set, **independent of th **A children-list position is owned by a birth over a move, then by the smaller element id.** A node's position in a children sequence is keyed `(list, stamp)` and a document holds at most one per key, since the snapshot codec refuses a duplicate. Two ops can nevertheless carry one stamp into one list and both be admissible — op dedup is by `OpId`, and the id-space record bounds only an *honest* mint — so the key is contended, and which op takes it is decided by that rank, never by which arrived first. A birth outranks because the key is where the born node's element id comes from: a birth that lost would leave a node whose id names a position it does not hold and which nothing can re-derive, while a move brings an id of its own and survives losing. The birth test is a pure function of the key — a stamp derives exactly two children, the tagged and the tagless — so nothing a reveal registers can invert the rank, and a move naming either of them is ranked as that child rather than as a move. A later-arriving winner evicts the incumbent from the key, from the move edge that came with it, and from the reachability edge the fold derived. A losing *move* is refused whole; a losing *birth* still materialises its child, which is then left with no position. **A node left with no position is left movable**, whether it was refused or evicted, because that is the state the opposite arrival order leaves it in and a move naming it must land the same way either way — and a snapshot carries that for a node created under a parent, which is what a reload re-derives it from. Arrival-order ownership folded one op set into two states, so two replicas that saw the same ops disagreed and their snapshots differed byte for byte. That 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 at one stamp derive one child between them whenever their kinds agree: there the key is already the claimant's own, nothing changes hands, and the position is the **meet** of the two anchors under a total order that is arbitrary but the same everywhere. A *contest* is what takes the winner's position outright, the loser's being nothing to it. A meet cannot show which arrived first, where a contest between two claims on one node would have had to know what put the incumbent at the key — and nothing answers that, since the move log dedups on the stamp alone and a move can hold a key having recorded no edge. -**A node's tag is owned by the smaller tag bytes.** A children-list node's element id derives from `(list, stamp, kind)` and carries no tag — deliberately, since a stamp derives exactly two children (the tagged and the tagless) and only that purity makes "is this node the child this key derives" answerable without guessing every tag. So two `XmlInsertChild`s at one stamp with different tags name **one** node, both admissible, and the tag it ends at is decided by a lexicographic order over the tags, never by which arrived first. That order is total, intrinsic, and held by every replica that has either op; there is nothing else two such claims differ in to read. Because it is a **meet**, it is idempotent and associative as well as commutative — a replay ties, a third claim lowers or does nothing, and a snapshot's tag is a bound later ops can only tighten, which is what makes the tag recoverable on reload with no new persisted state (it rides `encode_state` already). Every seam where a claim can meet a tag **already held** runs it — the birth, an `XmlReveal` shell (including one meeting a node already materialised, where returning early settled the two tags by arrival), and an element merge, whose sequence layer is structurally blind to a tag and so can never be where it is decided. A seam with no incumbent has nothing to rank and seats its tag directly: a first materialisation, a decode, and a detached deep clone. It runs even on a node whose id *does* determine its tag (a map slot's, which derives from `(map, key, tag)`): whether an id determines its tag is not a question an id answers, and where it does, every honest claim carries that same tag and the rank is a tie. A live handle stays valid across a restatement and observes it — a handle is a view onto convergent state, not a snapshot, and remote ops mutating state underneath one is the normal case. +**A node's tag is owned by the smaller tag bytes.** A children-list node's element id derives from `(list, stamp, kind)` and carries no tag — deliberately, since a stamp derives exactly two children (the tagged and the tagless) and only that purity makes "is this node the child this key derives" answerable without guessing every tag. So two `XmlInsertChild`s at one stamp with different tags name **one** node, both admissible, and the tag it ends at is decided by a lexicographic order over the tags, never by which arrived first. That order is total, intrinsic, and held by every replica that has either op; there is nothing else two such claims differ in to read. Because it is a **meet**, it is idempotent and associative as well as commutative — a replay ties, a third claim lowers or does nothing, and a snapshot's tag is a bound later ops can only tighten, which is what makes the tag recoverable on reload with no new persisted state (it rides `encode_state` already). Every seam where a claim can meet a tag **already held at that id** runs it — the birth, an `XmlReveal` shell (including one meeting a node already materialised, where returning early settled the two tags by arrival), and an element merge, whose sequence layer is structurally blind to a tag and so can never be where it is decided. A seam with no incumbent has nothing to rank and seats its tag directly: a first materialisation, a decode, and a detached deep clone. The qualifier *at that id* is the bound: where two tags produce two **ids** — a map slot, whose node id derives from `(map, key, tag)` — the rank never runs, and which element holds the slot is the map's own LWW, which at an equal stamp keeps the incumbent and so answers by arrival. That is a Map-layer question of the same family, not a tag question, and is filed separately. + +A shell that carries a tag must carry it **in its identity**. A redaction synthesises an `XmlReveal` by reading the node's current tag off live state, so the payload moves when the rank lowers it; keyed on the node alone, the corrected shell dedups against the stale one and a reader is pinned at a tag its own document can never revise. The shell's op id therefore derives from `(node, tag)`, which makes the correction a distinct op that reaches the rank — idempotent, so any order of shells lands on the meet. It runs even on a node whose id *does* determine its tag (a map slot's, which derives from `(map, key, tag)`): whether an id determines its tag is not a question an id answers, and where it does, every honest claim carries that same tag and the rank is a tie. A live handle stays valid across a restatement and observes it — a handle is a view onto convergent state, not a snapshot, and remote ops mutating state underneath one is the normal case. **The created-under relation spans the whole tree, the map half included.** A node's position among its siblings is a children-sequence concern, but the relation the move log's cycle check walks is not: a container keyed into a map is created under that map, and the map under the element that owns it. Without those edges the walk stops where the children lists stop, and a move under a node reachable only *through* a map — an element in the moved node's own attrs — reads as acyclic, closes a loop, and leaves the replica holding a document that is no longer a tree. Every rebuild of the relation re-seeds it: a movable node's edge from its birth placement, or from its parent link when it holds no placement at all, and every other container's one hop up that link. diff --git a/DECISIONS.md b/DECISIONS.md index ca2285cf..a0ccd233 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -44,7 +44,13 @@ The entries below (2026-07-02) are a backfill: design changes made during the v0 **A live handle stays valid and observes the restatement.** Invalidation was considered and rejected: a CRDT handle is a view onto convergent state, not a snapshot, and remote ops mutating state underneath one is the normal case rather than the exception. Invalidation would be an API contract change propagating through FFI, Python, Go and JS for no correctness gain. Pinned by a test that holds the node's `Rc` across the second claim's arrival and reads the settled tag. -**Two things the sequence layer structurally cannot do, measured rather than assumed.** `List::merge` ranks by `claim_rank`, built from `put_node_value`, which writes a composite as its kind and id and **never** its tag — so two same-id different-tag elements always compare equal and take the fold arm. The sequence engine therefore can never be where a tag contest is decided, and the `Seated::Vacant` arm's `deep_clone` is uncontested by definition. `Map::merge`'s LWW arm likewise installs a detached deep clone by slot stamp; the tag converges there because the slot LWW is symmetric, and the detachment is C134's already-filed shape, not this unit's. +**The sequence layer structurally cannot decide a tag, measured rather than assumed — and the first statement of it was wrong.** The claim "two same-id different-tag elements always rank equal" is false: `Rank` is `(kind, anchor, encoded value)` and two such claims differing in *anchor* do not rank equal. What carries the argument is narrower and is what the code actually does — `List::merge`'s fold-arm gate compares `(claim.0, &claim.2) == (seated.0, &seated.2)`, kind and encoded value only, **excluding** the anchor — and `put_node_value` writes a composite as its kind and id and never its tag. So the fold arm is selected for two same-id elements whatever their anchors, and the contest arm is unreachable for a tag. Measured on the differing-anchor pair (`Side::Left` vs `Side::Right`), which converges in both directions. The `Seated::Vacant` arm's `deep_clone` is uncontested by definition. + +**`Map::merge` does *not* converge on the tag, and the first cut of this entry said it did.** The claim was "the slot LWW is symmetric"; it is symmetric only where the two stamps are distinct. `Map::merge` keeps the destination whenever `!se.stamp.gt(&de.stamp)`, and an equal stamp is not `gt`, so the receiver wins: two replicas merging one node under two tags at one stamp measure `div` and `span` respectively. Corrected to the narrower true statement rather than left standing. It is not reachable through `Document::apply` (nothing folds two documents), and the detached clone is C134's shape. + +**A falsification pass found a fifth seat that this unit itself opens, and it is the substance of the second commit.** `reveal_ops` reads a node's tag off live state into a synthetic shell, and `reveal_op_id` derived that shell's `OpId` from the **node alone**. Before C44 a node's tag was immutable once materialised, so every shell for one node carried identical bytes and the constant id was harmless. Making the tag a meet made the payload time-dependent while its identity stayed constant: a reader served a shell before a smaller claim landed is pinned at the stale tag forever, because the corrected shell dedups away at `apply` — measured as two op-served readers of one node holding different tags and encoding different bytes, both differing from the replica whose tag is the meet, and falsifying `reveal_ops`' own docstring that an op-served reader converges with a snapshot-served one. There is no recovery path: `reveal_backfill` filters to the node's own subtree containers, never the parent list the tag-bearing insert targets, so the shell is the reader's only source of the tag. The id now derives from `(node, tag)` with a presence byte leading, so a tagless shell and an empty-tagged one stay distinct; the corrected shell is a distinct op that reaches the rank, which is idempotent, so shells in any order land on the meet. + +**Two more things the pass broke, both now pinned.** A mutant guarding the rank with `!tag.is_empty()` survived the entire workspace suite — `Some(vec![])` round-trips the wire intact and nothing validates op-level tag bytes, so the empty tag is an admissible claim and is the rank's bottom; under the mutant an empty-vs-nonempty pair resolves by arrival, which is the bug this unit exists to close. And the test named for the merge's two directions ran one direction twice (both merges had `div` as the receiver), so it stayed green with the rank removed. Both are now tests: the empty tag, a non-UTF8 tag, and a merge whose two receivers genuinely differ. ## 2026-08-09 · C14 redacted-delta frontier (#398) · the carrier C9 refused for the snapshot seam is the right answer one seam over — and a redaction owes the recipient **both** records its mint reads, without burying the ops it names diff --git a/KANBAN.md b/KANBAN.md index 627a81f0..0f87fb6b 100644 --- a/KANBAN.md +++ b/KANBAN.md @@ -30,7 +30,7 @@ Element + Map are one coupled unit (Map slots hold Elements; Element forwards li _Derived from code + git; a convenience view, not the source of truth._ -**C44 — two inserts at one stamp carrying *different* tags derive one child, whose tag was the first arrival's (crates/core) — DONE (#PR).** `xml_child_id` mixes the children list, the stamp and the *kind* into the derivation but never the tag, so `XmlInsertChild { tag: Some(b"div") }` and `XmlInsertChild { tag: Some(b"span") }` at one stamp into one list derive the **same** `XmlElement` id; `registered_handle`'s `or_insert_with` seated the first arrival's tag and no op ever rewrote it. Reproduced before fixing (the entry had said "by inspection"): `frag(a(),div())` one order, `frag(a(),span())` the other, and `encode_state` differing byte for byte, since the XML registry writes each node as its id plus its tag. Mixing the tag into the derivation stays rejected for the reason the filing gives — C24's rank leans on `born_at` being pure in the key. **Fixed by an intrinsic rank: the smaller tag bytes take the node** (`XmlElement::claim_tag`), the same principle C40 (#400) landed one layer down. It is a **meet**, which is what makes it hold at every seam without a per-seam argument — idempotent, commutative and associative, so a replay ties, a third claim only lowers, and a merge cannot answer by which side received; and a decoded tag (unranked — one tag per id in the registry, no contest at decode) is a bound later ops can only tighten, so the rule is recoverable on reload with **no new persisted state**. C40's warning about a missed seat path paid off: the filing named one and there are **four**. The birth (`insert_xml_child`); **`apply_reveal`**, which the filing does not mention and is the widest — an `XmlReveal` names an arbitrary `ElementId` with arbitrary tag bytes, so it can name exactly the node a birth derives, and it returned early on an already-materialised node, so fixing the birth alone leaves reveal(`aa`)→birth(`div`) folding to `aa` and the reverse to `div`; **`XmlElement::merge`**, whose doc said the tag "is identity, not state, so it is left untouched" — that resolved it by which side received; and `create_container`, safe against itself (`XmlElement::node_id` folds the tag into the id) but collidable by a reveal, running the rank through the same `registered_handle` seam. The rank runs wherever a claim can meet a tag **already held** (a seam with no incumbent — a first materialisation, a decode, a detached clone — has nothing to rank and seats directly), and on **every** such claim, including one whose id already determines its tag, because whether an id determines its tag is not a question an id answers and the reveal holds neither the map nor the key to check against — where it does determine it, every honest claim carries that tag and the rank is a tie. A **live handle stays valid** and observes the restatement: a handle is a view onto convergent state, not a snapshot. Two things measured rather than assumed: `List::merge`'s `claim_rank` is built from `put_node_value`, which writes a composite as its kind and id and never its tag, so two same-id different-tag elements always rank equal and take the fold arm — the sequence engine structurally cannot be where a tag contest is decided; and `Map::merge`'s LWW arm converges on the tag because the slot LWW is symmetric, its detached deep clone being C134's shape, not this one's. Spec `crates/core/tests/xml_tag_collision.rs` folds every shape in both orders and compares snapshot bytes, and pins the three properties the meet buys rather than arguing them: commutativity (two claims), associativity + idempotence (three claims, all six orders), and needing no state the snapshot lacks — a claim arriving at a **reloaded** replica is ranked against a decoded tag and lands where no restart does. A randomized permutation oracle shuffles pools mixing several tag claims at two stamps with a reveal and a delete. Mutation sweep under statement deletion: 6 mutants over the rank and every seat, **0 survivors**, with the reveal seat and the merge seat each killed by exactly one test. ARCHITECTURE §Tree Moves + DECISIONS updated. Residue: C154. → *Core / XML*. +**C44 — two inserts at one stamp carrying *different* tags derive one child, whose tag was the first arrival's (crates/core) — DONE (#PR).** `xml_child_id` mixes the children list, the stamp and the *kind* into the derivation but never the tag, so `XmlInsertChild { tag: Some(b"div") }` and `XmlInsertChild { tag: Some(b"span") }` at one stamp into one list derive the **same** `XmlElement` id; `registered_handle`'s `or_insert_with` seated the first arrival's tag and no op ever rewrote it. Reproduced before fixing (the entry had said "by inspection"): `frag(a(),div())` one order, `frag(a(),span())` the other, and `encode_state` differing byte for byte, since the XML registry writes each node as its id plus its tag. Mixing the tag into the derivation stays rejected for the reason the filing gives — C24's rank leans on `born_at` being pure in the key. **Fixed by an intrinsic rank: the smaller tag bytes take the node** (`XmlElement::claim_tag`), the same principle C40 (#400) landed one layer down. It is a **meet**, which is what makes it hold at every seam without a per-seam argument — idempotent, commutative and associative, so a replay ties, a third claim only lowers, and a merge cannot answer by which side received; and a decoded tag (unranked — one tag per id in the registry, no contest at decode) is a bound later ops can only tighten, so the rule is recoverable on reload with **no new persisted state**. C40's warning about a missed seat path paid off: the filing named one and there are **four**. The birth (`insert_xml_child`); **`apply_reveal`**, which the filing does not mention and is the widest — an `XmlReveal` names an arbitrary `ElementId` with arbitrary tag bytes, so it can name exactly the node a birth derives, and it returned early on an already-materialised node, so fixing the birth alone leaves reveal(`aa`)→birth(`div`) folding to `aa` and the reverse to `div`; **`XmlElement::merge`**, whose doc said the tag "is identity, not state, so it is left untouched" — that resolved it by which side received; and `create_container`, whose *id* is safe against itself (`XmlElement::node_id` folds the tag in) though its **slot** is not — filed as C155 — and which is collidable by a reveal, running the rank through the same `registered_handle` seam. A falsification pass found a **fifth** seat the fix itself opened: `reveal_ops` reads the tag off live state into a synthetic shell whose `OpId` derived from the node alone, so once the tag became a meet the corrected shell deduped against the stale one and an op-served reader was pinned at a tag it could never revise (two readers of one node encoding different bytes, both differing from the meet). The shell id now derives from `(node, tag)`. The rank runs wherever a claim can meet a tag **already held** (a seam with no incumbent — a first materialisation, a decode, a detached clone — has nothing to rank and seats directly), and on **every** such claim, including one whose id already determines its tag, because whether an id determines its tag is not a question an id answers and the reveal holds neither the map nor the key to check against — where it does determine it, every honest claim carries that tag and the rank is a tie. A **live handle stays valid** and observes the restatement: a handle is a view onto convergent state, not a snapshot. Two claims the falsifier corrected: the sequence engine structurally cannot decide a tag, but *not* because two same-id different-tag elements "always rank equal" (differing anchors rank unequal) — because `List::merge`'s fold-arm gate compares kind and encoded value only, **excluding** the anchor, and `put_node_value` never writes a tag, so the contest arm is unreachable for a tag whatever the anchors; and `Map::merge` does **not** converge on the tag — its LWW is symmetric only at distinct stamps, and at an equal stamp the receiver wins (`div` vs `span` measured). Neither is reachable through `Document::apply`. Spec `crates/core/tests/xml_tag_collision.rs` folds every shape in both orders and compares snapshot bytes, and pins the three properties the meet buys rather than arguing them: commutativity (two claims), associativity + idempotence (three claims, all six orders), and needing no state the snapshot lacks — a claim arriving at a **reloaded** replica is ranked against a decoded tag and lands where no restart does. A randomized permutation oracle shuffles pools mixing several tag claims at two stamps with a reveal and a delete. Mutation sweep under statement deletion: 6 mutants over the rank and every seat, **0 survivors**, with the reveal seat and the merge seat each killed by exactly one test. A falsification pass then found a 7th that survived the whole workspace (guarding the rank with `!tag.is_empty()`) and one test that was unwired (its two merge directions both had `div` as receiver); both are now pinned, along with a non-UTF8 tag. The randomized oracle is convergence-only by construction — it compares permutations against each other, so the rank's *direction* is pinned by the named tests, not by it. ARCHITECTURE §Tree Moves + DECISIONS updated. Residue: C154. → *Core / XML*. **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*. @@ -557,6 +557,10 @@ scalar / counter / register / element / map (#22–#27), list Fugue (#24), text **C22 — a global mint floor leaks cross-zone activity through a zone's lamport numbering (crates/core) — READY, needs a human ruling. Raised by C17 (#366).** A stamp is a document-global id, so the mint has to clear the replica's whole id space; every partition-scoped rule C17 tried was defeated by an op's envelope naming its own partition while its ids land in its target's, or by a snapshot declaring any clock it likes. The consequence is that a zone's lamports count on from the author's whole stamp position, so the gaps between one author's stamps inside a zone reveal how much it minted outside — the activity-inference channel ARCHITECTURE (§Isolation) says per-zone clocks close. Folding is untouched, so the streams stay causally independent and separately replicable, and the leak is *numbering*, not content or structure. **The ruling is which guarantee wins**, since correctness holds either way: keep the leak; or give the decoder a trustworthy way to attribute a decoded stamp to a partition (the only label that arrives with one today is the attacker-supplied envelope, and the honest one — `zone_of_op` over the target's path — needs a schema a decode does not have); or scope the mint per partition and accept that a peer can aim it at a victim's live ids. Whichever wins, ARCHITECTURE's zone-isolation wording needs to match it. → *Core / Zones*. +**C155 — two container creates at one key and one stamp resolve the map slot by arrival order (crates/core) — READY, no dependencies. Found by C44's falsification pass, reproduced.** Not XML-specific and not a tag question: `Map::set` resolves an equal-stamp contest with `let loses = prior.is_some_and(|e| !stamp.gt(&e.stamp))`, so the **first arrival keeps the slot**. Two ops under one `ClientId` at one `Stamp` naming one key are both admissible — dedup is on `OpId`, and an id-space record only bounds an honest mint — so the same op set folds to two states. Measured on three shapes: `XmlElementCreate{div}` vs `XmlElementCreate{span}` (whose derived node ids differ, since `XmlElement::node_id` folds the tag in, so the two elements contend for one slot), `MapCreate` vs `ListCreate`, and `MapSet(1)` vs `MapSet(2)` — each encoding different `encode_state` bytes in the two orders. This is the C24/C40/C44 family one layer over, in the Map rather than the sequence or the node registry, and the fix is the same shape: an intrinsic rank over the two claims at the key, decided before the stamp comparison keeps the incumbent. The rank has to cover a scalar against a container against a counter, so the natural key is the one C40 already uses for a sequence node — the kind tag, then the encoded value — read at the slot rather than at the sequence id. → *Core / Map*. + +**C156 — the XML diff reads a tag as stable identity, which a lowered tag makes conservative (crates/core) — READY, no dependencies. Found by C44's falsification pass; cosmetic, not a correctness bug.** `diff_xml_element` emits `Removed` + `Added` when two snapshots' tags differ at one slot, on the premise that "a tag is part of an element's identity, so a different tag at the same slot is a different element". Under C44 a node's tag can be *lowered* by a later claim, so that premise no longer holds for one node observed across the claim: the diff describes a structural replace of a node that never moved and never changed id. The emitted change list stays **sound** — nothing downstream reads a tag as stable, and a consumer re-reads the subtree — so this is a fidelity question, not a divergence: a consumer that rebuilds only what a change names does more work than it needs to, and an editor binding a selection into that subtree sees it torn down and rebuilt. The doc comments are corrected to say conservative rather than exact; the fix, if wanted, is a `Retagged` change variant carrying the node's id, which is a public API addition to `diff::Change` and to every SDK that re-marshals it. → *Core / XML*. + **C154 — a dishonest `XmlReveal` can lower a map-slot node's tag below the one its own id derives from (crates/core) — READY, no dependencies. Found by C44 while enumerating the tag seats, reproduced by inspection.** A map-slot `XmlElement`'s id is `XmlElement::node_id(map, key, tag)` — the tag *is* in the derivation, so create-vs-create at one key with two tags is two ids and there is no contest. C44's tag rank nonetheless runs on every claim, including one at such an id, because whether an id determines its tag is not a question an id answers and the seat that reaches a map-slot node — `apply_reveal`, which names an arbitrary `ElementId` with arbitrary tag bytes — holds neither the map nor the key to check it against. A narrower rule is therefore not implementable at that seam. The consequence: a reveal carrying tag bytes smaller than a map-slot node's takes the node's tag, leaving it disagreeing with its own id, and `slot_inverse` (doc.rs) then re-emits an `XmlElementCreate` carrying the *live* tag, which derives a **different** `node_id` — so an undo of that slot re-creates a different element. It **converges** (both arrival orders agree, which is what C44 was for), and it is not client-reachable: `crates/server/src/session.rs` refuses a client-authored `XmlReveal`, and the node-to-node `Replicate` seam never carries one because reveals are never logged. So it requires a compromised server, and a compromised server can seat a wrong tag at an unheld id regardless of the rank. What is open is whether `registered_handle` should be handed the fact that a caller derived the id *from* the tag — `create_container` knows it and the reveal does not — which would let a self-consistent claim outrank an inconsistent one without asking an id a question it cannot answer. → *Core / XML*. **C45 — a birth that loses its key while holding a move placement loses its created-under edge on reload (crates/core) — READY, no dependencies. Found by cold review during C24 (#371).** C24 re-seeds the created-under relation from three sources — a node's birth placement, its parent link when it holds no placement at all, and one hop up `parents` for everything else — which covers a losing birth that was left placeless. A node born at `(Lb, sb)`, *then moved* to `(L2, s2)`, and only afterwards evicted from `(Lb, sb)` by a smaller-id twin falls through all three: `birth_placement` finds nothing, `placements` is non-empty so the placeless source skips it, and its `parents` entry is the move destination (a children list) so the one-hop source skips it too. `evict_placement` returns early without re-pointing anything, because the node still holds a placement. The live replica keeps `base[node] = owner(Lb)`; a replica reloaded from its snapshot has no entry at all. While the move edge governs, `parent_of` agrees and nothing shows; withdraw that edge — a later eviction at `(L2, s2)`, or a projection that filters it — and the live replica falls back to the birth owner while the reloaded one reads the node as a root, `refold_moves` skipping it entirely and leaving its suppression flags at whatever the last fold wrote. It is also a hole in the tree the move log's cycle check walks. Nothing in the snapshot still names the birth owner, so the fix is to persist the edge (a base-edge section in the state codec, or the birth list beside each placement record) — new persisted state, which wants the decision made explicitly rather than inherited, the same bar C21's resolved-key set is held to. → *Core / XML*. diff --git a/crates/core/src/diff.rs b/crates/core/src/diff.rs index 94ea715b..95a44224 100644 --- a/crates/core/src/diff.rs +++ b/crates/core/src/diff.rs @@ -11,8 +11,11 @@ //! An XmlElement diffs as its children (a sequence, structural inserts/deletes at //! the element's own path) then its attrs (a keyed Map, value diffs at the deeper //! attr-key paths) — that order keeps the change list path-sorted; a fragment as -//! its children alone. A tag is part of an element's identity, so a changed tag at -//! a slot reads as a replace. Marks — named RangedElement annotations — live +//! its children alone. A tag distinguishes elements, so a changed tag at a slot +//! reads as a replace — including where the change is one node's tag being lowered +//! by a later claim ([`XmlElement::claim_tag`](crate::xml::XmlElement::claim_tag)), +//! which the emitted change list describes soundly as a replace of an unchanged +//! node rather than as the retag it is. Marks — named RangedElement annotations — live //! outside the tree, so they diff as a set by stable id (added / removed / value //! changed) and their changes append after the tree changes. @@ -301,8 +304,10 @@ fn diff_elem(a: &Element, b: &Element, prefix: &mut Vec>, out: &mut Vec< /// Diff two XmlElement snapshots at the same slot: children (an ordered sequence /// → structural inserts/deletes at the element's own path) then attrs (a keyed /// Map → value diffs at the deeper attr-key paths), so the change list stays -/// ordered by path. A tag is part of an element's identity, so a different tag at -/// the same slot is a different element — a structural replace, not a field diff. +/// ordered by path. A different tag at the same slot reads as a structural replace +/// rather than a field diff. That is exact for two different elements and +/// conservative for one node whose tag a later claim lowered — the change list is +/// sound either way, since nothing downstream reads a tag as stable. fn diff_xml_element( a: &Rc>, b: &Rc>, diff --git a/crates/core/src/doc.rs b/crates/core/src/doc.rs index ac667e3f..3a8ad3df 100644 --- a/crates/core/src/doc.rs +++ b/crates/core/src/doc.rs @@ -2075,7 +2075,7 @@ impl Document { // birth stamp: the birth stamp names the origin author, which a reader who // could not read the origin must not learn. A zero lamport under the shell's // own reveal client leaks nothing and advances no clock. - let id = reveal_op_id(*node); + let id = reveal_op_id(*node, tag.as_deref()); let stamp = Stamp { lamport: 0, client: id.client, @@ -4448,8 +4448,9 @@ impl Document { /// 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, + /// the kind is `XmlElement` and the tags differ, which this rank does not reach: + /// the tag runs its own, [`XmlElement::claim_tag`], and the smaller bytes take it. + /// 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. @@ -6072,22 +6073,45 @@ fn ranged_id(stamp: Stamp) -> ElementId { ElementId::derive(ns, &stamp_key(stamp), ElementKind::Scalar) } -/// The [`OpId`] a reveal shell for `node` carries. A reveal is a redaction-time -/// synthesis, not an authored op, so it has no real `(client, seq)` — but it must -/// dedup stably (a resumed catch-up re-derives the same shell) and never collide with a -/// real authored op the reader also receives. The client is derived from the node under -/// a fixed reveal namespace: deterministic and unique per node (so two revealed nodes -/// never alias). No *derived* replica id can coincide with it: this derivation's name is -/// a node id plus a kind tag (17 bytes), where the only other id-shaped derivation a -/// replica performs ([`for_channel`](ClientId::for_channel)) names a four-byte channel -/// number, so the two SHA-1 inputs differ by length alone whatever namespaces they run -/// under. A *declared* id is not a derivation at all — an embedder supplies its bytes — +/// The [`OpId`] a reveal shell for `node` under `tag` carries. A reveal is a +/// redaction-time synthesis, not an authored op, so it has no real `(client, seq)` — but +/// it must dedup stably (a resumed catch-up re-derives the same shell) and never collide +/// with a real authored op the reader also receives. +/// +/// **The tag is in the derivation, and has to be**, because a shell reads it off live +/// state and a node's tag is not fixed: two claims can name one node under two tags and +/// the smaller bytes take it ([`XmlElement::claim_tag`]). Keyed on the node alone, a +/// shell emitted before the smaller claim landed and the corrected shell after it are one +/// `OpId`, so the correction dedups away and a reader served the earlier one is pinned at +/// a tag its own document will never revise — two readers of one node encoding different +/// bytes. Keyed on both, the corrected shell is a distinct op that reaches +/// [`apply_reveal`] and runs the rank, which is idempotent, so a reader that sees the +/// shells in either order lands on the meet. +/// +/// The client is derived under a fixed reveal namespace: deterministic and unique per +/// `(node, tag)`. No *derived* replica id can coincide with it: this derivation's name is +/// a node id, a tag-presence byte and the tag (at least 17 bytes), where the only other +/// id-shaped derivation a replica performs ([`for_channel`](ClientId::for_channel)) names +/// a four-byte channel number, so the two SHA-1 inputs differ by length alone whatever +/// namespaces they run under. A *declared* id is not a derivation at all — an embedder +/// supplies its bytes — /// so an embedder that reuses a shell's id here collides, the same one-id-one-replica /// contract that governs two sessions sharing an id. `seq` is 0 — the derived client is /// a namespace of one. -fn reveal_op_id(node: ElementId) -> OpId { +fn reveal_op_id(node: ElementId, tag: Option<&[u8]>) -> OpId { let ns = ElementId::from_bytes(*b"crdtsync\0reveal\0"); - let derived = ElementId::derive(ns, &node.as_bytes(), ElementKind::XmlElement); + let mut name = node.as_bytes().to_vec(); + // The presence byte leads the tag, so a tagless shell and one carrying an empty + // tag are two shells rather than one — the empty tag is admissible and is the + // rank's bottom, so the two are not the same claim. + match tag { + Some(t) => { + name.push(1); + name.extend_from_slice(t); + } + None => name.push(0), + } + let derived = ElementId::derive(ns, &name, ElementKind::XmlElement); OpId { client: ClientId::from_bytes(derived.as_bytes()), seq: 0, diff --git a/crates/core/src/xml.rs b/crates/core/src/xml.rs index 34ff241d..3a741097 100644 --- a/crates/core/src/xml.rs +++ b/crates/core/src/xml.rs @@ -12,12 +12,14 @@ //! engines unchanged — this type only pairs them under one id and a tag. The //! attrs Map and children List take ids derived from the element's own id, so //! every replica agrees on them (the same convergence the [`ElementId`] derivation -//! gives Map slots). The `tag` is identity rather than editable state — no op -//! retags a node, and retagging in the API is a replace — but two claims can -//! still *name* one node under two tags, since a children-list node's id derives -//! from `(list, stamp, kind)` and carries no tag. So the tag is reconciled by its -//! own rank ([`XmlElement::claim_tag`]) wherever one is installed, a merge -//! included, rather than left at whichever claim landed first. +//! gives Map slots). The `tag` is identity rather than editable state: no op edits +//! a tag the way a `MapSet` edits a slot, and retagging in the API is a replace. +//! But every claim that *names* a node carries one, and a children-list node's id +//! derives from `(list, stamp, kind)` and carries no tag — so two claims can name +//! one node under two tags (an insert twin, or an `XmlReveal` shell), and either +//! can lower what a materialised node reads. The tag is therefore reconciled by +//! its own rank ([`XmlElement::claim_tag`]) wherever a claim meets one already +//! held, a merge included, rather than left at whichever claim landed first. use std::cell::{Cell, RefCell}; use std::rc::Rc; diff --git a/crates/core/tests/state_project_read.rs b/crates/core/tests/state_project_read.rs index 78f73215..06458bed 100644 --- a/crates/core/tests/state_project_read.rs +++ b/crates/core/tests/state_project_read.rs @@ -807,3 +807,94 @@ fn a_range_whose_anchor_seq_is_deleted_is_dropped() { "an identity predicate is a narrowing projection too, so the orphan still goes", ); } + +#[test] +fn a_reveal_shell_carries_a_tag_the_rank_can_still_revise() { + // A shell reads the node's tag off live state, and a node's tag is not fixed: two + // claims can name one node under two tags and the smaller bytes take it (C44). So a + // shell emitted before the smaller claim landed and the corrected shell after it must + // be **two** ops. Keyed on the node alone they were one, and the correction deduped + // away — a reader served the earlier shell was pinned at a tag its own document could + // never revise, so two op-served readers of one node encoded different bytes and both + // differed from the replica whose tag is the meet. + let reads = reads_top(false, &[b"a"]); + let mut author = doc(); + let a = xml_fragment(&mut author, &encode_path(&[b"a"])); + let b = xml_fragment(&mut author, &encode_path(&[b"b"])); + + // Born in the DENIED /b under the larger tag, then moved into readable /a. + let birth = xml_insert_element(&mut author, &encode_path(&[b"b"]), 0, b"bbb"); + let larger = birth + .iter() + .find(|op| matches!(op.kind, OpKind::XmlInsertChild { .. })) + .expect("the child insert") + .clone(); + // The twin: one stamp, a distinct OpId, the smaller tag. Both are admissible — + // dedup is on `OpId` and an id-space record only bounds an honest mint. + let mut smaller = larger.clone(); + smaller.id.seq = 9_000; + if let OpKind::XmlInsertChild { tag, .. } = &mut smaller.kind { + *tag = Some(b"aaa".to_vec()); + } + assert_eq!(smaller.stamp, larger.stamp, "the twin carries one stamp"); + let mv = xml_move_child( + &mut author, + &encode_path(&[b"b"]), + 0, + &encode_path(&[b"a"]), + 0, + ); + + // The reader is served the shell before the smaller claim reaches the author. + let early = author.reveal_ops(&reads); + assert!( + matches!(&early[0].kind, OpKind::XmlReveal { tag, .. } if tag.as_deref() == Some(b"bbb")), + "the early shell carries the tag the author then holds", + ); + let mut reader = doc(); + for op in a.iter().chain(&early).chain(&mv) { + reader.apply(op); + } + + // The twin lands on the author and the rank revises its tag; the re-derived shell + // must be a *different* op, or the reader can never be told. + author.apply(&smaller); + let late = author.reveal_ops(&reads); + assert!( + matches!(&late[0].kind, OpKind::XmlReveal { tag, .. } if tag.as_deref() == Some(b"aaa")), + "the author's tag is the meet of the two claims", + ); + assert_ne!( + early[0].id, late[0].id, + "the corrected shell must not dedup against the stale one", + ); + + for op in &late { + reader.apply(op); + } + let tag_of = |d: &Document| match d.get(b"a") { + Some(Element::XmlFragment(f)) => { + let kids = f.borrow().children(); + let v = kids.borrow().values(); + match v.first() { + Some(Element::XmlElement(x)) => Some(x.borrow().tag().to_vec()), + _ => None, + } + } + _ => None, + }; + assert_eq!( + tag_of(&reader).as_deref(), + Some(&b"aaa"[..]), + "the reader stayed pinned at the stale tag", + ); + + // And a reader served the shells in the other order lands in the same place — the + // rank is a meet, so a late *larger* shell changes nothing. + let mut other = doc(); + for op in a.iter().chain(&late).chain(&mv).chain(&early) { + other.apply(op); + } + assert_eq!(tag_of(&other).as_deref(), Some(&b"aaa"[..])); + let _ = b; +} diff --git a/crates/core/tests/xml_tag_collision.rs b/crates/core/tests/xml_tag_collision.rs index beeadec5..e6a2bb13 100644 --- a/crates/core/tests/xml_tag_collision.rs +++ b/crates/core/tests/xml_tag_collision.rs @@ -383,26 +383,89 @@ fn a_live_handle_observes_the_restated_tag() { ); } -/// Two independent replicas of one node under two tags. -fn twin_nodes(id: ElementId) -> (XmlElement, XmlElement) { - ( - XmlElement::new(id, b"div".to_vec()), - XmlElement::new(id, b"span".to_vec()), - ) -} - #[test] fn an_element_merge_ranks_the_tag_rather_than_taking_the_receiver_s() { // `XmlElement::merge` folds two replicas of one node. Leaving the tag alone // resolved it by which side received — the seam the op fold does not reach, // and the one C40's sweep found a layer down in `List::merge`. + // + // Both directions have to run, and they have to differ: a pair where the + // receiver is `div` in *both* merges tests one direction twice and stays green + // with the rank removed. let id = ElementId::from_bytes([7u8; 16]); - let (mut a, b) = twin_nodes(id); - a.merge(&b); - let (mut b, a2) = twin_nodes(id); - b.merge(&a2); - assert_eq!(a.tag(), b"div"); - assert_eq!(b.tag(), b"div", "the merge took the receiver's tag"); + let node = |tag: &[u8]| XmlElement::new(id, tag.to_vec()); + + let mut receives_larger = node(b"div"); + receives_larger.merge(&node(b"span")); + let mut receives_smaller = node(b"span"); + receives_smaller.merge(&node(b"div")); + + assert_eq!(receives_larger.tag(), b"div"); + assert_eq!( + receives_smaller.tag(), + b"div", + "the merge took the receiver's tag" + ); +} + +#[test] +fn the_empty_tag_is_the_rank_s_bottom() { + // `Some(vec![])` survives the wire — no op-level validation bounds a tag's + // bytes — so it is an admissible claim and, being the least byte string, it + // takes every node it names. A rank that special-cased it away would decide + // an empty-vs-nonempty pair by arrival order, which is the whole bug. + let mut author = Document::new(cid(1)); + let build = frag_with_a(&mut author); + let base = only_insert(author.transact(|tx| { + tx.xml_fragment(b"doc").children().insert_element(1, b"div"); + })); + let empty = twin_tagged(&base, 9_000, b""); + let div = twin_tagged(&base, 9_001, b"div"); + + let (tree_a, bytes_a) = fold(&build, &[&empty, &div]); + let (tree_b, bytes_b) = fold(&build, &[&div, &empty]); + assert_eq!(tree_a, tree_b, "the two orders folded to different trees"); + assert_eq!( + bytes_a, bytes_b, + "the two orders encode different snapshots" + ); + for order in [[&empty, &div], [&div, &empty]] { + let mut d = Document::new(cid(9)); + for op in build.iter().chain(order) { + d.apply(op); + } + assert_eq!( + contested_tag(&d).as_deref(), + Some(&b""[..]), + "the empty tag is the least byte string and must take the node" + ); + } +} + +#[test] +fn a_non_utf8_tag_ranks_on_its_bytes() { + // Nothing constrains a tag to UTF-8 either, and the rank is over bytes, so a + // tag that is no text at all still orders totally against one that is. + let mut author = Document::new(cid(1)); + let build = frag_with_a(&mut author); + let base = only_insert(author.transact(|tx| { + tx.xml_fragment(b"doc").children().insert_element(1, b"div"); + })); + let raw = twin_tagged(&base, 9_000, &[0x00, 0xff, 0xfe]); + let div = twin_tagged(&base, 9_001, b"div"); + + assert_eq!(fold(&build, &[&raw, &div]), fold(&build, &[&div, &raw])); + for order in [[&raw, &div], [&div, &raw]] { + let mut d = Document::new(cid(9)); + for op in build.iter().chain(order) { + d.apply(op); + } + assert_eq!( + contested_tag(&d).as_deref(), + Some(&[0x00u8, 0xff, 0xfe][..]), + "a leading 0x00 byte is below every printable tag" + ); + } } #[test] From 3f046062b9e1abb5142e8e9c7fb1d2d5310be3a2 Mon Sep 17 00:00:00 2001 From: Lucas Vieira Date: Sun, 9 Aug 2026 15:48:34 -0300 Subject: [PATCH 5/7] test(doc): pin the reveal shell id's injectivity, and drop the marker that bought nothing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A mutation sweep on the final code left one survivor: removing the tag-presence byte from `reveal_op_id` changed no observable. Two things came out of chasing it, and neither was the redundant mutant it first looked like. The property it guards was genuinely unpinned. A bare `node ++ tag` collides a tagless shell with one tagged by a single NUL byte, and a tag is unvalidated bytes off the wire, so that is authorable. `reveal_ops` emits one shell per node and reads its kind off the registry, so no honest stream asks for both today — which makes it a property of the caller, not of the id, and a later op family would break it in silence. Now a unit test on the derivation itself. The `None` marker was then dead. With `Some` writing its own marker, a tagless name is the node's 16 bytes and every tagged name is at least 17, so the tagless side needs nothing: no mutant of that byte is observable because nothing it separates is not already separated. Removed rather than pinned with a vacuous test — the subtraction pass asks whether the unit is still correct without it, and it is. Sweep on the shipped code: 9 mutants over the rank, all four claim seats and the shell id, zero survivors. --- crates/core/src/doc.rs | 59 +++++++++++++++++++++++++++++++++++------- 1 file changed, 50 insertions(+), 9 deletions(-) diff --git a/crates/core/src/doc.rs b/crates/core/src/doc.rs index 3a8ad3df..91b80f3a 100644 --- a/crates/core/src/doc.rs +++ b/crates/core/src/doc.rs @@ -6101,15 +6101,16 @@ fn ranged_id(stamp: Stamp) -> ElementId { fn reveal_op_id(node: ElementId, tag: Option<&[u8]>) -> OpId { let ns = ElementId::from_bytes(*b"crdtsync\0reveal\0"); let mut name = node.as_bytes().to_vec(); - // The presence byte leads the tag, so a tagless shell and one carrying an empty - // tag are two shells rather than one — the empty tag is admissible and is the - // rank's bottom, so the two are not the same claim. - match tag { - Some(t) => { - name.push(1); - name.extend_from_slice(t); - } - None => name.push(0), + // A marker leads the tag, so no tag's own bytes can spell the tagless case: a + // `None` name is the node's 16 bytes and every `Some` name is at least 17. That + // separates the tagless shell from the empty-tagged one — the empty tag is + // admissible and is the rank's bottom, so the two are different claims — and + // from a tag of one NUL byte, which is what a bare concatenation would spell. + // The tagless side needs no marker of its own: the one here is what puts every + // tagged name out of its reach. + if let Some(t) = tag { + name.push(1); + name.extend_from_slice(t); } let derived = ElementId::derive(ns, &name, ElementKind::XmlElement); OpId { @@ -7320,6 +7321,46 @@ mod tests { ClientId::from_bytes(b) } + /// A reveal shell's id is injective in the tag it carries — the absent tag and + /// every byte string alike. The `Some` marker buys that on its own: a tagless + /// name is the node's 16 bytes and every tagged name is at least 17, so no + /// tag's own bytes can spell the absent case. Drop it and `None` names the bare + /// node, which the empty tag then spells too — a tagless shell and an + /// empty-tagged one would be one op and the later would dedup away; a bare + /// concatenation collides `None` with a tag of one NUL byte the same way. A tag + /// is unvalidated bytes off the wire, so a NUL is as authorable as any letter. + /// A matching marker on the tagless side would be dead: nothing it separates is + /// not already separated. + /// + /// `reveal_ops` emits one shell per node and reads its kind off the registry, + /// so no honest stream asks for two of these today. The derivation is what + /// makes that a property of the id rather than of who calls it, which an op + /// family added later would otherwise break in silence. + #[test] + fn a_reveal_shell_id_is_injective_in_the_tag() { + let node = ElementId::from_bytes([3u8; 16]); + let nul: &[u8] = &[0u8]; + // The absent tag is separated from every tag that could otherwise spell it. + assert_ne!(reveal_op_id(node, None), reveal_op_id(node, Some(b""))); + assert_ne!(reveal_op_id(node, None), reveal_op_id(node, Some(nul))); + assert_ne!(reveal_op_id(node, Some(b"")), reveal_op_id(node, Some(nul))); + // The tag is read, not merely present. + assert_ne!( + reveal_op_id(node, Some(b"a")), + reveal_op_id(node, Some(b"b")) + ); + assert_eq!( + reveal_op_id(node, Some(b"a")), + reveal_op_id(node, Some(b"a")) + ); + // And the node still separates two shells carrying one tag. + let other = ElementId::from_bytes([4u8; 16]); + assert_ne!( + reveal_op_id(node, Some(b"a")), + reveal_op_id(other, Some(b"a")) + ); + } + /// A snapshot whose move log folds the parent relation into a cycle is /// rejected at decode, not left to hang a later `resolvable` walk. Replay and /// re-fold mutate `parents` after decode's first cycle check, so `restore_moves` From 711ade253420eef752ead48361fa1488ad3e4b3e Mon Sep 17 00:00:00 2001 From: Lucas Vieira Date: Sun, 9 Aug 2026 16:23:36 -0300 Subject: [PATCH 6/7] fix(server): emit a reveal shell when a revealed node is retagged, not only when it moves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit gave the shell an identity that carries its tag, and claimed that made a lowered tag reach its readers. A falsification pass measured that false: both redaction seams emit shells only for nodes an `XmlMove` in the batch names, and the claim that lowers a tag is an `XmlInsertChild` into the node's birth list. It emits no move, and the reader is denied the list, so it was served neither the claim nor a shell — a live reader frozen at `frag(card)` while a fresh joiner read `frag(aaa)`, against a real `Registry`. The trigger is now a move or a retag. `retagged_node` names the node an op could retag — a tagged `XmlInsertChild`, by the same `xml_child_id` derivation, or a tagged `XmlReveal` — deliberately without asking whether it did: an unchanged shell dedups on its `(node, tag)` id, and a changed one runs an idempotent rank, so over-reporting is inert. The live seam also stopped returning early on an empty readable subset. A retag is exactly the batch whose every op the recipient is denied, so the shell is the whole of what it is owed; the emptiness check now sits past the shell computation, where a batch yielding neither is still dropped. Two more from the same pass. An eighth mutant survived the workspace: guarding the reveal seat's claim with `!t.is_empty()`. The empty tag was pinned at the birth seat only, so it is now pinned at the reveal and merge seats too. And two statements in the shell-id comment described the previous shape — "at least 17 bytes" after a tagless name became exactly 16, and a NUL collision where what actually collides is the absent tag with the empty one. C155 now names three seats and says so is a floor; C157 files the tagless reveal naming a tagged birth's id, which pre-dates this unit. --- ARCHITECTURE.md | 2 +- DECISIONS.md | 4 +- KANBAN.md | 6 +- crates/core/src/doc.rs | 68 +++++++++++----- crates/core/src/lib.rs | 2 +- crates/core/src/xml.rs | 10 ++- crates/core/tests/xml_tag_collision.rs | 64 +++++++++++++++ crates/server/src/registry.rs | 42 ++++++---- crates/server/src/session.rs | 11 +++ crates/server/tests/acl_redaction.rs | 108 +++++++++++++++++++++++++ 10 files changed, 276 insertions(+), 41 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 85bab052..ffb86e65 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -321,7 +321,7 @@ The fold is a pure function of the move-set + tombstone-set, **independent of th **A node's tag is owned by the smaller tag bytes.** A children-list node's element id derives from `(list, stamp, kind)` and carries no tag — deliberately, since a stamp derives exactly two children (the tagged and the tagless) and only that purity makes "is this node the child this key derives" answerable without guessing every tag. So two `XmlInsertChild`s at one stamp with different tags name **one** node, both admissible, and the tag it ends at is decided by a lexicographic order over the tags, never by which arrived first. That order is total, intrinsic, and held by every replica that has either op; there is nothing else two such claims differ in to read. Because it is a **meet**, it is idempotent and associative as well as commutative — a replay ties, a third claim lowers or does nothing, and a snapshot's tag is a bound later ops can only tighten, which is what makes the tag recoverable on reload with no new persisted state (it rides `encode_state` already). Every seam where a claim can meet a tag **already held at that id** runs it — the birth, an `XmlReveal` shell (including one meeting a node already materialised, where returning early settled the two tags by arrival), and an element merge, whose sequence layer is structurally blind to a tag and so can never be where it is decided. A seam with no incumbent has nothing to rank and seats its tag directly: a first materialisation, a decode, and a detached deep clone. The qualifier *at that id* is the bound: where two tags produce two **ids** — a map slot, whose node id derives from `(map, key, tag)` — the rank never runs, and which element holds the slot is the map's own LWW, which at an equal stamp keeps the incumbent and so answers by arrival. That is a Map-layer question of the same family, not a tag question, and is filed separately. -A shell that carries a tag must carry it **in its identity**. A redaction synthesises an `XmlReveal` by reading the node's current tag off live state, so the payload moves when the rank lowers it; keyed on the node alone, the corrected shell dedups against the stale one and a reader is pinned at a tag its own document can never revise. The shell's op id therefore derives from `(node, tag)`, which makes the correction a distinct op that reaches the rank — idempotent, so any order of shells lands on the meet. It runs even on a node whose id *does* determine its tag (a map slot's, which derives from `(map, key, tag)`): whether an id determines its tag is not a question an id answers, and where it does, every honest claim carries that same tag and the rank is a tie. A live handle stays valid across a restatement and observes it — a handle is a view onto convergent state, not a snapshot, and remote ops mutating state underneath one is the normal case. +A shell that carries a tag must carry it **in its identity, and be emitted when it changes**. A redaction synthesises an `XmlReveal` by reading the node's current tag off live state, so the payload moves when the rank lowers it; keyed on the node alone, the corrected shell dedups against the stale one and a reader is pinned at a tag its own document can never revise. The shell's op id therefore derives from `(node, tag)`, which makes the correction a distinct op that reaches the rank — idempotent, so any order of shells lands on the meet. Identity alone is not enough, because a redaction emits shells only for the nodes a batch *acts on*: gated on the batch's moves, a retag emits nothing at all, since the claim that lowers a tag is a birth into the node's own list and a reader denied that list is served neither the claim nor a shell. So the trigger is **a move or a retag**, and a batch whose every op a recipient is denied still sends the shell — for a retag that shell is the entire delivery. It runs even on a node whose id *does* determine its tag (a map slot's, which derives from `(map, key, tag)`): whether an id determines its tag is not a question an id answers, and where it does, every honest claim carries that same tag and the rank is a tie. A live handle stays valid across a restatement and observes it — a handle is a view onto convergent state, not a snapshot, and remote ops mutating state underneath one is the normal case. **The created-under relation spans the whole tree, the map half included.** A node's position among its siblings is a children-sequence concern, but the relation the move log's cycle check walks is not: a container keyed into a map is created under that map, and the map under the element that owns it. Without those edges the walk stops where the children lists stop, and a move under a node reachable only *through* a map — an element in the moved node's own attrs — reads as acyclic, closes a loop, and leaves the replica holding a document that is no longer a tree. Every rebuild of the relation re-seeds it: a movable node's edge from its birth placement, or from its parent link when it holds no placement at all, and every other container's one hop up that link. diff --git a/DECISIONS.md b/DECISIONS.md index a0ccd233..301ec2f4 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -48,7 +48,9 @@ The entries below (2026-07-02) are a backfill: design changes made during the v0 **`Map::merge` does *not* converge on the tag, and the first cut of this entry said it did.** The claim was "the slot LWW is symmetric"; it is symmetric only where the two stamps are distinct. `Map::merge` keeps the destination whenever `!se.stamp.gt(&de.stamp)`, and an equal stamp is not `gt`, so the receiver wins: two replicas merging one node under two tags at one stamp measure `div` and `span` respectively. Corrected to the narrower true statement rather than left standing. It is not reachable through `Document::apply` (nothing folds two documents), and the detached clone is C134's shape. -**A falsification pass found a fifth seat that this unit itself opens, and it is the substance of the second commit.** `reveal_ops` reads a node's tag off live state into a synthetic shell, and `reveal_op_id` derived that shell's `OpId` from the **node alone**. Before C44 a node's tag was immutable once materialised, so every shell for one node carried identical bytes and the constant id was harmless. Making the tag a meet made the payload time-dependent while its identity stayed constant: a reader served a shell before a smaller claim landed is pinned at the stale tag forever, because the corrected shell dedups away at `apply` — measured as two op-served readers of one node holding different tags and encoding different bytes, both differing from the replica whose tag is the meet, and falsifying `reveal_ops`' own docstring that an op-served reader converges with a snapshot-served one. There is no recovery path: `reveal_backfill` filters to the node's own subtree containers, never the parent list the tag-bearing insert targets, so the shell is the reader's only source of the tag. The id now derives from `(node, tag)` with a presence byte leading, so a tagless shell and an empty-tagged one stay distinct; the corrected shell is a distinct op that reaches the rank, which is idempotent, so shells in any order land on the meet. +**A falsification pass found a fifth seat that this unit itself opens, and it is the substance of the second commit.** `reveal_ops` reads a node's tag off live state into a synthetic shell, and `reveal_op_id` derived that shell's `OpId` from the **node alone**. Before C44 a node's tag was immutable once materialised, so every shell for one node carried identical bytes and the constant id was harmless. Making the tag a meet made the payload time-dependent while its identity stayed constant: a reader served a shell before a smaller claim landed is pinned at the stale tag forever, because the corrected shell dedups away at `apply` — measured as two op-served readers of one node holding different tags and encoding different bytes, both differing from the replica whose tag is the meet, and falsifying `reveal_ops`' own docstring that an op-served reader converges with a snapshot-served one. There is no recovery path: `reveal_backfill` filters to the node's own subtree containers, never the parent list the tag-bearing insert targets, so the shell is the reader's only source of the tag. The id now derives from `(node, tag)` with a marker leading a present tag, so a tagless shell and an empty-tagged one stay distinct; the corrected shell is a distinct op that reaches the rank, which is idempotent, so shells in any order land on the meet. + +**A second pass then measured that the identity fix delivers nothing on its own, which falsified this unit's own commit subject.** Both redaction seams (`session.rs` catch-up, `registry.rs` live fan-out) emit shells only for nodes named by an `XmlMove` **in the batch**. A tag-lowering twin is an `XmlInsertChild` into the node's birth list and emits no move, so no shell was ever sent, and the twin itself is redacted — measured against a real `Registry` as a live reader frozen at `frag(card)` while a fresh joiner read `frag(aaa)`. The claim "a lowered tag reaches its readers" was true of core and false of the server. The trigger is now **a move or a retag**: `retagged_node` names the node an op could retag (a tagged `XmlInsertChild`, by the same `xml_child_id` derivation, or a tagged `XmlReveal`), deliberately without asking whether it did — re-emitting an unchanged shell is inert, since an identical shell dedups on its `(node, tag)` id and a changed one runs an idempotent rank. The live seam also had to stop returning early on an empty readable subset: a retag is exactly the case where every op in the batch is denied the recipient and the shell is the whole of what it is owed, so the emptiness check moved past the shell computation. **Two more things the pass broke, both now pinned.** A mutant guarding the rank with `!tag.is_empty()` survived the entire workspace suite — `Some(vec![])` round-trips the wire intact and nothing validates op-level tag bytes, so the empty tag is an admissible claim and is the rank's bottom; under the mutant an empty-vs-nonempty pair resolves by arrival, which is the bug this unit exists to close. And the test named for the merge's two directions ran one direction twice (both merges had `div` as the receiver), so it stayed green with the rank removed. Both are now tests: the empty tag, a non-UTF8 tag, and a merge whose two receivers genuinely differ. diff --git a/KANBAN.md b/KANBAN.md index 0f87fb6b..04f4bd10 100644 --- a/KANBAN.md +++ b/KANBAN.md @@ -30,7 +30,7 @@ Element + Map are one coupled unit (Map slots hold Elements; Element forwards li _Derived from code + git; a convenience view, not the source of truth._ -**C44 — two inserts at one stamp carrying *different* tags derive one child, whose tag was the first arrival's (crates/core) — DONE (#PR).** `xml_child_id` mixes the children list, the stamp and the *kind* into the derivation but never the tag, so `XmlInsertChild { tag: Some(b"div") }` and `XmlInsertChild { tag: Some(b"span") }` at one stamp into one list derive the **same** `XmlElement` id; `registered_handle`'s `or_insert_with` seated the first arrival's tag and no op ever rewrote it. Reproduced before fixing (the entry had said "by inspection"): `frag(a(),div())` one order, `frag(a(),span())` the other, and `encode_state` differing byte for byte, since the XML registry writes each node as its id plus its tag. Mixing the tag into the derivation stays rejected for the reason the filing gives — C24's rank leans on `born_at` being pure in the key. **Fixed by an intrinsic rank: the smaller tag bytes take the node** (`XmlElement::claim_tag`), the same principle C40 (#400) landed one layer down. It is a **meet**, which is what makes it hold at every seam without a per-seam argument — idempotent, commutative and associative, so a replay ties, a third claim only lowers, and a merge cannot answer by which side received; and a decoded tag (unranked — one tag per id in the registry, no contest at decode) is a bound later ops can only tighten, so the rule is recoverable on reload with **no new persisted state**. C40's warning about a missed seat path paid off: the filing named one and there are **four**. The birth (`insert_xml_child`); **`apply_reveal`**, which the filing does not mention and is the widest — an `XmlReveal` names an arbitrary `ElementId` with arbitrary tag bytes, so it can name exactly the node a birth derives, and it returned early on an already-materialised node, so fixing the birth alone leaves reveal(`aa`)→birth(`div`) folding to `aa` and the reverse to `div`; **`XmlElement::merge`**, whose doc said the tag "is identity, not state, so it is left untouched" — that resolved it by which side received; and `create_container`, whose *id* is safe against itself (`XmlElement::node_id` folds the tag in) though its **slot** is not — filed as C155 — and which is collidable by a reveal, running the rank through the same `registered_handle` seam. A falsification pass found a **fifth** seat the fix itself opened: `reveal_ops` reads the tag off live state into a synthetic shell whose `OpId` derived from the node alone, so once the tag became a meet the corrected shell deduped against the stale one and an op-served reader was pinned at a tag it could never revise (two readers of one node encoding different bytes, both differing from the meet). The shell id now derives from `(node, tag)`. The rank runs wherever a claim can meet a tag **already held** (a seam with no incumbent — a first materialisation, a decode, a detached clone — has nothing to rank and seats directly), and on **every** such claim, including one whose id already determines its tag, because whether an id determines its tag is not a question an id answers and the reveal holds neither the map nor the key to check against — where it does determine it, every honest claim carries that tag and the rank is a tie. A **live handle stays valid** and observes the restatement: a handle is a view onto convergent state, not a snapshot. Two claims the falsifier corrected: the sequence engine structurally cannot decide a tag, but *not* because two same-id different-tag elements "always rank equal" (differing anchors rank unequal) — because `List::merge`'s fold-arm gate compares kind and encoded value only, **excluding** the anchor, and `put_node_value` never writes a tag, so the contest arm is unreachable for a tag whatever the anchors; and `Map::merge` does **not** converge on the tag — its LWW is symmetric only at distinct stamps, and at an equal stamp the receiver wins (`div` vs `span` measured). Neither is reachable through `Document::apply`. Spec `crates/core/tests/xml_tag_collision.rs` folds every shape in both orders and compares snapshot bytes, and pins the three properties the meet buys rather than arguing them: commutativity (two claims), associativity + idempotence (three claims, all six orders), and needing no state the snapshot lacks — a claim arriving at a **reloaded** replica is ranked against a decoded tag and lands where no restart does. A randomized permutation oracle shuffles pools mixing several tag claims at two stamps with a reveal and a delete. Mutation sweep under statement deletion: 6 mutants over the rank and every seat, **0 survivors**, with the reveal seat and the merge seat each killed by exactly one test. A falsification pass then found a 7th that survived the whole workspace (guarding the rank with `!tag.is_empty()`) and one test that was unwired (its two merge directions both had `div` as receiver); both are now pinned, along with a non-UTF8 tag. The randomized oracle is convergence-only by construction — it compares permutations against each other, so the rank's *direction* is pinned by the named tests, not by it. ARCHITECTURE §Tree Moves + DECISIONS updated. Residue: C154. → *Core / XML*. +**C44 — two inserts at one stamp carrying *different* tags derive one child, whose tag was the first arrival's (crates/core) — DONE (#PR).** `xml_child_id` mixes the children list, the stamp and the *kind* into the derivation but never the tag, so `XmlInsertChild { tag: Some(b"div") }` and `XmlInsertChild { tag: Some(b"span") }` at one stamp into one list derive the **same** `XmlElement` id; `registered_handle`'s `or_insert_with` seated the first arrival's tag and no op ever rewrote it. Reproduced before fixing (the entry had said "by inspection"): `frag(a(),div())` one order, `frag(a(),span())` the other, and `encode_state` differing byte for byte, since the XML registry writes each node as its id plus its tag. Mixing the tag into the derivation stays rejected for the reason the filing gives — C24's rank leans on `born_at` being pure in the key. **Fixed by an intrinsic rank: the smaller tag bytes take the node** (`XmlElement::claim_tag`), the same principle C40 (#400) landed one layer down. It is a **meet**, which is what makes it hold at every seam without a per-seam argument — idempotent, commutative and associative, so a replay ties, a third claim only lowers, and a merge cannot answer by which side received; and a decoded tag (unranked — one tag per id in the registry, no contest at decode) is a bound later ops can only tighten, so the rule is recoverable on reload with **no new persisted state**. C40's warning about a missed seat path paid off: the filing named one and there are **four**. The birth (`insert_xml_child`); **`apply_reveal`**, which the filing does not mention and is the widest — an `XmlReveal` names an arbitrary `ElementId` with arbitrary tag bytes, so it can name exactly the node a birth derives, and it returned early on an already-materialised node, so fixing the birth alone leaves reveal(`aa`)→birth(`div`) folding to `aa` and the reverse to `div`; **`XmlElement::merge`**, whose doc said the tag "is identity, not state, so it is left untouched" — that resolved it by which side received; and `create_container`, whose *id* is safe against itself (`XmlElement::node_id` folds the tag in) though its **slot** is not — filed as C155 — and which is collidable by a reveal, running the rank through the same `registered_handle` seam. A falsification pass found a **fifth** seat the fix itself opened: `reveal_ops` reads the tag off live state into a synthetic shell whose `OpId` derived from the node alone, so once the tag became a meet the corrected shell deduped against the stale one and an op-served reader was pinned at a tag it could never revise (two readers of one node encoding different bytes, both differing from the meet). The shell id now derives from `(node, tag)` — and a second pass measured that the identity fix **delivers nothing on its own**: both redaction seams emit shells only for nodes an `XmlMove` in the batch names, and a retagging twin is an `XmlInsertChild`, so a live reader stayed frozen at the stale tag while a fresh joiner read the meet. The trigger is now a move **or a retag** (`retagged_node`), and the live seam no longer returns early on an empty readable subset, since a retag is precisely the batch whose every op the recipient is denied and whose shell is the whole delivery. Proven end to end against a real `Registry` in `crates/server/tests/acl_redaction.rs`. The rank runs wherever a claim can meet a tag **already held** (a seam with no incumbent — a first materialisation, a decode, a detached clone — has nothing to rank and seats directly), and on **every** such claim, including one whose id already determines its tag, because whether an id determines its tag is not a question an id answers and the reveal holds neither the map nor the key to check against — where it does determine it, every honest claim carries that tag and the rank is a tie. A **live handle stays valid** and observes the restatement: a handle is a view onto convergent state, not a snapshot. Two claims the falsifier corrected: the sequence engine structurally cannot decide a tag, but *not* because two same-id different-tag elements "always rank equal" (differing anchors rank unequal) — because `List::merge`'s fold-arm gate compares kind and encoded value only, **excluding** the anchor, and `put_node_value` never writes a tag, so the contest arm is unreachable for a tag whatever the anchors; and `Map::merge` does **not** converge on the tag — its LWW is symmetric only at distinct stamps, and at an equal stamp the receiver wins (`div` vs `span` measured). Neither is reachable through `Document::apply`. Spec `crates/core/tests/xml_tag_collision.rs` folds every shape in both orders and compares snapshot bytes, and pins the three properties the meet buys rather than arguing them: commutativity (two claims), associativity + idempotence (three claims, all six orders), and needing no state the snapshot lacks — a claim arriving at a **reloaded** replica is ranked against a decoded tag and lands where no restart does. A randomized permutation oracle shuffles pools mixing several tag claims at two stamps with a reveal and a delete. Mutation sweep under statement deletion: 6 mutants over the rank and every seat, **0 survivors**, with the reveal seat and the merge seat each killed by exactly one test. A falsification pass then found a 7th that survived the whole workspace (guarding the rank with `!tag.is_empty()`) and one test that was unwired (its two merge directions both had `div` as receiver); both are now pinned, along with a non-UTF8 tag. The randomized oracle is convergence-only by construction — it compares permutations against each other, so the rank's *direction* is pinned by the named tests, not by it. ARCHITECTURE §Tree Moves + DECISIONS updated. Residue: C154. → *Core / XML*. **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*. @@ -557,7 +557,9 @@ scalar / counter / register / element / map (#22–#27), list Fugue (#24), text **C22 — a global mint floor leaks cross-zone activity through a zone's lamport numbering (crates/core) — READY, needs a human ruling. Raised by C17 (#366).** A stamp is a document-global id, so the mint has to clear the replica's whole id space; every partition-scoped rule C17 tried was defeated by an op's envelope naming its own partition while its ids land in its target's, or by a snapshot declaring any clock it likes. The consequence is that a zone's lamports count on from the author's whole stamp position, so the gaps between one author's stamps inside a zone reveal how much it minted outside — the activity-inference channel ARCHITECTURE (§Isolation) says per-zone clocks close. Folding is untouched, so the streams stay causally independent and separately replicable, and the leak is *numbering*, not content or structure. **The ruling is which guarantee wins**, since correctness holds either way: keep the leak; or give the decoder a trustworthy way to attribute a decoded stamp to a partition (the only label that arrives with one today is the attacker-supplied envelope, and the honest one — `zone_of_op` over the target's path — needs a schema a decode does not have); or scope the mint per partition and accept that a peer can aim it at a victim's live ids. Whichever wins, ARCHITECTURE's zone-isolation wording needs to match it. → *Core / Zones*. -**C155 — two container creates at one key and one stamp resolve the map slot by arrival order (crates/core) — READY, no dependencies. Found by C44's falsification pass, reproduced.** Not XML-specific and not a tag question: `Map::set` resolves an equal-stamp contest with `let loses = prior.is_some_and(|e| !stamp.gt(&e.stamp))`, so the **first arrival keeps the slot**. Two ops under one `ClientId` at one `Stamp` naming one key are both admissible — dedup is on `OpId`, and an id-space record only bounds an honest mint — so the same op set folds to two states. Measured on three shapes: `XmlElementCreate{div}` vs `XmlElementCreate{span}` (whose derived node ids differ, since `XmlElement::node_id` folds the tag in, so the two elements contend for one slot), `MapCreate` vs `ListCreate`, and `MapSet(1)` vs `MapSet(2)` — each encoding different `encode_state` bytes in the two orders. This is the C24/C40/C44 family one layer over, in the Map rather than the sequence or the node registry, and the fix is the same shape: an intrinsic rank over the two claims at the key, decided before the stamp comparison keeps the incumbent. The rank has to cover a scalar against a container against a counter, so the natural key is the one C40 already uses for a sequence node — the kind tag, then the encoded value — read at the slot rather than at the sequence id. → *Core / Map*. +**C157 — a tagless `XmlReveal` naming a tagged birth's node id diverges by arrival order (crates/core) — READY, no dependencies. Found by C44's falsification pass, reproduced; pre-dates C44 (fails at `9af15485` too).** `apply_reveal` routes on the reveal's *own* tag: `Some` materialises an `XmlElement`, `None` a `Text` run. A reveal carrying `None` at an id a **tagged** birth derives therefore registers a `Text` at that id when it lands first, and is a no-op when it lands second — 856 vs 832 snapshot bytes for the two orders. C44's rank does not reach it and deliberately does not: the rank settles two tags on one node, while this is two *kinds* on one id, which `xml_child_id` exists to keep apart (a stamp's tagged and tagless children are different ids, so an honest pair never collides here). What makes it reachable is that a reveal names an arbitrary `ElementId` with an arbitrary kind, so nothing ties the kind it asserts to the id it names. Adjacent to C154 — both are a reveal asserting something about an id that the id itself already answers — and the likely fix is shared: give `registered_handle`/`apply_reveal` the derivation's own answer for a derivable id rather than the claim's. Not client-reachable (`crates/server/src/session.rs` refuses a client-authored `XmlReveal`), so it needs a compromised server, same boundary as C154. → *Core / XML*. + +**C155 — two writes at one key and one stamp resolve a map slot by arrival order, at three seats (crates/core) — READY, no dependencies. Found by C44's falsification pass, reproduced; the seat count is a lower bound.** Not XML-specific and not a tag question: `Map::set` resolves an equal-stamp contest with `let loses = prior.is_some_and(|e| !stamp.gt(&e.stamp))`, so the **first arrival keeps the slot** — and the identical tie is measured at two more seats, `Map::delete` (map.rs:510) and `Map::merge`'s LWW arm (map.rs:576-580, which keeps the destination whenever `!se.stamp.gt(&de.stamp)`, so the *receiver* wins). Three seats found, and this unit's own history says treat that as a floor: C44's filing named one seat and five existed, so the fix should enumerate every equal-stamp comparison in `map.rs` before concluding. Two ops under one `ClientId` at one `Stamp` naming one key are both admissible — dedup is on `OpId`, and an id-space record only bounds an honest mint — so the same op set folds to two states. Measured on three shapes: `XmlElementCreate{div}` vs `XmlElementCreate{span}` (whose derived node ids differ, since `XmlElement::node_id` folds the tag in, so the two elements contend for one slot), `MapCreate` vs `ListCreate`, and `MapSet(1)` vs `MapSet(2)` — each encoding different `encode_state` bytes in the two orders. This is the C24/C40/C44 family one layer over, in the Map rather than the sequence or the node registry, and the fix is the same shape: an intrinsic rank over the two claims at the key, decided before the stamp comparison keeps the incumbent. The rank has to cover a scalar against a container against a counter, so the natural key is the one C40 already uses for a sequence node — the kind tag, then the encoded value — read at the slot rather than at the sequence id. → *Core / Map*. **C156 — the XML diff reads a tag as stable identity, which a lowered tag makes conservative (crates/core) — READY, no dependencies. Found by C44's falsification pass; cosmetic, not a correctness bug.** `diff_xml_element` emits `Removed` + `Added` when two snapshots' tags differ at one slot, on the premise that "a tag is part of an element's identity, so a different tag at the same slot is a different element". Under C44 a node's tag can be *lowered* by a later claim, so that premise no longer holds for one node observed across the claim: the diff describes a structural replace of a node that never moved and never changed id. The emitted change list stays **sound** — nothing downstream reads a tag as stable, and a consumer re-reads the subtree — so this is a fidelity question, not a divergence: a consumer that rebuilds only what a change names does more work than it needs to, and an editor binding a selection into that subtree sees it torn down and rebuilt. The doc comments are corrected to say conservative rather than exact; the fix, if wanted, is a `Retagged` change variant carrying the node's id, which is a public API addition to `diff::Change` and to every SDK that re-marshals it. → *Core / XML*. diff --git a/crates/core/src/doc.rs b/crates/core/src/doc.rs index 91b80f3a..345805f0 100644 --- a/crates/core/src/doc.rs +++ b/crates/core/src/doc.rs @@ -6045,6 +6045,35 @@ fn stamp_key(stamp: Stamp) -> [u8; 24] { /// The element id an XML sequence child takes: derived from its children List /// and its node stamp, so the apply path, the readiness gate, and the cursor all /// agree. `kind` is `XmlElement` for an element child, `Text` for a text run. +/// The node an op can install or restate a tag on, if any — the trigger a redaction +/// reads to know a revealed node's shell has gone stale. +/// +/// A shell carries the node's tag as its payload *and* in its identity +/// ([`reveal_op_id`]), read off live state at emission. A node's tag is a meet over +/// the claims that named it, so a later claim can lower it, and the shell a reader +/// already holds then names a tag its own document will never revise. The fan-out +/// gates shells on the node appearing in an `XmlMove` in the batch, which a +/// retagging claim need not be: the claim that lowers a tag is an `XmlInsertChild` +/// into the node's *birth* list, and a reader that may not read that list is served +/// neither the claim nor a shell. So the trigger is widened by this, and both seams +/// read it. +/// +/// Over-reporting is safe and deliberate: this names the node an op *could* retag +/// without asking whether it did, since re-emitting an unchanged shell is inert — +/// its id is a function of `(node, tag)`, so an identical shell dedups, and a +/// changed one runs a rank that is idempotent. +pub fn retagged_node(op: &Op) -> Option { + match &op.kind { + // A tagged birth derives its child under the element kind; the tagless + // sibling of the stamp is a text run, which holds no tag. + OpKind::XmlInsertChild { tag: Some(_), .. } => { + Some(xml_child_id(op.target, op.stamp, ElementKind::XmlElement)) + } + OpKind::XmlReveal { node, tag: Some(_) } => Some(*node), + _ => None, + } +} + fn xml_child_id(list_id: ElementId, stamp: Stamp, kind: ElementKind) -> ElementId { ElementId::derive(list_id, &stamp_key(stamp), kind) } @@ -6090,11 +6119,11 @@ fn ranged_id(stamp: Stamp) -> ElementId { /// /// The client is derived under a fixed reveal namespace: deterministic and unique per /// `(node, tag)`. No *derived* replica id can coincide with it: this derivation's name is -/// a node id, a tag-presence byte and the tag (at least 17 bytes), where the only other -/// id-shaped derivation a replica performs ([`for_channel`](ClientId::for_channel)) names -/// a four-byte channel number, so the two SHA-1 inputs differ by length alone whatever -/// namespaces they run under. A *declared* id is not a derivation at all — an embedder -/// supplies its bytes — +/// a node id alone (16 bytes) or a node id, a marker and the tag (17 or more), where the +/// only other id-shaped derivation a replica performs +/// ([`for_channel`](ClientId::for_channel)) names a four-byte channel number, so the two +/// SHA-1 inputs differ by length alone whatever namespaces they run under. A *declared* +/// id is not a derivation at all — an embedder supplies its bytes — /// so an embedder that reuses a shell's id here collides, the same one-id-one-replica /// contract that governs two sessions sharing an id. `seq` is 0 — the derived client is /// a namespace of one. @@ -6102,12 +6131,12 @@ fn reveal_op_id(node: ElementId, tag: Option<&[u8]>) -> OpId { let ns = ElementId::from_bytes(*b"crdtsync\0reveal\0"); let mut name = node.as_bytes().to_vec(); // A marker leads the tag, so no tag's own bytes can spell the tagless case: a - // `None` name is the node's 16 bytes and every `Some` name is at least 17. That - // separates the tagless shell from the empty-tagged one — the empty tag is - // admissible and is the rank's bottom, so the two are different claims — and - // from a tag of one NUL byte, which is what a bare concatenation would spell. - // The tagless side needs no marker of its own: the one here is what puts every - // tagged name out of its reach. + // `None` name is the node's 16 bytes and every `Some` name is at least 17. What + // it separates is the tagless shell from the **empty-tagged** one, which a bare + // concatenation would make one name — and the empty tag is admissible and is the + // rank's bottom, so the two are different claims. The tagless side needs no + // marker of its own: the one here is what puts every tagged name out of its + // reach. if let Some(t) = tag { name.push(1); name.extend_from_slice(t); @@ -7325,12 +7354,12 @@ mod tests { /// every byte string alike. The `Some` marker buys that on its own: a tagless /// name is the node's 16 bytes and every tagged name is at least 17, so no /// tag's own bytes can spell the absent case. Drop it and `None` names the bare - /// node, which the empty tag then spells too — a tagless shell and an - /// empty-tagged one would be one op and the later would dedup away; a bare - /// concatenation collides `None` with a tag of one NUL byte the same way. A tag - /// is unvalidated bytes off the wire, so a NUL is as authorable as any letter. - /// A matching marker on the tagless side would be dead: nothing it separates is - /// not already separated. + /// node, which the **empty** tag then spells too — a tagless shell and an + /// empty-tagged one would be one op and the later would dedup away. The empty + /// tag is admissible: nothing validates op-level tag bytes, and it is the tag + /// rank's bottom, so it is a claim a node can genuinely end at. A matching + /// marker on the tagless side would be dead — nothing it separates is not + /// already separated — so there is none. /// /// `reveal_ops` emits one shell per node and reads its kind off the registry, /// so no honest stream asks for two of these today. The derivation is what @@ -7340,8 +7369,11 @@ mod tests { fn a_reveal_shell_id_is_injective_in_the_tag() { let node = ElementId::from_bytes([3u8; 16]); let nul: &[u8] = &[0u8]; - // The absent tag is separated from every tag that could otherwise spell it. + // The absent tag against the one that would otherwise spell it. This is the + // assert the marker is load-bearing for: drop the marker and it fails. assert_ne!(reveal_op_id(node, None), reveal_op_id(node, Some(b""))); + // And against tags that would not — inert against the marker, kept because + // they say the derivation reads bytes rather than emptiness. assert_ne!(reveal_op_id(node, None), reveal_op_id(node, Some(nul))); assert_ne!(reveal_op_id(node, Some(b"")), reveal_op_id(node, Some(nul))); // The tag is read, not merely present. diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 86f11ef9..79366510 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -52,7 +52,7 @@ pub use client::{ClientError, ClientSession, Redirect, Rejected, SubscribeError} pub use clientid::ClientId; pub use codec::{decode_op, decode_ops, encode_op, encode_ops, DecodeError}; pub use counter::Counter; -pub use doc::{Document, OrphanEvent}; +pub use doc::{retagged_node, Document, OrphanEvent}; pub use element::{Element, ElementKind}; pub use elementid::ElementId; pub use host::Host; diff --git a/crates/core/src/xml.rs b/crates/core/src/xml.rs index 3a741097..43ba18cf 100644 --- a/crates/core/src/xml.rs +++ b/crates/core/src/xml.rs @@ -14,10 +14,12 @@ //! every replica agrees on them (the same convergence the [`ElementId`] derivation //! gives Map slots). The `tag` is identity rather than editable state: no op edits //! a tag the way a `MapSet` edits a slot, and retagging in the API is a replace. -//! But every claim that *names* a node carries one, and a children-list node's id -//! derives from `(list, stamp, kind)` and carries no tag — so two claims can name -//! one node under two tags (an insert twin, or an `XmlReveal` shell), and either -//! can lower what a materialised node reads. The tag is therefore reconciled by +//! But every claim that *materialises* a node carries one, and a children-list +//! node's id derives from `(list, stamp, kind)` and carries no tag — so two such +//! claims can name one node under two tags (an insert twin, or an `XmlReveal` +//! shell), and either can lower what a materialised node reads. A claim that only +//! names a node without materialising it carries none: an `XmlMove` relocates a +//! node and says nothing about its tag. The tag is therefore reconciled by //! its own rank ([`XmlElement::claim_tag`]) wherever a claim meets one already //! held, a merge included, rather than left at whichever claim landed first. diff --git a/crates/core/tests/xml_tag_collision.rs b/crates/core/tests/xml_tag_collision.rs index e6a2bb13..2599fc22 100644 --- a/crates/core/tests/xml_tag_collision.rs +++ b/crates/core/tests/xml_tag_collision.rs @@ -609,3 +609,67 @@ fn a_shuffled_pool_of_tag_claims_converges_on_every_permutation() { ); } } + +#[test] +fn the_empty_tag_is_the_rank_s_bottom_at_the_reveal_seat_too() { + // The empty tag has to be admissible at *every* seat, not just the birth. A + // guard exempting it at the reveal seat alone left the whole workspace green + // while an `XmlReveal` and a birth naming one node resolved by arrival. + let mut author = Document::new(cid(1)); + let build = frag_with_a(&mut author); + let birth = only_insert(author.transact(|tx| { + tx.xml_fragment(b"doc").children().insert_element(1, b"div"); + })); + let node = { + let mut d = Document::new(cid(9)); + for op in build.iter().chain([&birth]) { + d.apply(op); + } + contested_id(&d).expect("the born child") + }; + let mut reveal = birth.clone(); + reveal.id.seq = 9_000; + reveal.kind = OpKind::XmlReveal { + node, + tag: Some(Vec::new()), + }; + + let (tree_a, bytes_a) = fold(&build, &[&birth, &reveal]); + let (tree_b, bytes_b) = fold(&build, &[&reveal, &birth]); + assert_eq!(tree_a, tree_b, "the two orders folded to different trees"); + assert_eq!( + bytes_a, bytes_b, + "the two orders encode different snapshots" + ); + for order in [[&birth, &reveal], [&reveal, &birth]] { + let mut d = Document::new(cid(9)); + for op in build.iter().chain(order) { + d.apply(op); + } + assert_eq!( + contested_tag(&d).as_deref(), + Some(&b""[..]), + "the empty tag must take the node at the reveal seat" + ); + } +} + +#[test] +fn the_empty_tag_is_the_rank_s_bottom_at_the_merge_seat_too() { + // And at the third seat. Both directions, since a merge that exempted the + // empty tag would answer by which side received. + let id = ElementId::from_bytes([7u8; 16]); + let node = |tag: &[u8]| XmlElement::new(id, tag.to_vec()); + + let mut receives_empty = node(b"div"); + receives_empty.merge(&node(b"")); + let mut receives_named = node(b""); + receives_named.merge(&node(b"div")); + + assert_eq!(receives_empty.tag(), b""); + assert_eq!( + receives_named.tag(), + b"", + "the merge took the receiver's tag" + ); +} diff --git a/crates/server/src/registry.rs b/crates/server/src/registry.rs index 75fbdef7..a41d7f00 100644 --- a/crates/server/src/registry.rs +++ b/crates/server/src/registry.rs @@ -2504,17 +2504,23 @@ impl Registry { // in, which an author's envelope only coincidentally does. A node the served // tree does not resolve keeps its move's answer, the only other partition claim // there is. For a room with no zones every zone is `None`, so this is a no-op. - let moved_nodes: Vec<(ElementId, Option)> = broadcast + let shell_nodes: Vec<(ElementId, Option)> = broadcast .iter() - .filter_map(|op| match &op.kind { - OpKind::XmlMove { node, .. } => { - let landed = schema - .as_deref() - .zip(index.get(node)) - .map(|(s, path)| crdtsync_core::zone::zone_id_of(s, path)); - Some((*node, landed.unwrap_or(op.zone))) - } - _ => None, + .filter_map(|op| { + // A move is not the only thing that staleses a shell: a node's tag is a + // meet over the claims that named it, so a claim in this broadcast can + // lower one. That claim is a birth into the node's own list, which a + // recipient denied the origin never sees, so on the move gate alone it + // would keep a tag its document can never revise. + let node = match &op.kind { + OpKind::XmlMove { node, .. } => *node, + _ => crdtsync_core::retagged_node(op)?, + }; + let landed = schema + .as_deref() + .zip(index.get(&node)) + .map(|(s, path)| crdtsync_core::zone::zone_id_of(s, path)); + Some((node, landed.unwrap_or(op.zone))) }) .collect(); for (peer, conn) in self.conns.iter_mut() { @@ -2578,7 +2584,13 @@ impl Registry { reads_whole, ) }); - if readable.is_empty() { + // An empty readable subset is not on its own a reason to send nothing: a + // shell can be due for a batch whose every op this recipient is denied. + // That is exactly the retag case — the claim that lowers a revealed node's + // tag is a birth into the node's own denied list — so the shell would be + // skipped precisely when it is the only thing that could carry the change. + // A batch that yields neither is dropped by the emptiness check below. + if readable.is_empty() && shell_nodes.is_empty() { continue; } // Reveal-on-move-in: for every node this batch moves into a position this @@ -2587,8 +2599,10 @@ impl Registry { // — the live-fan-out mirror of the catch-up reveal, derived from the same read // predicate. A recipient reading the node's origin all along gets no shell // (`reveal_ops` returns it only when the birth path is denied). Shells lead so - // the move lands onto them. - let readable = if moved_nodes.is_empty() { + // the move lands onto them. A batch that only *retags* a revealed node sends + // the shell alone: its payload and its identity both carry the tag, so the + // re-emitted shell is the change. + let readable = if shell_nodes.is_empty() { readable } else { let shells: Vec = hub @@ -2606,7 +2620,7 @@ impl Registry { ) .into_iter() .filter_map(|mut op| match &op.kind { - OpKind::XmlReveal { node, .. } => moved_nodes + OpKind::XmlReveal { node, .. } => shell_nodes .iter() .find(|(n, _)| n == node) .map(|(_, zone)| { diff --git a/crates/server/src/session.rs b/crates/server/src/session.rs index cc2f84fd..c35a44a3 100644 --- a/crates/server/src/session.rs +++ b/crates/server/src/session.rs @@ -898,6 +898,16 @@ pub fn step( _ => None, }) .collect(); + // A move is not the only thing that staleses a shell. A node's + // tag is a meet over the claims that named it, so a claim in + // this delta can lower one — and that claim is a birth into the + // node's own list, which this recipient may not read, so it is + // served neither the claim nor (on the move gate alone) a shell. + // It would sit at a tag its document can never revise. + let retagged_in_delta: std::collections::HashSet<_> = delta + .iter() + .filter_map(|rec| crdtsync_core::retagged_node(&rec.op)) + .collect(); let reveals = hub .reveal_ops( &room, @@ -915,6 +925,7 @@ pub fn step( .filter(|op| match &op.kind { crdtsync_core::OpKind::XmlReveal { node, .. } => { moved_in_delta.contains(node) + || retagged_in_delta.contains(node) } _ => false, }); diff --git a/crates/server/tests/acl_redaction.rs b/crates/server/tests/acl_redaction.rs index 02fc7dca..38f2ca6a 100644 --- a/crates/server/tests/acl_redaction.rs +++ b/crates/server/tests/acl_redaction.rs @@ -2509,3 +2509,111 @@ fn a_room_with_no_acl_fans_out_a_move_to_every_reader() { "no doc-ACL: the move fans out to bob unredacted" ); } + +#[test] +fn a_live_reader_is_revealed_a_lowered_tag_on_a_node_it_already_holds() { + // The shell's payload is the node's tag read off live state, and a tag is a meet + // over the claims that named it (C44) — so a claim landing after the shell can + // lower it. That claim is an `XmlInsertChild` into the node's *birth* list, which + // this reader may not read: it is served neither the claim nor, on a move gate + // alone, a second shell. It would hold a tag its own document can never revise, + // while a fresh joiner reads the meet — two readers of one node, different bytes. + let mut r = registry(); + let alice = auth(&mut r, 1, "t-alice"); + assert!(subscribe(&mut r, alice)); + r.take_outbox(alice); + let mut alice_doc = Document::new(cid(1)); + submit( + &mut r, + alice, + grant_read( + &mut alice_doc, + AclSubject::Actor(actor_key(b"bob")), + &col(COL_A), + ), + ); + submit(&mut r, alice, xml_fragment(&mut alice_doc, &col(COL_A))); + submit(&mut r, alice, xml_fragment(&mut alice_doc, &col(COL_B))); + let birth = xml_insert_element(&mut alice_doc, &col(COL_B), 0, b"card"); + let insert = birth + .iter() + .find(|op| matches!(op.kind, OpKind::XmlInsertChild { .. })) + .expect("the child insert") + .clone(); + submit(&mut r, alice, birth); + r.take_outbox(alice); + + // bob joins, then the card moves into his readable column: he is shelled at `card`. + let bob = auth(&mut r, 2, "t-bob"); + assert!(subscribe(&mut r, bob)); + let mut live = Document::new(cid(2)); + for op in received_ops(&mut r, bob) { + live.apply(&op); + } + submit( + &mut r, + alice, + crdtsync_core::path::xml_move_child(&mut alice_doc, &col(COL_B), 0, &col(COL_A), 0), + ); + for op in &received_ops(&mut r, bob) { + live.apply(op); + } + assert_eq!( + board_render(&live), + "colA=frag(card()) colB=absent", + "the live reader holds the card under its first tag", + ); + + // The twin: one stamp, a distinct OpId, a smaller tag. Admissible — dedup is on + // `OpId` — and it lands in colB, which bob may not read. + let mut twin = insert.clone(); + twin.id.seq = 9_000; + if let OpKind::XmlInsertChild { tag, .. } = &mut twin.kind { + *tag = Some(b"aaa".to_vec()); + } + assert_eq!(twin.stamp, insert.stamp, "the twin carries one stamp"); + alice_doc.apply(&twin); + submit(&mut r, alice, vec![twin]); + // The corrected shell is the whole of what bob is sent: the claim itself is + // denied him, and the shell's payload and identity both carry the tag. + let after_twin = received_ops(&mut r, bob); + assert!( + after_twin.iter().any( + |op| matches!(&op.kind, OpKind::XmlReveal { tag, .. } if tag.as_deref() == Some(b"aaa")) + ), + "the retag must re-emit the shell: {after_twin:?}", + ); + for op in &after_twin { + live.apply(op); + } + + // A reader joining after the twin reads the meet; the live reader must agree. + let carol = auth(&mut r, 3, "t-carol"); + submit( + &mut r, + alice, + grant_read( + &mut alice_doc, + AclSubject::Actor(actor_key(b"carol")), + &col(COL_A), + ), + ); + r.take_outbox(alice); + let _ = received_ops(&mut r, bob); + assert!(subscribe(&mut r, carol)); + let mut fresh = Document::new(cid(3)); + for op in received_ops(&mut r, carol) { + fresh.apply(&op); + } + + assert_eq!( + board_render(&fresh), + "colA=frag(aaa()) colB=absent", + "a fresh joiner reads the meet of the two claims", + ); + assert_eq!( + board_render(&live), + board_render(&fresh), + "the live reader kept a tag its own document can never revise", + ); +} From 0791ed228b667637c9c2aad82b4cf6d573de43fd Mon Sep 17 00:00:00 2001 From: Lucas Vieira Date: Sun, 9 Aug 2026 17:07:33 -0300 Subject: [PATCH 7/7] revert(server): take the retag delivery trigger back out, and file it as its own unit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An adversarial pass over the delivery rework falsified it three independent ways, and the measurement is the reason this is a revert rather than another fix. The catch-up seam's half was dead code. `delta` is shadowed by the read-filtered delta before the trigger reads it, and a retagging op is by construction one the recipient is denied — so the set is empty exactly when it is needed, and non-empty only for a reader who needs no shell. Reverting that one line left the entire server suite green, which is how it got written in the first place. The trigger also made `Document::reveal_ops` — an O(document) walk plus a sort over every placement — run per recipient on every ordinary tagged `XmlInsertChild`, measured 0 to 3 calls for a one-op batch with three subscribers, and `retagged_node` does not require the target list to exist, so ops that materialise nothing still bought the walk. That is a client-driven amplification lever on the commonest XML op. And "re-emitting an unchanged shell is inert" was a claim about state only. A re-emitted shell falls into the back-fill branch, so each retag re-sent the node's whole readable subtree: 31 outbound ops per one-op batch, unchanged when the shell was byte-identical. So the delivery is not contained. It needs a cheap "is any node revealed to this recipient" test before the walk, a shell-diff so an unchanged shell costs nothing, and a catch-up seam reading the pre-filter batch. Filed as C158 with all three measurements, and C159 beside it for the wider sibling the same pass found: an `AclGrant` that reveals a node emits no shell either, which loses a whole node rather than a tag and pre-dates this unit. What ships is the core rank and the shell's identity. ARCHITECTURE, DECISIONS and KANBAN now say identity is necessary and not sufficient, rather than claiming a delivery the server does not perform. --- ARCHITECTURE.md | 2 +- DECISIONS.md | 2 +- KANBAN.md | 6 +- crates/core/src/doc.rs | 29 ------- crates/core/src/lib.rs | 2 +- crates/server/src/registry.rs | 42 ++++------- crates/server/src/session.rs | 11 --- crates/server/tests/acl_redaction.rs | 108 --------------------------- 8 files changed, 22 insertions(+), 180 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ffb86e65..b000d706 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -321,7 +321,7 @@ The fold is a pure function of the move-set + tombstone-set, **independent of th **A node's tag is owned by the smaller tag bytes.** A children-list node's element id derives from `(list, stamp, kind)` and carries no tag — deliberately, since a stamp derives exactly two children (the tagged and the tagless) and only that purity makes "is this node the child this key derives" answerable without guessing every tag. So two `XmlInsertChild`s at one stamp with different tags name **one** node, both admissible, and the tag it ends at is decided by a lexicographic order over the tags, never by which arrived first. That order is total, intrinsic, and held by every replica that has either op; there is nothing else two such claims differ in to read. Because it is a **meet**, it is idempotent and associative as well as commutative — a replay ties, a third claim lowers or does nothing, and a snapshot's tag is a bound later ops can only tighten, which is what makes the tag recoverable on reload with no new persisted state (it rides `encode_state` already). Every seam where a claim can meet a tag **already held at that id** runs it — the birth, an `XmlReveal` shell (including one meeting a node already materialised, where returning early settled the two tags by arrival), and an element merge, whose sequence layer is structurally blind to a tag and so can never be where it is decided. A seam with no incumbent has nothing to rank and seats its tag directly: a first materialisation, a decode, and a detached deep clone. The qualifier *at that id* is the bound: where two tags produce two **ids** — a map slot, whose node id derives from `(map, key, tag)` — the rank never runs, and which element holds the slot is the map's own LWW, which at an equal stamp keeps the incumbent and so answers by arrival. That is a Map-layer question of the same family, not a tag question, and is filed separately. -A shell that carries a tag must carry it **in its identity, and be emitted when it changes**. A redaction synthesises an `XmlReveal` by reading the node's current tag off live state, so the payload moves when the rank lowers it; keyed on the node alone, the corrected shell dedups against the stale one and a reader is pinned at a tag its own document can never revise. The shell's op id therefore derives from `(node, tag)`, which makes the correction a distinct op that reaches the rank — idempotent, so any order of shells lands on the meet. Identity alone is not enough, because a redaction emits shells only for the nodes a batch *acts on*: gated on the batch's moves, a retag emits nothing at all, since the claim that lowers a tag is a birth into the node's own list and a reader denied that list is served neither the claim nor a shell. So the trigger is **a move or a retag**, and a batch whose every op a recipient is denied still sends the shell — for a retag that shell is the entire delivery. It runs even on a node whose id *does* determine its tag (a map slot's, which derives from `(map, key, tag)`): whether an id determines its tag is not a question an id answers, and where it does, every honest claim carries that same tag and the rank is a tie. A live handle stays valid across a restatement and observes it — a handle is a view onto convergent state, not a snapshot, and remote ops mutating state underneath one is the normal case. +A shell that carries a tag must carry it **in its identity**. A redaction synthesises an `XmlReveal` by reading the node's current tag off live state, so the payload moves when the rank lowers it; keyed on the node alone, the corrected shell dedups against the stale one and a reader is pinned at a tag its own document can never revise. The shell's op id therefore derives from `(node, tag)`, which makes a corrected shell a distinct op that reaches the rank — idempotent, so any order of shells lands on the meet. **Identity is necessary and not sufficient**: a redaction emits shells only for the nodes a batch's *moves* name, so a batch that only retags a revealed node emits none, and a reader keeps the stale tag until some later move re-derives its shell. Closing that is a redaction-scheduling question rather than a tag question — the trigger, its per-recipient cost, and the back-fill a re-emitted shell drags behind it are all one problem, filed separately. **The created-under relation spans the whole tree, the map half included.** A node's position among its siblings is a children-sequence concern, but the relation the move log's cycle check walks is not: a container keyed into a map is created under that map, and the map under the element that owns it. Without those edges the walk stops where the children lists stop, and a move under a node reachable only *through* a map — an element in the moved node's own attrs — reads as acyclic, closes a loop, and leaves the replica holding a document that is no longer a tree. Every rebuild of the relation re-seeds it: a movable node's edge from its birth placement, or from its parent link when it holds no placement at all, and every other container's one hop up that link. diff --git a/DECISIONS.md b/DECISIONS.md index 301ec2f4..f12fea3b 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -50,7 +50,7 @@ The entries below (2026-07-02) are a backfill: design changes made during the v0 **A falsification pass found a fifth seat that this unit itself opens, and it is the substance of the second commit.** `reveal_ops` reads a node's tag off live state into a synthetic shell, and `reveal_op_id` derived that shell's `OpId` from the **node alone**. Before C44 a node's tag was immutable once materialised, so every shell for one node carried identical bytes and the constant id was harmless. Making the tag a meet made the payload time-dependent while its identity stayed constant: a reader served a shell before a smaller claim landed is pinned at the stale tag forever, because the corrected shell dedups away at `apply` — measured as two op-served readers of one node holding different tags and encoding different bytes, both differing from the replica whose tag is the meet, and falsifying `reveal_ops`' own docstring that an op-served reader converges with a snapshot-served one. There is no recovery path: `reveal_backfill` filters to the node's own subtree containers, never the parent list the tag-bearing insert targets, so the shell is the reader's only source of the tag. The id now derives from `(node, tag)` with a marker leading a present tag, so a tagless shell and an empty-tagged one stay distinct; the corrected shell is a distinct op that reaches the rank, which is idempotent, so shells in any order land on the meet. -**A second pass then measured that the identity fix delivers nothing on its own, which falsified this unit's own commit subject.** Both redaction seams (`session.rs` catch-up, `registry.rs` live fan-out) emit shells only for nodes named by an `XmlMove` **in the batch**. A tag-lowering twin is an `XmlInsertChild` into the node's birth list and emits no move, so no shell was ever sent, and the twin itself is redacted — measured against a real `Registry` as a live reader frozen at `frag(card)` while a fresh joiner read `frag(aaa)`. The claim "a lowered tag reaches its readers" was true of core and false of the server. The trigger is now **a move or a retag**: `retagged_node` names the node an op could retag (a tagged `XmlInsertChild`, by the same `xml_child_id` derivation, or a tagged `XmlReveal`), deliberately without asking whether it did — re-emitting an unchanged shell is inert, since an identical shell dedups on its `(node, tag)` id and a changed one runs an idempotent rank. The live seam also had to stop returning early on an empty readable subset: a retag is exactly the case where every op in the batch is denied the recipient and the shell is the whole of what it is owed, so the emptiness check moved past the shell computation. +**Two adversarial passes then measured that the identity fix delivers nothing on its own, and that closing that is a separate unit.** Both redaction seams emit shells only for nodes named by an `XmlMove` **in the batch**. A tag-lowering twin is an `XmlInsertChild` into the node's birth list and emits no move, so no shell is sent, and the twin itself is redacted — measured against a real `Registry` as a live reader frozen at `frag(card)` while a fresh joiner read `frag(aaa)`. The first attempt at a fix widened the trigger to "a move **or** a retag", and a second pass falsified it three ways, each independent: (1) on the **catch-up** seam the widened trigger is dead code — `delta` is shadowed by the read-filtered delta before the trigger reads it, and the retagging op is by construction one the recipient is denied, so the set is empty exactly when it is needed and non-empty only for a reader who needs no shell; reverting that line left the entire server suite green. (2) The trigger made `Document::reveal_ops` — an O(document) path walk plus an O(placements log placements) sort — run **per recipient on every ordinary tagged `XmlInsertChild`**, measured 0 → 3 calls for a 1-op batch with three subscribers, and `retagged_node` does not require the target list to exist, so ops that materialise nothing still buy the walk. (3) A re-emitted shell is inert for *state* but not on the wire: it falls into the `reveal_backfill` branch, so each retag re-sent the node's whole readable subtree — measured 31 outbound ops per 1-op batch, unchanged when the re-emitted shell was byte-identical. So the delivery trigger is not contained; it needs a cheap "is this node revealed to anyone" test, a shell-diff so an unchanged shell costs nothing, and a catch-up seam reading the pre-filter delta. **Reverted, and filed as C158** rather than shipped half-working. What ships here is the core rank and the shell's identity, and ARCHITECTURE says exactly that: identity is necessary and not sufficient. **Two more things the pass broke, both now pinned.** A mutant guarding the rank with `!tag.is_empty()` survived the entire workspace suite — `Some(vec![])` round-trips the wire intact and nothing validates op-level tag bytes, so the empty tag is an admissible claim and is the rank's bottom; under the mutant an empty-vs-nonempty pair resolves by arrival, which is the bug this unit exists to close. And the test named for the merge's two directions ran one direction twice (both merges had `div` as the receiver), so it stayed green with the rank removed. Both are now tests: the empty tag, a non-UTF8 tag, and a merge whose two receivers genuinely differ. diff --git a/KANBAN.md b/KANBAN.md index 04f4bd10..f445cf61 100644 --- a/KANBAN.md +++ b/KANBAN.md @@ -30,7 +30,7 @@ Element + Map are one coupled unit (Map slots hold Elements; Element forwards li _Derived from code + git; a convenience view, not the source of truth._ -**C44 — two inserts at one stamp carrying *different* tags derive one child, whose tag was the first arrival's (crates/core) — DONE (#PR).** `xml_child_id` mixes the children list, the stamp and the *kind* into the derivation but never the tag, so `XmlInsertChild { tag: Some(b"div") }` and `XmlInsertChild { tag: Some(b"span") }` at one stamp into one list derive the **same** `XmlElement` id; `registered_handle`'s `or_insert_with` seated the first arrival's tag and no op ever rewrote it. Reproduced before fixing (the entry had said "by inspection"): `frag(a(),div())` one order, `frag(a(),span())` the other, and `encode_state` differing byte for byte, since the XML registry writes each node as its id plus its tag. Mixing the tag into the derivation stays rejected for the reason the filing gives — C24's rank leans on `born_at` being pure in the key. **Fixed by an intrinsic rank: the smaller tag bytes take the node** (`XmlElement::claim_tag`), the same principle C40 (#400) landed one layer down. It is a **meet**, which is what makes it hold at every seam without a per-seam argument — idempotent, commutative and associative, so a replay ties, a third claim only lowers, and a merge cannot answer by which side received; and a decoded tag (unranked — one tag per id in the registry, no contest at decode) is a bound later ops can only tighten, so the rule is recoverable on reload with **no new persisted state**. C40's warning about a missed seat path paid off: the filing named one and there are **four**. The birth (`insert_xml_child`); **`apply_reveal`**, which the filing does not mention and is the widest — an `XmlReveal` names an arbitrary `ElementId` with arbitrary tag bytes, so it can name exactly the node a birth derives, and it returned early on an already-materialised node, so fixing the birth alone leaves reveal(`aa`)→birth(`div`) folding to `aa` and the reverse to `div`; **`XmlElement::merge`**, whose doc said the tag "is identity, not state, so it is left untouched" — that resolved it by which side received; and `create_container`, whose *id* is safe against itself (`XmlElement::node_id` folds the tag in) though its **slot** is not — filed as C155 — and which is collidable by a reveal, running the rank through the same `registered_handle` seam. A falsification pass found a **fifth** seat the fix itself opened: `reveal_ops` reads the tag off live state into a synthetic shell whose `OpId` derived from the node alone, so once the tag became a meet the corrected shell deduped against the stale one and an op-served reader was pinned at a tag it could never revise (two readers of one node encoding different bytes, both differing from the meet). The shell id now derives from `(node, tag)` — and a second pass measured that the identity fix **delivers nothing on its own**: both redaction seams emit shells only for nodes an `XmlMove` in the batch names, and a retagging twin is an `XmlInsertChild`, so a live reader stayed frozen at the stale tag while a fresh joiner read the meet. The trigger is now a move **or a retag** (`retagged_node`), and the live seam no longer returns early on an empty readable subset, since a retag is precisely the batch whose every op the recipient is denied and whose shell is the whole delivery. Proven end to end against a real `Registry` in `crates/server/tests/acl_redaction.rs`. The rank runs wherever a claim can meet a tag **already held** (a seam with no incumbent — a first materialisation, a decode, a detached clone — has nothing to rank and seats directly), and on **every** such claim, including one whose id already determines its tag, because whether an id determines its tag is not a question an id answers and the reveal holds neither the map nor the key to check against — where it does determine it, every honest claim carries that tag and the rank is a tie. A **live handle stays valid** and observes the restatement: a handle is a view onto convergent state, not a snapshot. Two claims the falsifier corrected: the sequence engine structurally cannot decide a tag, but *not* because two same-id different-tag elements "always rank equal" (differing anchors rank unequal) — because `List::merge`'s fold-arm gate compares kind and encoded value only, **excluding** the anchor, and `put_node_value` never writes a tag, so the contest arm is unreachable for a tag whatever the anchors; and `Map::merge` does **not** converge on the tag — its LWW is symmetric only at distinct stamps, and at an equal stamp the receiver wins (`div` vs `span` measured). Neither is reachable through `Document::apply`. Spec `crates/core/tests/xml_tag_collision.rs` folds every shape in both orders and compares snapshot bytes, and pins the three properties the meet buys rather than arguing them: commutativity (two claims), associativity + idempotence (three claims, all six orders), and needing no state the snapshot lacks — a claim arriving at a **reloaded** replica is ranked against a decoded tag and lands where no restart does. A randomized permutation oracle shuffles pools mixing several tag claims at two stamps with a reveal and a delete. Mutation sweep under statement deletion: 6 mutants over the rank and every seat, **0 survivors**, with the reveal seat and the merge seat each killed by exactly one test. A falsification pass then found a 7th that survived the whole workspace (guarding the rank with `!tag.is_empty()`) and one test that was unwired (its two merge directions both had `div` as receiver); both are now pinned, along with a non-UTF8 tag. The randomized oracle is convergence-only by construction — it compares permutations against each other, so the rank's *direction* is pinned by the named tests, not by it. ARCHITECTURE §Tree Moves + DECISIONS updated. Residue: C154. → *Core / XML*. +**C44 — two inserts at one stamp carrying *different* tags derive one child, whose tag was the first arrival's (crates/core) — DONE (#PR).** `xml_child_id` mixes the children list, the stamp and the *kind* into the derivation but never the tag, so `XmlInsertChild { tag: Some(b"div") }` and `XmlInsertChild { tag: Some(b"span") }` at one stamp into one list derive the **same** `XmlElement` id; `registered_handle`'s `or_insert_with` seated the first arrival's tag and no op ever rewrote it. Reproduced before fixing (the entry had said "by inspection"): `frag(a(),div())` one order, `frag(a(),span())` the other, and `encode_state` differing byte for byte, since the XML registry writes each node as its id plus its tag. Mixing the tag into the derivation stays rejected for the reason the filing gives — C24's rank leans on `born_at` being pure in the key. **Fixed by an intrinsic rank: the smaller tag bytes take the node** (`XmlElement::claim_tag`), the same principle C40 (#400) landed one layer down. It is a **meet**, which is what makes it hold at every seam without a per-seam argument — idempotent, commutative and associative, so a replay ties, a third claim only lowers, and a merge cannot answer by which side received; and a decoded tag (unranked — one tag per id in the registry, no contest at decode) is a bound later ops can only tighten, so the rule is recoverable on reload with **no new persisted state**. C40's warning about a missed seat path paid off: the filing named one and there are **four**. The birth (`insert_xml_child`); **`apply_reveal`**, which the filing does not mention and is the widest — an `XmlReveal` names an arbitrary `ElementId` with arbitrary tag bytes, so it can name exactly the node a birth derives, and it returned early on an already-materialised node, so fixing the birth alone leaves reveal(`aa`)→birth(`div`) folding to `aa` and the reverse to `div`; **`XmlElement::merge`**, whose doc said the tag "is identity, not state, so it is left untouched" — that resolved it by which side received; and `create_container`, whose *id* is safe against itself (`XmlElement::node_id` folds the tag in) though its **slot** is not — filed as C155 — and which is collidable by a reveal, running the rank through the same `registered_handle` seam. A falsification pass found a **fifth** seat the fix itself opened: `reveal_ops` reads the tag off live state into a synthetic shell whose `OpId` derived from the node alone, so once the tag became a meet the corrected shell deduped against the stale one and an op-served reader was pinned at a tag it could never revise (two readers of one node encoding different bytes, both differing from the meet). The shell id now derives from `(node, tag)`. A second pass measured that identity alone **delivers nothing** — both redaction seams emit shells only for nodes an `XmlMove` in the batch names, and a retagging twin is an `XmlInsertChild`, so a live reader stays frozen at the stale tag while a fresh joiner reads the meet — and a third falsified the attempted trigger three independent ways (dead code on the catch-up seam, an O(document) walk per recipient on every ordinary tagged insert, and a whole-subtree back-fill re-sent per retag). The delivery is therefore **not** part of this unit: reverted and filed as **C158**. What ships is the core rank plus the shell's identity, and ARCHITECTURE says identity is necessary and not sufficient. The rank runs wherever a claim can meet a tag **already held** (a seam with no incumbent — a first materialisation, a decode, a detached clone — has nothing to rank and seats directly), and on **every** such claim, including one whose id already determines its tag, because whether an id determines its tag is not a question an id answers and the reveal holds neither the map nor the key to check against — where it does determine it, every honest claim carries that tag and the rank is a tie. A **live handle stays valid** and observes the restatement: a handle is a view onto convergent state, not a snapshot. Two claims the falsifier corrected: the sequence engine structurally cannot decide a tag, but *not* because two same-id different-tag elements "always rank equal" (differing anchors rank unequal) — because `List::merge`'s fold-arm gate compares kind and encoded value only, **excluding** the anchor, and `put_node_value` never writes a tag, so the contest arm is unreachable for a tag whatever the anchors; and `Map::merge` does **not** converge on the tag — its LWW is symmetric only at distinct stamps, and at an equal stamp the receiver wins (`div` vs `span` measured). Neither is reachable through `Document::apply`. Spec `crates/core/tests/xml_tag_collision.rs` folds every shape in both orders and compares snapshot bytes, and pins the three properties the meet buys rather than arguing them: commutativity (two claims), associativity + idempotence (three claims, all six orders), and needing no state the snapshot lacks — a claim arriving at a **reloaded** replica is ranked against a decoded tag and lands where no restart does. A randomized permutation oracle shuffles pools mixing several tag claims at two stamps with a reveal and a delete. Mutation sweep under statement deletion: 6 mutants over the rank and every seat, **0 survivors**, with the reveal seat and the merge seat each killed by exactly one test. A falsification pass then found a 7th that survived the whole workspace (guarding the rank with `!tag.is_empty()`) and one test that was unwired (its two merge directions both had `div` as receiver); both are now pinned, along with a non-UTF8 tag. The randomized oracle is convergence-only by construction — it compares permutations against each other, so the rank's *direction* is pinned by the named tests, not by it. ARCHITECTURE §Tree Moves + DECISIONS updated. Residue: C154. → *Core / XML*. **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*. @@ -559,6 +559,10 @@ scalar / counter / register / element / map (#22–#27), list Fugue (#24), text **C157 — a tagless `XmlReveal` naming a tagged birth's node id diverges by arrival order (crates/core) — READY, no dependencies. Found by C44's falsification pass, reproduced; pre-dates C44 (fails at `9af15485` too).** `apply_reveal` routes on the reveal's *own* tag: `Some` materialises an `XmlElement`, `None` a `Text` run. A reveal carrying `None` at an id a **tagged** birth derives therefore registers a `Text` at that id when it lands first, and is a no-op when it lands second — 856 vs 832 snapshot bytes for the two orders. C44's rank does not reach it and deliberately does not: the rank settles two tags on one node, while this is two *kinds* on one id, which `xml_child_id` exists to keep apart (a stamp's tagged and tagless children are different ids, so an honest pair never collides here). What makes it reachable is that a reveal names an arbitrary `ElementId` with an arbitrary kind, so nothing ties the kind it asserts to the id it names. Adjacent to C154 — both are a reveal asserting something about an id that the id itself already answers — and the likely fix is shared: give `registered_handle`/`apply_reveal` the derivation's own answer for a derivable id rather than the claim's. Not client-reachable (`crates/server/src/session.rs` refuses a client-authored `XmlReveal`), so it needs a compromised server, same boundary as C154. → *Core / XML*. +**C158 — a redaction never re-emits a revealed node's shell when only its tag changes (crates/server) — READY, no dependencies. Split out of C44, whose fix it is not; measured, and a first attempt at it was falsified three ways.** A shell carries a revealed node's tag read off live state, and C44 made that tag a **meet** a later claim can lower. Both seams (`session.rs` catch-up, `registry.rs` live fan-out) emit shells only for nodes an `XmlMove` in the batch names, so a batch that only retags emits none: measured against a real `Registry` as a live reader frozen at `frag(card)` while a fresh joiner read `frag(aaa)`. C44 keyed the shell's `OpId` on `(node, tag)` so a corrected shell is no longer deduped away — necessary, and not sufficient. **The naive fix — widen the trigger to "a move or a retag" — was built and reverted**, and each of the three reasons is a design constraint on the real one. (1) *Catch-up is dead code*: `delta` is shadowed by the read-filtered delta before the trigger reads it, and the retagging op is by construction denied to the recipient, so the trigger is empty exactly when needed; it needs the **pre-filter** batch, which is what `registry.rs` already uses. (2) *Cost*: it makes `Document::reveal_ops` — an O(document) walk plus an O(placements log placements) sort — run **per recipient on every ordinary tagged `XmlInsertChild`** (measured 0 → 3 calls for one op, three subscribers), a client-driven amplification lever; it needs a cheap "is any node revealed to this recipient" test before the walk. (3) *Wire*: a re-emitted shell falls into `reveal_backfill` and re-sends the node's whole readable subtree — measured 31 outbound ops per 1-op batch, identical when the shell was byte-identical; a retag-only shell needs no back-fill, since the subtree was delivered when the node was first revealed. Convergence is never at risk (client-side `OpId` dedup); what is at risk is a reader holding a tag its own document can never revise. → *Server / Redaction*. + +**C159 — an `AclGrant` that reveals a node emits no shell to a live reader (crates/server) — READY, no dependencies. Found by C44's delivery review, reproduced; pre-dates C44 and is wider than it.** Sibling of C158, the same shape one trigger over: a node becomes newly-revealed when a grant opens its **current position**, and an `AclGrant` is neither a move nor a birth, so `moved_nodes` is empty and the live fan-out sends nothing. Measured: a card born in denied `colB` and moved to `colA` while bob holds no grant; alice then grants bob read on `colA` while bob is live-subscribed — bob renders `colA=absent` where a joiner catching up after the same grant renders `colA=frag(card())`. This defeats a whole node rather than only its tag, so it is the wider bug. The existing coverage (`an_element_grant_reveals_a_born_denied_node_op_join_with_snapshot_join`) compares two *catch-up* joiners and cannot see it. Likely one fix with C158: the trigger is "which nodes are revealed to this recipient **now** that were not before", not "which nodes did this batch name". → *Server / Redaction*. + **C155 — two writes at one key and one stamp resolve a map slot by arrival order, at three seats (crates/core) — READY, no dependencies. Found by C44's falsification pass, reproduced; the seat count is a lower bound.** Not XML-specific and not a tag question: `Map::set` resolves an equal-stamp contest with `let loses = prior.is_some_and(|e| !stamp.gt(&e.stamp))`, so the **first arrival keeps the slot** — and the identical tie is measured at two more seats, `Map::delete` (map.rs:510) and `Map::merge`'s LWW arm (map.rs:576-580, which keeps the destination whenever `!se.stamp.gt(&de.stamp)`, so the *receiver* wins). Three seats found, and this unit's own history says treat that as a floor: C44's filing named one seat and five existed, so the fix should enumerate every equal-stamp comparison in `map.rs` before concluding. Two ops under one `ClientId` at one `Stamp` naming one key are both admissible — dedup is on `OpId`, and an id-space record only bounds an honest mint — so the same op set folds to two states. Measured on three shapes: `XmlElementCreate{div}` vs `XmlElementCreate{span}` (whose derived node ids differ, since `XmlElement::node_id` folds the tag in, so the two elements contend for one slot), `MapCreate` vs `ListCreate`, and `MapSet(1)` vs `MapSet(2)` — each encoding different `encode_state` bytes in the two orders. This is the C24/C40/C44 family one layer over, in the Map rather than the sequence or the node registry, and the fix is the same shape: an intrinsic rank over the two claims at the key, decided before the stamp comparison keeps the incumbent. The rank has to cover a scalar against a container against a counter, so the natural key is the one C40 already uses for a sequence node — the kind tag, then the encoded value — read at the slot rather than at the sequence id. → *Core / Map*. **C156 — the XML diff reads a tag as stable identity, which a lowered tag makes conservative (crates/core) — READY, no dependencies. Found by C44's falsification pass; cosmetic, not a correctness bug.** `diff_xml_element` emits `Removed` + `Added` when two snapshots' tags differ at one slot, on the premise that "a tag is part of an element's identity, so a different tag at the same slot is a different element". Under C44 a node's tag can be *lowered* by a later claim, so that premise no longer holds for one node observed across the claim: the diff describes a structural replace of a node that never moved and never changed id. The emitted change list stays **sound** — nothing downstream reads a tag as stable, and a consumer re-reads the subtree — so this is a fidelity question, not a divergence: a consumer that rebuilds only what a change names does more work than it needs to, and an editor binding a selection into that subtree sees it torn down and rebuilt. The doc comments are corrected to say conservative rather than exact; the fix, if wanted, is a `Retagged` change variant carrying the node's id, which is a public API addition to `diff::Change` and to every SDK that re-marshals it. → *Core / XML*. diff --git a/crates/core/src/doc.rs b/crates/core/src/doc.rs index 345805f0..5ede343e 100644 --- a/crates/core/src/doc.rs +++ b/crates/core/src/doc.rs @@ -6045,35 +6045,6 @@ fn stamp_key(stamp: Stamp) -> [u8; 24] { /// The element id an XML sequence child takes: derived from its children List /// and its node stamp, so the apply path, the readiness gate, and the cursor all /// agree. `kind` is `XmlElement` for an element child, `Text` for a text run. -/// The node an op can install or restate a tag on, if any — the trigger a redaction -/// reads to know a revealed node's shell has gone stale. -/// -/// A shell carries the node's tag as its payload *and* in its identity -/// ([`reveal_op_id`]), read off live state at emission. A node's tag is a meet over -/// the claims that named it, so a later claim can lower it, and the shell a reader -/// already holds then names a tag its own document will never revise. The fan-out -/// gates shells on the node appearing in an `XmlMove` in the batch, which a -/// retagging claim need not be: the claim that lowers a tag is an `XmlInsertChild` -/// into the node's *birth* list, and a reader that may not read that list is served -/// neither the claim nor a shell. So the trigger is widened by this, and both seams -/// read it. -/// -/// Over-reporting is safe and deliberate: this names the node an op *could* retag -/// without asking whether it did, since re-emitting an unchanged shell is inert — -/// its id is a function of `(node, tag)`, so an identical shell dedups, and a -/// changed one runs a rank that is idempotent. -pub fn retagged_node(op: &Op) -> Option { - match &op.kind { - // A tagged birth derives its child under the element kind; the tagless - // sibling of the stamp is a text run, which holds no tag. - OpKind::XmlInsertChild { tag: Some(_), .. } => { - Some(xml_child_id(op.target, op.stamp, ElementKind::XmlElement)) - } - OpKind::XmlReveal { node, tag: Some(_) } => Some(*node), - _ => None, - } -} - fn xml_child_id(list_id: ElementId, stamp: Stamp, kind: ElementKind) -> ElementId { ElementId::derive(list_id, &stamp_key(stamp), kind) } diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 79366510..86f11ef9 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -52,7 +52,7 @@ pub use client::{ClientError, ClientSession, Redirect, Rejected, SubscribeError} pub use clientid::ClientId; pub use codec::{decode_op, decode_ops, encode_op, encode_ops, DecodeError}; pub use counter::Counter; -pub use doc::{retagged_node, Document, OrphanEvent}; +pub use doc::{Document, OrphanEvent}; pub use element::{Element, ElementKind}; pub use elementid::ElementId; pub use host::Host; diff --git a/crates/server/src/registry.rs b/crates/server/src/registry.rs index a41d7f00..75fbdef7 100644 --- a/crates/server/src/registry.rs +++ b/crates/server/src/registry.rs @@ -2504,23 +2504,17 @@ impl Registry { // in, which an author's envelope only coincidentally does. A node the served // tree does not resolve keeps its move's answer, the only other partition claim // there is. For a room with no zones every zone is `None`, so this is a no-op. - let shell_nodes: Vec<(ElementId, Option)> = broadcast + let moved_nodes: Vec<(ElementId, Option)> = broadcast .iter() - .filter_map(|op| { - // A move is not the only thing that staleses a shell: a node's tag is a - // meet over the claims that named it, so a claim in this broadcast can - // lower one. That claim is a birth into the node's own list, which a - // recipient denied the origin never sees, so on the move gate alone it - // would keep a tag its document can never revise. - let node = match &op.kind { - OpKind::XmlMove { node, .. } => *node, - _ => crdtsync_core::retagged_node(op)?, - }; - let landed = schema - .as_deref() - .zip(index.get(&node)) - .map(|(s, path)| crdtsync_core::zone::zone_id_of(s, path)); - Some((node, landed.unwrap_or(op.zone))) + .filter_map(|op| match &op.kind { + OpKind::XmlMove { node, .. } => { + let landed = schema + .as_deref() + .zip(index.get(node)) + .map(|(s, path)| crdtsync_core::zone::zone_id_of(s, path)); + Some((*node, landed.unwrap_or(op.zone))) + } + _ => None, }) .collect(); for (peer, conn) in self.conns.iter_mut() { @@ -2584,13 +2578,7 @@ impl Registry { reads_whole, ) }); - // An empty readable subset is not on its own a reason to send nothing: a - // shell can be due for a batch whose every op this recipient is denied. - // That is exactly the retag case — the claim that lowers a revealed node's - // tag is a birth into the node's own denied list — so the shell would be - // skipped precisely when it is the only thing that could carry the change. - // A batch that yields neither is dropped by the emptiness check below. - if readable.is_empty() && shell_nodes.is_empty() { + if readable.is_empty() { continue; } // Reveal-on-move-in: for every node this batch moves into a position this @@ -2599,10 +2587,8 @@ impl Registry { // — the live-fan-out mirror of the catch-up reveal, derived from the same read // predicate. A recipient reading the node's origin all along gets no shell // (`reveal_ops` returns it only when the birth path is denied). Shells lead so - // the move lands onto them. A batch that only *retags* a revealed node sends - // the shell alone: its payload and its identity both carry the tag, so the - // re-emitted shell is the change. - let readable = if shell_nodes.is_empty() { + // the move lands onto them. + let readable = if moved_nodes.is_empty() { readable } else { let shells: Vec = hub @@ -2620,7 +2606,7 @@ impl Registry { ) .into_iter() .filter_map(|mut op| match &op.kind { - OpKind::XmlReveal { node, .. } => shell_nodes + OpKind::XmlReveal { node, .. } => moved_nodes .iter() .find(|(n, _)| n == node) .map(|(_, zone)| { diff --git a/crates/server/src/session.rs b/crates/server/src/session.rs index c35a44a3..cc2f84fd 100644 --- a/crates/server/src/session.rs +++ b/crates/server/src/session.rs @@ -898,16 +898,6 @@ pub fn step( _ => None, }) .collect(); - // A move is not the only thing that staleses a shell. A node's - // tag is a meet over the claims that named it, so a claim in - // this delta can lower one — and that claim is a birth into the - // node's own list, which this recipient may not read, so it is - // served neither the claim nor (on the move gate alone) a shell. - // It would sit at a tag its document can never revise. - let retagged_in_delta: std::collections::HashSet<_> = delta - .iter() - .filter_map(|rec| crdtsync_core::retagged_node(&rec.op)) - .collect(); let reveals = hub .reveal_ops( &room, @@ -925,7 +915,6 @@ pub fn step( .filter(|op| match &op.kind { crdtsync_core::OpKind::XmlReveal { node, .. } => { moved_in_delta.contains(node) - || retagged_in_delta.contains(node) } _ => false, }); diff --git a/crates/server/tests/acl_redaction.rs b/crates/server/tests/acl_redaction.rs index 38f2ca6a..02fc7dca 100644 --- a/crates/server/tests/acl_redaction.rs +++ b/crates/server/tests/acl_redaction.rs @@ -2509,111 +2509,3 @@ fn a_room_with_no_acl_fans_out_a_move_to_every_reader() { "no doc-ACL: the move fans out to bob unredacted" ); } - -#[test] -fn a_live_reader_is_revealed_a_lowered_tag_on_a_node_it_already_holds() { - // The shell's payload is the node's tag read off live state, and a tag is a meet - // over the claims that named it (C44) — so a claim landing after the shell can - // lower it. That claim is an `XmlInsertChild` into the node's *birth* list, which - // this reader may not read: it is served neither the claim nor, on a move gate - // alone, a second shell. It would hold a tag its own document can never revise, - // while a fresh joiner reads the meet — two readers of one node, different bytes. - let mut r = registry(); - let alice = auth(&mut r, 1, "t-alice"); - assert!(subscribe(&mut r, alice)); - r.take_outbox(alice); - let mut alice_doc = Document::new(cid(1)); - submit( - &mut r, - alice, - grant_read( - &mut alice_doc, - AclSubject::Actor(actor_key(b"bob")), - &col(COL_A), - ), - ); - submit(&mut r, alice, xml_fragment(&mut alice_doc, &col(COL_A))); - submit(&mut r, alice, xml_fragment(&mut alice_doc, &col(COL_B))); - let birth = xml_insert_element(&mut alice_doc, &col(COL_B), 0, b"card"); - let insert = birth - .iter() - .find(|op| matches!(op.kind, OpKind::XmlInsertChild { .. })) - .expect("the child insert") - .clone(); - submit(&mut r, alice, birth); - r.take_outbox(alice); - - // bob joins, then the card moves into his readable column: he is shelled at `card`. - let bob = auth(&mut r, 2, "t-bob"); - assert!(subscribe(&mut r, bob)); - let mut live = Document::new(cid(2)); - for op in received_ops(&mut r, bob) { - live.apply(&op); - } - submit( - &mut r, - alice, - crdtsync_core::path::xml_move_child(&mut alice_doc, &col(COL_B), 0, &col(COL_A), 0), - ); - for op in &received_ops(&mut r, bob) { - live.apply(op); - } - assert_eq!( - board_render(&live), - "colA=frag(card()) colB=absent", - "the live reader holds the card under its first tag", - ); - - // The twin: one stamp, a distinct OpId, a smaller tag. Admissible — dedup is on - // `OpId` — and it lands in colB, which bob may not read. - let mut twin = insert.clone(); - twin.id.seq = 9_000; - if let OpKind::XmlInsertChild { tag, .. } = &mut twin.kind { - *tag = Some(b"aaa".to_vec()); - } - assert_eq!(twin.stamp, insert.stamp, "the twin carries one stamp"); - alice_doc.apply(&twin); - submit(&mut r, alice, vec![twin]); - // The corrected shell is the whole of what bob is sent: the claim itself is - // denied him, and the shell's payload and identity both carry the tag. - let after_twin = received_ops(&mut r, bob); - assert!( - after_twin.iter().any( - |op| matches!(&op.kind, OpKind::XmlReveal { tag, .. } if tag.as_deref() == Some(b"aaa")) - ), - "the retag must re-emit the shell: {after_twin:?}", - ); - for op in &after_twin { - live.apply(op); - } - - // A reader joining after the twin reads the meet; the live reader must agree. - let carol = auth(&mut r, 3, "t-carol"); - submit( - &mut r, - alice, - grant_read( - &mut alice_doc, - AclSubject::Actor(actor_key(b"carol")), - &col(COL_A), - ), - ); - r.take_outbox(alice); - let _ = received_ops(&mut r, bob); - assert!(subscribe(&mut r, carol)); - let mut fresh = Document::new(cid(3)); - for op in received_ops(&mut r, carol) { - fresh.apply(&op); - } - - assert_eq!( - board_render(&fresh), - "colA=frag(aaa()) colB=absent", - "a fresh joiner reads the meet of the two claims", - ); - assert_eq!( - board_render(&live), - board_render(&fresh), - "the live reader kept a tag its own document can never revise", - ); -}