Skip to content

fix(privacy): /active_work shows and stops only the work of chats the caller could open - #257

Merged
Broccolito merged 21 commits into
mainfrom
claude/ecstatic-einstein-dd05d6
Sep 12, 2026
Merged

fix(privacy): /active_work shows and stops only the work of chats the caller could open#257
Broccolito merged 21 commits into
mainfrom
claude/ecstatic-einstein-dd05d6

Conversation

@Broccolito

@Broccolito Broccolito commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator

Stacked on #237 — merge #237 first. This branch starts at #237's head (01115e7a, claude/sweet-pare-965d18) because it uses the reach gate #237 introduces (routes::session_reach::HttpCaller). It targets main on purpose, so merging #237 (which auto-deletes its branch) cannot close this PR. Until #237 lands, GitHub also shows #237's five commits here. Just this change: claude/sweet-pare-965d18...claude/ecstatic-einstein-dd05d6

What was open

docs/deployment/programmatic-session-access.md's residual table recorded it as the most significant open item. GET /active_work returned every running background job, foreground command, subagent, detached turn and scheduled run to any caller holding the daemon secret. Each row carries its chat's sessionId and a title/detail holding the shell command or task prompt, which is content rather than metadata. POST /active_work/{id}/cancel stopped any of them by registry id, also ungated.

Fail-before. These are the new regression tests, run against #237's head with only the tests added:

routes::session_reach::bypass_tests::running_work_is_listed_only_to_a_caller_that_could_open_its_chat ... FAILED
  assertion `left == right` failed: []: a private chat's work listed = true
routes::session_reach::bypass_tests::running_work_is_cancelled_only_by_a_caller_that_could_open_its_chat ... FAILED
  assertion `left == right` failed: a private chat's work was not refused exactly as the private chat's read is
   left: (200, "{\"message\":\"Requested cancel of 'sub-5'\"}")
  right: (403, "That chat is private, or there is no chat with that id. …")
developer::rmcp_developer::tests::a_running_foreground_command_names_the_chat_that_ran_it ... FAILED
   left: None
  right: Some("20260911_4242")
developer::rmcp_developer::tests::a_background_job_names_the_chat_that_started_it ... FAILED
   left: None
  right: Some("20260911_4343")

All four pass after the change.

What changed

The list. GET /active_work resolves the caller once (http_caller) and keeps a row only if HttpCaller::lists_work(manager, row.session_id) admits it. That is exactly lists_session's answer for the row's chat, resolved metadata-only through target_tier. A refused row is omitted, never redacted. A caller that is shown every row (the desktop's proof, a private capability, or tiers off) is answered without a store read. This is the sidebar's fast path, and a test pins its soundness at every (switch × capability × served standing × proof) corner.

The cancel. The route resolves the id to the owning chat: the registry entry (new ActiveWorkRegistry::get), or the running schedule (get_running_job_info). It then asks work_reach before anything is stopped. For a chat, work_reach is session_reach, by the same call, so the status and the plain-text words match GET /sessions/{id}'s byte for byte. Two rows added to every_gated_route_resolves_the_tier_before_it_touches_the_session pin the gate ahead of both effects (kill_running_job( and active_work().cancel().

Decision: a row with no session id is treated as Unreadable. That means it is handled exactly like a private chat's row. It is listed to, and can be stopped by, only a caller that could open a private chat: the desktop app (proof), a program stating a private capability, or anyone with tiers switched off. The same answer covers a chat that cannot be read, a handle that names nothing, and a schedule that is not running, so a refusal does not say which of them it was. A caller that is admitted still gets the truthful 404. The reason: an unattributed row still carries some chat's command or prompt, and nothing says whose. This is written down in session_reach.rs (lists_work, work_reach), in routes/active_work.rs's module header, on ActiveWorkItem::session_id, and in the user-facing doc.

Attribution, so that decision bites only where it must. Background jobs and foreground commands registered with session_id: None. The rule above alone would therefore have hidden every shell command, public chats' included, from any caller not shown private chats. The developer server now reads the dispatching chat from the biorouter-session-id _meta key and threads it into BackgroundJobs::spawn and ForegroundWorkGuard::register. Biorouter's MCP client stamps that key on every tool call, and the client composes it itself (McpMeta::inject_into_extensions); the model supplies arguments, never _meta. The Unreadable arm is left to work that genuinely has no chat. This is a separate commit and can be dropped on its own if unwanted.

SD-10. GET /active_work is a listing, so a biorouter serve browser keeps its operator's reach there. The cancel names one chat's work, so, like session_reach, it never reads the served-operator standing. The browser can be listed work it cannot stop, just as it is listed chats it cannot open or delete. Pinned in a_served_interface_keeps_its_listing_reach_and_gains_no_transcript.

Renderer. Measured: nothing in ui/desktop/src calls listActiveWork or cancelActiveWork (the panel is deferred; only the generated client exports them), so there was no call site to add userActionHeaders() to. Instead, ui/desktop/src/activeWorkUserProof.test.ts is a source guard that fails the day such a call is added without the proof. Without the proof the call is not an error: it returns a silent 200 with the private chats' work missing. The guard's positive controls show it firing, and a walk-size check keeps it from passing vacuously.

Census. In crates/biorouter/tests/privacy_guard_wiring.rs: new rows for lists_work and work_reach (both Wired, from routes/active_work.rs), and updated counts and reasons for session_reach, refuse_unless_reachable, http_caller and target_tier. lists_session now delegates to a private admits, so there is still one spelling of the listing decision.

Docs. In programmatic-session-access.md, both /active_work rows moved out of "Ungated, and a known residual" into the gated and listing tables, and the no-session-id decision and the SD-10 split are written down. Also updated: session_reach.rs's module header, open question 10 in privacy-tiers.md, question 10 in the execution plan, the "What shipped" ledger, developer.md, and the CLAUDE.md rule list.

Nothing refused before is permitted now

Every change narrows what a caller gets. A public chat's work stays listed to, and stoppable by, a secret-only caller, exactly as before. The gate is deliberately inert on public chats, as it is for GET /sessions. Rows of private chats, rows naming no chat, and rows naming an unreadable chat are now withheld from a caller with neither proof nor private capability. A cancel that used to answer an unknown id with 404 now answers such a caller with the 403. Attribution only adds a chat id to rows that had none, and each of those rows is then judged by that chat's tier.

Also closed here: the schedule routes that made the sched: gate bypassable

Added after an adversarial security review rated POST /schedule/{id}/kill HIGH, and
it was right.
This PR's /active_work/{id}/cancel gate has a sched: arm that stops a
scheduled run. POST /schedule/{id}/kill reached the same Scheduler kill, by the
same schedule id, with no gate of any kind — so that arm's gate was bypassable by a
one-word change of URL and protected nothing, while reading as protection. A documented
residual is honest; a gate that is trivially routed around is worse than no gate. Three
commits, one per surface, each revertable alone.

Route Before Now
POST /schedule/{id}/kill stopped any chat's run for any secret holder resolves the run to its chat, refuses through work_reach, and the stop is checked against the run it was authorized against
GET /schedule/{id}/inspect named the running chat and start time to any secret holder gated on that chat; a private run, an idle schedule and an absent one answer alike
GET /schedule/list named every schedule's creator chat and each running one's chat every row kept, the two chat-naming fields redacted

GET /schedule/list redacts fields rather than dropping rows, and that is deliberate.
Everywhere else a row IS its chat's content, so the row goes. A schedule is not a chat: it
is a cron line and a workflow path that merely name chats, and an idle or paused schedule
names none at all. Applying the sibling routes' "names no chat ⇒ Unreadable" rule row by
row would therefore drop every non-running schedule for every caller without a private
capability — emptying the Schedules view in order to close an association. The new
test's fixture list carries an idle schedule naming no chat precisely to pin that it
survives for every caller.

No schema change: both fields were already Option<String>, redaction yields the null an
idle schedule always produced, and the generated TS is already ?: string | null. What the
client does with a missing one was measured before the change —
ScheduleDetailView.tsx:504 guards running && current_session_id && so it omits the line,
and creator_session_id has no renderer reader at all.

lists_work is reused rather than joined by a new may_name_chat: "may this caller be told
this chat exists" must have ONE spelling, and a second is the drift the census exists to
catch. Only what a false does differs.

The TOCTOU this change made live, and closed

A precondition for anyone touching this again: jobs must be held across
check-and-kill.
get_running_job_info took and released Scheduler::jobs
(scheduler.rs:2000) and kill_running_job re-acquired it (:1957). A schedule id is
stable across runs while current_session_id is not, so between the gate's read and the
kill a public run can end and a private run of the same schedule begin — and the kill lands
on the run nobody authorized. Ungated, that window granted nothing (the whole route was
open). Gating the route is exactly what makes it exploitable, so the two had to ship
together.

Scheduler::kill_running_job_in_session acquires jobs once and holds it across the
check and the token.cancel(), and refuses when the run no longer matches the chat the
caller was admitted to. There is deliberately no .await between the lock and the
cancel; re-introducing one re-opens the window and nothing in the type system will say so.
running_tasks (a std mutex) is taken inside the jobs guard — the same nesting
claim_run_slot and run_now use, so the order cannot deadlock. /active_work's scheduler
arm now calls the checked variant too. SchedulerTrait gains the method with no default
body
, deliberately: a defaulted delegation to the unchecked kill would be a silent hole
for any future implementor.

Fail-before evidence (each measured by breaking exactly one thing)

  • kill: pub async fn kill_running_job( does not consult its session-reach gate (work_reach()
  • inspect: pub async fn inspect_running_job( does not consult its session-reach gate (work_reach()
  • list, decision (the current_session_id arm neutered): a private chat was named in the schedule listing (proof: false)
  • list, wiring (the handler's call removed): `GET /schedule/list` does not redact the chats it names

A discarded first attempt, recorded because it is a trap for the next person. The list
test was originally a real HTTP test seeding schedules with add_scheduled_job. It captured
the leak verbatim ("current_session_id":"20260912_1" for a private chat) and then passed
alone and failed in the suite
with SchedulerInternalError("CantAdd"):
add_scheduled_job registers a task on the process-global tokio-cron-scheduler, while every
#[tokio::test] brings its own runtime, so a scheduler started under an earlier test's
runtime is already dead. It is now a decision test (real chats, real tiers, real
HttpCaller; hand-built rows) plus a body scan for the wiring.

Still not closed

POST /schedule/{id}/run_now and POST /schedule/create remain in the residual table.
They launch work that may run in a private session, which is a different question from
naming or stopping an existing run, and nothing here closes them.

Not closed here

  • The scheduled half, through the schedule routes (recorded in the residual table).
    Correction to an earlier reading of this residual: GET /schedule/{id}/sessions is
    NOT part of it — fix(privacy): one reach gate for every HTTP route that names a chat or a knowledge base (QA H2, M1, M2, F0) #237 already gated it
    (sessions_handler builds an
    http_caller and filters rows before applying the limit). What remains open is
    exactly three routes, none of whose handlers even accepts a HeaderMap:
    GET /schedule/list (routes/schedule.rs:191), GET /schedule/{id}/inspect (:604)
    and POST /schedule/{id}/kill (:549). A caller this PR refuses a private chat's
    scheduled run can still find its chat and stop it there.

    Why this is a design decision and not a small mechanical addition — the part worth
    carrying forward, because the mechanical version looks tempting and is wrong twice:

    1. Closing inspect and kill alone would be security theater. Both resolve
      their owner through scheduler::get_running_job_info, the very call
      POST /active_work/{id}/cancel already uses, so gating them is three lines — but
      GET /schedule/list hands out the identical current_session_id for every running
      job, so a caller refused by inspect reads the same fact one route over. A gate
      that visibly refuses what its neighbour gives away is worse than an honest hole: it
      invites the belief that the surface is closed.
    2. GET /schedule/list cannot be closed by applying lists_work row by row. An
      idle or paused schedule has no current_session_id at all, so this PR's "names no
      chat ⇒ Unreadable" rule would hide every non-running schedule from every caller
      without a private capability
      — emptying the Schedules interface for an ordinary
      public caller. A schedule is not a chat: it has a creator chat, a current-run chat,
      and sometimes neither, and deciding which governs a row (and what an idle row falls
      back to) is a decision this PR does not take.

    A precondition on whoever closes POST /schedule/{id}/kill: hold the jobs lock
    across check-and-kill.
    get_running_job_info takes and releases self.jobs
    (scheduler.rs:2000) and kill_running_job re-acquires it (:1957). A schedule id is
    stable across runs while current_session_id changes per run, so between the two a
    public run can end and a private run of the same schedule can begin, and the kill lands
    on the private one. This PR's sched: cancel arm inherits that window. It grants
    nothing today — POST /schedule/{id}/kill is entirely ungated, so the same stop is
    available with no race at all — but it becomes a live finding the moment that route is
    gated. Closing the hole without closing the window would reintroduce it as the
    surviving path. The registry arm has no such window: ids are minted from a monotonic
    AtomicU64 and never reused, and an entry's session_id is written once at register,
    so the id→chat binding cannot be rebound under the gate.

  • A subagent row names its child's id in detail, and the row is judged by the parent's tier. In normal operation the two are equal, because a spawn never changes the tier. They diverge only if the user hand-raises the running child alone to private. A public caller shown the public parent's row would then learn the private child's id, but not its content. That is a narrow metadata edge. Closing it needs the registry to carry both chats structurally rather than the child's id as text.

  • ActiveWorkRegistry::get is a registry accessor, not a reach decision, and lives outside the census's COMPLETE_MODULES, so it carries no census row. That is the same reasoning caller_capability records for itself.

Base branch state

Resolved — this branch is now current and mergeable. #237 (claude/sweet-pare-965d18)
brought itself up to date with main in its own catch-up merge, resolving the seven
conflicted files that were previously attributed to this PR — auth.rs,
commands/agent.rs, web_ui.rs, serve-decisions.md, MentionPopover.tsx,
KnowledgeContext.tsx, CreateWorkflowFromSessionModal.tsx. None of the seven is in
this PR's own 15-file diff
; the conflicts were always #237's.

This branch then merged #237's current head. The merge was clean (no conflicts), and
git merge origin/main reported Already up to dategit rev-list --count HEAD..origin/main is 0. #237's two load-bearing resolutions were verified in the
merged tree rather than taken on trust: web_ui.rs scopes its absolute sentence to
authentication ("gates the document, and authenticates nothing else") so the SD-10
narrowing paragraph below it is not a contradiction, and serve-decisions.md runs
SD-1…SD-10 in number order with nothing renumbered.

The base stays main on purpose. Merging #237 auto-deletes its head branch, which
would close this PR if it were targeted there. This PR is stacked on #237's content
while targeting main, and must stay that way. #237 must still merge first — this
branch cannot be cherry-picked onto main, because routes/active_work.rs calls
HttpCaller, http_caller, lists_work, work_reach and CALLER_PROVIDER_HEADER, and
HttpCaller does not exist on main.

Reading this PR's diff: gh pr view 257 --files reports ~62 files, the union of
#237's commits and this one's, because #237's head is an ancestor. This PR owns 15
files, +1318/−69. The reliable anchor is the merge-base, git rev-parse 4eabd13f^
(01115e7a) — not origin/claude/sweet-pare-965d18..HEAD, which went stale the moment
#237 merged main and now reports 141 files.

Verification

All re-run on the merged tree (this branch is 0 behind main).

Command Result
cargo test -p biorouter --test privacy_guard_wiring --test privacy_capability 3 + 4 passed, 0 failed
cargo test -p biorouter-server --lib --bins --no-fail-fast lib 625 passed, biorouterd bin 612 passed, 0 failed
cargo test -p biorouter-server --tests --no-fail-fast 1390 passed, 1 failed — the failure is #237's, see below
cargo test -p biorouter-mcp --lib -- developer:: active_work 275 passed, 1 ignored, 0 failed
just generate-openapi + npm run generate-api openapi +15/−1, types.gen.ts +24/−1, sdk.gen.ts 4 lines, index.ts 2
ui/desktop: npm run lint:check OK — tsc, ESLint, themes, 332 contrast assertions, token mirrors
ui/desktop: npm run test:run 445 files / 5012 passed, 1 skipped, 0 test failures
cargo fmt --all && cargo fmt --check clean
cargo clippy -p biorouter-server -p biorouter --all-targets -- -D warnings clean

Cargo runs used BIOROUTER_DISABLE_KEYRING=true.

npm run test:run exits 1 with zero failing tests. The only failure is a suite
failure: src/utils/artifactCdnAssets.browser.test.ts's afterAllbrowser.close()
exceeding the 30 s hook timeout. Pre-existing and reproducible on pristine main; unrelated
to this change.

One RED Rust test, and it is #237's — proven, not asserted.
cargo test -p biorouter-server --test privacy_ar15_is_retired fails
no_document_still_describes_it_as_open: "privacy-tiers-execution-plan.md mentions AR-15 at
byte 491052 with nothing nearby saying it was retired."
Two independent measurements place
it outside this PR:

  • Every one of the test's four inputs is byte-identical before and after this PR's work.
    git diff --quiet 187cd216 HEAD reports UNCHANGED for tests/privacy_ar15_is_retired.rs,
    privacy-tiers-execution-plan.md, privacy-tiers.md and
    privacy-tiers-implementation-brief.md. A test whose every input is unchanged cannot have
    been turned red here.
  • The offending passage was last written by 936f7f8b, which git merge-base --is-ancestor places on claude/sweet-pare-965d18 and NOT on origin/main — so it is
    fix(privacy): one reach gate for every HTTP route that names a chat or a knowledge base (QA H2, M1, M2, F0) #237's, and not a pre-existing failure of main either.

Deliberately not fixed here: it is a one-line doc edit, but it is #237's security
documentation and that branch is being actively committed to, so editing it concurrently
invites a merge conflict in exactly the files that most need a careful read. #237 owns the
fix, and since #237 merges first, this PR goes green the moment it lands.

Security-sensitive (privacy gates on auth-adjacent routes, plus a lock-ordering change in the
scheduler), so it needs human review. Do not merge without it, and not before #237.

🤖 Generated with Claude Code

…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.
…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.
The /active_work cancel route has to resolve an id to the chat that owns
the work before it fires anything (issue #56). `get` gives it the one
entry, built by the same snapshot `list` uses, so the two cannot
disagree about an entry.

Documents `ActiveWorkItem::session_id` as load-bearing: GET /active_work
now shows a row only to a caller that could open its chat, and a row
with no chat only to a caller that could open a private one.
… ran it

Background jobs and foreground commands registered with no session id.
The /active_work reach filter (next commit) answers such a row as a
private chat's, so it would have hidden every shell command, a public
chat's included, from any caller not shown private chats.

The developer server now reads the dispatching chat from the
`biorouter-session-id` key that Biorouter's MCP client stamps on every
tool call's `_meta`. The client composes that key itself; the model
supplies arguments, never `_meta`. The chat id is threaded into
`BackgroundJobs::spawn` and `ForegroundWorkGuard::register`.

Both new tests drive `shell()` with a stamped context, and both failed
before this change (`left: None`).
Seventeen PRs landed since this branch left 7c96d79. Seven files conflicted;
every resolution keeps both sides' gates.

- auth.rs: the served-operator standing (SD-10) and #231's failed-auth budget
  constants were added at the same spot. Both kept.
- commands/agent.rs: #226 gave run() an exit_with_parent parameter. Taken,
  beside served_operator_capability(), which is unchanged.
- routes/web_ui.rs: doc comment only. #226's paragraph (the token is not
  consumed, SD-9) and this branch's (the cookie's one narrowing reader, SD-10)
  both kept.
- serve-decisions.md: #226's SD-9 and this branch's SD-10 were both appended
  after SD-8. Both kept, in number order. SD-10 gains a bullet: the standing
  follows the address, not a person, because SD-9 made the address reusable.
- MentionPopover.tsx, CreateWorkflowFromSessionModal.tsx: #235/#243's
  readKnowledgeSelection() replaces this branch's inline getActive(). It sends
  the proof and returns null on failure, and its capture-nothing-on-failure
  path is kept. listBases() keeps the proof: under this branch the daemon omits
  a private base from a caller without it, which main did not.
- KnowledgeContext.tsx: comments only. Both sides already sent the proof, and
  main's explanatory comments are taken.

Also reconciled: browser-access.md no longer says the cookie does "nothing
else" (it narrows listings, SD-10), and CLAUDE.md points at SD-1..SD-10.

No route came in from main that names a chat or a knowledge base, and every
gated call in the renderer files main changed still sends the proof.
… caller could open

GET /active_work returned every running job, subagent, detached turn and
scheduled run to a caller holding only the daemon secret. Each row
carries its chat's id and the shell command or task prompt. POST
/active_work/{id}/cancel stopped any of them. The residual table in
programmatic-session-access.md recorded both as open.

- The list resolves the caller once (`http_caller`) and keeps a row only
  if `HttpCaller::lists_work` admits it. That is `lists_session`'s answer
  for the row's chat, resolved metadata-only. Refused rows are omitted,
  never redacted, and a caller shown every row skips the store reads.
- The cancel resolves its id to the owning chat (the registry entry, or
  the running schedule) and asks `work_reach` before anything stops. For
  a chat, `work_reach` is `session_reach` by the same call, so it refuses
  with GET /sessions/{id}'s exact status and words.
- A row that names no chat is `Unreadable`, so both routes answer it as
  a private chat's row. So are an unreadable chat, a handle that names
  nothing and a schedule that is not running.
- SD-10: the list honours a serve browser's operator standing, because
  it is a listing. The cancel does not, like `session_reach`.

`lists_session` now delegates to a private `admits`, so the listing
decision keeps one spelling. The census gains rows for `lists_work` and
`work_reach`, and updated counts for `session_reach`,
`refuse_unless_reachable`, `http_caller` and `target_tier`.

Tests are lib tests, so CI runs them. The two HTTP regressions in
`bypass_tests` failed before this change: the private chat's row was
listed, and its cancel answered 200 "Requested cancel of 'sub-5'"
instead of the read's 403. Also added:
- the fast path is checked sound at every corner;
- `lists_work` is checked against a real temp store;
- `work_reach` is byte-equal to `session_reach`;
- the route's filter is checked over every row kind, a scheduled run
  included;
- a served interface is listed the work and cannot stop it;
- two ordering rows pin the gate ahead of both effects.
…rk gate

`just generate-openapi` + `npm run generate-api`. The list route's
description now says what it withholds, and the cancel route documents
its 403. No other drift.
…'s proof

Nothing in the renderer calls `listActiveWork` or `cancelActiveWork`
yet (measured; the panel is deferred), so there was no call site to add
`userActionHeaders()` to. A call without the proof is not an error. The
list comes back 200 with the private chats' work missing. So this
source guard fails the day a call is added without the proof.

Its positive controls show it firing. A walk-size check stops it passing
vacuously, and so does a real file that names `/active_work` only in
comments.
… the schedule residual

programmatic-session-access.md: both /active_work rows move out of the
known-residual table. The cancel joins the gated routes and the list
joins the listings. The page now records three things:
- work that names no chat is treated as a private chat's;
- a serve browser keeps its operator's reach on the list and not on the
  cancel;
- the scheduled half is still open through /schedule/list,
  /schedule/{id}/inspect and /schedule/{id}/kill. The last of these is
  added to the residual table, with the chat ids /schedule/list exposes.

privacy-tiers.md: a "What shipped" entry, and open question 10 answered.
The execution plan: question 10 answered. developer.md: the row names
the chat that ran the command. CLAUDE.md: the rule, beside #237's.
`POST /schedule/{id}/kill` called `Scheduler::kill_running_job` with no gate
of any kind, so any holder of the daemon secret stopped any chat's scheduled
run. On its own that was a documented residual. What made it worse than that
is the route beside it: `POST /active_work/{id}/cancel` gates the SAME kill,
on the SAME scheduler, by the SAME schedule id. A caller refused there
re-issued the request one URL over and stopped the run anyway — so the gate
was bypassable by a one-word change of URL, and a gate that is trivially
routed around is worse than none, because it reads as protection.

The kill route now resolves the run to the chat it is in and asks the chat
READ's own gate (`work_reach`) before anything is stopped: the same status and
the same bytes as the cancel route, and the same answer for a schedule that is
not running and one that does not exist, so a refusal says nothing about which
it was.

The stop is also now checked against the run it was authorized against.
`kill_running_job_in_session` holds the scheduler's `jobs` lock across the
check AND the cancel, where the two used to be separate acquisitions. A
schedule id is stable across runs while `current_session_id` is not, so a
public run could end and a private run of the same schedule begin between the
gate's read and the kill, and the kill would land on the run nobody
authorized. Both routes now pass the chat they admitted, and both refuse
rather than stop a run that changed underneath. There is deliberately no
`.await` between the lock and `token.cancel()`.

Fail-before evidence: the new row in
`every_gated_route_resolves_the_tier_before_it_touches_the_session` panicked
with "pub async fn kill_running_job( does not consult its session-reach gate
(`work_reach(`)" against the unfixed handler.

Census: the `work_reach` guard gains a second site (schedule.rs), and the
`session_reach` module-qualifier row for schedule.rs goes 1 -> 2 refs. The
`cancel_active_work` ordering row's action needle follows the rename to
`kill_running_job_in_session(`.
…hat could open it

`GET /schedule/{id}/inspect` answered any holder of the daemon secret with the
`session_id` of the chat a schedule is running in, and the run's start time.
That is exactly the work-to-chat association `GET /active_work` omits and `GET
/schedule/list` redacts — handed over whole, one route away, by a handler that
did not even accept a `HeaderMap`.

It now resolves the run once, gates on the chat with `work_reach`, and builds
the answer from the value it already resolved. Asking the scheduler a second
time after the gate would be a second read of a fact that can change between
them, which is the shape of defect the sibling kill route just fixed.

The refusal is the chat read's own, so an unadmitted caller cannot tell a
private chat's running schedule from a schedule that is idle or absent: all
three resolve to no reachable chat and answer identically. An admitted caller
still gets the truth, including the 404.

Fail-before evidence: the new ordering row panicked with "pub async fn
inspect_running_job( does not consult its session-reach gate (`work_reach(`)"
against the unfixed handler.

Census: `work_reach` in schedule.rs goes 1 -> 2 calls, and the module-qualifier
row for that file 2 -> 3 refs. Both are the existing rows extended, not new
ones.
…d open

`GET /schedule/list` returned every schedule on the machine with the chat that
created it (`creator_session_id`) and, for a running one, the chat the run is
in (`current_session_id`) — to any holder of the daemon secret, from a handler
that did not accept a `HeaderMap`. That is the same work-to-chat association
`GET /active_work` omits, and it made this route the easy way around it.

⚠ This one REDACTS rather than omitting, and the difference is the subject.
Everywhere else a row IS its chat's content, so the row goes. A schedule is
not a chat: it is a cron line and a workflow path that merely NAME chats, and
an idle or paused schedule names none at all. Applying the sibling routes'
"names no chat is unreadable" rule row by row would therefore drop every
non-running schedule for every caller without a private capability — emptying
the Schedules interface in order to close an association. So every row stays
and the two chat-naming fields go. The fixture list in the new test carries an
idle schedule naming no chat precisely to pin that.

The two fields are asked about separately: a schedule created from a public
chat can be running in a private one, and the reverse.

No response shape change, so no client regeneration is forced: both fields are
already `Option<String>`, redaction yields the `null` an idle schedule has
always produced, and the generated TS is already `?: string | null`. Measured
what the client does with a missing one before changing it —
`ScheduleDetailView.tsx:504` guards with `running && current_session_id &&`, so
it simply omits the line; `creator_session_id` has no renderer reader at all.

`lists_work` is reused rather than joined by a `may_name_chat` beside it: "may
this caller be told this chat exists" must have one spelling, and a second is
the drift the census exists to catch. Its doc now names both subjects.

Fail-before evidence, both halves, each measured by breaking exactly one thing:
- the decision, with the `current_session_id` arm neutered: "a private chat was
  named in the schedule listing (proof: false)";
- the wiring, with the handler's call removed: "`GET /schedule/list` does not
  redact the chats it names".
An earlier HTTP-level version of this test also captured the raw leak
(`"current_session_id":"20260912_1"` for a private chat) before the fix; it was
replaced because seeding a schedule needs `add_scheduled_job`, which registers
on the process-global tokio-cron-scheduler while each `#[tokio::test]` brings
its own runtime — it passes alone and fails with `CantAdd` in the suite. The
chats, tiers and caller in the replacement are real; only the rows are
hand-built, and a body scan holds the route's wiring.

Census: `lists_work` gains a schedule.rs site (2 calls), `http_caller` there
goes 1 -> 2, and the module-qualifier row 3 -> 5 refs. Existing rows extended,
never duplicated.
Three documents and two module headers still described `GET /schedule/list`,
`GET /schedule/{id}/inspect` and `POST /schedule/{id}/kill` as an open hole.
They are now gated, so saying otherwise is not a harmless stale line — a
residual table that lists a closed hole teaches the next reader to distrust the
table, and one that omits an open one is worse.

- `programmatic-session-access.md`: the three routes leave "Ungated, and a known
  residual". `kill` and `inspect` join the gated table, with the reason `kill`
  had to be gated (it reaches the same `Scheduler` kill as
  `/active_work/{id}/cancel` by the schedule id, so an ungated twin made that
  gate bypassable by a one-word change of URL). `GET /schedule/list` joins the
  listing table with its own note: the one listing that redacts FIELDS instead
  of dropping rows.
- `routes/active_work.rs` and `routes/session_reach.rs` headers say the same,
  and say where the gate lives, so neither file claims a hole its neighbour
  closed.

⚠ `POST /schedule/{id}/run_now` and `POST /schedule/create` stay in the
residual table. They launch scheduled work that may run in a private session,
which is a different question from naming or stopping an existing run, and
nothing here closes them.

OpenAPI: `just generate-openapi` (+ `npm run generate-api`). `kill` gains 403 /
404 / 400 and an `id` param it never documented; `inspect` gains 403; `list`'s
200 now says which fields a row may lose and that rows are never dropped. The
400 wording follows `classify_kill_error`, which maps the run-changed refusal to
BAD_REQUEST — the annotation said 409 for one draft and was corrected to what
the code actually returns.

`ScheduledJob`'s two fields were already `Option<String>`, so redaction changes
no schema; the +24 lines in `types.gen.ts` are the new responses.
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
@Broccolito
Broccolito merged commit 903fc1a into main Sep 12, 2026
17 checks passed
@Broccolito
Broccolito deleted the claude/ecstatic-einstein-dd05d6 branch September 12, 2026 08:33
Broccolito added a commit that referenced this pull request Sep 12, 2026
Broccolito added a commit that referenced this pull request Sep 12, 2026
Merged with `-X diff-algorithm=patience`, which is what kept
`session_reach.rs` to four hunks of ≤14 lines instead of one interleaved
block: 20 hunks over 10 files, and the two branches had already agreed about
the thing that mattered most.

**`callable_tool_count` needed no reconciling.** #257 and #237 resolved it
identically — main's body inside a wrapper that returns the refusal through
`SessionOutOfReach`'s own `IntoResponse`, plain text, not `?` through this
route's JSON `ErrorResponse`. All three hunks in `routes/agent.rs` were
comment-only; the code matched line for line. One gate remains, and it is that
one. The two ⚠ notes are combined: the integration side names the mechanism and
where the rule is written down, this branch's names the sibling with the same
shape (`get_tools`) and the test that fails on the wrapping alone with the words
unchanged.

**The census caught what neither branch could see.**
`refuse_unless_reachable` was declared `c(4, 0, 0)` on BOTH sides and the count
auto-merged clean, because each branch had added one subject and written
"four": #257/#278 added `work_reach`'s no-chat arm (and folded
`lists_session`/`lists_work` into `HttpCaller::admits`), #237 added
`mints_knowledge_base`. Measured on the merged tree: **5 calls**. Corrected to
`c(5, 0, 0)` by running the census, not by counting with grep. The other rows
needed no number: `agent.rs` stayed `c(6, 6, 0)` and `knowledge.rs`
auto-merged to `c(1, 7, 0)`, where only this branch's prose explains the
seventh ref — `http_caller` is called from four handlers now that
`POST /knowledge/bases` is gated. One row per file throughout; `lists_work`,
`work_reach` and `mints_knowledge_base` are three separate registry entries,
kept in the file's thematic order.

`routes/session_reach.rs`, four hunks: the module header takes this branch's
correction (the sidebar does NOT page by scanning any more, and `has_more`
cannot count what it hid because the tier is a SQL predicate and the page
resumes from a keyset) *and* the integration's `GET /active_work` addition.
The other three take the integration side, which is a strict superset: it
counts three helper-gated rows and seven routes where this branch counted two
and six, and it names `reply::steer_refusal`. `authorize_steer` no longer
exists, so every mention of it here was stale — SD-11's settled ruling is that
`/interrupt` asks for the proof on both kinds of daemon.

Docs: `serve-decisions.md` keeps SD-1..SD-13 in number order, nothing
renumbered, no duplicate heading. `CLAUDE.md` keeps the numbering-free "(the
`SD-n` records)" — this branch's "SD-1..SD-11" was already wrong at SD-13.
`programmatic-session-access.md` keeps the integration's `/schedule/list`
field-redaction row and `/active_work` section, with this branch's
`next_offset` → `next_cursor` correction. The execution-plan note takes the
integration's fuller form, which carries the anchors and says why AR-15 is
closed.

`openapi.json` and `types.gen.ts` were not hand-resolved: one side taken, then
`generate_schema` + `openapi-ts` run, and a second run confirmed zero diff.

Verified on the merged result, BIOROUTER_DISABLE_KEYRING=true throughout:
biorouter-server --lib routes:: 544 · knowledge_routes 68 · privacy_capability
4 · privacy_guard_wiring 3 · privacy_toggle 1 · privacy_disclosure_toggle 4 ·
biorouter --lib privacy:: 246 · cargo fmt --check clean · frontend lint:check
and format:check clean · vitest 5293 passed, 1 skipped, 0 failed over 470
files. The `declassify` DEFERRED-transaction race did not fire in this run.
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