fix(privacy): a busy store says so, and declassifying keeps History's order - #310
Merged
Merged
Conversation
… order Two items from the 1.90.4 hold, both in the one writer that lowers a chat's classification (`privacy::declassify`). Privacy-critical code: needs a human reviewer. The write-first lock ordering from #294 and who may declassify are unchanged. Item 8 - a lock timeout answered a bodyless 500. `declassify` now names a waited-out store (SQLITE_BUSY 5/261/773 or a pool timeout; NOT the 517 snapshot conflict #294 removed) with a `StoreBusy` context whose Display is `DECLASSIFY_STORE_BUSY`. The route answers it 503 + Retry-After with that sentence, and every other failure 500 with `DECLASSIFY_FAILED`. An Err still changes nothing (the transaction rolls back on drop); both sentences claim only that. No retry inside the call: SQLite's busy handler already waited 5 s, and an immediate second attempt under the load that causes this failed again 3 times in 7. The desktop dialog reads the status off the Response instead of throwing the body (which toasted "[object Object]"), shows the daemon's sentence, and escalates to the typed phrase only on the 400 that means the grade moved - a busy store used to swap the single click for the phrase under a false "record has changed". Item 11 - declassifying reordered History. The UPDATE no longer stamps `updated_at`; a classification change is not use. Nothing learned of a declassification through that stamp (measured: a second window kept the private badge for 30 s), so the change now announces itself: `utils/sessionRowSync.ts` re-reads the row once per window and the session list cache, the sidebar's rows and the session page patch it in place.
…, and failures say only what was read Repairs the four defects an independent tester found in this PR's first round. Privacy-critical desktop code: needs a human reviewer. No Rust changed; who may declassify and the write-first lock ordering are untouched. D1 - a declassified chat's open tab kept its private icon (past 12 min). ChatStreamRegistry's live tier map only ever rose, "mirroring the ratchet", and the ratchet has one exit. It now adopts each store's newest defined reading (a store with no row still retracts nothing), and a row read made in the window nudges a store holding another tier to re-read, which also covers stores past the change feed's 64-id cap. mergeSessionTiers stays max across sources. D4 - after a pushed lowering, a turn re-raised the chat and another window's History and sidebar rows badged it PUBLIC. A store that sees its chat's tier change now announces it on sessionRowSync (not re-announced when the window was already handed that tier). A list or sidebar answer that raced a row read shows the higher tier and is settled by a third read, instead of adopting an older list over a newer raise. D2 - an answer lost after the daemon wrote was reported as "not marked public". After any non-200 the dialog reads the row: public is a success, private says "still private", unreadable claims neither. D3a - failure toasts never expire, so an overturned failure stayed beside the success. A chat's failure toast is retracted by the next outcome, across a close and reopen, and by a row read showing the chat public. D3b - a Radix modal sets pointer-events:none on <body>, so a toast above the dialog could not be clicked and the press closed the dialog. Toast cards take pointer events back (main.css) and a press on the toast layer is no longer a press outside the dialog (dialog.tsx).
…r chat's failure report Repairs the D3a regression an independent tester found in this PR's repair round. Renderer-only: no Rust changed, and neither the write-first lock ordering (#294) nor who may declassify is touched. Reproduced in the dev app first (sandboxed config, this branch's daemon): chat 20260809_21 failed with sessions.db write-locked (POST -> 503, "The chat store was busy"), then 20260809_23 failed the same way and one toast was still on screen. Retrying 20260809_23 answered 200: "Chat marked public" was the only toast, and 8 s later there were none, with the DB reading _21 private and _23 public. Cause: DeclassifySessionDialog keeps outstanding failure reports by chat, but toastError deduplicated them by title + message, and the busy sentence is the same for every chat. Both reports were one toast id, so _23's retraction dismissed _21's report too. A different failure on one chat, or a public row read about one chat, did the same. Fix: toastError takes an optional dedupeScope, and the dialog raises each report under declassify:<session id>, with a title that names its chat ("The chat store was busy — chat 20260809_21" for a placeholder name, "— “Subagent delegation request”" otherwise). Identical failures on the same chat still share an id, so a retry replaces its report instead of stacking. Rejected alternative: reference-counting the shared id. It keeps _21's report alive, but as one toast standing for several chats and saying "this chat". After _23 succeeded, it would sit beside "Chat marked public" still saying "this chat was not marked public", about a chat it doesn't name. Measured after the fix, same steps and chats: two toasts, each naming its chat. After _23's 200, _21's report was still on screen at +8 s and +30 s, and _23's was gone. A second busy failure on _21 left one _21 toast, not two. _21's own 200 then retracted it, and no toasts were left at +8 s. Tests: DeclassifySessionDialog.toastLayer.test.tsx (new, 6) renders the real toasts.tsx and react-toastify container, with only the exit animation replaced, because jsdom runs none. On the unfixed code 5 fail ("expected [] to have a length of 1 but got +0", "expected [ Array(1) ] to have a length of 2"). The same-chat retry guard passes before and after.
The repair before this one made each chat's failure report its own toast and titled it with the chat's name. An independent tester found the gap: names are not unique. Auto-generated names repeat (the seed data holds 20260809_23 and 20260809_25, both "Subagent delegation request"), so two such chats failing at once raised two toasts that read identically, and after one succeeded nothing on the survivor said which chat it meant. A real name is now followed by the chat's id, the same id the dialog shows under the name; a placeholder name is still named by its id alone. New test "tells two chats with the same name apart" fails with the id suffix removed (expected '"Subagent delegation request"' not to be the same) and passes with it. DeclassifySessionDialog.test.tsx: 25 passed. lint:check clean, prettier clean over both files.
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.
Fixes items 8 and 11 of the 1.90.4 release hold.
Repair round 2: one chat's success took down another chat's failure report (commit
114c4db7)The tester found that D3a's fix from the last round introduced a new defect. Renderer-only: no Rust changed. The lock ordering (#294) and the rules for who may declassify are untouched.
Before (reproduced in the dev app on
debdff89, sandboxed config, this branch's daemon)20260809_21(named "New Session") withsessions.dbwrite-locked (BEGIN IMMEDIATE, 12 s). The POST returned503and the toast read "The chat store was busy". I cancelled the dialog and let the lock go.20260809_23("Subagent delegation request"):503, and exactly one toast on screen._23:200. At +0/+1/+3 s after the success toast appeared, "Chat marked public" was the only toast. At +8 s there were none. The DB read_21 private|turn:versa_azure,_23 public|declassified_by_user.Cause
outstandingFailureToaststracks failure reports by chat.toastErrordeduplicated them by title + message, and the busy sentence is the same for every chat. So both reports got one toast id, and_23's retraction dismissed it.Fix
toastErrortakes an optionaldedupeScope. The key becomeserror[<scope>]:title:msg, and unscoped callers keep their key unchanged.declassify:<session id>, with a title that names its chat:The chat store was busy — chat 20260809_21when the chat has a placeholder name, which dozens of rows share, so it is named by id.The chat store was busy — “Subagent delegation request”otherwise. Long names are cut to 60 characters.Why per-chat keys and not reference-counting the shared id
_21's report alive, but as one toast standing for several chats and saying "this chat"._23succeeded, that toast would sit beside "Chat marked public" still saying "this chat was not marked public". That is the stale report D3a exists to retract, now about a chat it doesn't name, and it cannot name one.After (same steps, same chats, fixed renderer served from this commit)
_23's200:_21's report was still on screen at +0, +1, +3, +8 and +30 s._23's report was gone, and the success toast expired on schedule.503on_21while its report was showing: still one_21toast (replaced, not stacked)._21's own200retracted it. At +8 s no toasts were left. DB: bothpublic|declassified_by_user. POSTs:_21:503 _23:503 _23:200 _21:503 _21:200.Tests and deliberate red runs
DeclassifySessionDialog.toastLayer.test.tsx(new, 6). It renders the realtoasts.tsxand react-toastify container; only the exit animation is replaced, because jsdom runs none and would never remove a dismissed toast. Cases: a success on one chat leaves another's report; two chats failing at once are two named reports; two chats with the same name keep their own reports; a same-chat retry replaces; a different failure on one chat does not take down another's report; a public row read retracts only its chat's reportexpected [] to have a length of 1 but got +0(Y's report gone) andexpected [ Array(1) ] to have a length of 2 but got 1(one shared toast). The same-chat guard passes before and after. Scope dropped from the dialog's call: the same-name case goes red (length of 2 but got 1), plus 3 exact-call asserts.toasts.tsxignoring the scope: the same-name case and thetoastErrorunit test go red. Title not naming the chat: 8 red, e.g.expected 'The chat store was busyNothing…' to contain 'chat 20260809_21a'toasts.test.tsx+1: a scope separates identical failures about different subjects, the same subject still coalesces, and unscoped keys are unchangedDeclassifySessionDialog.test.tsx: +2 fordeclassifyToastSubject(placeholder names → id; long names cut by characters, never splitting an emoji); the exact-call asserts now include the named title and the scope; the mock's id mirrors the scoped keyThe other toasts in this PR still pass their existing tests: busy, refusal and escalation, lost answer, still private, unreadable row, retraction across close and reopen, and retraction by a row read.
Gates, on this commit:
npm run test:run: 488 files, 5446 passed, 1 skipped, 0 failed.npm run lint:check: exit 0.npx prettier --checkon the 5 changed files, passed as a bash array: clean. A deliberately misformatted probe file was flagged, so the check does match files.Repair round: four defects found by an independent tester (commit
debdff89)The tester failed
445a7fb4: items 8 and 11 held, but D1–D3 (found in the first round, not fixed) reproduced, and D4 was new and caused by this PR's cross-window push. All four reproduced again here before any change, in the dev app from this worktree (daemon…/worktrees/declass/target/debug/biorouterd, sandboxed config, Versa GPT-5.5 for turns, two windows). No Rust changed in this round.D1: a declassified chat's open tab kept its private icon
20260905_5in a tab, declassified it from History, then went back to the chat. The tab icon readdata-privacy="private"at every sample (every 3 s for 12 s). The sidebar row and the DB readpublic, and the daemon loggedsession meta revision 1: 20260905_5 … (Some("public"))1 s after the write.ChatStreamRegistry's live tier map only ever rose ("mirroring the ratchet"). The ratchet has one exit, so the store's lowered reading was discarded, andmergeSessionTiersthen tookmaxagainst it.refreshSessionBinding. The change feed does this too, within 2 s, but it only watches 64 ids and the registry keeps every store it made.mergeSessionTiersstaysmaxacross sources. Two sources disagree only until the slower one is re-read, and during that time the chat is shown private.20260905_7open in a tab, and A declassified it from History. B's tab turned public by my first sample, ≤0.26 s after the write reached the DB. No reload.D4 (new, caused by this PR): another window drew a private chat PUBLIC
20260905_8in A, and B's History and sidebar rows turned public. I then opened it in A and sent one turn onversa_azure. The DB went back toprivate|turn:versa_azure, and A's sidebar turned private at +4 s. B's History row and sidebar row were stillpublicat +32 s, the end of the watch.sessionRowSync, so every window's History, sidebar, cached tiers and session page re-read the row.lastKnownSessionTier).settleRowsReadDuringFetch). This replaces the first round's "a snap-back to private is the safe miss", which only covered one direction.20260905_6and20260905_8. B's History row and sidebar row were bothprivate0.20 s after the raise reached the DB, and stayed private for the 32 s watch. Cost: oneGET /sessions/{id}?metadata_only=trueper window per tier change. I measured it with async initiator stacks on a new chat's first private turn: A madereadSessionRowFacts < reread < announceSessionRowChanged < noteControllerTier, B made one, and the other reads there are the existing post-turn refresh and rename polls.D2: a request that landed, with its answer lost, was reported "not marked public"
Fetch.failRequestat the Response stage, after the daemon's 200) on20260905_6. The DB readpublicwith 1 new audit row, but the toast said "Biorouter could not be reached, so this chat was not marked public." The dialog stayed open, and both windows' rows stayed private.20260905_7) and phrase + OS seam (20260610_28): each showed "Chat marked public", closed the dialog, wrote 1 audit row, and turned the rows public in both windows.D3a: an error toast outlived a later success
BEGIN IMMEDIATE), then a 200.mcp:behind a cachedturn:): the 400 escalated to the phrase, then the phrase succeeded.D3b: clicking a toast's × while the dialog is open closed the dialog
bodyand the button both atpointer-events: none, andelementFromPointreturned the dialog overlay. The click closed the dialog, and the toast stayed.pointer-events: noneon<body>while a modal is open, and the toast layer inherited it.main.css:.Toastify__toast-container > * { pointer-events: auto; }.ui/dialog.tsx: a press inside.Toastifyis not counted as a press outside.elementFromPointreturns the toast's close button and the card readspointer-events: auto. Clicking × removed the toast and left the dialog open, and Cancel still closed it. A press on the backdrop still closes a dialog.Repair-round tests, and the deliberate red run for each
chatStreamStore.declassify.test.tsx(new, 5): lowers a tier its store re-read; a row read makes a store re-read at once; announces a raise (heard on a secondBroadcastChannel); announces nothing for a first reading; does not re-announce a change the window was already handed{…: 'private'}vs'public'). Row-channel hook removed → 2 red. Announcement removed →expected [] to include 'declass-raise-3'. Echo check removed → echo test red. First-reading guard removed → 2 redsessionListCache.test.ts+2: a list that raced a raise is not drawn public; a list that raced a declassification is private until the third read, then publicexpected 'public' to be 'private'). Third read removed → 3 reduseSidebarSessions.test.ts+1: a page that raced a raise does not draw the row publicDeclassifySessionDialog.test.tsx+8: lost answer + row public → success; + row private → "still private"; + unreadable → claims neither; daemon sentence kept when row unreadable; success retracts failure; retracts across close/reopen; retracted by a public row read elsewhere; different failure replaces, identical keepsui/dialog.test.tsx+1: a press on a toast does not dismiss; the backdrop still doestoastLayer.test.ts+1: toast cards take pointer events back (asserted at the source; jsdom applies no stylesheet)sessionRowSync.test.ts+2:lastKnownSessionTierholds only delivered reads;readSessionRowFactsanswers null on failure or mismatchRepair-round gates:
npm run test:run: 487 files, 5436 passed, 1 skipped, 0 failed. The row-read retraction test was added afterwards, and its file was re-run: 22 passed.npm run lint:check: exit 0.npx prettier --checkon all 16 changed frontend files: matched and clean.cargo test -p biorouter --lib -- privacy::: 252 passed. That includesthe_desktop_dialog_carries_the_same_clauses, which readsDeclassifySessionDialog.tsx.biorouter servesmoke with a web bundle built from this tree: a new chat's firstversa_azureturn gave a private tab icon, no console errors, and the announcement's metadata reads answered 200.Item 8: a lock timeout returned an empty 500
Before (reproduced in the dev app at
35757426, sandboxed config)I held
sessions.db's write lock withBEGIN IMMEDIATEfrom a separate process, then declassified aturn:*chat from History with a single click:Failed to declassify session 20260803_1046: error returned from database: (code: 5) database is locked. The response was a 500 with an empty body.Could not mark this chat public/[object Object]. The generated client throws the parsed body, andString({})produced that text.I also ran the tester's load test: 30 declassifications against two heavy external writers on the same
sessions.db. 7 of 30 failed with an empty 500 after 5.5–8.7 s. An immediate client retry fixed 4 of the 7. The other 3 failed again, after up to 9.5 s.After
declassify.rs):declassifynow tags errors where it gave up waiting for the store with aStoreBusycontext. That covers SQLite extended codes 5, 261 and 773, plus sqlxPoolTimedOut. The context'sDisplayisDECLASSIFY_STORE_BUSY, so the CLI door prints the sentence too.SQLITE_BUSY_SNAPSHOT(517) is deliberately not treated as busy. fix(privacy): declassify takes the write lock before it reads #294 removed that error from this code path. If it comes back, the lock ordering has regressed, and it should stay a real 500.Retry-After: 1and the body "Nothing was changed and this chat was not marked public, because other writes kept the chat store busy. Try again in a moment."DECLASSIFY_FAILEDin its own words, and never suggests retrying.Errguarantees: the transaction rolled back, so this request changed nothing.DeclassifySessionDialog):[object Object].503at +5.5 s withRetry-After: 1.private,ledger=0, and the daemon logged aWARNwith the cause.ledger=1, on both the single-click and strong paths.Why there's no retry inside the call
sqlx::Transaction, which rolls back if the handler future is dropped. But any retry would have to wrap only the write, never the OS prompt (DR-20 allows one prompt per operation), and that adds complexity to privacy code for a pathological load.Item 11: declassifying reordered History
Before
20260803_1550changed itsupdated_atfrom2026-08-03 14:29:06to2026-09-13 22:12:58. After the next list refresh it appeared under TODAY with the time 3:12 PM.What relied on
updated_atmoving (checked before removing it)GET /sessions/changes/session_meta_rows: comparesprivacy_tierandprivacy_reasonand neverupdated_at(documented on purpose). Not affected.updated_at, on the daemon or in the renderer. It is only used for ordering, date buckets and the sidebar keyset cursor.useSidebarSessionsre-reads only the first page of its keyset, so a months-old row would never refresh once the stamp is gone.biorouter session listsort both key onupdated_at. They benefit from this change.After
UPDATEno longer setsupdated_at.utils/sessionRowSync.ts(biorouter:session-rowBroadcastChannel). After a 200, the dialog announces the chat id. Each window re-reads that row once (GET /sessions/{id}?metadata_only=true, with proof-of-user) and passes the result to three subscribers: the session list cache (History, Home, tab-strip cached tiers),useSidebarSessions(patches the row wherever it is), andSessionHistoryView's page badge.20260905_7(sidebar row 3)updated_atstays2026-09-05 17:58:10. A's History row and sidebar row turn public. B's sidebar row turns public ~1 s after the write and stays at row 3. No reload.20260803_38GET /sessions/20260803_38?metadata_only=trueand no list refetch.backfill:*, phrase + OS seam) on20260610_28, sidebar row 2updated_atunchanged.Known limits
Tests
Each new test failed on
origin/main(or on the fix reverted), and I broke each fix on purpose to confirm the test goes red for the right reason:declassify::tests::declassifying_a_chat_does_not_move_it_in_history(fails on main as written)declassify::tests::a_store_that_stays_busy_is_named_and_changes_nothing(holds the lock for real, ~5 s)declassify::tests::a_snapshot_conflict_is_not_classified_as_a_busy_store(a real 517 on a WAL file)declassify::tests::a_fault_that_is_not_a_busy_store_is_not_called_onebiorouter-server --test declassify_store_busy: 503 + Retry-After + sentence + nothing written + retry succeeds; and 500 in its own wordsleft: 500 right: 503 (body: "")andleft: ""DeclassifySessionDialog.test.tsx: busy toast keeps the single click; a response with no body never shows[object Object](the mock behaves like the real client for boththrowOnErrormodes)expected '[object Object]' not to contain '[object Object]'. With escalate-on-any-failure put back: the textbox appearssessionRowSync.test.ts(5),sessionListCache.test.ts(+1),useSidebarSessions.test.ts(+1, row on page 2 from a sibling window),SessionHistoryView.test.tsx(+1)The new server test binary holds
sessions.db's lock for more than 5 s, so it gets its own binary rather than joining the lib tests, where it would starve parallel tests of the shared store. It needssqlxas a new server dev-dependency (same version and featuresbiorouteralready builds). That's also whyCargo.lockchanged.Suites run:
cargo test -p biorouter --lib -- privacy::252 passed;-- session::292 passed-p biorouter --test privacy_toggle --test privacy_capability --test privacy_disclosure_toggle --test privacy_guard_wiring --test privacy_spawn_classification: all passed (census rows unchanged)-p biorouter-server --lib -- routes::session auth::123 passed-p biorouter-server --test declassify_store_busy --test declassify_no_user_key --test privacy_toggle_config --test every_test_binary_is_sandboxed: all passed-p biorouter-mcp --test privacy_toggle_export: passed-p biorouter-cli --lib -- declassif: 3 passedcargo fmt --checkand./scripts/clippy-lint.shboth cleannpm run lint:checkclean,npx prettier --checkon every changed file cleannpm run test:run: Tests 5417 passed, 1 skipped, 0 failed (486 files)Files other groups may also touch
crates/biorouter-server/src/routes/session.rs(declassify handler only),ui/desktop/openapi.json/src/api/types.gen.ts(regenerated; one route's responses),utils/sessionListCache.ts,BioRouterSidebar/useSidebarSessions.ts,crates/biorouter-server/Cargo.toml(dev-dep),docs/security/privacy-tiers.md(one paragraph under "What shipped").Added in the repair round:
hooks/chatStreamStore.tsx:ChatStreamRegistry.followSessionRowsandnoteControllerTieronly. fix(desktop): opening a chat reads its row once, not ten times #308 and fix(chat): a stopped reply stays marked after a reload (item 7) #311 also touch this file; both merge cleanly with this branch (git merge-tree). fix(desktop): opening a chat reads its row once, not ten times #308 reduces the row reads made when a chat opens, and this adds one read per window per tier change.chatGroups/ChatGroupsShell.tsx: a doc comment only. fix(desktop): a subagent's tab is named, and marked, from its own row #306 also touches this file and merges cleanly.ui/dialog.tsx(the outside-press guard) andstyles/main.css(one rule next to the toast close-button rules).privacy/sessionTier.ts: docs only.Added in repair round 2:
toasts.tsx: an optionaldedupeScopeontoastError. Unscoped callers keep their exact key.🤖 Generated with Claude Code