Skip to content

fix: stop row-stamped peer drafts from laundering stale buffers into a valid save token (post-#73 multi-tab clobber) - #74

Merged
chasehuh merged 1 commit into
mainfrom
task/multitab-clobber
Aug 4, 2026
Merged

fix: stop row-stamped peer drafts from laundering stale buffers into a valid save token (post-#73 multi-tab clobber)#74
chasehuh merged 1 commit into
mainfrom
task/multitab-clobber

Conversation

@chasehuh

@chasehuh chasehuh commented Aug 4, 2026

Copy link
Copy Markdown
Member

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. But broadcastDraft still stamped outgoing drafts with the LIST ROW's updated_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+):

  1. Tab B saves S2 → server generation T2. Tab A still holds a clean buffer of S1@T1.
  2. The user switches to tab A and types before the focus-pull/poll adopts S2 (RTT window, or inside the 1.5s poll gap). A is now dirty on stale content, base pinned at T1 — 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. ✅
  3. A's poll/upsert refreshes A's list row to (S2, T2) while (correctly) refusing the body. ✅
  4. But A's keystrokes broadcast drafts stamped baseUpdatedAt = row.updated_at = T2stale content laundered with the current generation. ❌
  5. Tab B (clean at T2) passes isDraftBaseCurrent(T2, T2), adopts A's stale body into its editor, sets lastAcked/base to T2, shows "saved".
  6. The user's next keystroke in B PUTs the stale lineage with a valid token → server accepts → the newer tail of S2 is 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): broadcastDraft stamps the buffer's pinned base — baseUpdatedAtRef — plus baseFingerprint, a cheap content fingerprint (lib/body-fingerprint.ts, 2×FNV-1a) of the raw server body at that generation (new baseBodyRef, 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] accepts base_fingerprint; when present, updateNote 409s 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's expected_updated_at hardening); not racy despite the separate read because every body write bumps updated_at, so the atomic token gate in the UPDATE still binds.

Intentional flows are unaffected: Overwrite / Use server refresh 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 (broadcastDraft must stamp baseUpdatedAtRef, never existing?.updated_at; drafts must never advance baseBodyRef; PUT must carry base_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 build passes.

Rollout notes

  • Hard-refresh all open agentnote.dev tabs after deploy. Until then: old→old tabs behave as today; old→new laundered drafts are refused (fail-closed fingerprint), and a new tab that took a poisoned list row can only reach a visible 409 conflict, never a silent overwrite, thanks to the server guard.
  • No DB migration; base_fingerprint is request-payload only.
  • CRDT (NEXT_PUBLIC_AGENTNOTE_CRDT / collab URL) remains off in prod (no note_doc_snapshots for 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

  • Known fix: stop 409-rebased stale buffers from silently clobbering newer note bodies (0804 wipe RCA) #73 leftover unchanged (different flow): archive/delete broadcasts still replace a dirty buffer of the archived/deleted note.
  • During the mixed-bundle window, an old-bundle laundered draft can still poison a new tab's list-row preview (cosmetic); selecting that note and editing surfaces a 409 conflict banner instead of silently losing data.
  • Fingerprint is a 64-bit non-crypto hash — staleness detection, not integrity against adversaries.

🤖 Generated with Claude Code

…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>
@vercel

vercel Bot commented Aug 4, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
memo Ready Ready Preview Aug 4, 2026 3:10pm

Request Review

@chasehuh
chasehuh merged commit 57be44e into main Aug 4, 2026
2 checks passed
@chasehuh
chasehuh deleted the task/multitab-clobber branch August 4, 2026 15:12
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