Skip to content

fix(session): a deleted chat's side rows go with it, and its id is never minted again - #264

Merged
Broccolito merged 5 commits into
mainfrom
fix/deleted-chat-side-rows
Sep 12, 2026
Merged

Broccolito merged 5 commits into
mainfrom
fix/deleted-chat-side-rows

Conversation

@Broccolito

@Broccolito Broccolito commented Sep 12, 2026

Copy link
Copy Markdown
Collaborator

Security-sensitive: privacy approval inheritance. Wants human review.
cross_affiliation_grants is a standing authorisation, not a record — the
first-crossing approval (DR-26) the user answered once. This PR is about a chat
inheriting one it was never shown.

The bug

create_session minted <prefix>_<N> with N = MAX(N) + 1 over the rows that
still existed. So deleting the newest chat of the day handed its id to the
next one, and emptying sessions restarted 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 keeps
under 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_audit survives deletion by design (privacy-tiers §12.5). Closing
the id reuse is the other half.

What the WIP commit contained, and what survived

47fdd4ec was made by the campaign coordinator, not by this branch's author, and
its 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_water returned zero hits on
the branch and on origin/main).

I kept all of it and added the missing pieces:

WIP content Verdict
classification_audit.session_incarnation (DDL + additive ALTER from the reconcile) Kept unchanged
LEDGER_SESSION_INCARNATION subquery on both ledger writers Kept unchanged
NOT_DECLASSIFIED_BY_USER incarnation clause + its two shape tests Kept unchanged
The five doc files Kept unchanged — they already described the allocator correctly; this PR makes them true
Guarding classification_audit.session_incarnation in the backfill arm Kept, and completed — see below
The high-water allocator its tests and docs assume Did not exist. Written here.

The WIP's commit message is not carried into history — it is replaced by one
that describes what actually landed. 61e5b45b and a507667d, the author's two
finished commits, are untouched.

The defect the WIP shipped

The new guard names two columns. The WIP added the missing one on
classification_audit and missed the one on sessions. sessions.incarnation
arrives from ensure_session_incarnation_schema, in the reconcile — which runs
after 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) — a
table in sessions.db, one row per id prefix, holding the largest N ever
minted under it.

It survives each of the three paths the tests name because nothing lowers it:

  • A delete. delete_session names the tables it clears one by one; this is
    not among them.
  • A History reset. clear_all_sessions likewise, and deliberately — it sits
    beside 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.
  • A reopen. It is on disk, and ensure_session_id_high_water_schema runs
    from reconcile_loop_schema on every open.

CLAIM_NEXT_SESSION_N is one upsert returning MAX(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_WATER runs from the
reconcile on every open, which closes the one-mint window on upgrade (a database
at _1..5 with no mark would otherwise let its first delete lower MAX(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.

Behaviour Test Before After
A deleted chat's id goes to the next chat deleting_the_newest_chat_does_not_hand_its_id_to_the_next_one FAILED ok
A reset + reopen restarts the ids neither_a_history_reset_nor_a_reopen_restarts_the_ids FAILED ok
Upgrade dies on the guard's other column 5 migration tests, all no such column: sessions.incarnation FAILED ok
A reissued id inherits grant / recall text / checkpoint a_reissued_id_inherits_no_grant_no_recall_text_and_no_checkpoint FAILED (see below) ok
An id only a leftover checkpoint row names is reissued (first-upgrade window) the_mark_rises_above_an_id_that_only_a_leftover_checkpoint_names FAILED56bc72ab_1 minted twice ok

The 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_idempotent and
migration_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_mintedMAX(N) + 1 over
    surviving 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 passed
    vacuously: 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 this
section 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_checkpoint is the
whole-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_grants row, messages_fts text and a checkpoint; all three
are asserted present; the chat is deleted; the id is forced back; and then:

  • no grant, twice over — the row is gone, and grant::is_granted, the
    function every gate actually calls, answers false;
  • no recall text — the row is gone, and an FTS MATCH for the text finds
    nothing anywhere in the file, so nothing is left for a later reader or a backup;
  • no checkpoints.

What that fail-before actually measured is worth a reviewer's attention. With
delete_chat_side_rows disabled, the test fails on the is_granted
assertion
— not on a row count. GRANT_IS_THE_CHATS_OWN compares
datetime(g.granted_at) >= datetime(s.created_at), which is second-granular, so
a 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::grant backdates
its 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.db

Raised 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 and
does not run the seed. That is correct in isolation: the seed reads
sessions, which create_schema has just created empty, so calling it there
would 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_session un-claims its id. A mark
in 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 rm that loses
the database, while additionally being a file an agent with developer__shell
can 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 checkpoints as well as
sessions. An id an older build minted and deleted has no sessions row, so
sessions alone 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 of
that chat's state no per-table delete reaches. Its checkpoints rows do
outlive 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_chats retires those rows, and that ordering is now
pinned by a test. Once raised, the mark stays raised.

messages_fts and cross_affiliation_grants are deliberately not read by the
seed, 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 the
allocator and deleted by nothing:

State Crosses when
<knowledge root>/.active-kb-sessions/<sha256(id)> (a chat's primary KB — its write target) sessions.db lost/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) same
<data>/checkpoints/<id>/ left by an older build's delete closed by the checkpoints seed above

Why the KB files are not closed here, and what closes them. They are
reachable — biorouter depends on biorouter-mcp, and paths::primary_kb_sessions_dir,
paths::hidden_kb_sessions_dir and raw::hash_bytes are all pub — so the fix
is a post-commit removal in delete_session mirroring
remove_checkpoint_repository. It is a follow-up rather than a late addition
here 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: removing
every 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 its
gate on a session id, so until this allocator lands a reissued id defeats
that gate.

The test seam, and why it is pub

Ten fixtures — in the biorouter lib's own tests, its
conversation_writeback_stress integration binary, and biorouter-cli — build
an 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 exactly
the 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 pub only because two of those tests sit outside the lib's cfg(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 what
it is, and the_id_reuse_seam_is_only_reached_from_tests walks
crates/*/{src,tests} and pins the five files that may name it, one row per file
saying 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 the
section above.

Row Follows the chat? Where
messages, message_blobs Yes pre-existing
token_events (folded into deleted_chat_usage first) Yes PR #224 / F10
messages_fts Yes a507667d
checkpoints + the shadow git repository Yes a507667d
cross_affiliation_grants Yes a507667d, plus GRANT_IS_THE_CHATS_OWN refusing at read time
classification_audit No — by design (privacy-tiers §12.5) its backfill guard now matches sessions.incarnation, so an entry cannot shield a later chat under the same id
Subagent runs No — by design kept and never adopted; a run can hold text the user typed

Verification

Merged origin/main (19 commits) first, then:

BIOROUTER_DISABLE_KEYRING=true cargo test -p biorouter --lib -- session:: privacy::
#   → 526 passed; 0 failed
BIOROUTER_DISABLE_KEYRING=true cargo test -p biorouter --test privacy_capability --test privacy_guard_wiring
#   → 4 passed; 3 passed
cargo fmt --check                                        # clean

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:

cargo test -p biorouter --lib -- agents::session_skills            # 10 passed
cargo test -p biorouter --test conversation_writeback_stress       #  9 passed
HOME=$(mktemp -d) CARGO_HOME=… RUSTUP_HOME=… \
  cargo test -p biorouter-cli --lib -- commands::term              #  5 passed

./scripts/clippy-lint.sh is red on a clean main for an unrelated reason
(send_prompt_turn / too_many_lines), owned elsewhere.

🤖 Generated with Claude Code

…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
Broccolito merged commit 9096d37 into main Sep 12, 2026
23 of 24 checks passed
@Broccolito
Broccolito deleted the fix/deleted-chat-side-rows branch September 12, 2026 07:08
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.
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