Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,8 @@ The fold is a pure function of the move-set + tombstone-set, **independent of th

**Fugue** (Weidner & Kleppmann 2023, "The Art of the Fugue"). Tree-based, formally proven no-interleaving on concurrent inserts at the same point. Same algorithm reused for Text.

**A sequence id is owned by the kind tag, then the position, then the encoded value.** Two ops can carry one `Stamp` into one sequence and both be admissible — op dedup is by `OpId`, and the id-space record bounds only an *honest* mint — so which of them the id ends at is decided by that rank, never by which arrived first, and the winner takes the id with its value **and** its anchor. Every seat path runs the rank, each reading as much of it as is still open to that seam — and a claim owns the key it named and not the id, so a seam that read none of it would keep arrival-order ownership there alone, which is how this class hides. A claim nothing has ranked reads the whole order: a plain `ListInsert`, a `Text` run's codepoints, a children-list birth whose placement key was free, and a sequence merge against a live node. A claim the document has *already* ranked — an eviction or a join at the `(list, stamp)` key — has its composite half answered and reads only the leading tag here, which is the half no placement key covers. A claim meeting a **tombstone** can read only the position, since a delete leaves nothing else. The **tag leads** because it is the one key a scalar and a composite both have, and it is read as the number it is rather than as a preference for either: a numeric order stays total when a kind is added, where a semantic one re-opens the question on every new op. The **position outranks the value** because it is the only part of a claim a delete leaves behind — a tombstone drops the value and keeps the anchor, so a claim landing on a tombstoned id is ranked on the position alone, and where the two claims are of one class that settles the whole difference, since the position is also all a tombstone encodes. Two *composites* are ranked one layer up instead: the document's `(list, stamp)` rank (§Tree Moves) decides them before the sequence is touched, which is why the tag decides across the scalar/composite boundary and never inside it. That layering is also its one open edge, and a tombstone is where it shows: a delete leaves no kind to read, so a scalar and a composite claim that one lands between are still ordered by arrival, and a sequence *merge* — whose tombstone arm can rank nothing at all — loses associativity on the same shape (C133).

## LWW

Used by Register values, Map scalar set, XmlElement attr values, mark values of `kind: value`. Resolution: higher lamport wins, tiebreak by client_id.
Expand Down
16 changes: 16 additions & 0 deletions DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,22 @@ The entries below (2026-07-02) are a backfill: design changes made during the v0
**The one element the pass found chosen rather than forced, named rather than smuggled:** the JS gate's *fold-outcome* check (`lastApplied`). The carrier is correct without it — the frontier carries tag 54 and never matches the catch-up allowlist — and the bug it closes (a catch-up reply that names the right tag and channel and then fails to decode, opening the socket against an empty replica) pre-dates this unit and is reachable without any frontier. It ships here because the gate it belongs to is being rewritten in this diff and leaving a known hole in a predicate one is authoring is worse for the next reader than a three-line fix with a test; the boundary is recorded so that judgement is visible rather than absorbed. Nothing else survived the question.

**Mutation-swept under statement deletion, re-run against the shipped code — and the re-run is the reason four more tests exist.** Seventeen branches, each deleted in turn: the frame never sent → 9 named tests; sent on every catch-up → 6; naming every author rather than the recipient → 6; the connection id in place of `for_channel` → the second-channel test here and in C9's suite; the frame trailing the delta → 1; a client-sent frame accepted → 1; the server always sending `reach: 0` → 1; the client ignoring the frame → 6; the reservations folded into `seen` → 3; `free_seq` not consulting them → 16; its walk bound excluding them → 12; `note_published` ignoring `reach` → 3; the decoder's reserved-over-buffered check → 1. **Four survived the first pass and none of them was redundant code** — every one was a hole in the suite, and the encoding is why: a reservation is written as a bare *sequence*, correct only while every entry belongs to the document's own client. `apply`'s clear (an id in both sets is a snapshot the replica cannot read back), `adopt_as`'s clear (inert immediately after, real on the next round trip, where the sequences are re-read under the adopter's identity — the existing test looked only immediately after and was vacuous), the decoder's reserved-over-applied check, and both projections' clear. The last of those exposed a second vacuous test: reading a projected snapshot back through `decode_state_as` proves nothing about the projection, because `adopt_as` clears reservations as it takes the snapshot over and masks the rule entirely — so the assertion moved onto the projected *bytes*. With those four pinned the sweep leaves no survivor, and the unmutated tree fails 0. → *Server / Fan-out*.
## 2026-08-09 · C40 sequence stamp collision · one rank owns a sequence id, and the position outranks the value

**Changed:** ARCHITECTURE §List gains the rank a sequence id is owned by — the kind tag, then the position, then the encoded value — the list of seat paths that run it, and the one edge it leaves open (C133). No wire or snapshot format change: the rank is computed from what a node already carries.

**The gap, measured.** `List::insert_at` was idempotent on the id. Two `ListInsert` ops under one `ClientId` at the identical `Stamp` are both admissible — dedup is on `OpId`, and the id-space record bounds only an *honest* mint — so the first to land kept the id with its value and its Fugue anchor, and the two delivery orders encoded different snapshots (the node carried `2` on one and `99` on the other). C24 (#371) closed the same collision one layer up, at the `(list, stamp)` placement key; it does not reach here, because a scalar has no element id to rank by and a plain `ListInsert` never passes through the placement index at all.

**The tag leads, and it is read as a number.** A scalar and a composite have exactly one key in common, and ordering them by it is what makes the mixed case fall out of the same comparator instead of being a rule of its own. The alternative — a semantic "a composite outranks a scalar" — is rejected: it is a preference, so it has to be re-decided every time the op set grows a kind, where the numeric order is total on its own and extends mechanically. Two *composites* are not ranked here at all: the document ranks them at the placement key first (a birth over a move, then the smaller element id) and hands the sequence its verdict, which is why the tag decides across the scalar/composite boundary and never inside it — inside it, tagged and tagless children carry *different* tags, and letting the tag speak there would have silently reversed C24 for the pair it was built for.

**The position outranks the value, and that ordering is forced by a measurement rather than chosen.** A delete drops a node's value and keeps its anchor — §Tombstone GC, a tombstone holds a position and nothing else. So the anchor is the only key of the rank that survives a delete, and it is also the only thing a tombstone encodes. Read the value first and a contest a delete lands in the middle of is decided by *which claim the delete buried*: `[insert 2, delete, insert 99]` freezes one anchor and `[insert 99, delete, insert 2]` the other, and the two encode different bytes while rendering the same sequence. Read the position first and the winner is the claim with the smaller anchor whether or not its value is still there to read, so all four orders of `[insert, insert, delete]` agree. The value stays in the rank below it, so two claims at one position still separate — and where a claim ties outright, nothing is contested and the id is left alone, which is what keeps a replay inert.

**The fix is that every seat path runs it, each reading as much of the order as is still open to that seam — the half the filing said would hide.** A claim owns the key it named, not the id. `Claim::Fresh` therefore found `insert_at` inert against a scalar already sitting at the id, while an eviction or a join re-seated over it — so `[ListInsert, tagged, tagless]` replaced the scalar and `[ListInsert, tagless, tagged]` kept it, one op set folding to two trees. Stating the rule in terms of the *key* means five paths take it, and what each reads is worth being exact about, because they do not all read the same thing. The plain `ListInsert`, every codepoint of a `Text` run, a children-list birth whose key was free, and `List::merge` against a live node read the **whole** order. The document-ranked eviction and join read only the **leading tag**: their composite half was answered at the placement key, and what is left is the scalar a placement key never ranks them against. A claim meeting a **tombstone** reads only the **position**, because a delete leaves nothing else — which is the whole of the edge below. A seam that read none of it would have kept arrival-order ownership in that seam alone, which is exactly how this class hides.

**`List::merge` was the fourth route and had been answering by which side received; it is fixed for commutativity and left short of associativity.** Two replicas that each folded one of two colliding claims used to converge on whichever list was the receiver — measured non-commutative on 2638 of 3000 random collision pools, and commutative on all 3000 after. Associativity is a strictly narrower property here and it does not survive: a merge whose receiver holds a *tombstone* at the contested id can rank nothing (the peer's live node is dropped and the run keeps the receiver's own anchor), so `A·(B·C)` and `(A·B)·C` can bury the id at different positions — 104 of 3000 pools, and none without a delete. It is the same root cause as the edge below reached by a second route, and it is filed with it rather than claimed closed.

**What is left open, and why it is a separate ruling.** A tombstone holds no kind to read, so a *mixed* pair that a delete lands between is ranked on the anchor alone and lands somewhere the tag would not have put it: the two delete-between orders agree with each other, the two delete-last orders agree with each other, and the classes differ. Measured, and filed as C133 together with the merge associativity loss above, which is the same cause on a seam that can rank even less. This unit narrows it — on `main` the same three ops split on *which claim arrived first* — but does not close it, because closing it needs the winning rank to outlive the delete, and a sequence has nowhere to put one: a tombstone is a run record covering any number of ids, so a rank per dead id is O(deletions) in memory and on the wire and defeats the compression §Tombstone GC exists for. The composite half is total across a delete only because the *document* persists that rank, which is a record the scalar half does not have and cannot be given without deciding what a sequence may remember about a dead id.


## 2026-08-09 · C53 branch catch-up floor · a shared base is only as durable as `main`'s retained log, and a catch-up that cannot serve it refuses rather than serving what is left

Expand Down
Loading
Loading