Add the identity merge rules and note dispositions (CRDT step 6) - #137
Merged
Conversation
Resolves the union of every device's map rows into one view, and answers what this device should do about a note as a result. Pure arithmetic over rows: the reader deliberately returns contradictions unresolved, so resolving them happens once, here, where it needs no filesystem to test. Two rules answering different questions, kept visibly apart because the temptation is to collapse them. Contradictions about one ULID resolve by the locked tiebreak comparator — OperationId.compareTo, reused rather than reimplemented, so the map cannot drift from the op-log. Two live ULIDs claiming one path elect the lowest ULID instead, because that is an election between distinct identities rather than a last-writer-wins over one value, and the answer that should survive is the earliest mint rather than the latest claim. The seed is resolved separately from the row that carries it. A device can record the newest row about a note — noticing a rename, most often — without that making it the seeder, so carrying the winning row's claim forward blindly would drop a claim that is still live and invite a second seed under a ULID that already has a history. The claims are collected across every row for a ULID and the contest settled on its own terms. Deleted rows own no path. That is what stops a path freed by a delete and reused later from adopting the dead note's ULID and resurrecting its history under unrelated content — while the ULID itself stays resolvable, because identity outlives the file and a peer's operations arrive keyed by it. The dispositions name the four cases a device can be in, and separate the two things "adopt" means: adopting a folder is minting applied to every file in it, because a folder with no marker has no map to adopt from; adopting a ULID never seeds on sight. An unclaimed seed is claimable on the user's first edit rather than on open, which is what stops two devices merely opening an engram offline from provoking a race over a seed neither is using. Deferred to the steps that own them: retracting a lost seed's elements and reconciling the loser's file are op-log surgery and drift, so they belong with the materializer and the scan. What lands here is the decision — who won, and who must retract — which is what those steps will ask for. No user-facing change; docs/manual-test-plan.md is untouched, with no cases added, changed, or invalidated. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The comment justifying it described a device recording a rename and thereby dropping the seeder's claim. That case does not arise: a writer writes whole rows having read the directory first, so the renaming device already carries the claim forward and the row that wins rule 1 holds the right one. The test beside it modelled a row no correct writer would produce. What the union defends is that the read and the write are not one atomic step, and no lock exists across machines that may never be online together. A device can read a note as unclaimed, another can take the seed, and the first can then write a newer row still carrying the "unclaimed" it read. Taking the winning row's claim verbatim would republish a seeded note as unclaimed and invite a third device to seed a ULID that already has a history — reached without anyone breaking the whole-row rule. Keeping a claim that turns out to be stale costs nothing; dropping a live one costs duplicated content, so the union resolves in the safe direction. Replaces the test with that sequence, and adds the consequence that makes it worth defending: with the claim recovered, a reader's disposition is adopt-without-seeding rather than the claimable one that would produce the second seed. Also asserts a single claim plus a stale read is not recorded as a contested seed, since nothing about it needs retracting. Behaviour is unchanged; the code already resolved this correctly. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The enum's doc said adopting a ULID was "the other three", which asked the reader to scroll past the sentence, count the remaining values, and infer which were meant. It was also wrong on two counts: alreadyOurs is not an adoption at all — the note is already this device's own — and a fifth disposition would have made the sentence false with nothing to catch it. Names each value with a doc link instead, so the reference survives a rename and resolves from the generated API docs, and files alreadyOurs where it belongs. Comments only; no behaviour change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Coverage after merging worktree-crdt-merge-rules into main will be
Coverage Report |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Resolves the union of every device's map rows into one view, and answers what
this device should do about a note as a result. Pure arithmetic over rows —
step 5's reader deliberately returns contradictions unresolved, so resolving
them happens once, here, where it needs no filesystem to test.
Two rules, kept visibly apart
The temptation is to collapse them, and they answer different questions:
—
OperationId.compareTo, reused rather than reimplemented, so the mapcannot drift from the op-log. Latest wins.
not the comparator: this is an election between distinct identities rather
than a last-writer-wins over one value, so the earliest mint survives rather
than the latest claim. A test pins that a much newer stamp on the loser
changes nothing.
The seed claim is unioned across rows, not taken from the winner
This is the part I'd most like a second pair of eyes on, and the reason is
narrower than it first looks.
In the ordinary case the union is redundant. A writer writes whole rows having
read the directory first, so a device recording only a rename already carries
the seeder's claim forward, and the row that wins rule 1 holds the right one.
Worth stating plainly so nobody removes the union as dead weight.
What it defends is that the read and the write are not one atomic step, and no
lock exists across machines that may never be online together:
B's row wins rule 1. Taking its claim verbatim would republish a seeded note as
unclaimed and invite a fourth device to seed a ULID that already has C's
history — the duplication hazard, reached without anyone breaking the whole-row
rule.
The asymmetry decides the direction: keeping a claim that turns out to be stale
costs nothing, dropping a live one costs duplicated content.
Deleted rows own no path
That's what stops a path freed by a delete and reused later from adopting the
dead note's ULID and resurrecting its history under unrelated content. The
ULID itself stays resolvable — identity outlives the file, and a peer's
operations arrive keyed by it long after the local scan concluded it was gone.
Dispositions
Four cases, and they make the two senses of "adopt" structural rather than a
matter of remembering:
mintadoptPendingadoptClaimablealreadyOursadoptClaimabledefers to an edit rather than acting on open, which is whatstops two devices merely opening an engram offline from provoking a race over
a seed neither is using. The race stays handled if it happens anyway.
What's deferred, and why
The plan's "a contested seed heals" test has three parts: the comparator picks
one, the loser retracts its seeded elements, and the resulting content matches
the file on disk exactly once. Only the first is in this PR. Retracting
elements is op-log surgery and matching the file needs the materializer, so
those belong to steps 8 and 10 — the design's own wording routes the loser's
repair through "reconciles its file against the winner's materialized content
as ordinary drift (Decision 6)."
What lands here is the decision those steps will ask for:
contestedSeedsnames the winning claim per ULID, and
mustRetractSeedtells a device whetherit lost. Both are tested from every side, including the winner and a device
that never claimed.
Same reasoning for retirement:
retirednames the ULIDs that lost a pathelection, and the doc comment records why the loser retires rather than
re-keys — re-pointing a document at the winner's id puts two independently
seeded element universes under one id, which is the duplication the frozen
suite pins.
Tests
30 tests covering the plan's five named cases: a cold copy adopts rather than
mints; deleting the map turns adoption back into minting (content unchanged,
history lost — Decision 9's degraded path); a non-minting device's rename
propagates; a freed path does not resurrect a dead note; a contested seed is
decided. Plus order-independence of the merge, since every reader must reach
the same answer from the same files and directory listing order is not a
guarantee anyone offers.
identity_merge.dartat 100% line coverage; tree at 98.57%.No user-facing change, so
docs/manual-test-plan.mdis untouched: no casesadded, changed, or invalidated.
🤖 Generated with Claude Code