Skip to content

Land the thirteen finished branches as one tree (#229 #240 #247 #250 #256 #257 #261 #267 #268 #273 #276 #277 #278) - #275

Merged
Broccolito merged 120 commits into
mainfrom
land/remaining-prs
Sep 12, 2026
Merged

Land the thirteen finished branches as one tree (#229 #240 #247 #250 #256 #257 #261 #267 #268 #273 #276 #277 #278)#275
Broccolito merged 120 commits into
mainfrom
land/remaining-prs

Conversation

@Broccolito

@Broccolito Broccolito commented Sep 12, 2026

Copy link
Copy Markdown
Collaborator

Lands every finished branch that was still open, as one tree, because the alternative was not working: each of these PRs appends a record to docs/deployment/serve-decisions.md or CLAUDE.md, so merging any one of them conflicted the rest. Serially that is one conflict resolution and one full CI cycle per PR; merged as one tree each branch conflicts exactly once, against an accumulating resolution, and CI runs once. Every branch's own head is a parent here, so each PR closes as merged.

What is in it

PR Branch Subject
#229 fix/serve-private-default-new-chat SD-12 — a new chat starts on the host's configured private model
#240 claude/nostalgic-fermi-6569af the steer gate hands back its refusal instead of a large Err
#247 fix/f3-app-model-cross-window every window's model chip follows the app-wide selection
#250 claude/sad-brattain-13f61d the planning gate: the checklist is enforced for multi-step turns
#256 claude/great-panini-672c85 a workflow never invents a primary knowledge base
#257 claude/ecstatic-einstein-dd05d6 SD-10 — /active_work shows and stops only the work of chats the caller may reach
#261 claude/nice-villani-42c71b the terminal asks the daemon before it asks you for the user-action key
#267 claude/gifted-shtern-1b6949 SD-13 — biorouter web serves no transcripts, and gates the turn that reaches a chat
#268 fix/ui-a11y-and-approval-card six measured interaction and a11y defects
#273 fix/serve-token-apps-sigterm-and-cli-gaps four serve / apps-serve / CLI lifecycle defects
#276 fix/license-tag-still-searched a licence republished as a tag is no longer searched, in all three matchers
#277 fix/provider-copy-workflow-kb-and-docs six finished fixes (provider copy, workflow knowledge capture, esbuild discovery, phrase search, docs)
#278 fix/gate-h-alt-provider-tier Gate H's ratcheting half

Five defects only the merged tree has

Each is a pair of PRs that were green apart and wrong together. None is visible from either side alone, which is the argument for integrating before landing rather than after.

  1. commands/serve.rs did not compile. Four serve / apps serve / CLI lifecycle defects: an ignored browser token, an orphaned daemon, a stale cancellation warning, and an ungated scheduled run #273 replaced the browser token's Option<String> with the BrowserToken enum and does the env set/remove in its own match browser_token.value() above the spawn; fix(serve): a new chat starts on the host's configured private model (QA F1) #229's branch still carried the older .envs(browser_token.iter()…) line, and the merge kept both. Dropped the superseded line — Four serve / apps serve / CLI lifecycle defects: an ignored browser token, an orphaned daemon, a stale cancellation warning, and an ungated scheduled run #273's match is strictly better, because it also removes an inherited token for --no-token, which the old line could not.
  2. An eighth chat-start surface reported a failure a fifth way. fix(serve): a new chat starts on the host's configured private model (QA F1) #229's census in ui/desktop/src/utils/startChatFailure.test.ts scans src/ for anything calling createSession|startNewSession|startAgent and requires the shared notice. Main's ExtensionLoadFailureNotice ("Ask Biorouter") starts a chat and let the rejection go unhandled: the notice stayed on screen and nothing said the chat had not opened. Wired to startChatFailureNotice, as GroupedExtensionLoadingToast already was. The census did exactly what it was written to do.
  3. A spec reached the network. Browser: a subagent's tab is read-only, and says so before you try (SD-8) #260's chatStreamStore.browserSubagent.test.tsx sets BROWSER_SURFACE_MARKER; fix(serve): a new chat starts on the host's configured private model (QA F1) #229 made userActionHeaders() state the host's BIOROUTER_PROVIDER on a browser surface, so the spec now reads /config/read. Stubbed readConfig in that spec's own mock list — the network guard is what caught it.
  4. The website's licence fix was one field short, and the field was the version. fix(search): a licence republished as a tag is no longer searchable #276 removed the licence from the searched fields; Six finished fixes: provider error copy and a /models route that answers, two workflow knowledge-selection defects, esbuild discovery leaving its worktree, phrase search on the marketplace shelves, and three doc corrections #277 replaced the whole-phrase matcher with the tokenised port of catalog_search.rs. Composed, data-tags turned out to be name + organization + version + description + tags, so v0.2.0 tokenises to 2 and 0 — and Apache-2.0 tokenises to exactly those two digits. Measured: 35 of 37 extension cards matched "Apache-2.0" through their version numbers alone. Version tokens are now dropped there and stripped from the searched .ext-org label, which also restores parity: Rust, the desktop port and the website now answer that query with the same single entry (ucsfomopagent, whose description reads "v0.2.0 adds built-in OMOP/SQL-Server context").
  5. An integration test encoded a ruling that changed under it. the_terminals_empty_steer_is_answered_by_the_gate_and_touches_nothing failed on the merged tree only — CI runs tests/*.rs on ubuntu alone, so fix(cli): the terminal asks the daemon before it asks you for the user-action key (SD-11 follow-up) #261 and fix(serve): Stop and steering work on a daemon with no user-action key (SD-11) #240 were both green apart. fix(cli): the terminal asks the daemon before it asks you for the user-action key (SD-11 follow-up) #261 was written when SD-11 read "Stop and steering work on a keyless daemon"; SD-11 then settled narrower, and reply::steer_refusal now answers from the headers before any chat is resolved. The test's expectations were updated to the settled ruling, and what the terminal actually needs is unchanged and still asserted: never the EMPTY 403 (which would send a serve user to find a key that does not exist), always a sentence it can print, and the question touches neither the turn nor the queue.

Conflict resolutions, and what each preserved

Also fixed here: ./scripts/clippy-lint.sh was failing on main

Three clippy::too_many_lines violations, two already on main (102/100 in handle_execute_code, from #246; 105/100 in backfill_privacy_from_recorded_provenance) and one this tree added (101/100 in commands/agent.rs::run). All three are extractions, not rewrites — no statement moved relative to another, and each helper keeps the comments that say why its guards exist. The gate now passes.

Verified locally on this tree

  • cargo fmt --check clean; cargo check --workspace --all-targets clean; ./scripts/clippy-lint.sh — all baseline checks pass.
  • npx vitest run (excluding artifactCdnAssets.browser, whose afterAll browser.close() exceeds 30 s on pristine main too): 469 files, 5291 passed, 1 skipped, 0 failed.
  • npm run lint:check: tsc --noEmit, ESLint, check:themes, 332 contrast assertions, check:tokens — all pass.
  • npx prettier --check "src/**/*.{ts,tsx}": 0 failing files, measured 2026-09-12. Nothing in CI runs Prettier, so this is a measurement, not a gate.
  • landing/: baam-privacy-facet 16/16, baam-search 17/17, build-registry 56/56, check-docs-privacy 21/21, build-registry.mjs --check current (37 extensions, 129 skills), check-consistency.mjs --check clean. No registry datum touched.
  • Rust --lib --bins and every tests/*.rs integration binary CI runs (the sandbox and ui_example_apps exclusions aside) — see the check runs on this PR.

Not in it

🤖 Generated with Claude Code

…vate model

biorouter serve spawns biorouterd with no user-action key (SD-7), and the
new-chat gate demanded that key's proof before binding a private default,
so every POST /agent/start on a serve daemon configured with a private
provider was refused 409 — the 2026-09-10 QA run's F1.

The configured default is the person's choice, made out of band with
biorouter configure (open question 24 put the raise at the write), so on
a daemon that holds no key the new-chat bind no longer asks for a proof
nobody can give. A daemon that holds a key (the desktop's) still refuses
a proof-less first bind: the renderer sends the proof for free, and a
model holding the recovered secret should not mint a private chat.

The exemption covers one provider at one moment. On a keyless daemon,
/agent/update_provider now measures every move onto a private model from
Public (raise_baseline), so a new chat cannot be moved sideways to a
private model nobody configured.
the_documented_closure_is_the_one_the_code_performs took the FIRST
TierRaiseNeedsUser in routes/agent.rs. Since eb594de that has been the
new-chat gate, not update_agent_provider's, so deleting the proof check
from update_provider left the scan green (measured). The scan now starts
at the handler AR-15 is about and is bounded by the next route.
A failed POST /agent/start reached console.error and nothing else on the
Home composer (the QA run's F1): the text vanished and nothing appeared.
One pure notice, startChatFailureNotice, now words every failure for a
person (the daemon's own text stays behind Copy error), and every surface
that starts a chat reports through it: Home, a fresh tab, a workflow
window, the launcher, both Ask Biorouter buttons and Workflows. A source
scan keeps an eighth surface from picking its own way to fail.

Also fixed on the way: a workflow window whose start failed re-ran its
creation effect in a loop, and a failed workflow start replaced the
Workflows list with a list-load error.

A browser tab now states the host's configured model (X-Caller-Provider)
where the desktop proves the person: measured, a chat started on a
private host model 403s its next request once its first reply makes it
private, and 200s with the host provider stated.
The notice set a traceback only when it replaced the daemon's words, so
the commonest failure on a serve host — the configured model has no
credential there — showed no Copy error. It is always set now.
… a proof

Records the ruling behind the new-chat fix: what it exempts (the configured
default, at creation, on a daemon with no user-action key), what it does
not (any other private model; any daemon that holds a key), the two
halves that keep it from opening more, and the consequence to accept,
with the privacy checklist's two questions answered as an enumeration.

browser-access.md no longer promises a private-provider serve host works
while it refused every chat, and says what a browser chat on one does.
programmatic-session-access.md names the browser as a sender of
X-Caller-Provider and the one proof-less bind. SD-1 named a route that
does not exist (/config/provider); it is /config/set_provider. The two
409 descriptions in the OpenAPI spec now say which daemon refuses.
It said the daemon refuses every request that raises a session's privacy
capability. A new chat binding the configured provider is no longer one of
them, so an operator reading it would conclude a private model cannot work.
…r a knowledge base

QA on merged main 7c96d79 (2026-09-10) measured four places where a caller
holding nothing but the daemon secret - which a public chat's shell recovers
with ps eww - was answered by the daemon while the tool path refused it:

- H2: every /knowledge/bases/{id}/... route served a private base's pages,
  graph, history, location and a .brkb export; GET /knowledge/bases listed it.
- M1: GET /sessions returned every chat on the machine, private ones titled.
- M2: GET /agent/tools?session_id=<private> returned private-extension tool
  names while add_extension on the same chat refused.
- F0: DELETE /sessions/{id} deleted a private chat the read refused, 4 of 4.

Each is now the singular read's own gate (session_reach's pure decision),
composed rather than re-derived:

- Knowledge bases: one route_layer (session_reach::gate_knowledge_base) on a
  sub-router holding exactly the routes that name a base by {id}, reads and
  writes alike. An absent or malformed id is answered as a private one. The
  bases list and /knowledge/active omit what the caller cannot reach, and a
  selection write cannot move what its caller cannot see
  (KnowledgeService::set_selection_within).
- Chats: session_reach on DELETE, rename, workflow values, the in-place edit
  arm (it truncates), extensions, usage, /agent/tools, callable_tool_count,
  /workflows/create and /skills/session; every refusal is GET
  /sessions/{id}'s byte for byte. GET /sessions, /sessions/sidebar and
  /schedule/{id}/sessions filter to the rows that gate admits (the sidebar
  scans so paging stays whole). Conversation ingest checks every named chat.
- biorouter serve: its own interface (the served document's cookie) keeps the
  operator's configured-provider tier on listings and knowledge bases, which
  were open to it before; the transcript gate never reads it.

Nothing refused before is permitted now. Tests show each route failing before
and passing after; the wiring census names every new call site.
It claimed 'not a widening'. It adds no reach for a caller holding the
daemon secret, but a tab on a private-model host now opens private chats,
including desktop-started ones — which SD-9 records.
…covers

The daemon now answers a request without X-User-Action as a public model on
every route that names a chat or a knowledge base (previous commit), so the
desktop - the person at the keyboard - says so on each of them:

- Knowledge view: knowledgeFetch and the ingest/lint SSE stream attach the
  proof centrally; listBases, getActive, getGraph, getLocation, getPageBody,
  previewState, listHistory, restoreState, getKbTier and deleteBase carry it.
  The listing matters twice: KnowledgeContext prunes its selection against
  it, so a filtered list would read as deleted bases.
- Chats: the session list cache, sidebar, first-run privacy notice, delete,
  rename, workflow values, in-place edit, extensions, usage, tool count,
  skills, schedule runs and create-workflow.
- History's Export also sends it: the export route was gated in an earlier
  sweep and exporting a private chat from History had no proof to show.

sessionListCache captures include_subagents before the proof's async hop, so
an orphaned request still asks for the list it was issued for. Tests that
pinned exact call arguments now assert the proof is sent.

Docs: privacy-tiers.md records what shipped and what did not change; the
execution plan's 'the Knowledge view is the user' scope note is marked
superseded and open question 15(b) answered; serve-decisions.md gains SD-9
(the served interface keeps its operator's reach on listings and knowledge
bases, and gains no transcript); programmatic-session-access.md's route
tables match the tree; session_reach.rs answers its open listing question.
OpenAPI spec and TS client regenerated for the new 403 responses.
…ere CI looks

CI runs cargo test --workspace --lib --bins, so the integration binaries that
held the H2 route sweep (tests/knowledge_routes.rs) and the served-operator
standing (tests/serve_operator_reach.rs) were not what kept either door shut.
Both now have a copy in routes::session_reach's lib tests, driven through
routes::configure — the tree the daemon serves.

The serve decision record becomes SD-10 (SD-9 is claimed by PR #229), and its
transcript bullet is reworded so it holds whether or not the interface states
a capability: the cookie never reaches a transcript, and a stated capability is
judged at those routes exactly as any caller's is.
…bin also links

routes::session_reach is compiled into the biorouterd bin as well as the
library, and the bin has no auth module of its own, so the new served-operator
test named crate::auth and failed to build there (clippy caught it; cargo test
--lib alone did not). It now goes through biorouter_server::auth, which is the
static http_caller reads in either binary.
F3 (provider QA, 2026-09-10): the per-chat binding already crossed windows on
`biorouter:session-binding`, but the app-wide selection — BIOROUTER_PROVIDER /
BIOROUTER_MODEL, the pair `/agent/start` binds a new chat to — had no
announcement at all. The module header claimed each window "picks it up from
its own config read"; each window read it once, at mount.

Add `announceAppModelSelection` / `subscribeAppModelSelectionChanges` on the
same channel, told apart from a binding by shape. The message is a nudge with
no provider and no model: two windows' writes can be announced in the opposite
order from the one they landed in, so a receiver must re-read the daemon
rather than apply a payload. Local listeners run synchronously so the writing
window re-reads too.
…(F3)

F3 (provider QA, 2026-09-10, HIGH). Change the app-wide model in window 1 and
window 2's chip never moved. Window 2 read `gpt-5.5-2026-04-24 (Private model,
UCSF)` at the instant of send; the chat it created bound `claude_code`, was
classified public — correctly — and its turn went to a consumer subscription
with no BAA. `ModelAndProviderContext` read BIOROUTER_PROVIDER/BIOROUTER_MODEL
once, on mount, while `/agent/start` binds whatever those keys say on the
daemon at that instant.

- ModelAndProviderContext re-reads the pair (a pure read, never the fallback
  seeding) on the app-wide announcement and when its window regains focus or
  becomes visible. Every statement of the selection — mount read, re-read, own
  switch — is ticketed and publishes only if nothing issued after it has been
  published, compared against what was last APPLIED (a failed newer read must
  not condemn an older good one). A read that returns no body keeps the label
  rather than erasing it.
- Every renderer write of the two keys announces: `changeModel`, the
  first-run default seeding, and ConfigContext's `upsert`/`remove` — which
  covers onboarding's local and coding-agent cards, Lead/Worker and reset,
  none of which updated even their own window's chip before.
- A switch made from inside a chat now changes THAT chat only, unless the new
  "Also use for new chats" box is ticked (unticked by default). This is
  privacy-tiers §14.3 P4's recommended decoupling: QA F bound Claude Code in
  one chat for one check and the next chat it opened came up public. With no
  chat (Home, a chat not yet started, Settings, onboarding) a switch sets the
  model new chats start on, and the dialog, the success toast and the chip's
  dropdown ("Model for new chats") now say so, including "in every window".
- Both new-chat composers (Home and a not-yet-started chat) re-read the pair
  immediately before `createSession`. If the chip was stale — a
  `biorouter configure` in the terminal docked inside the window never takes
  its focus — the send is refused, the fresh model and its tier go on screen,
  a toast names what changed, and the composer gets the text back.

The pin still outranks a stale row: the app-wide selection touches neither.
… ledger

New docs/desktop-ui/model-selection-across-windows.md: the two facts a model
chip can state (a chat's binding vs the app-wide selection /agent/start binds),
what a switch changes from each surface and why (privacy-tiers §14.3 P4), how
each window stays current (announcements, focus re-reads, ticketed reads), the
last look before a new chat, what it does not cover, the tests, and how to
check it in the running app. Indexed in docs/desktop-ui/README.md.

privacy-tiers.md's "What shipped" ledger gains a dated line for P4, so §14.3
no longer reads as open.
The Todo capability could always keep a checklist; nothing made the model
keep one. The only trigger was an advisory paragraph in system.md, the todo
MOIM rendered nothing while the list was empty, the old "don't stop with
unchecked todos" gate had been removed, and under Code Execution mode the
todo tools were reachable only from inside a script. Three multi-step QA
sessions on 2026-09-10 produced zero checklists.

This makes the checklist a harness behaviour rather than a prompt hint:

- Code Execution filter: the five todo__* tools stay directly callable
  (exact names via todo_extension::TODO_TOOL_NAMES, never a prefix).
- agents::planning_gate: a small tested classifier reads the user's prompt
  (numbered/bulleted list of actions, 3+ instructions, or 2+ joined by
  then/after that/finally/steps; how-to questions and code never count).
  For a multi-step turn with an empty checklist:
  * the MOIM carries a "write the checklist first" reminder until it exists;
  * the first non-todo tool batch is refused with a pointer to todo_write,
    once per turn, as a synthetic inspection denial in
    inspect_and_gate_tool_requests (not a registered inspector: the bridge
    and approval relay would run it too, and the bridge flattens reasons);
  * a turn that created or changed the list cannot end while items are
    unfinished unless the final message names each one; at most
    STOP_HOOK_BLOCK_CAP blocks per turn, on a count tools do not reset.
- Scope is one predicate (enforcement_applies) read by the turn and by the
  system prompt, so the prompt's new clause describes exactly what runs:
  not Chat mode, not subagents, not coding-agent bridge providers, only
  with todo__todo_write on the roster. Todo disabled: none of it fires.
- The Stop-hook arms move out of the reply_internal generator into
  decide_turn_stop (checklist check, then hooks), shrinking its poll frame.
- Docs: todo.md, code-execution.md and hooks-reference.md say what is
  enforced; stale sessionTodos/ChatSummary comments corrected; a ChatSummary
  test pins a direct todo_write appearing and ticking in the summary.
…race the stop check

Every offset was a regex match boundary or a find() result, so a char
boundary, but the workspace denies clippy::string_slice; str::get states the
same thing without an index that could panic. The stop check now logs its
block and give-up decisions, so a runtime run can be read from the daemon log.
Found by driving the running app. `Checkbox` draws its square beside an
`sr-only` input, and the switcher's label was a SIBLING (`htmlFor`), so only
the words toggled the box — a click on the square itself did nothing. Wrap the
box and its words in one `label`, as SessionListView's checkbox already is.

The new test clicks the square (Checkbox's own target) and fails against the
sibling-label markup with `expect(element).toBeChecked()`.
Correct the sandbox store path (`<run>/data/sessions/sessions.db`), add the
recipe for seeing the last look refuse an unannounced hand edit, record the
toast-class trap (`TOAST_SURFACE_CLASS_NAME`, not `Toastify__toast`), and the
2026-09-11 measurements: 310 ms and 390 ms cross-window lag, both turns'
token_events equal to the chip at send, the last look refusing both
directions within 100 ms with no focus event.

Also records, under "What this does not cover", that `/config/set_provider`
writes provider then model as two writes — `config.yaml` held a mixed pair for
~55 ms — which a `/agent/start` in the gap would bind. Daemon work.
A private provider's request log is metadata-only, so neither the reminder
nor the prompt clause can be read back from it, and the first live run could
not tell a gated turn from a model that planned on its own. The turn now logs
one line saying whether the gate is out of scope, in scope, or armed (with the
signal's kind and counts, never the prompt), and debug lines when the reminder
is added or the checklist disarms it.
Only the naming of an open item is checked, not the reason, so the
intro no longer says the agent 'tells you which ones and why'.
…s on it

# Conflicts:
#	docs/deployment/serve-decisions.md
…3f61d

# Conflicts:
#	crates/biorouter/src/agents/agent.rs
main moved 60 commits (19 QA-fix PRs). One textual conflict, in
ConfigContext.tsx's imports: main widened the privacy-tiers import for H3's
master-switch record (`PRIVACY_TIERS_RECORD_KEY`, `privacyTiersRecordFromConfig`,
`PrivacyTiersRecord`); this branch added the `sessionBindingSync` import beside
it. Both kept.

Auto-merged and checked by hand: SwitchModelModal.tsx (#222's derived
`validation` + `aria-describedby` alongside this branch's scope copy, "Also use
for new chats" box and `changeModel` options), its privacy test (#222's three
new tests use call counts; the one exact-argument assertion is this branch's),
Hub.tsx and BaseChat.tsx (#227's PrivacyTiersOffNote mount beside this
branch's pre-send check), and privacy-tiers.md (both ledger additions). The
renderer's writers of BIOROUTER_PROVIDER/BIOROUTER_MODEL are unchanged by main,
so every one still announces.
serve-decisions.md conflicted: main's #226 added SD-9 (the launch token
works until the daemon stops) and made the same SD-1 route fix this branch
made. SD-1 takes main's wording; main's SD-9 stays SD-9; this branch's
record becomes SD-12, after it. SD-10 and SD-11 are claimed by open #237
and #240, so SD-12 duplicates no number on main or in any open PR.

Every reference to this branch's record moves with it (code comments, the
two OpenAPI 409 descriptions, the daemon's keyless warning, the docs and
their anchors, CLAUDE.md). References to main's SD-9 are untouched.
CLAUDE.md's '(SD-1..SD-9)' range is replaced by no number at all, since
every record that lands would make it stale.
main's H3 note (PrivacyTiersOffNote) now renders in Hub and needs a router
and two ConfigContext hooks this test's mocks do not provide; it has
nothing to do with starting a chat.
…primary

The create-workflow modal computes the captured default in two places: from the
chat's own selection read, and from the generated workflow's `knowledge_bases`
block. Both fell back to `visible[0]` when the chat named no primary, so the
saved workflow's `default` was the first visible base. `apply_knowledge_selection`
maps `default: Some(id)` to `PrimaryUpdate::Set(id)`, so every chat the workflow
started got a primary, the target of KB-less writes, that the chat it was
captured from never had.

The daemon's rule is the opposite. `plan_knowledge_selection` never infers the
primary from `visible`, and the doc on `plan_workflow_knowledge_selection` says
why: a promoted primary turns "I did not say where to write" into a commit into
someone's base. With no primary, a KB-less write fails and names the candidates.

Both paths now go through `primaryAmong`: the named primary if it is among the
bases the workflow will see, and otherwise `null`. The daemon's block for a
chat with no primary omits `default` entirely (`skip_serializing_if`), and
that reads as `null` too.

A primary outside `visible` becomes `null`, not unioned in the way the daemon
unions an author's `default`. The daemon does that because somebody wrote the
workflow and meant it. A captured primary outside its own set is an
inconsistent read. The block is one locked snapshot and never is one, but the
modal's own read is two requests (the base list and the selection), and a base
created or deleted between their answers leaves the selection naming a base the
list lacks. Unioning could save a deleted base as the default, which
`set_visible_kbs` refuses, so every chat the workflow starts would fail with a
400. After a failed list it would save the primary as the only visible base,
hiding every other one.

Tests: the read path with no generated block; the generated block with
`default: null` and with `default` absent (the daemon's own wire shape); and a
primary outside the visible bases, once from the read and once from the block.
All five fail before this change: each saved the first visible base.
…wledge base

The previous commit stops the create-workflow modal from inventing a primary when
it captures a chat's selection. Editing the selection afterwards still invented
one, in two places:

- The picker (`WorkflowResourcePicker`). Switching a base on made it the
  default whenever none was set, and switching the default off handed the role
  to `next[0]`.
- The modal's `onKnowledgeBaseIdsChange`. It promoted `ids[0]` on any change
  while no default was set, including switching off an unrelated base.

So a user who captured a chat with no primary and then removed one base got the
first remaining base as the write target of every chat the workflow starts.

These are user gestures, not captures, which is the case for keeping them. They
go anyway. The gesture is "this workflow may search this base", and the
promotion adds "and KB-less writes go here", which the user did not ask for.
It is the promotion the daemon removed in 3a6e688 ("never promote a sole visible
base to the primary"): "exactly one candidate" was treated as consent, which it
is not, and the author who wants that base as the write target has a field to
say so. In the picker, that field is the Default control, one click away on
every selected row.

- Switching a base on changes the selection only.
- Switching the default off leaves no default, rather than passing it on.
- The modal's handler keeps only the membership rule: a named default stays
  while its base is selected and becomes `null` when it is not.
- The Default control is now a toggle (`aria-pressed`), so pressing the current
  default clears it. Without that, "these bases, and no default", the state a
  chat with no primary now captures, could not be restored once any base had
  been made the default, short of switching it off and on again. Each control
  is named for its row ("Default KB: lab-notes") so the pressed state says
  which base it belongs to.

Tests, in the modal: switching a base on, switching another off, and switching
the primary off each save no primary. All three fail on the previous commit.
Switching a base on still fails with only the handler fixed (the picker names
the base) or only the picker fixed (the handler names `ids[0]`). In the picker:
switching on leaves the default unset, switching the default off clears it,
and the Default control names a base and clears it when pressed again. All
three fail before this change. A fourth, switching another base off leaves the
default alone, passes either way as a guard against clearing it on every
toggle.
Both sides appended a decision record to serve-decisions.md; keep both,
in number order (SD-11 from #260, then this branch's SD-13).
# Conflicts:
#	CLAUDE.md
#	crates/biorouter-server/src/commands/agent.rs
#	docs/deployment/serve-decisions.md
…kflow-kb-and-docs

# Conflicts:
#	crates/biorouter-server/src/routes/config_management.rs
Each is a pair of PRs that were green apart and wrong together.

1. `commands/serve.rs` did not compile. #273 replaced the browser token's
   `Option<String>` with the `BrowserToken` enum and does the env set/remove in
   its own `match browser_token.value()` above the spawn; #229's branch still
   carried the older `.envs(browser_token.iter()...)` line, which the merge kept
   beside it. Dropped the superseded line — #273's match is strictly better, since
   it also REMOVES an inherited token for `--no-token`.

2. `components/extensions/ExtensionsView.tsx` was an eighth chat-start surface
   reporting a failure a fifth way. #229's census in `utils/startChatFailure.test.ts`
   scans `src/` for anything calling `createSession|startNewSession|startAgent` and
   requires the shared notice; main's `ExtensionLoadFailureNotice` (an "Ask
   Biorouter" button) starts a chat and let the rejection go unhandled, so the
   notice stayed on screen and nothing said the chat had not opened. Wired to
   `startChatFailureNotice`, as `GroupedExtensionLoadingToast` already was. The
   census was doing exactly what it was written to do.

3. `chatStreamStore.browserSubagent.test.tsx` reached the network. #260's spec
   sets `BROWSER_SURFACE_MARKER`; #229 made `userActionHeaders()` state the host's
   `BIOROUTER_PROVIDER" on a browser surface, so the spec now reads `/config/read`.
   Stubbed `readConfig` in the spec's own mock list.

vitest: startChatFailure 14/14, chatStreamStore.browserSubagent 7/7.
Picks up PR #262, which fixes the harness defect this branch's CI kept
tripping over: `MentionPopover.privateChat.test.tsx` installed a jsdom
`scrollIntoView` stub in `beforeEach` and deleted it in `afterEach`, but
the scroll runs from a PASSIVE effect that React flushes during setup.ts's
`cleanup()` — which vitest runs AFTER the spec's own `afterEach`. Measured:
the polyfill is on `origin/main` (99bf415) and on neither PR branch, and
both branches still carried the per-test install/delete at lines 77-78 and
109-111. One scheduler turn wide, so it only opens on a loaded runner.

Conflicts, and how each was settled:

* CLAUDE.md, docs/deployment/serve-decisions.md,
  docs/deployment/programmatic-session-access.md — both sides appended a
  numbered record or a table row. Kept BOTH, in number order: this branch's
  SD-10 then main's SD-11, and main's `/agent/cancel` row beside the
  `authorize_agent_control` row it shares a mechanism with. The SD range in
  CLAUDE.md becomes the union, SD-1..SD-11.

* routes/session_reach.rs — the module doc's gated list and its "two
  spellings, one list" paragraph. Union of both sides' rows; the count is
  now seven routes reached through a helper (four via
  `authorize_agent_control`, two more via `authorize_turn_control` into it,
  and `/active_work/{id}/cancel` via `work_reach`). The over-read control
  list drops `get_session_extensions`, which this branch GATED and which
  therefore can no longer serve as a control, and keeps main's extra
  `reply::routes` control.

* routes/agent.rs — both branches gated `GET /agent/callable_tool_count`,
  independently. Kept main's body (it handles an initializing child and
  places the gate before the agent is minted) inside this branch's wrapper,
  because the wrapper is what makes the refusal PLAIN TEXT through
  `SessionOutOfReach::into_response` instead of JSON through the route's
  `ErrorResponse`. That is not cosmetic: `one boundary has one body` is
  measured by two tests in `routes::session_reach`, and `?` failed both.
  This branch's `GET /agent/tools` gate is untouched by the merge and its
  `/agent/tools` row replaces main's now-false "deliberately not here" note
  — reach and Gate-E filtering are different questions, and only the second
  stays deliberate there.

* tests/privacy_guard_wiring.rs — one census row per file, extended not
  added: agent.rs is `c(6, 6, 0)`, this branch's two new gates plus the
  four that were already there, with main's SD-11 note that the turn-control
  gate adds no call. Verified by running the census rather than by counting.

* ui/desktop KnowledgeContext.tsx, useHistory.ts, useKnowledgeBases.ts —
  main's refactor already sends the user-action proof this branch was
  adding (`fetchKnowledgeSelection`, `refreshBases`), so main's side stands,
  keeping only this branch's proof header on the base delete. The
  `setPrimaryKbId(null)` beside it goes: the docstring directly above it
  says there must not be one, because the delete is itself the repair (D2).

* openapi.json / types.gen.ts — kept in step with the utoipa annotation.

Also fixes the one genuine, deterministic failure on this branch:
`crates/biorouter-server/tests/privacy_ar15_is_retired.rs` refused the new
line `the secret as "not a human" for changing a tier (AR-11/AR-15)` in
docs/security/privacy-tiers-execution-plan.md — a bare citation of a closed
risk with no disclosure word within 250 bytes, which reads as a live one.
Fixed in the prose: both risks are now links, and the sentence says AR-15
is retired and that the tier route's posture is *why*.

Measured after the merge:
  cargo test -p biorouter-server --test privacy_ar15_is_retired  7 passed
  cargo test -p biorouter --test privacy_guard_wiring            3 passed
  cargo test -p biorouter-server --lib -- routes::session_reach 47 passed
…re two counts

Follow-up to merging 133 commits of main into this branch. Nothing here changes
behaviour a user can see; each item is a claim in the six commits that the merge
made false.

**The canonical matcher moved, so the port's three citations were dead.** PR #266
renamed `crates/biorouter/src/marketplace/search.rs` to
`crates/biorouter/src/catalog_search.rs`. `landing/marketplace-search.js`,
`landing/scripts/baam-search.test.mjs` and `landing/baam.html` each named the old
path as the module to read for the reasoning -- the one pointer a future reader
of a port most needs. All three now name the new path and say when it moved, the
way `ui/desktop/src/components/baam/search.ts` already does on main.

**And PR #266 did not only move it -- it changed a rule the port mirrors.** The
verbatim-phrase bonus became `written_in`: present as a substring AND not buried
inside a longer word at either end, with the boundary imposed only by an end that
is itself a word character. The port still asked `indexOf(...) !== -1`, so it had
drifted from the module it claims to mirror rule for rule.

Ported, with that module's own six assertions run against it. Worth being exact
about the blast radius, in both directions: the shelves call `matching`, which
collapses `rank` to a membership set, so **nothing on the page moves** -- a
visitor sees the same cards in the same order either way. That is also why the
drift could have sat there indefinitely, and why the rule is now pinned by a test
rather than left to the next reader to notice.

**`declared_model_names`' own doc contradicted the comment 100 lines below it.**
The route's `Ok(None)` arm still said "six of those nine ship a curated
`with_models(...)` catalog" -- the figure produced by the instrument the
function's doc-comment exists to warn against, which undercounts by three because
`ProviderMetadata::new` also takes a `model_names` list. In a commit whose subject
is copy that does not contradict itself, that is the wrong comment to leave
standing. It now reads nine, measured off `known_models`, and points at the
function that explains the measurement.

**The subagent count moved 197 -> 198 while this branch waited.** The whole point
of that CLAUDE.md line is that a wrong figure is worse than none, because this
repo asserts "pre + N" against it. Re-measured on the merged tree:

    BIOROUTER_DISABLE_KEYRING=true cargo test -p biorouter --lib -- subagent
    test result: ok. 198 passed; 0 failed; 3794 filtered out

so the line carries 198 and today's date, and now also records that it moved by
one in a single week -- which is the argument for re-measuring rather than for
trusting any figure written here, this one included.

Verified: node --test landing/scripts/baam-search.test.mjs 17/17 (the browser
half drives the real page, so the baam.html edit is covered);
baam-privacy-facet.test.mjs 14/14; build-registry.mjs --check reports all three
outputs current, and no registry datum is touched.
No conflicts. The point of the merge is PR #262, which fixes the harness
defect this branch's `Unit tests (vitest)` run tripped over:

  FAIL src/components/MentionPopover.privateChat.test.tsx
       > offers this chat's knowledge bases, not every base, and names its primary
  TypeError: selectedElement.scrollIntoView is not a function
    at src/components/MentionPopover.tsx:819 (commitHookPassiveMountEffects)

Not this branch's feature and not an assertion: the spec installed a jsdom
`scrollIntoView` stub in `beforeEach` and DELETED it in `afterEach`, but the
palette's scroll runs from a PASSIVE effect that React flushes when
setup.ts's `cleanup()` unmounts — and vitest runs the spec's own `afterEach`
BEFORE that. The property is already gone when the queued effect fires, so
the throw lands inside React and fails a test that had already asserted its
point. The window is one scheduler turn wide, which is why it opens on a
loaded CI runner and reads as flakiness.

Measured rather than assumed:
  * the polyfill now lives once, process-wide, in ui/desktop/src/test/setup.ts
    with a regression guard in src/test/scrollIntoViewPolyfill.test.tsx —
    both present on origin/main at 99bf415 (PR #262) and on NEITHER PR
    branch, which merged an older main;
  * this branch still carried the per-test install/delete at
    MentionPopover.privateChat.test.tsx:77-78 and :109-111, and the merge
    replaces the whole spec with main's;
  * vitest.config.ts sets no `pool`/`isolate`, so the defaults (forks,
    isolate: true) apply and cross-FILE pollution is structurally
    impossible — the leak was always inside this one file.

After the merge, on this branch:
  npx vitest run --testTimeout=30000 --hookTimeout=30000
    (excluding src/utils/artifactCdnAssets.browser.test.ts, whose afterAll
     browser.close() exceeds 30 s under load on pristine main too)
  Test Files  458 passed (458)
        Tests  5179 passed | 1 skipped (5180)
PR #242 and its port PR #255 stopped searching the `license` FIELD, because
every BAAM entry is Apache-2.0 and a licence separates nothing. The defect
survived, one field over: the registry publishes the licence again as one of
each entry's own TAG chips — and, for a skill, a third time among its
keywords — and labels are searched, rightly. A test asserting "the license is
not searched" passed while `PACS` still listed most of the catalog.

Measured by driving the real Browse-extensions modal against the live 37-entry
registry, with the field already gone:

    (empty)      37
    PACS         31
    pac          31
    apache       31
    Apache-2.0   32
    zzzznope      0

`PACS` ≡ `pac` ≡ `apache` = 31 identifies the path: `PACS` → its singular
`pac` → inside `apache` → the `Apache-2.0` chip on 31 rows, none of them about
PACS (BenchlingAgent, DNAnexusAgent, OMEROAgent…).

One rule, in all three copies: a label that says nothing its entry's own
licence does not is dropped when the searchable text is assembled. By WORDS,
not by equality — the tag is `Apache-2.0` and the keyword is `apache`, so an
equality test drops the tag and leaves the keyword matching 49 skills. Over
all 166 live entries the rule drops the 129 licence labels and nothing else.

Counts after, identical on both matchers (Rust / TypeScript):

    (empty)    37 ->  37 ext, 129 -> 129 skill
    PACS       31 ->   1 ext,  51 ->   7 skill
    pac        31 ->   1 ext,  51 ->   7 skill
    apache     31 ->   0 ext,  49 ->   0 skill
    Apache-2.0 32 ->   1 ext,  49 ->   0 skill

Every legitimate query is byte-identical before and after: `R scripting ggplot
visualization`, `r-scripting`, `SPOKE knowledge graph`, `ggplot`, `heatmap`,
`python`. Differential parity re-run over 1,192 shared queries × 2 catalogs:
0 set mismatches, before and after (the 391 tie-order differences are the one
divergence the port's header already documents — Rust breaks ties by id, the
document by registry order).

The website shelf shared the defect and was worse, because its `data-license`
is on every card rather than only the tagged ones: `apache`, `Apache-2.0` and
`pac` each matched all 37 extension cards and all 132 skill cards. Registry
data is untouched; only the haystack changed.
`./scripts/clippy-lint.sh` fails on `main` today, and this tree added a third
violation to the two already there. All three are extractions, not rewrites: no
statement moved relative to another, and each helper keeps the comments that say
why its guards exist.

- `commands/agent.rs` `run` (101/100) — the block this tree grew. The
  proof-of-user read, the level of its report and SD-12's launch-state pin move
  into `install_user_action_proof`, which keeps them beside the read they all
  depend on. The pin still lands before `AppState::new()` and before any route
  is mounted, because the one call site is where the inline block was.
- `agents/code_execution_extension.rs` `handle_execute_code` (102/100, from
  #246) — the `_meta` assembly moves into `collected_meta`. Assembly only; every
  key there has a reader in the desktop artifact panel.
- `session/session_manager.rs` `backfill_privacy_from_recorded_provenance`
  (105/100) — the shape guards move into `prepare_backfill_shape`, which answers
  `None` for "skip the backfill" and `Some(turn_ledger)" for "run it, and here is
  whether the second evidence source is readable". Each guard in it is a failed
  startup that happened; the comments saying so travel with them.

`./scripts/clippy-lint.sh`: all baseline checks pass. `cargo fmt --check` clean.
`cargo check --workspace --all-targets` clean.
`clippy::string_slice` is `warn` in the workspace `Cargo.toml`, so under
`scripts/clippy-lint.sh`'s `-D warnings` the new
`every_failure_before_a_new_chat_is_returned_discards_it` was a hard error:
`&body[start..end]` panics when either bound falls inside a UTF-8 character.

It cannot here -- both bounds come from `str::find` -- but "cannot" is the
argument the lint exists to stop being made in a comment, so the bounds are taken
through `get` and the reasoning moved into the `expect`.

Measured on the merged tree:

    cargo test -p biorouter-server --lib -- routes::agent
    test result: ok. 63 passed; 0 failed

    cargo clippy --all-targets -- -D warnings \
        -A clippy::result_large_err -A clippy::string_slice
    exit 0, zero findings

⚠ Those two `-A` flags are NOT this branch's debt and must not be read as a
waiver of it. Both fire on files this branch does not touch, and pristine
origin/main was measured failing the same way:

  * `result_large_err` at routes/reply.rs:1863 (`authorize_steer`) -- reproduced
    on a detached origin/main worktree, `cargo clippy -p biorouter-server --lib
    -- -D warnings`, exit 101, same line;
  * `string_slice` at commands/agent.rs:480 (`&digest[..62]`) -- the line is
    byte-identical on origin/main and absent from this branch's diff.

Allowing exactly those two is what lets the sweep reach every other target and
show that this branch adds nothing. CI is unaffected either way: rust.yml's
clippy step is `cargo clippy --workspace --all-targets --locked` with no
`-D warnings` and is labelled informational, so both are warnings there.
#276 and #277 both change how the website's shelves are searched, from
different ends, and the merge needs both:

* #277 replaces the whole-phrase `hay.indexOf(q)` with the tokenised, ranked
  matcher in `landing/marketplace-search.js` (`hits.has(c)`), a port of
  `catalog_search.rs`.
* #276 removes the LICENCE from what is searched — it reaches the haystack as
  `data-license`, as an `Apache-2.0` chip inside `textContent`, and as a tag.

Taken naively the merge keeps #277's matcher and silently loses #276's fix,
because #277 assembles its own fields in `cardFields` rather than through
#276's `searchHaystack`. Composed instead:

- The three conflicted call sites take #277's `hits.has(c)`. `searchHaystack`
  stays, because the FACET loop still reads `hay` and must read the
  licence-free one.
- `cardFields` drops the licence by WORDS, not by equality: `apache` and
  `Apache-2.0` are one licence spelled two ways, and the skills shelf carries
  both. `namesOnlyTheLicense` is #276's own predicate.
- And `data-tags` is not the tag list — it is
  `name + organization + version + description + tags`, so it also carried the
  VERSION. `v0.2.0` tokenises to `v0`, `2`, `0`, and a bare `2` or `0` — which
  is exactly what "Apache-2.0" tokenises to — matched **35 of 37** cards through
  their version numbers alone. Version tokens are dropped there and stripped
  from the searched `.ext-org` label. Neither `catalog_search.rs` nor the
  desktop port searches a version, so this is what keeps the three in step.

That took `Apache-2.0` from 35 hits to 1, and the one that remains is
`ucsfomopagent`, whose description reads "v0.2.0 adds built-in OMOP/SQL-Server
context" — the digits, not the licence. The Rust and desktop matchers answer
this query with the same single entry.

So #276's website assertion was rewritten rather than satisfied. "nothing
matches Apache-2.0" is a property of a whole-phrase matcher; under a token
matcher the honest claim is that **the licence explains none of it**, and the
test now asserts the hits for `Apache-2.0` equal the hits for `2 0`. `apache`
and `APACHE` still assert exactly `[]`.

landing: baam-privacy-facet 16/16, baam-search 17/17, build-registry 56/56,
check-docs-privacy 21/21, `build-registry.mjs --check` current (37 extensions,
129 skills), `check-consistency.mjs --check` clean. No registry datum touched.
…tise its own base

`bind_allowed` is asymmetric on purpose: Gate A refuses only the DOWNWARD bind,
because putting a MORE private model in front of a conversation cannot leak that
conversation. Gate H inherited that predicate for every alternate-provider
construction site, and on one of them the premise does not hold.

`build_model_ref_provider` (`agents/knowledge_tool.rs`) builds a provider whose
tier does not stop in this process: it becomes `SourceIngestArgs::caller_capability`
/ `ConversationIngestArgs::caller_capability`, crosses to `caller_is_private`, and
lands in `knowledge::tier::raise_unlocked` — a permanent, monotone ratchet on a
knowledge base. So the "harmless" upward choice let a PUBLIC chat name a PRIVATE
model in `platform__ingest_source`'s `model` argument and mark its own base
private for good, after which every KB read choke point refused that chat. The
ratchet fires in `prepare_ingest_base`, before the sub-agent runs, so a curation
that failed outright still cost the user the base — and the tool answered with an
ordinary per-source report rather than an error.

Both knowledge paths reach an alternate provider through that one function — the
`model` argument (a provider name the MODEL wrote) and a base's stored
`default_model` on a scheduled digest — so the gate goes there and a third
knowledge path cannot be added without passing it. It now asks
`assert_alt_provider_matches_session`: Gate A's rule AND DR-16's, i.e. the tiers
must match. The laxer `assert_alt_provider_allowed` stays correct for CLI plan
mode and prompt hooks, whose provider is named by a person's own configuration
and whose tier stops in this process.

The new half asks `privacy::tool_bind_allowed` rather than re-spelling
`is_private() == is_private()`. That predicate arrived on main after this work
was written, for the same reason on the sibling surface (`workspace_set_tools`'
provider switch), and DR-16's rule must not have two sets of cells; the census
rows are what keep the two sites asking one predicate.

Every construction site was re-enumerated, not assumed: grep the workspace for
`providers::create` / `create_from_persisted` / `create_with_named_model`, keep
the calls where the provider is not the one the session row records AND a
session's content reaches it. The result is the three sites already documented,
plus two KB-keyed exemptions (the HTTP macro route and its CLI twin, which have a
base id and no session). Everything else falls out as a bind (Gate A), the spawn
(which already refuses both directions), a canned probe with no session content,
or DR-21's app bind. The method is written into the module doc so the next reader
can redo it instead of trusting a summary.

Also recorded, because a stale record is worse than none: `privacy-tiers.md`'s
"Did not ship" item 3 claimed DR-16's raise was HTTP-only and that
`workspace_set_tools` had no self-target guard. Both were already false on main
and are now corrected there, with this finding appended as the second half of the
same story.

This replaces a WIP commit whose own message said its ~419 insertions were
unverified. What was measured, on the tree merged with main at 9096d37, every
cargo invocation under BIOROUTER_DISABLE_KEYRING=true:

  cargo check -p biorouter --lib --tests          clean, 0 warnings
  cargo test -p biorouter --lib privacy::         246 passed, 0 failed
  cargo test -p biorouter --test privacy_capability
      --test privacy_guard_wiring --test privacy_toggle
      --test privacy_disclosure_toggle            4 / 3 / 4 / 1 passed, 0 failed
  cargo test -p biorouter-mcp --lib knowledge::tier    31 passed, 0 failed
  cargo test -p biorouter --lib -- knowledge_source_tool knowledge_tool
                                                  25 passed, 0 failed
  cargo fmt --check                               clean (the WIP was unformatted
                                                  in three places; fixed)
  ./scripts/clippy-lint.sh                        strict clippy clean on
                                                  -p biorouter -p biorouter-mcp

The instrument was verified rather than trusted. Neutering the new predicate, and
separately removing its master-switch read, each turned
`the_master_toggle_governs_every_gate_in_both_directions` RED at the armed and
the quiet assertion respectively. Reverting the call site to the laxer sibling
turned both behavioural tests RED. The toggle test gained the new gate in both
directions, which the WIP had left untested: the ratcheting half has its own
`privacy_tiers_enabled()` read, so the sibling's assertion said nothing about it.

`./scripts/clippy-lint.sh` still fails the whole workspace on
`result_large_err` at `routes/reply.rs:1863` (`authorize_steer`, from SD-11's
5c399eb). That is pre-existing: the file is byte-identical to origin/main, and
the same error was reproduced on a detached control worktree at 9096d37. CI's
clippy step passes no `-D warnings` and is labelled informational, which is why
main can carry it. Two `too_many_lines` baseline warnings are pre-existing the
same way, in files this branch does not touch.
…11, not the draft

`the_terminals_empty_steer_is_answered_by_the_gate_and_touches_nothing` failed on
the merged tree — `test (ubuntu-latest)` on #275, `turn_control_no_user_key.rs:625`,
`9 passed; 1 failed`. Neither side is broken; the test encodes a ruling that
changed under it, and only an integration binary could see it (CI runs
`tests/*.rs` on ubuntu only, so #261 and #240 were both green apart).

#261 was written when SD-11 read "Stop **and steering** work on a daemon with no
key": `/interrupt` would pass the reach gate, so an empty steer reached the text
check and answered **400** for a chat the gate admitted. SD-11 then settled
narrower — `/interrupt` asks for the proof on BOTH kinds of daemon, because
`/reply` is refused 409 by the BR-33 single-turn lock in exactly the state where
a steer lands, so admitting it would add silent mid-turn injection that nothing
else there can do. `reply::steer_refusal` therefore answers from the HEADERS,
before the body is parsed and before any chat is resolved: every row is a 403
carrying `STEER_NO_KEY`.

What the terminal needs is unchanged, and is what the test still asserts: never
the EMPTY 403 that means "this daemon holds a key" (which would send a `serve`
user to find one that does not exist), always a sentence it can print instead
(`key_verdict` → `Refused`), and the question touches neither the turn nor the
agent's queue — now trivially, since nothing is reached. The five rows are kept
rather than collapsed, so a change that admits the steer for SOME chat fails
here instead of passing quietly.

Also sharpened `steer_gate_question`'s doc in the CLI: it listed all three
answer shapes correctly but let a reader expect the 400 from a keyless daemon,
where it is now unreachable.
…ining it

The last copy of the pattern PR #262 removed. `src/test/setup.ts` installs
`Element.prototype.scrollIntoView` ONCE for the process and its own comment says
why a per-test install-and-delete is a race the polyfill cannot win: the strip
calls it from a PASSIVE effect, and vitest runs a spec's own `afterEach` BEFORE
the shared `cleanup()` whose unmount flushes that effect. `ChatTabStrip.reveal`
still did it, and its `delete` was unconditional — so it also removed the shared
polyfill for the rest of that file's process, which is the wider half of the bug.

Now `vi.spyOn(...).mockRestore()`, exactly as `setup.ts` prescribes and as
`IngestPanel.test.tsx` already did. Swept the rest of `ui/desktop/src`: this was
the only remaining redefiner. `boot-splash.test.ts` deletes
`Element.prototype.animate`, which `setup.ts` does not install, so it restores
jsdom's own state and is not the same hazard.

`ChatTabStrip.reveal` + `scrollIntoViewPolyfill`: 5/5. Prettier clean.
@Broccolito Broccolito changed the title Land the nine finished branches as one tree (#229 #240 #247 #250 #256 #261 #267 #268 #273) Land the thirteen finished branches as one tree (#229 #240 #247 #250 #256 #257 #261 #267 #268 #273 #276 #277 #278) Sep 12, 2026
@Broccolito
Broccolito merged commit 35a6784 into main Sep 12, 2026
17 checks passed
@Broccolito
Broccolito deleted the land/remaining-prs branch September 12, 2026 08:33
Broccolito added a commit that referenced this pull request Sep 12, 2026
…anch already holds

Content-free by construction: `git diff b1d3232 origin/main` is empty, because
#275 merged the integration branch this PR was already merged with. Recorded so
the branch CONTAINS main in its ancestry and not merely in its content, which is
what stops a later read of this PR coming back DIRTY. Tree unchanged — verified
by comparing the tree hash either side of this commit.
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