Skip to content

fix(core): a children-list placement collision resolves by an intrinsic rank, not arrival order (C24) - #371

Draft
vieiralucas wants to merge 1 commit into
mainfrom
core/c24-stamp-placement-collision
Draft

fix(core): a children-list placement collision resolves by an intrinsic rank, not arrival order (C24)#371
vieiralucas wants to merge 1 commit into
mainfrom
core/c24-stamp-placement-collision

Conversation

@vieiralucas

@vieiralucas vieiralucas commented Jul 28, 2026

Copy link
Copy Markdown
Member

A children-list position is keyed (list, stamp) and a document holds at most one, since read_state refuses a duplicate. Two ops can carry one stamp into one list and both pass every gate — dedup is on OpId, and the id-space record only bounds an honest mint — so which of them takes the key had to stop being a function of delivery order. Two XmlMoves under one ClientId at one Stamp moved different nodes on two replicas that folded the same ops, and their encode_state bytes differed; XmlInsertChild reached it more quietly, xml_child_id mixing the kind in so a tagged and a tagless insert at one stamp derive different children that collide on the position without colliding on the element.

The rank. The key goes to a birth over a move, then to the smaller element id — a total order over data both replicas already hold. A birth outranks because the key is where the born node's 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. The birth test reads the key, not the registry, so a forged XmlReveal under the other kind cannot invert it. placement_index carries the owning node rather than being a set.

Eviction restores the state the opposite order leaves. The incumbent's Placement goes; its move edge is withdrawn (TreeMoves::remove, by the same undo-and-replay a late arrival uses); its reachability edge is re-derived from the surviving log; and a node left with no placement returns to awaiting its first, which is where a refusal leaves a claimant too. The sequence slot is re-seated rather than re-inserted (insert_at is idempotent on the id), and that reaches the tombstones — a delete landing between the two ops would otherwise freeze the loser's anchor into the dead run on one replica and the winner's on the other, so reseat lifts the id out of its run, re-seats it and re-deletes.

The rank orders nodes, which is the whole question only while two claims name two. A contest takes the winner's position outright — the loser's is nothing to it, and meeting the two would let the loser's show through exactly when it arrived first. Two moves of one node, or two inserts carrying one tag, hold one id between them and the position was still falling to arrival order — the first one's anchor for two moves, the last one's for two inserts, that second direction being new since re-seating replaced an idempotent insert_at. There the key is already the claimant's own and nothing changes hands: the sequence re-seats the id at the meet of the two anchors, which cannot show which landed first. A contest would have needed to know what put the incumbent at the key, and nothing answers that — TreeMoves::apply dedups on the stamp alone, so a move of another node at that stamp into another list takes the log slot and the key-holder records no edge. A move onto the key its own node was born at therefore keeps the edge it logs, where an earlier rule had the birth withdraw it; the edge names the parent the node was created under, so folding it moves nothing.

The created-under relation is re-seeded before the log replay, at both sites that rebuild it. A birth records the list it was created in whether or not it takes that list's key, and a decode re-derived that edge only from a birth placement, which a losing birth does not have: the live replica refused a move under such a node as a cycle and then could not decode its own snapshot (BadTag { parent cycle }), while a replica started from those bytes admitted the move and the node it took vanished. refold_projected_moves was the quiet one — it empties the log and replays it as the last step before an encode, so a source missing there is a debug_assert in a test build and a cyclic snapshot in a release one.

The map half of the tree anchors the cycle check, one hop at a time — a container keyed into a map records that map, and the map the element that owns it, so the walk runs as deep as the nesting rather than one hop over the first. This is the only part of the change that alters behaviour for an honest document; ARCHITECTURE and DECISIONS record it, with the cost stated: a small class of moves that used to apply is now refused as the cycle it always was.

Also. refold_moves now re-points parents for a node it finds deleted, instead of leaving that edge at whatever the previous fold wrote; a node's placement record is written key-sorted, since grown in arrival order it made two honest replicas encode different bytes. Spec crates/core/tests/xml_stamp_collision.rs folds each shape in both orders and compares snapshot bytes, and names the winner in six tests so a flipped comparison fails by name rather than converging on the wrong answer.

The tag two same-kind inserts at one stamp also name in common is still the first arrival's — the derivation carries no payload, by the purity the birth test needs — and a losing birth that kept a move placement has no created-under edge a snapshot can recover. Both are filed rather than folded in.

Six defects found while pinning this one are filed as C40-C45 (a plain-ListInsert collision; a reveal shell not surviving a snapshot; a create arriving after a reveal and a move; two moves at one stamp into different lists; the tag; the unrecoverable edge). The first four took C27-C30 at filing time and were renumbered, those ids being C15's.

@vieiralucas
vieiralucas requested a review from Copilot July 28, 2026 22:37

This comment was marked as resolved.

@vieiralucas
vieiralucas requested a review from Copilot July 28, 2026 22:43

This comment was marked as resolved.

@vieiralucas
vieiralucas requested a review from Copilot July 28, 2026 23:01

This comment was marked as resolved.

@vieiralucas
vieiralucas requested a review from Copilot July 28, 2026 23:40

This comment was marked as resolved.

@vieiralucas
vieiralucas requested a review from Copilot July 29, 2026 00:06

This comment was marked as resolved.

@vieiralucas
vieiralucas requested a review from Copilot July 29, 2026 00:29

This comment was marked as resolved.

@vieiralucas
vieiralucas force-pushed the core/c24-stamp-placement-collision branch from f20f1fa to 549656d Compare July 29, 2026 00:38
@vieiralucas
vieiralucas requested a review from Copilot July 29, 2026 00:39

This comment was marked as resolved.

@vieiralucas
vieiralucas marked this pull request as draft July 29, 2026 02:12
@vieiralucas

Copy link
Copy Markdown
Member Author

Held in draft: cold review pass 4 found a convergence hole in the rank this PR introduces, plus a regression. CI is green on 2c0ffae (17/17), so neither is caught by the suite — which is the point.

H1 — the rank is still arrival-order dependent when the incumbent is the other derivable child of the key. born_at is pure in the key, so it answers true for any node whose id equals a derivation, however that node reached the key. The arms then disagree about one pair: (Move, held-born-shaped) refuses unconditionally, while (Birth, held-born-shaped) falls through to the id compare. A move naming xml_child_id(list, stamp, Text) against a tagged birth deriving a larger id gives [birth, move] -> frag(a(),c()) and [move, birth] -> frag(a(),T"") — one op set, two snapshots. Reachable wherever XmlReveal shells exist.

Fix: rank on whether each side is the child this key derives, not on the arriving op's kind — (claimant_born, held_born): (true,false) wins, (false,true) loses, else smaller id. Symmetric by construction, still pure in the key, and needs no stored provenance (so it survives a decode). The held == node short-circuit stays, keyed on Claimant, because there the two ops name one node and only the op kind separates them.

H2 — the unconditional reparent in refold_moves widens a pre-existing hole into an unloadable snapshot. TreeMoves::is_ancestor walks tree ∪ base, and create_container sets parents[child] without a moves.set_base, so the cycle check is blind to the map half of the tree. Moving a node under the children of an element inside that node's own attrs map cycles parents; reject_parent_cycles runs only on decode, so the replica accepts it live and read_state then refuses its own bytes. Pre-existing for a live node, new for a deleted one. Fix belongs in create_container (record the base edge) rather than re-narrowing the reparent, which is itself a convergence fix.

Also open, smaller: two XmlMoves at one stamp into different lists still resolve by arrival order (TreeMoves::apply dedups on stamp alone) — pre-existing, but this PR's prose cites it as a safety property and must not; Map::set is first-wins on an exact stamp tie (same shape as C27, worth folding into that entry); and the suite pins that two orders agree, never which op wins — flipping the tiebreak direction passes all 1733 tests.

@vieiralucas

Copy link
Copy Markdown
Member Author

Still held in draft. Cold review pass 5 found two more HIGHs, both reproduced with probes against the committed tree, plus three MEDIUMs. CI is green on every head so far, which is again the point — none of these is visible to a suite that only asks whether two arrival orders agree.

H1 — a decode drops the created-under edge of a node whose birth placement is gone, and the replica then cannot load its own snapshot. insert_xml_child records moves.set_base for every birth, winner or loser, but the decode re-derives base only from a birth placement or a map parent. A refused or evicted birth has neither, so restore_unplaced restores its movability and not its edge, and the move log's cycle check walks a shorter chain after a reload than it did live. Measured with a tagged/tagless collision, a child born inside the loser's own children list, and a move into that child: live refuses the move as a cycle and decode_state on its own bytes answers BadTag { parent cycle }; the reloaded replica admits the move and the node vanishes. Fix: have restore_unplaced seed the edge too, and run it before the log replay — seeding after leaves the replay's cycle check blind.

H2 — two ops at one key naming the same node still resolve by arrival order. The rank is total over nodes, not over claims: when the element id ties, held == node short-circuits on the arriving op's kind and nothing orders the two Fugue anchors. Two moves of one node into one list at one stamp with different anchors keep the first arrival's anchor; two inserts with the same tag keep the last, because Claim::Evicted re-seats where the old unconditional insert_at was idempotent. Both diverge in tree and in bytes. Not C27 (values identical), not C30 (one list), not C29. Fix: rank the tie by (Birth < Move, encoded anchor bytes) and re-seat to the winner.

M1refold_projected_moves is the third base-edge seeding site and was not updated, so a projection folds a move the live replica refused; the debug_assert at the projection's own decode check fires, and in release the recipient is served a snapshot with a parents cycle. M2birth_placement still reads the registry, the exact dependency born_at was hardened against, so a forged reveal can drop an honest node's base edge on decode. M3 — the born_at test asserted only that the orders agree; the winner is now named (pushed). M4/M5 — the map-half cycle-check change is the only part of this diff that alters behaviour for honest documents and appears in no document; and two claims in ARCHITECTURE/KANBAN about the reload re-deriving a loser's state are falsified by H1.

Pushed in this round: the self-claim direction and the birth test now assert which op wins rather than only that two orders agree — the gap that let an inverted comparison pass all 1733 tests.

@vieiralucas
vieiralucas force-pushed the core/c24-stamp-placement-collision branch 3 times, most recently from 2faef55 to e417325 Compare July 29, 2026 06:59
@vieiralucas
vieiralucas requested a review from Copilot July 29, 2026 07:03

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.

…ic rank, not arrival order (C24)

A children-list position is keyed `(list, stamp)` and a document holds at
most one, since `read_state` refuses a duplicate. Two ops can carry one
stamp into one list and both pass every gate — dedup is on `OpId`, and the
id-space record only bounds an honest mint — so which of them takes the
key had to stop being a function of delivery order.

The key goes to a birth over a move, then to the smaller element id. A
birth outranks because the key is where the born node's id comes from. The
birth test reads the key rather than the registry, so a reveal under the
other kind cannot invert it. A later-arriving winner evicts: the
incumbent's placement goes, its move edge is withdrawn by the same
undo-and-replay a late arrival uses, its reachability edge is re-derived
from the surviving log, and a node left with no placement returns to
awaiting its first, which is where a refusal leaves a claimant too. The
sequence slot is re-seated rather than re-inserted, and that reaches the
tombstones: a delete landing between the two ops would otherwise freeze
the loser's anchor into the dead run on one replica and the winner's on
the other.

That rank orders nodes, which is the whole question only while two claims
name two. Two moves of one node, or two inserts carrying one tag, hold one
id between them, and the position was falling to arrival order — the first
one's anchor for two moves, the last one's for two inserts. There the key
is already the claimant's own and nothing changes hands: the sequence
re-seats the id at the meet of the two anchors, which cannot show which
landed first. A contest would have needed to know what put the incumbent
at the key, and nothing answers that — the move log dedups on the stamp
alone, so a move of another node at that stamp into another list takes the
slot and the key-holder records no edge.

The created-under relation is re-seeded before the log replay at both
sites that rebuild it. A losing birth keeps that edge live but had none
re-derived on decode, so the live replica refused a move under such a node
as a cycle and then could not decode its own snapshot, while a replica
started from those bytes admitted it and the node it took vanished. The
projection re-fold empties the log and replays it as the last step before
an encode, so a source missing there shipped a cyclic snapshot in a
release build. The relation also runs through the map half one hop at a
time, so the check reaches as deep as the nesting rather than one hop over
the first map — the only part of this change that alters an honest
document, recorded in ARCHITECTURE and DECISIONS.

The four units C24 filed take C40-C43, the ids C15 (#368) had already
merged having been taken.
@vieiralucas
vieiralucas force-pushed the core/c24-stamp-placement-collision branch from e417325 to db07f0f Compare July 29, 2026 07:24
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