fix: gate frontmatter Y.Map value writes on provider sync - #127
Draft
petergaultney wants to merge 1 commit into
Draft
fix: gate frontmatter Y.Map value writes on provider sync#127petergaultney wants to merge 1 commit into
petergaultney wants to merge 1 commit into
Conversation
The frontmatter Y.Map stores values with last-writer-wins semantics per key. A value written from not-yet-synced local text is a fresh op that races every peer's newer write, with an arbitrary winner - key pruning and baseline seeding already waited for provider sync before touching the map (see the prune-gate comment in syncFrontmatterToMap), but ongoing value writes did not. In practice: a client returning from being offline (or otherwise not yet caught up with the server) can push its stale frontmatter values into the Y.Map before its local text has reconciled with the merged server state. If the LWW race lands in its favor, repairFrontmatter then rewrites every other client's text from that stale value - silently, with no conflict banner on the clients that lose data. Reproduced with two clients: a live client's just-edited alias was reverted to its pre-edit value by a third client reconnecting after being offline, with no indication anything had happened on either machine that received the reverted value. - syncFrontmatterToMap: skip writing a value that already matches (agreement should never mint a new LWW op); defer value writes while the provider is unsynced. The genesis enrollment seed keeps its existing bypass, since its text is the causal baseline and there are no peers yet to race. - seedFrontmatterMapFromCurrentText: drain any deferred value writes once the map already has entries and the provider is synced - by this point mergeRemoteToLocal has already run in the same action list (see the PROVIDER_SYNCED transition), so the local text is reconciled merged truth rather than pre-merge state. Validated with a two-client reproduction: unpatched, a live client's edit was silently reverted through several oscillations as a stale client reconnected repeatedly; patched, the returning client's stale write is suppressed until its text is reconciled, and the map converges to the same value as the text everywhere.
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.
🍋:
What happens
A returning client (offline, or otherwise behind on the server's merged state) can silently revert a frontmatter value that another client edited while it was gone - not just on itself, but on every client, including the one that made the edit. No conflict banner appears anywhere; the value just changes back.
Why
Frontmatter values live in two places: the YAML text inside
contents(a Y.Text, sequence-merged), and afrontmatterY.Map that mirrors it, used for structured lookups and repair. The map is last-writer-wins per key.syncFrontmatterToMapalready gates the map's baseline seeding and key pruning on provider sync - there's a comment explaining why ("a delete issued from stale text would destroy them for everyone"). But the ongoing value writes in the same function had no such gate, andymap.setfires even when the value hasn't changed.A client that hasn't yet reconciled its local text with the server (typically: just reconnected after being offline) can therefore write its stale value into the map as a brand-new LWW op, before its own text has merged. If that op's timestamp wins the race against a live client's more recent write,
repairFrontmatterFromMaprewrites every client's text back to the stale value - the live client that made the edit loses it with no indication anything happened.Reproduced with two clients:
Repeating steps 1-3 a few times produces an oscillation: the value flips between the two states as each client's write races the other's.
The change
syncFrontmatterToMap:seedFrontmatterMapFromCurrentText:PROVIDER_SYNCEDtransition,mergeRemoteToLocalhas already run in the same action list, so the local text reflects the reconciled merge rather than pre-merge state. Draining any earlier than that (I first tried draining directly frommarkProviderSynced) re-introduces the exact stale value the gate is meant to suppress, because the text hasn't merged yet at that point.The diff is scoped to
syncFrontmatterToMapandseedFrontmatterMapFromCurrentTextinMergeHSM.ts- no changes to the state chart, no new actions.Testing
npx tsc --noEmit- cleannpx eslint src/merge-hsm/MergeHSM.ts- cleannpm run build- cleanReproduced and validated with two real clients (not a unit test - I didn't see existing coverage of
syncFrontmatterToMapto extend, happy to add something if there's a preferred harness): unpatched, a live client's edit was reverted through several oscillations as a stale client reconnected repeatedly, with no conflict UI on the client that lost the value. Patched, the returning client's stale write is suppressed until its own text has reconciled with the merge, and the map converges to the same value as the text on every client.The design intent here is that a genuine concurrent edit should surface as a held conflict for the user to resolve, not get silently auto-resolved by an arbitrary LWW race that can revert someone else's work without telling them. This change doesn't add a conflict path - it just stops the map from being a second, less-supervised place where that silent auto-resolution can happen alongside (or after) the properly-gated text merge.