Skip to content
4 changes: 4 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,10 @@ 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 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 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.

## List
Expand Down
25 changes: 25 additions & 0 deletions DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,31 @@ 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 — 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.

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

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

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

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

Expand Down
Loading
Loading