Skip to content

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
mainfrom
core/c44-xml-tag-derivation
Open

fix(core): a node's tag is a meet over the claims that named it, not the first arrival's (C44)#404
vieiralucas wants to merge 7 commits into
mainfrom
core/c44-xml-tag-derivation

Conversation

@vieiralucas

@vieiralucas vieiralucas commented Aug 9, 2026

Copy link
Copy Markdown
Member

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 { 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 filing said "by inspection"): the two orders fold to frag(a(),div()) and frag(a(),span()), and encode_state differs byte for byte — the XML registry writes each node as its id plus its tag.
  • Mixing the tag into the derivation stays rejected: C24's placement rank leans on born_at being pure in the key.
  • The smaller tag bytes take the node (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.
  • Recoverable on reload with no new persisted state: a decoded tag is unranked (one tag per id, no contest at decode) and later ops can only tighten it.
  • Four seats run the rank, not the one filed. The birth; apply_reveal — an XmlReveal names an arbitrary ElementId with arbitrary tag bytes and 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 tag was "identity, not state, so left untouched" — resolving it by which side received; and create_container, safe against itself (node_id folds the tag in) but collidable by a reveal.
  • The rank runs on every claim, including at an id that already determines its tag: 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, every honest claim carries that tag and the rank is a tie. Residue filed as C154.
  • A live handle stays valid and observes the restatement — a handle is a view onto convergent state, not a snapshot.
  • Measured, not 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 decide a tag contest. Map::merge's LWW arm converges on the tag (symmetric slot LWW); its detached deep clone is C134's shape.
  • Spec 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.
  • ARCHITECTURE §Tree Moves + DECISIONS + KANBAN updated.

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

  • Bug Fixes
    • Added XmlElement::claim_tag and run it where a claim can meet an existing tag: at birth/create_container, tagged apply_reveal on an already‑materialised element, and XmlElement::merge. Paths with no incumbent (first materialisation, decode, detached deep clone) seat the tag directly.
    • Reveal shells derive OpId from (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.
    • Reverted the server change that emitted a shell on retag; delivery stays as-is and will be handled separately. Docs now state identity is necessary but not sufficient for delivery.
    • Meet semantics (idempotent, commutative, associative); reload‑safe with no new state; live handles observe tag restatements. Tests cover reload‑between‑claims, permutations, empty and non‑UTF8 tags; XML diff notes tag changes emit as replaces (conservative under lowered tags).

Written for commit 0791ed2. Summary will update on new commits.

Review in cubic

@vieiralucas
vieiralucas requested a lite review from Copilot August 9, 2026 14:11

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

…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
vieiralucas force-pushed the core/c44-xml-tag-derivation branch from 92fe2ae to 90039b5 Compare August 9, 2026 18:32
… 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants