Skip to content

fix: gate frontmatter Y.Map value writes on provider sync - #127

Draft
petergaultney wants to merge 1 commit into
No-Instructions:mainfrom
TrilliantHealth:fix/frontmatter-map-value-lww-race
Draft

fix: gate frontmatter Y.Map value writes on provider sync#127
petergaultney wants to merge 1 commit into
No-Instructions:mainfrom
TrilliantHealth:fix/frontmatter-map-value-lww-race

Conversation

@petergaultney

Copy link
Copy Markdown
Contributor

🍋:

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 a frontmatter Y.Map that mirrors it, used for structured lookups and repair. The map is last-writer-wins per key.

syncFrontmatterToMap already 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, and ymap.set fires 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, repairFrontmatterFromMap rewrites 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:

  1. Client A (live, connected) edits a frontmatter alias.
  2. Client B was offline; before reconnecting, it independently edits the same key locally (its own local text change, unrelated to A's edit - just enough to give it a fresh, later-looking Map op once it reconnects).
  3. Client B reconnects. Its stale value lands in the map before its text has reconciled.
  4. Client A's editor - and its file on disk - gets rewritten back to B's older value. No conflict, no banner, nothing in the UI to explain it.

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:

  • Skips writing a value that's already equal to what's stored (agreement should never mint a new op - this alone doesn't fix the race, but it means a converged state stays converged instead of drifting from repeated no-op writes).
  • Defers value writes while the provider is unsynced, tracked with one boolean. The genesis enrollment seed keeps its existing bypass (its text is the causal baseline; there's no peer state to race against yet).

seedFrontmatterMapFromCurrentText:

  • When called on a document whose map is already populated (i.e. not a genesis seed) and the provider is synced, drains any deferred value write. By this point in the PROVIDER_SYNCED transition, mergeRemoteToLocal has 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 from markProviderSynced) 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 syncFrontmatterToMap and seedFrontmatterMapFromCurrentText in MergeHSM.ts - no changes to the state chart, no new actions.

Testing

  • npx tsc --noEmit - clean
  • npx eslint src/merge-hsm/MergeHSM.ts - clean
  • npm run build - clean

Reproduced and validated with two real clients (not a unit test - I didn't see existing coverage of syncFrontmatterToMap to 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.

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.
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