Skip to content

Commit 92fe2ae

Browse files
committed
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.
1 parent d7302a1 commit 92fe2ae

8 files changed

Lines changed: 239 additions & 42 deletions

File tree

‎ARCHITECTURE.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -319,7 +319,9 @@ The fold is a pure function of the move-set + tombstone-set, **independent of th
319319

320320
**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.
321321

322-
**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.
322+
**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.
323+
324+
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.
323325

324326
**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.
325327

‎DECISIONS.md‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,13 @@ The entries below (2026-07-02) are a backfill: design changes made during the v0
2323

2424
**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.
2525

26-
**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.
26+
**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.
27+
28+
**`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.
29+
30+
**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.
31+
32+
**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.
2733

2834
## 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
2935

0 commit comments

Comments
 (0)