Skip to content

fix(privacy): a busy store says so, and declassifying keeps History's order - #310

Merged
Broccolito merged 5 commits into
mainfrom
fix/declassify-speaks-and-keeps-order
Sep 14, 2026
Merged

Broccolito merged 5 commits into
mainfrom
fix/declassify-speaks-and-keeps-order

Conversation

@Broccolito

@Broccolito Broccolito commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator

This touches privacy code and needs a human reviewer. It changes privacy::declassify, the only writer that lowers a chat's classification, and the one route that calls it, plus the desktop code that draws a chat's privacy badge in the tab strip, History and the sidebar. The write-first lock ordering from #294 is unchanged, and so are the rules for who may declassify (user-action proof, typed phrase, OS prompt). concurrent_declassifications_survive_racing_writes still passes.

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)

  1. Declassified 20260809_21 (named "New Session") with sessions.db write-locked (BEGIN IMMEDIATE, 12 s). The POST returned 503 and the toast read "The chat store was busy". I cancelled the dialog and let the lock go.
  2. Did the same on 20260809_23 ("Subagent delegation request"): 503, and exactly one toast on screen.
  3. Pressed Make public again on _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

  • outstandingFailureToasts tracks failure reports by chat.
  • toastError deduplicated 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.
  • The same collision let a different failure on one chat, or a public row read about one chat, take down the shared toast.

Fix

  • toastError takes an optional dedupeScope. The key becomes error[<scope>]:title:msg, and unscoped callers keep their key unchanged.
  • The dialog raises each report under declassify:<session id>, with a title that names its chat:
    • The chat store was busy — chat 20260809_21 when 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.
  • Identical failures on the same chat still share an id, so a retry replaces its own report instead of stacking.

Why per-chat keys and not reference-counting the shared id

  • Reference-counting would also keep _21's report alive, but as one toast standing for several chats and saying "this chat".
  • After _23 succeeded, 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.
  • With per-chat keys, two chats failing at once give two toasts with the same daemon sentence. Each title says which chat it means; see the screenshot check below.
  • Two chats with the same custom name still keep separate reports, because the scope is the id. Their titles read the same, though. That is a rare case, and I left it rather than lengthening every title with an id.

After (same steps, same chats, fixed renderer served from this commit)

  • After both failures, two toasts: "The chat store was busy — chat 20260809_21" and "The chat store was busy — “Subagent delegation request”". Checked in a screenshot: both titles fit (the second wraps to two lines), and the message still shows its full three lines, including "Try again in a moment."
  • _23's 200: _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.
  • Another busy 503 on _21 while its report was showing: still one _21 toast (replaced, not stacked).
  • _21's own 200 retracted it. At +8 s no toasts were left. DB: both public|declassified_by_user. POSTs: _21:503 _23:503 _23:200 _21:503 _21:200.

Tests and deliberate red runs

Test Red run
DeclassifySessionDialog.toastLayer.test.tsx (new, 6). It renders the real toasts.tsx and 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 report Unfixed code: 5 red. expected [] to have a length of 1 but got +0 (Y's report gone) and expected [ 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.tsx ignoring the scope: the same-name case and the toastError unit 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 unchanged shown above
DeclassifySessionDialog.test.tsx: +2 for declassifyToastSubject (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 key shown above

The 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 --check on 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

  • Before: I opened 20260905_5 in a tab, declassified it from History, then went back to the chat. The tab icon read data-privacy="private" at every sample (every 3 s for 12 s). The sidebar row and the DB read public, and the daemon logged session meta revision 1: 20260905_5 … (Some("public")) 1 s after the write.
  • Cause: ChatStreamRegistry's live tier map only ever rose ("mirroring the ratchet"). The ratchet has one exit, so the store's lowered reading was discarded, and mergeSessionTiers then took max against it.
  • Fix:
    • The map adopts each store's newest defined reading. A store with no row still retracts nothing.
    • A row read made in the window nudges any store holding a different tier to re-read, through refreshSessionBinding. The change feed does this too, within 2 s, but it only watches 64 ids and the registry keeps every store it made.
    • mergeSessionTiers stays max across sources. Two sources disagree only until the slower one is re-read, and during that time the chat is shown private.
  • After:
    • Same window: the tab is public 1 s after returning to the chat (sampled every second for 6 s).
    • Cross-window: B had 20260905_7 open 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

  • Before: I declassified 20260905_8 in A, and B's History and sidebar rows turned public. I then opened it in A and sent one turn on versa_azure. The DB went back to private|turn:versa_azure, and A's sidebar turned private at +4 s. B's History row and sidebar row were still public at +32 s, the end of the watch.
  • Cause: the new channel pushed only lowerings. Nothing pushed the raise.
  • Fix:
    • A chat store that sees its chat's tier change announces it on sessionRowSync, so every window's History, sidebar, cached tiers and session page re-read the row.
    • A store's first reading of a chat nothing here has an opinion about announces nothing. A change this window's lists were already handed is not announced again (lastKnownSessionTier).
    • While a list or sidebar request is in flight, a row read and the list answer have no known order. Adopting the list could undo a raise read a moment earlier. The disagreeing row now shows the higher tier, and a third read issued after both settles it (settleRowsReadDuringFetch). This replaces the first round's "a snap-back to private is the safe miss", which only covered one direction.
  • After: same steps on 20260905_6 and 20260905_8. B's History row and sidebar row were both private 0.20 s after the raise reached the DB, and stayed private for the 32 s watch. Cost: one GET /sessions/{id}?metadata_only=true per window per tier change. I measured it with async initiator stacks on a new chat's first private turn: A made readSessionRowFacts < 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"

  • Before: I failed the POST's response over CDP (Fetch.failRequest at the Response stage, after the daemon's 200) on 20260905_6. The DB read public with 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.
  • Fix: after any answer that is not a 200, the dialog reads the row before it says anything:
    • Public: it takes the success path (toast, announce, close).
    • Private: it shows the daemon's sentence if there was one, otherwise "No answer came back from Biorouter, and this chat is still private. Try again."
    • Unreadable: it shows the daemon's sentence if there was one, otherwise a sentence that claims neither outcome.
    • Escalation to the typed phrase still happens only on a 400 while the row is private.
  • After:
    • Single click (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.
    • Request blocked at the Request stage: "…this chat is still private. Try again.", and the DB stayed private.
    • POST and row read both blocked: "No answer came back from Biorouter, and it could not be asked whether this chat is now public. Reopen chat history to check." The DB stayed private, and the sentence fits the toast's three lines (screenshot checked).

D3a: an error toast outlived a later success

  • Before: after the lost-answer failure above, a retry answered 200. "Chat marked public" appeared and closed, and the error toast was still on screen at +30 s.
  • Fix: a chat's failure toast is retracted by the next outcome of the same operation. ⚠ As first shipped, this retracted another chat's identical report too; see repair round 2 above. That includes a close and reopen of the dialog, and a row read that shows the chat public, for example after a declassification from another window. An identical repeated failure keeps its deduplicated toast rather than dismissing it.
  • After (each: no failure toast left once the success lands; all toasts gone by +8 s):
    • Two busy 503s (store locked with BEGIN IMMEDIATE), then a 200.
    • A blocked request, then a retry.
    • A stale grade (mcp: behind a cached turn:): the 400 escalated to the phrase, then the phrase succeeded.
    • A failed in A with its dialog closed, then B declassified the chat: A's toast was gone 0.16 s after the write.

D3b: clicking a toast's × while the dialog is open closed the dialog

  • Before: busy 503 over "Make this chat public?". At the toast's × I measured body and the button both at pointer-events: none, and elementFromPoint returned the dialog overlay. The click closed the dialog, and the toast stayed.
  • Cause: the app-wide interaction between every Radix modal and the toast layer, not something in this dialog.
    • Radix sets pointer-events: none on <body> while a modal is open, and the toast layer inherited it.
    • To Radix, a press on a toast is a press outside the dialog.
  • Fix:
    • main.css: .Toastify__toast-container > * { pointer-events: auto; }.
    • ui/dialog.tsx: a press inside .Toastify is not counted as a press outside.
    • Both are in shared primitives, so every modal gets the fix.
  • After: elementFromPoint returns the toast's close button and the card reads pointer-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

Test Red run
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 second BroadcastChannel); announces nothing for a first reading; does not re-announce a change the window was already handed Raise-only rule restored → 3 red ({…: '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 red
sessionListCache.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 public Settlement a no-op (first round's rule) → 3 red across both files. Higher-tier-while-unsettled removed → 2 red (expected 'public' to be 'private'). Third read removed → 3 red
useSidebarSessions.test.ts +1: a page that raced a raise does not draw the row public same mutations as above
DeclassifySessionDialog.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 keeps Row read removed (old wording back) → 3 red. Retraction removed → 3 red. Identical failure dismissed → 1 red. Row-read retraction removed → 1 red
ui/dialog.test.tsx +1: a press on a toast does not dismiss; the backdrop still does Toast-layer guard removed → red
toastLayer.test.ts +1: toast cards take pointer events back (asserted at the source; jsdom applies no stylesheet) Declaration removed → red
sessionRowSync.test.ts +2: lastKnownSessionTier holds only delivered reads; readSessionRowFacts answers null on failure or mismatch

Repair-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 --check on all 16 changed frontend files: matched and clean.
  • cargo test -p biorouter --lib -- privacy::: 252 passed. That includes the_desktop_dialog_carries_the_same_clauses, which reads DeclassifySessionDialog.tsx.
  • biorouter serve smoke with a web bundle built from this tree: a new chat's first versa_azure turn 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 with BEGIN IMMEDIATE from a separate process, then declassified a turn:* chat from History with a single click:

  • Daemon log: Failed to declassify session 20260803_1046: error returned from database: (code: 5) database is locked. The response was a 500 with an empty body.
  • Toast: Could not mark this chat public / [object Object]. The generated client throws the parsed body, and String({}) produced that text.
  • Dialog: it switched from the single click to the typed phrase and said "That request was refused. This chat's record has changed since this list was loaded". That wasn't true. The dialog escalated on any error.
  • Store: the chat stayed private with no ledger row, which is correct.

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

  • Core (declassify.rs): declassify now tags errors where it gave up waiting for the store with a StoreBusy context. That covers SQLite extended codes 5, 261 and 773, plus sqlx PoolTimedOut. The context's Display is DECLASSIFY_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.
  • Route:
    • A busy store returns 503 with Retry-After: 1 and 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."
    • Any other failure returns 500 with DECLASSIFY_FAILED in its own words, and never suggests retrying.
    • Both only claim what an Err guarantees: the transaction rolled back, so this request changed nothing.
    • OpenAPI and the TS client are regenerated.
  • Desktop (DeclassifySessionDialog):
    • It now reads the status from the Response instead of catching a thrown body. It shows the daemon's sentence and titles a 503 "The chat store was busy".
    • It escalates to the typed phrase only on a 400, which means the chat's grade changed.
    • A response with no body gets a written sentence, never [object Object].
    • Only a 200 counts as success.
  • Measured in the dev app on this branch, with the lock held over the request:
    • The daemon returned 503 at +5.5 s with Retry-After: 1.
    • The toast showed the full sentence in three lines, including "Try again in a moment". My first wording lost that clause to the toast's three-line clamp, so I shortened it.
    • The dialog kept the single click; on the strong path it kept the typed phrase filled in.
    • The store stayed private, ledger=0, and the daemon logged a WARN with the cause.
    • Clicking Make public again after the lock cleared returned 200 with ledger=1, on both the single-click and strong paths.

Why there's no retry inside the call

  • SQLite's busy handler already is a bounded retry loop: every statement polls the lock for the pool's full 5 s before this error exists.
  • Under the load that causes it, busy periods are correlated. An immediate second attempt failed again 3 times in 7 and made the worst case ~18 s of spinner.
  • A clear sentence at ~5.5 s, with the chat still private, lets the person decide.
  • Cancellation doesn't argue either way. Every attempt runs in a real 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.
  • One side effect: on the strong path, a busy store after the OS prompt means the person enters their password again when they retry. That follows from DR-20 not caching grants.
  • My two follow-up 30-request bursts, under a similar load in a different database state, produced no busy failures (max 4.1 s). Random reproduction rates depend on the state of the WAL, so the 503 is pinned deterministically by holding the lock, not by load.

Item 11: declassifying reordered History

Before

  • In the app, declassifying 20260803_1550 changed its updated_at from 2026-08-03 14:29:06 to 2026-09-13 22:12:58. After the next list refresh it appeared under TODAY with the time 3:12 PM.
  • My 118 API declassifications moved July/August chats to the top of Recents.

What relied on updated_at moving (checked before removing it)

  • GET /sessions/changes / session_meta_rows: compares privacy_tier and privacy_reason and never updated_at (documented on purpose). Not affected.
  • Session list cache and History: no cache keyed on updated_at, on the daemon or in the renderer. It is only used for ordering, date buckets and the sidebar keyset cursor.
  • Cross-window badge:
    • I measured it before the fix with History open in two windows. Window B kept the private badge for the full 30 s after window A declassified. There was no live signal at all.
    • B only learned when some unrelated refresh re-read a list, and that list had been re-sorted by the stamp.
    • For the sidebar, that accidental path is all there was. useSidebarSessions re-reads only the first page of its keyset, so a months-old row would never refresh once the stamp is gone.
  • Home "sessions in last 7/30 days" and biorouter session list sort both key on updated_at. They benefit from this change.

After

  • Core: the UPDATE no longer sets updated_at.
  • Desktop: new utils/sessionRowSync.ts (biorouter:session-row BroadcastChannel). 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), and SessionHistoryView's page badge.
  • It's a nudge, not a payload. A receiver re-reads instead of applying "public" from the message, so a chat re-raised by a turn never shows a public badge. For the same reason, a row read that lands during an in-flight list fetch is not replayed over that fetch's result.
  • Measured in the dev app on this branch, two windows:
Case Result
A declassifies 20260905_7 (sidebar row 3) updated_at stays 2026-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.
Both windows on History, A declassifies 20260803_38 Group stays "Sunday, August 2" in both windows. B's History row turns public ~1 s after the write. B's network log shows exactly one GET /sessions/20260803_38?metadata_only=true and no list refetch.
Strong path (backfill:*, phrase + OS seam) on 20260610_28, sidebar row 2 Public in B's sidebar within 1 s, still row 2, updated_at unchanged.

Known limits

  • The tab icon staying private after a declassification was listed here as found-not-fixed. It is fixed in the repair round (D1).
  • A change no window's chat store sees still reaches list rows only on their next refresh. This covers a CLI declassification, or a raise by a scheduled run or CLI turn, on a chat no window has open. When some window does hold the chat, its store now announces the change and every window's rows follow. A months-old sidebar row that no store holds stays stale until reload, as it was before this PR for a raise.

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:

Test Red run (fix reverted / rule mutated)
declassify::tests::declassifying_a_chat_does_not_move_it_in_history (fails on main as written) Put the stamp back: "moved its last-used time from 2026-02-24 11:01:03 UTC to 2026-09-13 22:30:23 UTC"
declassify::tests::a_store_that_stays_busy_is_named_and_changes_nothing (holds the lock for real, ~5 s) Busy context removed: "not named as one: … (code: 5) database is locked"
declassify::tests::a_snapshot_conflict_is_not_classified_as_a_busy_store (a real 517 on a WAL file) Added 517 to the busy set: "a snapshot conflict was classified as a busy store"
declassify::tests::a_fault_that_is_not_a_busy_store_is_not_called_one
New binary biorouter-server --test declassify_store_busy: 503 + Retry-After + sentence + nothing written + retry succeeds; and 500 in its own words Route mapping reverted to main's: left: 500 right: 503 (body: "") and left: ""
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 both throwOnError modes) On main's component: fails with expected '[object Object]' not to contain '[object Object]'. With escalate-on-any-failure put back: the textbox appears
sessionRowSync.test.ts (5), sessionListCache.test.ts (+1), useSidebarSessions.test.ts (+1, row on page 2 from a sibling window), SessionHistoryView.test.tsx (+1) Generation guard removed / cache patch disabled / sidebar patch disabled / page subscription disabled: each red

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 needs sqlx as a new server dev-dependency (same version and features biorouter already builds). That's also why Cargo.lock changed.

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 passed
  • cargo fmt --check and ./scripts/clippy-lint.sh both clean
  • npm run lint:check clean, npx prettier --check on every changed file clean
  • npm 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:

Added in repair round 2:

  • Shared primitive toasts.tsx: an optional dedupeScope on toastError. Unscoped callers keep their exact key.

🤖 Generated with Claude Code

… 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.
@Broccolito
Broccolito merged commit 2bf9b95 into main Sep 14, 2026
16 checks passed
@Broccolito
Broccolito deleted the fix/declassify-speaks-and-keeps-order branch September 14, 2026 03:59
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