fix(session): a deleted chat's token_events go with it; its spend stays anonymous (F10) - #224
Merged
Merged
Conversation
F10 of the 2026-09-10 provider QA run (M24 of the 2026-09-09 drive): deleting a chat left every token_events row it had written - model, provider and a timestamp per turn - and every usage total kept counting them. Session ids are reissued (`<day>_<MAX(N)+1>`), so the next chat created that day also inherited the deleted chat's ledger in its cost popover. Against the current production code these fail: - deleting_a_chat_keeps_its_spend_anonymously_and_deletes_its_rows - a_reissued_session_id_does_not_inherit_the_deleted_chats_usage - usage_aggregations_never_count_a_token_event_whose_chat_is_gone - reopening_the_store_retires_token_events_whose_chat_is_gone - clear_all_sessions_removes_history_usage_and_side_tables (extended) - fresh_database_contains_full_v16_schema (extended) pr13_v12_database_reconciles_usage_and_adds_loop_schema, which pins that a delete does not lower the usage totals, passes and must keep passing.
…ys anonymous (F10) Deleting a chat left every token_events row it had written - which model and provider answered each turn, and when - and because session ids are reissued (`<day>_<MAX(N)+1>`), the next chat created that day inherited that ledger. A JOIN at read time cannot tell those rows apart, so: - delete_session folds the chat's billable turns into deleted_chat_usage and deletes its token_events in the same transaction as the chat. - deleted_chat_usage keeps sums and known-turn counts per local day, model and provider, with no session id, event key or turn time. There is a product reason to keep the spend: the Usage panel exists to be held against the provider's own meter and the UCSF allowance (issue #1), and 77eae66 pinned that a delete does not lower it - its test passes unmodified. - Existing orphans are retired the same way (fold, then delete) by a startup sweep in reconcile_usage_schema, not a numbered migration arm: it is idempotent, merge-order safe, and repairs rows an older build sharing the database leaves behind. An upgrade changes no Usage number. - Every aggregation joins sessions, so a per-turn row whose chat is gone is never read. The Usage report, summary and per-model rollup read BILLABLE_USAGE (live turns + the anonymous total); Home's heatmap and insight tiles count only chats that still exist, like their session and message series. - clear_all_sessions wipes deleted_chat_usage too. Documented in docs/cli/command-reference.md (session remove).
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.
What this fixes
F10 of the 2026-09-10 provider QA run (recorded by the security tester as INFO "token_events rows survive chat deletion"; M24 of the 2026-09-09 drive). Deleting a chat removed its
sessions,messagesandmessage_blobsrows and left everytoken_eventsrow it had written:model_id,provider,session_idand a timestamp per turn, i.e. a record of which model answered when, for a chat the user chose to delete. QA-e measured 18 such rows after deleting ten probe chats.It is worse than hygiene, because session ids are reissued.
create_sessionmints<day>_<MAX(N)+1>, so if you delete the newest chat of the day, the next chat created that day gets the same id and inherits the whole ledger. Its cost popover (GET /sessions/{id}/usage) then lists turns it never ran, on a provider it may never have used. AJOIN sessionsat read time can't catch that, because the id exists again. Only deleting the rows together with the chat can.a_reissued_session_id_does_not_inherit_the_deleted_chats_usagereproduces it; before the fix, the new chat reported the deleted chat'sversa_azure / gpt-5.5turn.What changed (all in
crates/biorouter/src/session/session_manager.rs)token_eventsin the same transaction as the chat. An unknown id still rolls back and writes nothing.reconcile_usage_schema, not a numbered migration arm. A migration arm would consume a migration number, and this file already documents both failure modes: development branches have collided on numbers (v11–v14, 17), and work added to a number a tester's database has already passed never runs there (the O10 hazard, arms 18–20). The sweep is idempotent (a second open finds nothing), runs under the reconcile'sBEGIN IMMEDIATE, and also repairs rows left by an older build sharing the samesessions.db(daemon, CLI, scheduled jobs).sessions, so a per-turn row whose chat is gone is never read by any total.77eae66c("make accounting durable") pinned that deletes do not lower usage:pr13_v12_database_reconciles_usage_and_adds_loop_schemaassertsall_time.turnsis unchanged afterdelete_session. A delete does not refund tokens at the provider. So a deleted chat's billable turns are first folded into a new table,deleted_chat_usage: one row per (local day, model, provider) holding sums plus per-bucket known-turn counts, with no session id, event key or turn time. Only then are the rows deleted.biorouter usage, and the global per-model rollup readBILLABLE_USAGE= live chats' turns (joined) + the anonymous aggregate. Completeness semantics are unchanged:SUM(turns)/SUM(*_known)replaceCOUNT(*)/COUNT(col). Thepr13test passes unmodified.deleted_chat_usageis additive and created idempotently (house pattern, no migration number).clear_all_sessionswipes it (a reset is a reset).docs/cli/command-reference.md(session remove) documents the behaviour. There is no living usage or heatmap doc underdocs/; the only hits are history and design records.Residual, documented at the code: an orphan whose id has already been reissued (before this fix) reads as the new chat's, and existence can't tell them apart. The only discriminator would be a timestamp guess (a row older than its chat's
created_at) running destructively on every open. I measured that population at 0 on a real 11,780-chat store (3,626 ledger rows,min(ts − created_at) = 0). The delete path closes it for every chat deleted from now on.Tests: fail-before, then pass
The first commit adds only the tests; against the original production code they fail:
After the fix:
Runtime check (sandboxed daemon, real
versa_azureturn)BIOROUTER_PATH_ROOTsandbox cloned from~/biorouter-runs/seed-config, running abiorouterdbuilt from this branch. Steps: one orphan pre-seeded as a pre-fix delete leaves it; create a chat; one turn;DELETE /sessions/{id}over HTTP. The daemon and sandbox were removed afterwards.Out of scope, filed separately
The same id reuse makes
delete_session's other survivors inheritable:cross_affiliation_grants(a privacy-gate approval, security-relevant),messages_fts(the deleted chat's text stays on disk), andcheckpoints.classification_auditsurvives by design (privacy-tiers.md§12.5), but its backfill guard joins on the bare id. I filed these as their own task rather than widening this LOW fix.🤖 Generated with Claude Code