Skip to content

Add the identity merge rules and note dispositions (CRDT step 6) - #137

Merged
pedersen merged 3 commits into
mainfrom
worktree-crdt-merge-rules
Sep 6, 2026
Merged

Add the identity merge rules and note dispositions (CRDT step 6)#137
pedersen merged 3 commits into
mainfrom
worktree-crdt-merge-rules

Conversation

@pedersen

@pedersen pedersen commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator

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:

  • 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. Latest wins.
  • Two live ULIDs claiming one path elect the lowest ULID. Deliberately
    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:

  1. B reads the map and sees a note unclaimed.
  2. C takes the seed and writes its row.
  3. B writes a newer row — a rename — still carrying the "unclaimed" it read.

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:

Disposition When Seeds?
mint No live row claims the path Yes — folder adoption is this, per file
adoptPending Row exists, seed claim held elsewhere Never — history-pending
adoptClaimable Row exists, no seed claim at all On the user's first edit
alreadyOurs Row exists, we hold the claim Nothing to do

adoptClaimable defers to an edit rather than acting on open, which is what
stops 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: contestedSeeds
names the winning claim per ULID, and mustRetractSeed tells a device whether
it lost. Both are tested from every side, including the winner and a device
that never claimed.

Same reasoning for retirement: retired names the ULIDs that lost a path
election, 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.dart at 100% line coverage; tree at 98.57%.

No user-facing change, so docs/manual-test-plan.md is untouched: no cases
added, changed, or invalidated.

🤖 Generated with Claude Code

pedersen and others added 3 commits September 5, 2026 17:03
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>
@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown

Coverage after merging worktree-crdt-merge-rules into main will be

98.47%

Coverage Report
FileStmtsBranchesFuncsLinesUncovered Lines
lib
   app.dart96.61%100%100%96.61%142–143
   startup_options.dart100%100%100%100%
   cli_output_io.dart0%100%100%0%10–11, 9
   cli_output_stub.dart0%100%100%0%5
lib/about
   about_screen.dart98.66%100%100%98.66%94–95
lib/commands
   app_commands.dart100%100%100%100%
   app_menu_bar.dart100%100%100%100%
   app_shortcuts.dart97.22%100%100%97.22%161
   text_editing_commands.dart100%100%100%100%
   pending_saves.dart100%100%100%100%
lib/engram
   engram.dart100%100%100%100%
   engram_repository.dart98.33%100%100%98.33%311, 49
   engram_scope.dart100%100%100%100%
   engram_startup_gate.dart100%100%100%100%
   repository_scope.dart100%100%100%100%
   asset_engram_store.dart98.15%100%100%98.15%92
   engram_store.dart100%100%100%100%
   built_in_engrams.dart100%100%100%100%
   desktop_folder_adoption.dart90%100%100%90%70
   engram_file_ops.dart100%100%100%100%
   metadata.dart100%100%100%100%
   id.dart100%100%100%100%
   container_resolver.dart100%100%100%100%
lib/engram/crdt
   app_data_source.dart100%100%100%100%
   app_data_resolver_io.dart100%100%100%100%
   app_data_resolver_stub.dart100%100%100%100%
   metadata_db_stub.dart100%100%100%100%
   store_exceptions.dart100%100%100%100%
   catalog.dart100%100%100%100%
   metadata_db_io.dart100%100%100%100%
   catalog_io.dart100%100%100%100%
   identity_map.dart100%100%100%100%
   identity_merge.dart100%100%100%100%
   note_document_io.dart100%100%100%100%
   identity_map_io.dart100%100%100%100%
   schema.dart100%100%100%100%
lib/engram/fs
   engram_location.dart100%100%100%100%
   fs_store_io.dart100%100%100%100%
   fs_store_stub.dart100%100%100%100%
lib/engram/ui
   engram_browser.dart98.06%100%100%98.06%195, 197, 199–200, 204–205, 399, 423, 917
   browser_preferences.dart92.31%100%100%92.31%26
   document_edit_controller.dart100%100%100%100%
   engram_switcher.dart93.81%100%100%93.81%102–105, 212, 216
   file_tree.dart100%100%100%100%
   file_tree_node.dart100%100%100%100%
   file_viewer.dart100%100%100%100%
   folder_picker.dart100%100%100%100%
   help_overlay.dart90.91%100%100%90.91%100–102, 72
   file_path_breadcrumb.dart100%100%100%100%
   markdown_editor_pane.dart100%100%100%100%
   markdown_reader.dart100%100%100%100%
   find_in_page.dart100%100%100%100%
   markdown_source_editor.dart100%100%100%100%
lib/settings
   app_settings_controller.dart100%100%100%100%
   settings_scope.dart75%100%100%75%45–46
   device_settings.dart100%100%100%100%
   settings_store.dart98.04%100%100%98.04%206
   settings_screen.dart100%100%100%100%
   engram_pane.dart100%100%100%100%
   housekeeping_pane.dart100%100%100%100%
   setting_control.dart76.47%100%100%76.47%100, 124, 86, 93
   settings_controls.dart100%100%100%100%
   settings_registry.dart85.09%100%100%85.09%215–216, 237, 246–247, 259, 31, 36–37, 41, 52–55, 64, 82–83
   settings_shell.dart99.44%100%100%99.44%244
lib/theme
   app_settings.dart100%100%100%100%
   app_theme.dart90.91%100%100%90.91%14
   design_language.dart100%100%100%100%
lib/widgets
   app_scaffold.dart100%100%100%100%
lib/window
   window_state_stub.dart66.67%100%100%66.67%13

@pedersen
pedersen merged commit a1f0346 into main Sep 6, 2026
2 checks passed
@pedersen
pedersen deleted the worktree-crdt-merge-rules branch September 6, 2026 00:57
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.

1 participant