fix(core): a node's tag is a meet over the claims that named it, not the first arrival's (C44)

VI
3mo ago
+985-42

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