fix(core): a node's tag is a meet over the claims that named it, not the first arrival's (C44) - #404
Open
vieiralucas wants to merge 7 commits into
Open
fix(core): a node's tag is a meet over the claims that named it, not the first arrival's (C44)#404vieiralucas wants to merge 7 commits into
vieiralucas wants to merge 7 commits into
Conversation
…the first arrival's (C44) `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.
…'s tag claim 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.
…ery one does `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.
… 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.
vieiralucas
force-pushed
the
core/c44-xml-tag-derivation
branch
from
August 9, 2026 18:32
92fe2ae to
90039b5
Compare
… that bought nothing 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.
…t only when it moves 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.
… as its own unit 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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
xml_child_idmixes the children list, the stamp and the kind into the derivation but never the tag, soXmlInsertChild { tag: Some(b"div") }and{ tag: Some(b"span") }at oneStampinto one list derive the sameXmlElementid.registered_handle'sor_insert_withseated the first arrival's tag and no op ever rewrote it.frag(a(),div())andfrag(a(),span()), andencode_statediffers byte for byte — the XML registry writes each node as its id plus its tag.born_atbeing pure in the key.XmlElement::claim_tag) — the intrinsic rank C40 (fix(core): one rank owns a sequence id, so two ops at one stamp do not resolve by arrival order (C40) #400) landed one layer down. A meet, so idempotent/commutative/associative: a replay ties, a third claim only lowers, a merge cannot answer by which side received.apply_reveal— anXmlRevealnames an arbitraryElementIdwith arbitrary tag bytes and returned early on an already-materialised node, so fixing the birth alone leaves reveal(aa)→birth(div) folding toaaand the reverse todiv;XmlElement::merge, whose tag was "identity, not state, so left untouched" — resolving it by which side received; andcreate_container, safe against itself (node_idfolds the tag in) but collidable by a reveal.List::merge'sclaim_rankis built fromput_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 decide a tag contest.Map::merge's LWW arm converges on the tag (symmetric slot LWW); its detached deep clone is C134's shape.crates/core/tests/xml_tag_collision.rs: 9 tests, every shape folded in both (or all six / all four) orders against snapshot bytes, plus a round-trip and a live-handle test.Summary by cubic
Fixes XML tag collisions by ranking tag claims: the smaller tag bytes win. Reveal shells are keyed by
(node, tag); the server retag delivery trigger was reverted, so delivery is unchanged in this PR; replicas converge and encode identically (C44).XmlElement::claim_tagand run it where a claim can meet an existing tag: at birth/create_container, taggedapply_revealon an already‑materialised element, andXmlElement::merge. Paths with no incumbent (first materialisation, decode, detached deep clone) seat the tag directly.OpIdfrom(node, tag); the id is injective and separates tagless from empty‑tagged shells, so corrected shells no longer dedup away and readers converge with snapshots.Written for commit 0791ed2. Summary will update on new commits.