Tool Data Sync

Account-linked storage for the tools' own saved work. What you save in them — a favourited video, a practice round, a scouted opponent, a judge's ballot history, a scored card, a flow annotation, a prep note, a coaching program — is kept on your account and follows you to another device, instead of living in one browser's localStorage and vanishing the moment you open the app somewhere else.

It started with the Coaching and Practice tools and now covers the whole sidebar: the research and team stores, and the video library's favourites and hidden list. Two things changed to make that breadth practical, and they are what most of this note is about — the sync no longer needs each tool's package to be wired for it, and a signed-out user is now told their work is browser-only at the moment they save it.

This closes the "per-browser localStorage, not a shared team resource / not account-synced" Known gap that Judge Profiles, Opponent Team Profiles, Flow Annotations, Prep Notes, Coaching Programs, Coach Materials' conversation history, Pre-Round Briefings, Practice Round Simulator, Flow Summaries, Judge Paradigm Selections and the Argument Tree Outline each carried, and extends the pattern flows, rounds, word-count rounds, drill sets and standings already had.

  • Route: none of its own, and no UI of its own either. The sync runs app-wide, in the background. /settings → Account → Tool data used to list every collection's state and offer a Sync now button; that page is the card editor's settings now (see User Settings), so that list and button moved to /tools instead (ToolSyncStatusPanel, see Known gaps) — /settings links to /tools for anyone looking for them there.
  • Package: debate-data-sync (the catalog, the fetch client, the mirror), apps/debate-ai.com (/api/tool-records/[collection], the saved_tool_records D1 table, lib/hooks/useToolRecordSync.ts, lib/sign-in-prompt-preference.ts, lib/sign-in-prompt-cooldown.ts, components/layout/ToolRecordSyncProvider.tsx, components/layout/SignInPromptProvider.tsx). Some tool packages (debate-round, debate-practice-rounds, debate-speech-writer, debate-team-collaboration, debate-videos) additionally call the mirror from their own stores; most do not need to — see Syncing without wiring.

Which tools sync

One entry per localStorage store, from debate-data-sync/src/state/toolRecordCollections.ts's TOOL_RECORD_COLLECTIONS — the list itself is the source of truth, and it is long enough now that the table below groups it rather than restating every row. Each entry's section field (one of TOOL_RECORD_SECTIONS) names the same group, which is what the table below is ordered by. It was the heading /settings' sync-status list rendered, and outlives that list as the catalog's own grouping.

Videos (/videos) — debateVideosFavorites and debateVideosHidden, both keyed by videoId, debateVideoReports keyed by id, and the watch page's cached AI outcome-simulation runs (speechOutcomeRuns) keyed by a derived ${videoId}::${speechKey}::${lens} id.

Practice — the Practice Round Simulator (practiceRounds), Pre-Round Briefings (preRoundBriefings) and Debate Versus AI (aiVersusRounds), each keyed by roundId; your own round history (ownRoundHistory) by id.

Scouting and judging — Opponent Team Profiles (opponentTeamProfiles, by teamId) and the scouted rounds they aggregate (opponentRoundRecords, by id); Judge Profiles (judgeProfiles, by judgeId) and the ballots they aggregate (judgeRoundRecords, by id); the Judge Paradigm Picker (judgeParadigmSelections, by roundId) and vulnerability reports (vulnerabilityReports, by roundId); the Opponent Persona Picker (opponentPersonaSelections, by sessionId).

Flowing and writing — Speech Summaries (flowSummaries), the Argument Tree Outline (argumentTrees) and its per-round filter selection (argumentTreeFilters), by roundId; Prep Notes (prepNotes), their replies (prepNoteReplies) and assignment notifications (prepNoteNotifications), Flow Annotations (flowAnnotations), the CardMirror editor's personal spellcheck dictionary (spellcheckDictionary), the Debate Flow workspace's auto-saved undo/version history (flowHistory), and the Debate Docs workspace's open chat tabs (docsChatTabs, the tab layout and titles only — the chat content itself is the third-party research-agent-ui package's own backend), and the same workspace's configured storage backends (fileSources — which SSH, S3, R2, B2, Google Docs or Turso sources are set up, but not what would let another device connect as this one: see Redacted fields below), all by id.

Coaching — AI Coach Mode's history (coachConversation), Coaching Programs (coachingPrograms), the Coach Workspace's current coaching session (coachingSessions) and its session history (coachingSessionHistory), and the Flow Edit Log (flowEdits), all by id.

Research — the Evidence Library (evidenceLibraryEntries), card scores (cardScores) and their history (cardScoreHistory), the Contributions Feed (contributions), the Argument Library (trackedArguments), card revision history (revisionHistory), reuse checks (reuseCheckHistory) and Topic Coverage snapshots (topicCoverageSnapshots), all by id; the Review Queue (peerReviews) and LLM Card Scoring's AI assessments (aiCardAssessments), both by cardId.

Team — Team Brainstorm Assist (brainstormIdeas), the Prep Room checklist (prepRoomChecklist), notes (sprintNotes) and whiteboard (sprintWhiteboardNotes), Group Challenges (groupChallenges) and their recorded win events (challengeWinEvents, by a derived id — see below), Daily Quests (dailyQuestTemplates) and their quest-competition teams (questTeams), Quest Streaks' day-by-day mission-result history (dailyMissionResults, by a derived ${contributorId}::${dayKey} id — see below; Quest Streaks covers the separate, signed-in-visitor-only sync for its freeze/reminder preferences), award nominations (contributorAwardNominations), and Progress Unlocks' celebration-toast "last-seen badges" baseline (unlockCelebrations, by the contributor's id), all by id; frozen Daily Best Card winners (dailyBestCardAnnouncements) and Top Contributor Award standings (contributorAwardAnnouncements), both by dayKey; the Task Inbox (routedTaskQueues) by topicId and its pending verification queue (pendingTaskVerifications) by a derived id; contributor flows (roundContributorFlows) and availability (contributorAvailability) by contributorId; completed research-task history (completedResearchTasks) by id.

Practice Drills (saved_drill_sets), Word-Count Speeches (saved_word_count_rounds), Round Pairings (saved_round_pairings), Scout-to-Strategy (saved_strategy_recommendations), the AI Judge Decision (saved_judge_decisions), the Response-Outcome Charts (saved_counsel_panel_assessments), the Opponent Persona Picker's custom library (saved_custom_opponent_personas), Coach Materials and their versions (saved_coach_materials / saved_coach_material_versions), the Prep Room's sprints (saved_sprint_sessions) and daily best-card comments (saved_daily_best_card_comments) all sync too, but each through its own dedicated table and route rather than this one — see the next section.

TOOL_RECORD_COLLECTIONS is an allowlist, not a convenience: the route rejects any collection key not in it, so the table can't be used as a free-form per-user blob store by a caller inventing names.

What deliberately does not sync

The requirement is a shape, not a preference: a JSON array under one localStorage key whose elements are objects carrying a stable string id. So these stay local on purpose —

  • Single-object settings stores (myTeamProfile, fontFamily, the ebb keymap and display settings). There is no per-record id to key them by, and they are mostly per-device choices anyway.
  • Presence heartbeats (flowPresenceHeartbeats, topicPresenceHeartbeats) and per-device playback state (persistent-video-player, debate-playback-speed, video-timestamps). These describe this browser, not this user; syncing them would have one device overwrite another's idea of where the video is paused.
  • Undo/redo stacks (judgeRoundRecordEditHistory and friends). What syncs is the current version of a record.
  • coachingSessions, the Coach Workspace's current session per round and side, kept only by (roundId, sideKey) with no single id field to key a row by. Giving the record its own id is what it would take to sync directly. Fixed: saveCoachingSession now stamps every record with a derived ${roundId}::${sideKey} id, so the current session per round+side syncs alongside its own version history (coachingSessionHistory).
  • dailyMissionResults, Quest Streaks's day-by-day contributor mission-result history, kept only by (contributorId, dayKey) with no single id field to key a row by — the same shape problem coachingSessions had. Fixed: saveDailyMissionResult now stamps every record with a derived ${contributorId}::${dayKey} id, so a contributor's streak/badge history follows them to another device.
  • unlockCelebrations, Progress Unlocks' per-contributor "last-seen badges" celebration-toast baseline, kept as a plain Record<contributorId, string[]> map rather than an array of records — no per-record id to key a row by, the same shape gap every collection here started with. Fixed: the store now persists an array of { id, badges } records (id the contributor's id, migrating any pre-existing legacy map on read), so a badge already celebrated on one device isn't celebrated again as "new" on a second one.

Redacted fields

One collection can't join with a plain catalog entry the way the rest do: fileSources (/doc's file browser, REASON-file-sources) has the required shape, but its records can carry an SSH password or private key, an S3/R2/B2 secret access key, or a Google OAuth refresh token (fileSource-types.ts's SSHCredentials/S3Credentials/R2Credentials/ B2Credentials/GoogleDocsCredentials) — and the route stores whatever JSON a collection hands it verbatim, with no redaction of its own. A plain entry would put those secrets into saved_tool_records unencrypted.

So ToolRecordCollection carries one more, optional field: redact, a pure (record) => record applied at every push (the immediate mirror, the auto-sync watcher, and the first-sign-in push of local-only records) and never to what a tool reads back out of its own localStorage. fileSources' is redact-file-source.ts's redactFileSource — an allowlist per source type (host/port/username for SSH, region/bucket for S3, …), not a blocklist of secret-looking names, so a credential field added to fileSource-types.ts later defaults to held back rather than defaulting to synced. A source's connection details follow you to another device; what would let that device connect as you does not, and has to be re-entered there.

Redacting on the way up only works if the merge, on the way back down, doesn't read the account's now-missing field as the account clearing it — mergeToolRecords' restoreRedactedFields fills back whatever the local copy has that the remote copy doesn't, recursing into credentials itself, so the password stays on the device that saved it instead of being wiped out by the record's own next reconcile.

Every other collection has no redact, and none needs one: this is a fix for the one collection whose shape happens to also carry a credential, not a step every collection has to take.

Tools already backed by a saved_* table of their own — flows, rounds, tournament results, drill sets, word-count rounds, round pairings, scout-to-strategy, AI judge decisions, response-outcome charts, custom opponent personas, coach materials and their versions, prep-room sprints, and daily best-card comments — keep those. This table is for the stores that had nothing. (Eleven of those once had a redundant entry here too — reconciled through both their own table and this one, with nothing ever reading the second copy back. Removed rather than fixed forward, since nothing pointed at the generic copy in the first place.)

Local-first, by design

Each tool still reads and writes its own localStorage store directly and stays fully usable signed out. The account is a mirror kept up behind it, not a replacement for it — the same rule every other synced store in this repo follows ("local apply is never blocked by a sync failure"):

  • A local save applies immediately; the upload is fire-and-forget.
  • A failed upload is swallowed, never surfaced as a broken save.
  • A 401 (a session that expired mid-visit) switches mirroring off rather than retrying every subsequent write against an endpoint that will refuse.

Data flow

state/toolRecordCollections.ts  (pure — no fetch, no localStorage)
  → TOOL_RECORD_COLLECTIONS          the allowlist: key, storageKey, idField,
                                       label, href
  → toolRecordId / isSyncableToolRecord
                                     what a syncable record is (an object with
                                       a non-empty string at `idField`) —
                                       shared by the route and the client
  → mergeToolRecords                 local ∪ remote, account wins per id
  → toolRecordsMissingRemotely       the local-only records a sign-in pushes up

state/tool-records-client.ts    (fetch only)
  → listToolRecords                  GET  …/[collection]   (null on 401)
  → listAllToolRecords               GET  /api/tool-records
                                       every collection at once (null on any
                                       failure, so a dropped request is never
                                       mistaken for an empty account)
  → saveToolRecordToAccount          PUT  …/[collection]/[recordId]
  → saveToolRecordsToAccount         PUT  …/[collection]   (bulk)
  → deleteToolRecordFromAccount      DELETE …/[collection]/[recordId]
  → clearToolRecordsInAccount        DELETE …/[collection]

state/tool-record-mirror.ts     (the glue the tool stores call)
  → setToolRecordSyncEnabled         off until the app says otherwise
  → mirrorToolRecordSave/Delete/sSave/sClear
                                     fire-and-forget, never throws
  → readLocalToolRecords / writeLocalToolRecords
                                     a collection's store, by storage key
  → hydrateToolRecords               the account merge for one collection
  → beginToolRecordPrefetch / endToolRecordPrefetch
                                     brackets a reconcile so all its merges
                                       share one bulk GET

state/tool-record-auto-sync.ts  (the floor: no per-package wiring needed)
  → markToolRecordsSynced            baseline a collection post-merge
  → flushToolRecordCollection        diff one store vs. its snapshot and push
  → flushToolRecords                 the whole catalog, sequentially
  → startToolRecordAutoSync          interval + storage + pagehide triggers
  → resetToolRecordAutoSync          forget every snapshot (sign-out)

state/sign-in-prompt.ts         (framework-free; no React, no fetch)
  → setSignedIn / isSignedIn         the same flag the mirror follows
  → requireSignIn                    true, or false having raised a prompt
  → subscribeToSignInPrompts         what the app's dialog host listens on

apps/debate-ai.com
  → lib/hooks/useToolRecordSync.ts   enables the mirror and the guest prompt
                                       for a signed-in session; reconciles
                                       every collection once per tab; then
                                       starts the watcher
  → components/layout/ToolRecordSyncProvider.tsx
                                       mounts that hook, renders nothing
  → components/layout/SignInPromptProvider.tsx
                                       opens LoginDialog for a raised prompt
  → app/api/tool-records/route.ts                     GET (every collection)
  → app/api/tool-records/[collection]/route.ts        GET / PUT (bulk) / DELETE
  → app/api/tool-records/[collection]/[recordId]/route.ts   PUT / DELETE

Why one table instead of one per tool

The stores all have the same shape — a JSON array under one localStorage key, each record identified by one string field — so they share one saved_tool_records table keyed by (user_id, collection, client_id) and one route pair, rather than the file set per feature that saved_flows/saved_drill_sets/saved_tournament_results each took. data holds the whole record JSON-stringified, mirroring those tables' blob-column approach: a record is read and written as one unit by the tool that owns it, and its fields are that tool's business rather than the table's.

Syncing without wiring

The original design synced a store by having its own save*/delete* functions call mirrorToolRecord*. That is precise, immediate, and the right thing for a store whose writes all funnel through one pair of functions. It does not scale: the tools keep around ninety localStorage stores between them, written from panels, hooks, CSV importers, undo stacks and each other (logging a judge's round rebuilds that judge's derived profile, so one call mirrors into two collections). Wiring each of those call sites by hand is both a very large mechanical change across a dozen packages and a standing invitation for the next call site to forget — and a store that forgets is silently back to being per-browser, with no error anywhere.

So state/tool-record-auto-sync.ts syncs by observation instead. It keeps a snapshot of each collection's serialized records and, whenever something might have changed, diffs the store against it and pushes what moved: upserts for records that are new or whose JSON differs, deletes for ids that are gone. Adding a tool to the sync is therefore one entry in the catalog, with no edit to the owning package at all.

It re-checks on a 15-second interval, on a cross-tab storage event, and when the tab is hidden or unloaded — the last of those being what saves the change someone made immediately before closing their laptop.

The two mechanisms compose rather than compete. An instrumented store's mirrorToolRecordSave still goes out the moment the user clicks; the watcher then sees that record already matching its snapshot and sends nothing. The watcher is the floor, not the path.

Three properties it must have, all of them pinned by test/tool-record-auto-sync.test.ts, because each fails silently:

  • It baselines after the merge, not before. useToolRecordSync calls markToolRecordsSynced per collection as that collection finishes reconciling. Without it, every sign-in would push the entire account back at itself; doing it after the whole loop instead would let a save made mid-reconcile be baselined as already-synced and never go up at all.
  • A failed write does not advance the snapshot. The record is retried on the next tick rather than quietly forgotten — the one bug a sync like this cannot be allowed to have.
  • A sign-out resets the snapshots. They described the previous account; kept, the next user's first flush would push nothing.

One record that cannot be sent is not allowed to cost its collection the flush. The route rejects an entire bulk PUT if any record in it is over MAX_TOOL_RECORD_BYTES, so an oversized record is held back from the batch and baselined anyway — otherwise it would take every other changed record in that collection down with it, on every tick, forever. If it is later edited back under the cap its JSON changes and the next flush picks it up.

Two properties make all of this safe to call from an otherwise pure state module:

  • Off by default. Nothing syncs until setToolRecordSyncEnabled(true), which only ToolRecordSyncProvider calls, and only once useSession reports a signed-in user. A signed-out browser, a server render and a unit test therefore make no requests at all.
  • Never throws, never blocks. A mirror call returns immediately and swallows its own failure.

Telling a guest

The tools being usable signed out is deliberate, but it had quietly become "favourite fifty rounds on your laptop, open your phone, find an empty library". The work was real; the guest just had nowhere to keep it, and no way to find that out until they lost it.

So a store can now raise a prompt at the click. state/sign-in-prompt.ts is a plain subscribe/publish pair — no React, no next/*, no fetch — because the sign-in UI lives in the app and debate-data-sync is a leaf package that cannot import it. A tool publishes an intent naming the feature and one sentence about what signing in keeps; components/layout/SignInPromptProvider.tsx subscribes and opens LoginDialog with that text, returning the user to the page they were on. It reads the same signed-in flag the mirror does, so the two can never disagree about whether there is an account to save to.

Three rules keep it an offer rather than a gate:

  • The local save still happens. requireSignIn returning false does not mean the caller skipped the write — a guest who dismisses the dialog keeps the favourite they just made, and the first sign-in pushes it up, because the merge treats a local-only record as something to adopt rather than discard.
  • It asks once per feature, not once per click. Favouriting is a rapid, repeated action, and a dialog on every one of a dozen clicks is not a helpful offer — it is a tool that has stopped working. The provider keeps a 30-minute per-feature cooldown in localStorage (lib/sign-in-prompt-cooldown.ts), so it survives across this browser's tabs rather than resetting every time a guest opens a new one — a sessionStorage version of the same cooldown used to reset that way; see Known gaps for what it still doesn't cover. Removing something never prompts at all: un-favouriting and un-hiding are not moments to ask anyone to sign in.
  • A guest can say "don't ask me again", and it sticks. The dialog's secondary action calls setSignInPromptOptedOut(true) (lib/sign-in-prompt-preference.ts), a plain localStorage flag the provider checks before raising any prompt — unlike the cooldown above, this never expires on its own, on purpose. Nothing surfaces or reverses it any more: the row that showed a signed-out visitor the current state and offered a button to flip it back was on /settings, and the dialog that could otherwise change their mind never shows again once they've opted out (see Known gaps).

The merge

On the first page load of a signed-in tab, useToolRecordSync fetches the whole catalog in one GET /api/tool-records and then reconciles each collection in turn against its slice of that payload. The bulk read matters at this size: a GET per collection would be dozens of round trips, each its own D1 read, fired while the page is still painting — and the rows all live in one table behind one index on (user_id, collection). A prefetch that fails falls back to per-collection fetches rather than being mistaken for an empty account, which would have the merge adopt nothing and the push re-upload everything.

Then, per collection:

  • A record the account has and this browser doesn't is adopted locally.
  • A record both hold resolves to the account's copy — it is the shared truth across devices.
  • A record only this browser has is pushed up, in one bulk request. A first sign-in is not a reset: nothing local is dropped.
  • A local record with no usable id can't be keyed, so it is left alone rather than discarded.

Records are create/replace/delete — never edited field-by-field from two devices at once — so this is a union rather than a conflict resolution, the same rule useStandingsAccountSync's tournament-result merge follows.

Writing the merged records back to localStorage also dispatches a synthetic storage event. The panels already refresh on that event (see each package's live-update.ts), but the browser fires it only in other tabs — never in the one that wrote — so dispatching it by hand is what makes an account merge show up in the open tool without every panel needing to know this sync exists.

The merge is deduped per tab through a sessionStorage stamp with a 10-minute TTL, because the shell document and each framed dock destination both mount the provider (a page's tool panels run inside the frame, and the mirror's flag is module state belonging to whichever document imported the store).

The watcher starts only once the merge has finished and baselined every store. Running it during hydration would race the merge's own writes and push back records it had just adopted.

Seeing it

Nothing shows it now. /settings → Account → Tool data listed every synced tool with a link to it, grouped under the same section headings as the table above, and said plainly which state you were in — "This browser" while signed out, "Syncing…" then "Synced" (or "Not synced" for a collection whose merge failed) once signed in — with a search box over the list and a Sync now button. That page is the card editor's settings now, and the list, its search box and the button went with it.

What it reported is all still computed: useToolRecordSync returns the same per-collection results, and syncToolRecords is still what a Sync now would have called. Only the surface is gone, so a failed merge, a browser-only record over the size cap, and a guest's sign-in opt-out are each invisible until something renders them again — see Known gaps.

Tests

  • packages/debate-data-sync/test/toolRecordCollections.test.ts — the catalog (unique keys, every field filled, the allowlist refusing an invented key) and the merge rules, including that a first sign-in never drops a local-only record.
  • packages/debate-data-sync/test/tool-record-mirror.test.ts — the two properties above: no request at all while disabled, and a swallowed failure (plus the 401 stand-down) rather than a rejection into the calling store.
  • packages/debate-data-sync/test/tool-record-sync-wiring.test.ts, packages/debate-round/test/tool-record-sync-wiring.test.ts, packages/debate-practice-drills/test/tool-record-sync-wiring.test.ts, packages/debate-speech-writer/test/tool-record-sync-wiring.test.ts, packages/debate-team-collaboration/test/tool-record-sync-wiring.test.ts — that each store actually calls the mirror. The mirror is invisible from inside a store, so dropping the wiring breaks nothing the store's own tests would notice.
  • packages/debate-data-sync/test/tool-record-auto-sync.test.ts — the watcher: it pushes what changed, deletes what went, sends nothing when nothing moved, retries a failed push instead of losing it, stands down on a 401, resets on sign-out, and holds back an oversized record without costing its collection the flush.
  • packages/debate-data-sync/test/tool-record-catalog.test.ts — the catalog's own integrity, which is now the only thing needed to make a tool sync: a duplicated key would merge two tools' rows, and a mistyped idField would make every record in that store fail validation, leaving the tool working locally and never syncing, with no error anywhere.
  • packages/debate-data-sync/test/sign-in-prompt.test.ts — the prompt bus, including the property that is easiest to "tidy up" into a bug: a guest's local save still happens.
  • apps/debate-ai.com/lib/__tests__/sign-in-prompt-preference.test.ts — the persistent opt-out: defaults to "ask", persists across reads, clears, and never throws when localStorage is unavailable or refuses the read/write.
  • apps/debate-ai.com/lib/__tests__/sign-in-prompt-cooldown.test.ts — the 30-minute per-feature cooldown: unshown defaults to false, true immediately after marking and up to the boundary, false once the window elapses, each feature tracked independently, and the same never-throws guarantee as the opt-out above.
  • apps/debate-ai.com/lib/tools/__tests__/tool-sync-status.test.ts — ToolSyncStatusPanel's summarizeToolSyncFailures: a genuine failure surfaces with its tool's label and link, a signed-out-shaped synced: false with no error is not mistaken for one, a stale collection key is dropped, and multiple failures sort by label.
  • packages/debate-webview/test/lib/tools/tool-sync-summary.test.ts — ToolSyncSection's summarizeToolSyncStatus: the "syncing" sentence while unreconciled regardless of a stale failure count, "all N tools" once reconciled with none, and singular vs. plural failure wording.
  • packages/debate-videos/test/video-library.test.ts — favourites, hidden videos and reports, with the legacy bare-id format cases: a store written in the old format still reads, keeps its order, de-duplicates against the new format, and is dated to the epoch rather than to the upgrade (which would reorder a season's collection).
  • packages/debate-videos/test/tool-record-sync-catalog.test.ts — every collection's href is a destination the sidebar links to, so a catalog entry can't point at a 404.

Known gaps

  • Still per-user, not per-team. A squad's judge profiles and prep notes sync to the account that wrote them; sharing them across a team would need the sharing model card_shares/contacts use, not just this table.
  • No identity/permission check on who may log a round for a judge or against a team beyond "it's your own account's data" — the same gap Judge Profiles and Opponent Team Profiles already note.
  • The merge runs once per tab (10-minute TTL), and that is now the only time it runs — Sync now was on /settings' removed Tool data list. There is no push channel either, so a record saved on another device mid-session shows up on the next reload rather than live, and a reload is the only way to ask for it. The synthetic storage event covers other tabs of the same browser only.
  • The watcher is a poll, so an uninstrumented store's change reaches the account at the next tick (up to 15 seconds, or immediately when the tab is hidden or closed) rather than on the click. A store that also calls mirrorToolRecord* — the video library, and the Practice/Coaching stores wired before the watcher existed — does not wait. Nothing is lost either way; the difference is only latency.
  • A record edited to an identical value is not re-sent. The diff is on serialized JSON, which is correct for these create/replace/delete stores but would not be for one that needed a "touched at" signal.
  • Edit-undo/redo stacks (judgeRoundRecordEditHistory and friends) stay local: what syncs is the current version of a record, so an undo performed on one device is not replayable on another.
  • A record over MAX_TOOL_RECORD_BYTES (200 KB) never reaches the account: the route refuses it with a 413, and the watcher holds it back from the batch so it cannot take its collection's other records down with it. It still saves locally, and it syncs if later edited under the cap — but until then it is browser-only. ToolSyncStatusPanel (/tools, see below) shows it as a sync failure with the 413's own message, including when the watcher finds it on a background tick rather than during the tab's initial reconcile (startToolRecordAutoSync takes an onFlush callback and useToolRecordSync folds its results into the same results state the reconcile populates).
  • A bulk push of more than ~50 records answered a bare 500. Fixed: PUT /api/tool-records/[collection] awaited one upsert per record, and each spends one of D1's per-invocation queries (50 on the Workers Free plan), so a long-lived store — a docsChatTabs list of a hundred-odd tabs — failed mid-loop after several seconds. The upserts now go out through db.batch() in chunkStatements runs (lib/database/query-budget.ts), a few queries for even a full 500-record push.
  • Nothing surfaces the sync any more, and two things can only be reached through it. Fixed: components/tools/ToolSyncStatusPanel.tsx, mounted on /tools, renders useToolRecordSync's results as a list of any collection whose merge or flush failed (name, link to the tool, the error), next to a Sync now button wired to resync(); and when a guest has opted out of the sign-in prompt, a "Turn sign-in reminders back on" button calling setSignInPromptOptedOut(false). Both controls, and the state behind them, already existed — this is /settings' old Tool data list re-mounted at /tools instead, per this entry's own prior suggestion. /settings' Preferences tab now also has a compact link back: components/settings/ToolSyncSection.tsx renders the same results as one status sentence (lib/tools/tool-sync-summary.ts's summarizeToolSyncStatus) and a link to /tools, so the sync is discoverable from Preferences without duplicating the failure list or the Sync now button there.
  • The 30-minute per-feature cooldown is per-browser-session, not per-account. It lives in sessionStorage, so a user who dismisses a prompt without opting out entirely is asked again about that feature in a new tab. Fixed: lib/sign-in-prompt-cooldown.ts moved the same bookkeeping to localStorage, which a browser shares across tabs, so the cooldown now survives opening a new one — the actual bug this entry described. "Per-account" was never quite the right framing: the prompt only ever shows to a guest, who by definition has no account row to key a cross-device cooldown against; the moment they sign in the prompt stops entirely (SignInPromptProvider's second effect clears it). So this is cross-tab, same-browser persistence, matching the "don't ask me again" opt-out it sits next to — which already worked this way. (A guest who picks "don't ask me again" instead still gets that separate, non-expiring opt-out described above.)
  • Audited, stays separate: TOOL_RECORD_COLLECTIONS does not derive from app/tools/tool-groups.ts's ALL_TOOLS, unlike /tools, the favorites system, and the command palette. This is deliberate, not an oversight: it is an opt-in allowlist of the specific localStorage stores that still had the "per-browser, not account-synced" gap and happen to share one shape (a JSON array keyed by one string id field) — not a list of every tool, and several ALL_TOOLS entries correctly have no row here at all (they're backed by their own saved_* table, like flows and rounds, or they persist nothing). Deriving it from ALL_TOOLS would either pull in tools that can't be keyed this way or silently drop the shape check this table's safety depends on, so it keeps its own list. See Unify the four hand-maintained tool catalogs for the sibling catalogs that do now cross-check each other.