fix(session): a deleted chat's side rows go with it, and its id is never minted again - #264
Merged
Merged
Conversation
…the next chat inherits Follow-up to F10 (#224). delete_session removes a chat's messages, blobs and token_events and nothing else keyed by its id, and create_session mints <day>_<MAX(N)+1>, so deleting the newest chat of the day hands its id to the next one. A side row that outlives its chat is read as the new chat's. Against the current production code these fail: - a_deleted_chats_grant_is_not_inherited_by_the_next_chat_to_get_its_id (DR-26: the next chat inherits a cross-institutional acceptance) - a_grant_recorded_before_its_chat_existed_is_not_read - clearing_history_removes_cross_affiliation_grants - deleting_a_chat_deletes_its_recall_index_rows (messages_fts) - a_reissued_session_id_does_not_list_the_deleted_chats_checkpoints - deleting_a_chat_removes_its_checkpoint_repository (shadow git repo) - reopening_the_store_retires_side_rows_whose_chat_is_gone - a_deleted_chats_declassification_does_not_shield_the_next_chat_to_get_its_id (the backfill guard reads classification_audit by bare id) - deleting_the_newest_chat_does_not_hand_its_id_to_the_next_one - neither_a_history_reset_nor_a_reopen_restarts_the_ids These pass now and must keep passing: an id that is not one path component removes no directory; a ledger row with no incarnation still shields its chat; a deleted chat's subagent runs are kept and never adopted; the next id is above any an older build minted; concurrent creates never mint the same id.
… with it delete_session removed messages, blobs and (since F10) token_events, and left every other row keyed by the chat's id. Because create_session minted <day>_<MAX(N)+1>, the next chat handed that id read those rows as its own. - delete_session now deletes the chat's messages_fts rows, checkpoints and cross_affiliation_grants in its own transaction, and removes the BR-43 shadow repository (<data dir>/checkpoints/<id>/) after the commit. CheckpointManager::gc was written for that and never called. The directory comes from checkpoint::repository_dir, which refuses any id that is not exactly one plain path component, so a free-text id from a restored or hand-edited database cannot name another chat's repository, all of them, or anything outside checkpoints/. - clear_all_sessions clears cross_affiliation_grants too. - privacy::grant: the reader joins the chat's row and refuses a grant recorded before the chat now holding its id existed (GRANT_IS_THE_CHATS_OWN). Grants left by earlier builds, by a lagging terminal biorouter sharing the file, or restored from a backup are refused at read time instead of until the next sweep. - A startup sweep at the end of reconcile_loop_schema (BEGIN IMMEDIATE, no numbered migration arm - F10's shape and reasons) retires what earlier deletes left: recall rows whose message no longer exists (exact, since message rowids are never minted twice; ~0.12 s on a 100,000-row index), checkpoints whose chat is gone, and grants that are not their chat's own. The grant age test is shape-guarded for sessions tables without created_at. classification_audit is deliberately untouched: it survives deletion by design (privacy-tiers §12.5). A deleted chat's subagent runs are kept.
…nothing `create_session` took `N` from `MAX(N) + 1` over the rows that still existed, so deleting the newest chat of the day handed its id to the next one. The per-table deletes on the previous commit remove what this file keeps under that id; they cannot reach what anything else keeps — the daemon's cached agent, the knowledge base's per-chat selection, the renderer's chat state. Emptying `sessions` restarted the ids at 1 outright, which is the #51 W3 ABA. `N` now comes from `session_id_high_water`, one row per id prefix holding the largest `N` ever minted under it. Nothing lowers it: `delete_session` and `clear_all_sessions` name the tables they clear one by one and this is not among them, for the reason the AUTOINCREMENT counters beside it are already left standing. `CLAIM_NEXT_SESSION_N` is a single upsert returning `MAX(mark, surviving MAX(N)) + 1`, so it is a floor under the old rule and never a ceiling: an id another writer has already put on disk cannot be minted twice, and two creators racing in separate transactions serialise on the row. `SEED_SESSION_ID_HIGH_WATER` runs from the reconcile on every open, which closes the one-mint window on upgrade and keeps the mark abreast of an older build sharing the file. The mark is defence in depth, not the barrier. A build without it, a restored backup or a hand-edited database can still hand an id out twice, so every existing guard has to hold without it — and the tests that prove them now reproduce the reuse deliberately, through `SessionManager::forget_minted_session_ids_for_test`. That seam is `pub` only because three of those tests live outside this crate; `the_id_reuse_seam_is_only_reached_from_tests` walks `crates/*/{src,tests}` and pins the five files that may name it. Also fixes an upgrade-path defect introduced with the incarnation clause in `NOT_DECLASSIFIED_BY_USER`: the guard names `sessions.incarnation`, which arrives from `ensure_session_incarnation_schema` in the reconcile — after the numbered arms the backfill runs from. Every database upgrading from schema <= 17 therefore reached the backfill without that column and died with `no such column: sessions.incarnation`. Five migration tests were red. The backfill now calls the idempotent helper, as it already did for the ledger's own column. `a_reissued_id_inherits_no_grant_no_recall_text_and_no_checkpoint` is the whole-chat statement the per-table tests cannot make between them: one chat given a cross-affiliation grant, recall text and a checkpoint, then deleted and its id forced back. The grant is why this is a privacy fix — it is a standing authorisation, the first-crossing approval answered once in a chat that no longer exists, and inheriting it pre-approves a cross-institution flow the new chat's user was never shown.
Broccolito
added a commit
that referenced
this pull request
Sep 12, 2026
…optional (#56) Four holes on the same surface, each of which left an equivalent route to the capability the reflected XSS fix was meant to close. **No other origin may read the page the token is in.** `build_cors_layer` allow-listed `http://localhost:3000`, `http://127.0.0.1:3000` and this server's own origin whenever no `--auth-token` was passed. Without a token the auth middleware lets every request through, so a cross-origin `fetch` of `/session/…` that the browser permits reads the page — and `data-ws-token` is in it. Escaping the reflection and leaving this would have closed the sink and left the outcome. `--port` defaults to 3000, so on a default run all three entries are this server and the grant means nothing; on any other port it hands `http://…:3000` read access to a chat page on, say, `:8080`, and `--port 8080` is a documented invocation. There is deliberately no opt-in flag: the two routes a cross-origin browser client could have wanted are the ones the previous commit deleted, nothing in the repository reads any of the rest from another origin (`scripts/test_web.sh` uses `curl`), so an opt-in would be an opt-in to the token leak and nothing else. **`websocket_handler` had no `Origin` check on any path**, while `routes/workspace.rs` and `routes/apps.rs` both have one. CORS does not govern a handshake, so a page on any origin holding the token could drive an agent with `developer__shell`. Measured before the fix: a handshake carrying `Origin: http://localhost:3000` opened the socket. `origin_is_this_server` mirrors the daemon's `origin_matches_host` rather than calling it, because `biorouter-cli` does not depend on `biorouter-server` and must not start — SD-7 is why `serve` spawns `biorouterd` instead of linking it — exactly as `token_matches` already mirrors its `secret_matches` in this file. It is that rule's strict core with neither exception: no `is_local_origin` (PR #233 is removing that from the daemon's own socket gates, and here it would re-open the allowance above) and no `file://`, which exists for a renderer this server has no equivalent of. **The socket token was checked only when `--auth-token` was absent**, and `handle_web` made `ws_token` the empty string in exactly the other mode — so the skip was load-bearing. `token_matches("", "")` is `true`, which means deleting the `if` without changing the generation would have admitted every socket while reading like a tightening. Generation and check are both unconditional now, and an empty expected token is refused outright. **`--auth-token ""` is not a token.** `Some("")` satisfied the network-exposure guard, so `--host 0.0.0.0 --auth-token ""` bound to every interface behind a credential that `Authorization: Bearer ` satisfies. Refused at argument-parse time, and `validate_network_auth` treats an empty value as absent as well, since `handle_web` is public. SD-13 records all four, with the displaced alternatives and the note that PR #264 must merge first: the reach gate keys on a session id, and #264 is what stops ids being reissued after a delete.
…ames Raised by review: the high-water mark closed the id reuse this build can cause, but not the FIRST-UPGRADE window. An id an older build minted and then deleted has no `sessions` row, so the seed could not see it there and the mark could not rise above it — the very next chat took that id back. What it took back matters: `<data>/checkpoints/<id>/`, the shadow repository holding snapshots of the chat's working tree, is the one piece of a deleted chat's state no per-table delete reaches. The new chat opened it and committed on top of it. The chat's `checkpoints` ROWS do outlive it — that is precisely the leak `retire_side_rows_of_deleted_chats` exists to clean up — so they are evidence the id was once minted. `SEED_SESSION_ID_HIGH_WATER` now reads them alongside `sessions`. The ordering in `reconcile_loop_schema` is therefore load-bearing: the seed runs BEFORE that sweep, so it still sees the rows the sweep is about to delete. Moving the sweep earlier reopens the window, and `the_mark_rises_above_an_id_that_only_a_leftover_checkpoint_names` is what goes red. Once raised the mark stays raised, so a later open that finds the rows gone loses nothing. `messages_fts` and `cross_affiliation_grants` are deliberately not read here, for a plain reason rather than a principled one: both are created later in that same function, so at this point they need not exist. Fail-before: with the `sessions`-only seed the new test fails with the orphan's own id minted a second time (`56bc72ab_1` both sides). Still open, and now stated as such in the PR rather than claimed closed: the per-session knowledge-base selection files keyed by sha256(id). They are reachable from here, but removing them writes into the knowledge tree from the session store and wants its own tests.
Broccolito
added a commit
that referenced
this pull request
Sep 12, 2026
Eleven conflicts, and two of them were substantive rather than textual. `routes/agent.rs` — both sides gated `GET /agent/callable_tool_count`, this branch through a wrapper and main (8437e94, the SD-8 review of #260) inline. The resolution keeps ONE gate: main's body and its fuller reasoning, in this branch's wrapper shape, because the gate has to hand back the refusal the way `session_reach` wrote it. Taking main's `?` into this route's `ErrorResponse` compiled and passed 526 tests, then failed the two that matter — the refusal came back as `{"message": …}` where every other gated route answers the PLAIN-TEXT bytes `GET /sessions/{id}` returns. One boundary, one body; the envelope is part of the boundary, and `get_tools` beside it has the same shape for the same reason. `useSidebarSessions.ts` — auto-merged with no conflict and did not compile. Main's deleted-chat handler (#264) decremented `nextOffsetRef`, which this branch had renamed; git took both halves. The decrement is now gone rather than renamed: it existed because an offset counts rows and the server's list had lost one, and a cursor names the sort key of the last row a page returned, so the boundary lands correctly even when the deleted row is the one it names. The rest: - `CLAUDE.md`, `serve-decisions.md` — this branch appended SD-10, main SD-11; both kept, in number order, and the CLAUDE.md range is now SD-1..SD-11. - `programmatic-session-access.md` — main's turn-control row placed with the other lifecycle rows; and the `next_offset` this branch renamed, which this file still spelled the old way. - `session_reach.rs` — main's SD-11 wording supersedes ours on `/agent/cancel` (SD-11 changed that fact); main's row joins the gated-list table beside the other helper-gated one; the "last two rows" sentence now NAMES those rows, since both sides appended after them and it had already stopped being true. `get_session_extensions` is dropped as an over-read control — this branch gated it, so main's row for it would now fail — and main's `reply.rs` `routes(` control is kept. - `privacy_guard_wiring.rs` — the `agent.rs` row is the union at c(6, 6, 0), not a second row: both sides gated `callable_tool_count` (one call either way) and this branch also gated `GET /agent/tools`; main's own text says SD-11 added no call in that file. - `KnowledgeContext.tsx` — main's #249 had already absorbed this branch's proof header and error handling into `refreshBases`/`fetchKnowledgeSelection`, so main's side stands, with the one thing only our comment said (a filtered list is pruned against, so a missing base reads as "deleted") folded in. - `useKnowledgeBases.ts` — our proof header on the delete, without our `setPrimaryKbId(null)`: main removed that deliberately, the daemon repairs the pointer, and `primaryKbId` is no longer in scope. - `openapi.json`, `types.gen.ts` — regenerated from the merged routes. Verified on the merged tree: privacy_capability 4, privacy_guard_wiring 3, biorouter-server --lib routes:: 528, knowledge_routes 68, biorouter --lib session::session_manager 215, frontend lint:check + format:check clean, vitest 306 over BioRouterSidebar + knowledge. `cargo fmt --check` clean. The OpenAPI spec and TS client regenerate to no diff.
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.
The bug
create_sessionminted<prefix>_<N>withN = MAX(N) + 1over the rows thatstill existed. So deleting the newest chat of the day handed its id to the
next one, and emptying
sessionsrestarted the ids at 1 outright (#51 W3's ABA).Everything keyed by a chat id then read the deleted chat's state as the new
chat's.
The previous commit on this branch (
a507667d) removes what this file keepsunder that id. It cannot reach what anything else keeps — the daemon's cached
agent, the knowledge base's per-chat selection, the renderer's chat state — and
classification_auditsurvives deletion by design (privacy-tiers §12.5). Closingthe id reuse is the other half.
What the WIP commit contained, and what survived
47fdd4ecwas made by the campaign coordinator, not by this branch's author, andits own message says it was never compiled or reviewed. It did compile — but
it broke five migration tests, and the allocator that its tests and docs describe
did not exist anywhere in the tree (
git grep high_waterreturned zero hits onthe branch and on
origin/main).I kept all of it and added the missing pieces:
classification_audit.session_incarnation(DDL + additiveALTERfrom the reconcile)LEDGER_SESSION_INCARNATIONsubquery on both ledger writersNOT_DECLASSIFIED_BY_USERincarnation clause + its two shape testsclassification_audit.session_incarnationin the backfill armThe WIP's commit message is not carried into history — it is replaced by one
that describes what actually landed.
61e5b45banda507667d, the author's twofinished commits, are untouched.
The defect the WIP shipped
The new guard names two columns. The WIP added the missing one on
classification_auditand missed the one onsessions.sessions.incarnationarrives from
ensure_session_incarnation_schema, in the reconcile — which runsafter the numbered migration arms that the backfill runs from. So every
database upgrading from schema ≤ 17 reached the backfill without that column and
died at startup with
no such column: sessions.incarnation.No fresh-database test can see this; the five that caught it are migration tests.
The backfill now calls the idempotent helper, exactly as it already did for the
ledger's own column.
Where the high-water mark lives, and why nothing can reset it
session_id_high_water(prefix TEXT PRIMARY KEY, last_n INTEGER NOT NULL)— atable in
sessions.db, one row per id prefix, holding the largestNeverminted under it.
It survives each of the three paths the tests name because nothing lowers it:
delete_sessionnames the tables it clears one by one; this isnot among them.
clear_all_sessionslikewise, and deliberately — it sitsbeside the existing comment explaining why the AUTOINCREMENT counters are left
standing. A reset that lowered it would hand the first chat afterwards the id
of one the user had just wiped.
ensure_session_id_high_water_schemarunsfrom
reconcile_loop_schemaon every open.CLAIM_NEXT_SESSION_Nis one upsert returningMAX(mark, surviving MAX(N)) + 1,so the mark is a floor under the old rule, never a ceiling: an id another
writer has already put on disk can never be minted twice. It is the transaction's
first statement, so racing creators serialise on that row rather than on a read
that must later upgrade its lock.
SEED_SESSION_ID_HIGH_WATERruns from thereconcile on every open, which closes the one-mint window on upgrade (a database
at
_1..5with no mark would otherwise let its first delete lowerMAX(N)before anything had recorded 5) and keeps the mark abreast of an older build
sharing the file.
The mark is defence in depth, not the barrier. A build without it, a restored
backup or a hand-edited database can still hand an id out twice, so every
existing guard still has to hold without it. That is why the fixtures below force
the reuse rather than assuming it is gone.
Fail-before evidence
Baseline is
47fdd4ec(the WIP as committed),cargo test -p biorouter --lib -- session::→ 273 passed, 7 failed.deleting_the_newest_chat_does_not_hand_its_id_to_the_next_oneneither_a_history_reset_nor_a_reopen_restarts_the_idsno such column: sessions.incarnationa_reissued_id_inherits_no_grant_no_recall_text_and_no_checkpointthe_mark_rises_above_an_id_that_only_a_leftover_checkpoint_names56bc72ab_1minted twiceThe five migration tests were
migrates_v7_db_to_v8_external_key,migrates_v15_db_to_v16_message_blobs,migration_11_preserves_unknown_model_history_and_is_idempotent,migration_12_adds_nullable_accounting_columns_and_is_idempotentandmigration_15_backfills_fts_index.Two tests on the branch pass before and after, and should not be read as
evidence — I would rather say so than have a reviewer discover it:
the_next_id_is_above_every_id_an_older_build_minted—MAX(N) + 1oversurviving rows already yields 8 there. It is a real guard (it goes red if the
mark is implemented as a ceiling, ignoring rows another writer added), but it
is not a fail-before.
a_deleted_chats_subagent_runs_are_kept_and_never_adopted— it passedvacuously: the subagent's own row survived the parent's delete and kept
MAX(N)high enough that the id did not come back anyway.The inheritance test
⚠ Scope of this claim, stated precisely: a reissued id inherits nothing inside
sessions.db. It is not a whole-machine claim, and an earlier draft of thissection overclaimed it. Two windows outside the database remain open — see
"Two windows outside
sessions.db" below.a_reissued_id_inherits_no_grant_no_recall_text_and_no_checkpointis thewhole-chat statement the per-table tests cannot make between them — each of those
passes while the chat as a whole still carries over. One chat is given a
cross_affiliation_grantsrow,messages_ftstext and a checkpoint; all threeare asserted present; the chat is deleted; the id is forced back; and then:
grant::is_granted, thefunction every gate actually calls, answers false;
MATCHfor the text findsnothing anywhere in the file, so nothing is left for a later reader or a backup;
What that fail-before actually measured is worth a reviewer's attention. With
delete_chat_side_rowsdisabled, the test fails on theis_grantedassertion — not on a row count.
GRANT_IS_THE_CHATS_OWNcomparesdatetime(g.granted_at) >= datetime(s.created_at), which is second-granular, soa grant recorded in the same second the replacement chat is created ties, and
a tie reads as "the chat's own". The sibling test in
privacy::grantbackdatesits fixture by an hour precisely to avoid that; a real user deleting a chat and
starting another needs no backdating. So the read-time guard alone does not
close this — deleting the row is what does, and the guard is the second line for
rows this build never saw. That is recorded on the test.
Scope of the guarantee, and two windows outside
sessions.dbRaised by an independent review; both are real and neither is closed by this PR.
The mark is database-scoped, deliberately, and that is the strongest scope
available.
create_schema— the fresh-database path — creates the table anddoes not run the seed. That is correct in isolation: the seed reads
sessions, whichcreate_schemahas just created empty, so calling it therewould insert zero rows. Adding the call would be a no-op, so "seed on the fresh
path too" does not by itself buy anything.
Moving the mark outside the database does not help either, and would cost the
property that makes it trustworthy. The claim is made in the same transaction
as the row it names, so a rolled-back
create_sessionun-claims its id. A markin a sibling file could not be transactional with that insert — it would
over-claim on every rollback — and it would be lost by the same
rmthat losesthe database, while additionally being a file an agent with
developer__shellcan edit (the argument the privacy master switch already records). So the honest
guarantee is: an id is minted once per session database.
One of the two windows is now closed. The seed reads
checkpointsas well assessions. An id an older build minted and deleted has nosessionsrow, sosessionsalone could not raise the mark above it and the next chat took it back— inheriting the shadow repository at
<data>/checkpoints/<id>/, the one piece ofthat chat's state no per-table delete reaches. Its
checkpointsrows dooutlive it (that is the leak the startup sweep exists to clean up), so they are
evidence the id was minted. This works only because the seed runs before
retire_side_rows_of_deleted_chatsretires those rows, and that ordering is nowpinned by a test. Once raised, the mark stays raised.
messages_ftsandcross_affiliation_grantsare deliberately not read by theseed, for a plain reason rather than a principled one: both are created later in
the same reconcile, so at that point they need not exist.
What remains open. Per-id state outside
sessions.db, blocked only by theallocator and deleted by nothing:
<knowledge root>/.active-kb-sessions/<sha256(id)>(a chat's primary KB — its write target)sessions.dblost/recreated; or the first-upgrade window for an id that left no checkpoint row<knowledge root>/.hidden-kb-sessions/<sha256(id)>(a chat's visible KB set)<data>/checkpoints/<id>/left by an older build's deletecheckpointsseed aboveWhy the KB files are not closed here, and what closes them. They are
reachable —
biorouterdepends onbiorouter-mcp, andpaths::primary_kb_sessions_dir,paths::hidden_kb_sessions_dirandraw::hash_bytesare allpub— so the fixis a post-commit removal in
delete_sessionmirroringremove_checkpoint_repository. It is a follow-up rather than a late additionhere because it writes into the knowledge tree from the session store and wants
its own tests; that is a layering decision, not a one-liner. A directory sweep
for orphaned
checkpoints/<id>/is explicitly not the answer either: removingevery directory whose id has no row would, on a lost or fresh
sessions.db,delete every checkpoint snapshot on the machine. The safe form drives removal
from the ids whose rows the sweep actually just retired.
Sequencing
#264 must merge before #267. PR #267 (
biorouter web's reach gate) keys itsgate on a session id, so until this allocator lands a reissued id defeats
that gate.
The test seam, and why it is
pubTen fixtures — in the
biorouterlib's own tests, itsconversation_writeback_stressintegration binary, andbiorouter-cli— buildan ABA by deleting or wiping and then relying on the id coming back. With the
allocator it does not, so each now forces it through
SessionManager::forget_minted_session_ids_for_test, which reproduces exactlythe state a build without the mark, a restored backup or a hand-edited database
leaves behind. This keeps every guard being tested for the case that still
matters, instead of quietly going vacuous.
It is
pubonly because two of those tests sit outside the lib'scfg(test)scope — one in an integration binary of this crate, one in
biorouter-cli—where a
#[cfg(test)]item is invisible. It is#[doc(hidden)], named for whatit is, and
the_id_reuse_seam_is_only_reached_from_testswalkscrates/*/{src,tests}and pins the five files that may name it, one row per filesaying which guard it exercises there.
Side rows: what does and does not follow a deleted chat
Everything in this table lives in
sessions.db; for state outside it see thesection above.
messages,message_blobstoken_events(folded intodeleted_chat_usagefirst)messages_ftsa507667dcheckpoints+ the shadow git repositorya507667dcross_affiliation_grantsa507667d, plusGRANT_IS_THE_CHATS_OWNrefusing at read timeclassification_auditsessions.incarnation, so an entry cannot shield a later chat under the same idVerification
Merged
origin/main(19 commits) first, then:And the three suites this PR edits that are outside that list — each holds a
fixture that now has to force the id reuse, so none of them is incidental:
./scripts/clippy-lint.shis red on a cleanmainfor an unrelated reason(
send_prompt_turn/too_many_lines), owned elsewhere.🤖 Generated with Claude Code