fix(core): one rank owns a sequence id, so two ops at one stamp do not resolve by arrival order (C40)

Merged
VI
3mo ago
+851-55

Two ListInsert ops under one ClientId at the identical Stamp are both admissible (dedup is on OpId; the id-space record bounds only an honest mint), and List::insert_at was idempotent on the id — so the first to land kept the slot with its value and its Fugue anchor, and the two delivery orders encoded different snapshots (the node carried 2 on one replica, 99 on the other).

  • One rank over (kind tag, position, encoded value), in List so it reaches Text runs on the same seam. Smaller rank takes the id with its value and its anchor; a tie changes nothing (replay stays inert, two claims on one node take the meet of their positions).
  • Every seat path runs it — a claim owns the key it named, not the id. Plain ListInsert, every codepoint of a Text run, a children-list birth whose key was free, the document-ranked eviction and join (which now yield to a live scalar on the id), and List::merge, which was answering by which side received.
  • Tag leads, read as the number it is — mixed scalar/composite falls out of the same comparator, no semantic "composite beats scalar" to re-decide per new kind. Two composites are still ranked at the (list, stamp) key (C24, birth over move then smaller element id) before the sequence is touched.
  • Position outranks the value: it is the only key a delete leaves behind, and all a tombstone encodes. Value-first would have let a delete landing between two claims decide the winner by which one it buried; measured, all four orders of [insert, insert, delete] now agree.
  • Fixes [ListInsert, tagged, tagless] replacing the scalar while [ListInsert, tagless, tagged] kept it — one op set, two trees.

Spec crates/core/tests/list_stamp_collision.rs (14) folds every delivery order per shape and compares encode_state bytes.

Residue filed as C133: a tombstone holds no kind, so a mixed pair a delete lands between is still ordered by arrival, and List::merge — whose tombstone arm can rank nothing — is left non-associative on the same shape (104/3000 collision pools, 0 without a delete; C40 closes the commutativity half, 2638/3000 before → 0 after). Narrowed here (the mixed split no longer keys on which claim arrived first), not closed — closing it needs a rank persisted per dead id, which the tombstone run compression forbids. C134 files the detached deep clone the winning merge arm installs (pre-dates C40 in the vacant arm, widened here, unreachable from Document).

ARCHITECTURE §List + DECISIONS updated.

Reviewer note: Copilot's quota is spent (a review request does not stick) and cubic produces a summary only, so the local gate stands in: full workspace (226 suites), targeted Miri on the ten touched suites, a 22-mutant statement-deletion sweep over the comparator and every call site (5 survivors on the first pass, each closed by a new test or by deleting the unreachable branch; second pass: no survivors), and an adversarial falsification pass in a separate worktree.

The falsification pass broke two claims, both now fixed in prose or filed: "every seat path runs the one rank" was overstated (a document-ranked eviction/join reads only the leading tag; a claim meeting a tombstone reads only the position — ARCHITECTURE/DECISIONS/spec now say which seam reads what), and List::merge lost associativity on collision+delete (filed on C133). It verified the anchor-travels claim, C24-unchanged-for-composites, replay inertness, Miri, and the main-vs-branch split measurement the prose asserts; 4000 random scalar-only op sets folded in every permutation give 0 divergences here against 1345/4000 on the parent commit.