fix: stop row-stamped peer drafts from laundering stale buffers into a valid save token (post-#73 multi-tab clobber) - #74
Merged
Conversation
…a valid save token Post-#73, multi-tab clobber survived through BroadcastChannel drafts: broadcastDraft stamped outgoing drafts with the LIST ROW's updated_at, which a poll/upsert advances past a stale dirty buffer (exactly the state #57/#73 enforce). A clean peer at that generation passed isDraftBaseCurrent, adopted the stale body as 'saved', took the current token, and its next keystroke PUT the stale lineage with a valid token — silent overwrite, no 409 anywhere on the winning path (0804.md rev 556 divergence). - broadcastDraft now stamps the buffer's pinned base (baseUpdatedAtRef) plus a fingerprint of the base body; never the list row. - Receivers refuse editor apply unless the sender proves matching base CONTENT (fail closed on missing fingerprint, so old bundles cannot keep laundering after this ships). - New baseBodyRef tracks the raw server body at the base generation, advancing only alongside the base token — never from unacked peer text. - PUT carries base_fingerprint; the server 409s a valid-token write whose base content is not the current body (guards against paths client-side gates miss and against victims of still-open pre-fix bundles). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
This was referenced Aug 5, 2026
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.
RCA — why #73 was not enough under multiple tabs
#73 correctly pinned the PUT token to the buffer's own base generation (
baseUpdatedAtRef) and stopped the 409-rebase. ButbroadcastDraftstill stamped outgoing drafts with the LIST ROW'supdated_at(existing?.updated_at), and the list row is exactly the thing #51/#57/#73 let advance past a dirty buffer (poll/upsert refreshes the row while refusing the body).The surviving clobber path, with the operator's default setup (N tabs on agentnote.dev, same note open in 2+):
S2→ server generationT2. Tab A still holds a clean buffer ofS1@T1.S2(RTT window, or inside the 1.5s poll gap). A is now dirty on stale content, base pinned atT1— so far exactly the state fix: stop 409-rebased stale buffers from silently clobbering newer note bodies (0804 wipe RCA) #73 designed for: A's own PUT would 409. ✅(S2, T2)while (correctly) refusing the body. ✅baseUpdatedAt = row.updated_at = T2— stale content laundered with the current generation. ❌T2) passesisDraftBaseCurrent(T2, T2), adopts A's stale body into its editor, setslastAcked/base toT2, shows "saved".S2is silently gone. No 409 anywhere on the winning path (A's own save 409s separately, but B has already carried the stale lineage forward).This matches the fresh DB evidence for
dsb-wbhi-aqa(0804.md): rev 556 (len 1680, ~6 min after the #73 deploy) diverges from the current body at ~offset 1463 — a stale lineage based on the ~1463-char generation won with a valid token and then grew normally (revs 557/558 are prefixes of current, len 1900). The timing also implicates pre-#73 bundles still open in other tabs, which is why this fix includes a server-side guard rather than client changes only.Fix
Client (sender):
broadcastDraftstamps the buffer's pinned base —baseUpdatedAtRef— plusbaseFingerprint, a cheap content fingerprint (lib/body-fingerprint.ts, 2×FNV-1a) of the raw server body at that generation (newbaseBodyRef, which advances only alongside the base token: open/adopt/ack/conflict-resolve — never from unacked peer drafts).Client (receiver): draft editor-apply now requires
isDraftBaseContentCurrent— the sender must prove its base content matches the receiver's, failing closed when the fingerprint is missing. Old bundles that still stamp row tokens can no longer walk stale text into a clean peer.Server:
PUT /api/notes/[id]acceptsbase_fingerprint; when present,updateNote409s if it doesn't match the current body — a valid-token write must also prove it is based on the body it is replacing. This catches any laundering path the client gates miss, including new-bundle tabs that adopted stale drafts from still-open old-bundle tabs during the rollout window. Optional for pre-fingerprint bundles (same rollout posture as #73'sexpected_updated_athardening); not racy despite the separate read because every body write bumpsupdated_at, so the atomic token gate in the UPDATE still binds.Intentional flows are unaffected:
Overwrite/Use serverrefresh both the token and the base body before re-PUT, so explicit conflict resolution passes the fingerprint guard.Tests (all 239 green)
lib/remote-apply-guard.test.ts: sequence test walking the exact incident — shows the regressed row-stamped draft passes the generation gate (the hole), and that buffer-stamped generation + content fingerprint close it; plus source-pattern regression guards (broadcastDraftmust stampbaseUpdatedAtRef, neverexisting?.updated_at; drafts must never advancebaseBodyRef; PUT must carrybase_fingerprint).lib/notes-base-fingerprint.test.ts: server guard 409s a valid-token PUT with mismatched base content (incident shape), passes on match, and skips cleanly for legacy payloads.lib/body-fingerprint.test.ts: determinism, tail-divergence sensitivity (1463/1680 incident shape), non-ASCII.Lint: identical error/warning count to
main(all pre-existing).pnpm buildpasses.Rollout notes
base_fingerprintis request-payload only.NEXT_PUBLIC_AGENTNOTE_CRDT/ collab URL) remains off in prod (nonote_doc_snapshotsfor this note; Vercel env not readable from this environment — worth confirming in the dashboard). Enabling CRDT for personal multi-tab is still the long-term BP and is unblocked/unchanged by this PR.Residual risks
🤖 Generated with Claude Code