Summary
Two independent lattices, one column, one predicate, five gates. The full design — capability matrix, enforcement points, migration, and the eleven rulings it needs — is committed at docs/security/privacy-tiers.md (Status: Proposed). This issue is the summary and the tracking point.
- Capability — what a session may do — is the least privileged model currently bound to it. A mixed lead/worker configuration therefore has public reach, because its transcript already goes to the public worker.
- Classification — how sensitive a session's contents are — is the most sensitive thing it has ever touched, and is a permanent ratchet in SQL.
A public model must never reach a private session — not once, not read-only, not indirectly. The converse is unrestricted: a private model may read anything.
A session can therefore be classified private while holding only public capability. Those are genuinely independent properties, and conflating them is the mistake the design is built to avoid.
2. The problem, and why today's code cannot express it
2.1 There is no tier anywhere in the backend
| Concept |
Today |
Verified |
| Model tier |
Does not exist in Rust. ProviderMetadata has exactly eight fields (name, display_name, description, default_model, known_models, model_doc_link, config_keys, allows_unlisted_models) and no tier. |
crates/biorouter/src/providers/base.rs:145-164 |
| The only tier-shaped list |
Lives in the renderer: const INSTITUTIONAL = new Set(['versa_azure','versa_bedrock']); const LOCAL = new Set(['llamacpp','ollama']); — presentation only, unreachable from the CLI, the daemon or any gate. |
ui/desktop/src/components/settings/providers/providerOrdering.ts:4-5 |
| Session tier |
No column. The sessions DDL ends provider_name, model_config_json, diverged_from, external_key, branch_point_msg_uid. CURRENT_SCHEMA_VERSION is 16. |
session_manager.rs:29, :1865-1889 |
| Extension tier |
No field on struct Extension (config, client, server_info, _temp_dir, inprocess, _pooled) and no classification field in the marketplace registry. |
extension_manager.rs:56-72; landing/registry.json (version 1, 37 extensions, 129 skills, keys id name organization version description tags github download filename license) |
2.2 The one existing approximation is backwards
provider_class (routes/apps.rs:2089) classifies a provider name into Local /
Institutional / External and gates three real sites (provider_allowed_for_app,
resolve_route, worker-profile admission). Membership is exact equality:
if p.contains("local") || LOCAL_PROVIDERS.iter().any(|x| p == *x) { return Local; }
if p.contains("institution") || INSTITUTIONAL_PROVIDERS.iter().any(|x| p == *x) { return Institutional; }
External
versa_azure and versa_bedrock match neither list nor either substring, so they fall through to
External — while azure, azure_openai, aws_bedrock, bedrock, databricks, vertex
are listed Institutional. Agent Drafter today refuses a Versa route for a sensitive app and
permits a direct Bedrock one. The function's own test table (:6447) never exercises a versa_*
name, which is why the inversion is green.
2.3 Five present-tense paths put private history in front of a public model
Each verified by reading the code, each fixed as a by-product of this design:
Agent::update_provider performs no check at all (agent.rs:4936-4956) and swaps the
in-memory provider before it persists. It is nevertheless the sole writer of both
Agent::provider and sessions.provider_name — a grep for .provider_name( outside
session_manager.rs returns exactly one hit, at agent.rs:4951. That is what makes a single
bind gate sufficient.
chatrecall LOAD mode has no filter of any kind (chatrecall_extension.rs:91-158). Given
any session id it calls get_session(&sid, true) and emits the session name, working
directory, message count and six verbatim messages. It does not even carry SEARCH's
exclude_session_id guard. This is the only fully-open cross-session read in the product today.
- Three independent session-copy paths carry the conversation but not the provider.
copy_session (session_manager.rs:4138-4168), diverge_session (:4204-4265 — the primary
GUI diverge, and it does not call copy_session) and import_session (:4096-4135) each
hand-roll their own update builder. None sets provider_name or model_config, so a branch of
a private chat resolves through restore_provider_from_session's Config::global() fallback
(agent.rs:4963-4978) and runs private history on the user's default public model, with no
prompt.
- Agent Drafter's
ClientFrame::ModelSelect (routes/apps.rs:3409-3428) calls
create_provider then agent.update_provider with no check whatsoever, over the
GET /apps/{id}/agent WebSocket, which auth.rs:52-77 exempts from secret-key auth.
POST /agent/call_tool (routes/agent.rs:1140-1163) dispatches straight into
ExtensionManager::dispatch_tool_call, skipping Agent::dispatch_tool_call and every
ToolInspector. Any control expressed as an inspector is invisible to it.
4. The two lattices
They are not two reductions over one set. They are reductions over different domains, which is
what makes them provably consistent rather than contradictory.
capability(S) = least over { components of the provider bound to S RIGHT NOW }
domain: providers. Pure function of live state. NOT STORED.
classification(S) = max over { floor(capability) at each turn S has ever run,
Private for each private MCP call S made,
the classification inherited at creation }
domain: events over time. Monotone accumulator. STORED.
Capability has no memory. Classification has no reference to live state. The independence is
enforced three ways:
-
Type level. Two Rust types that do not interconvert.
// crates/biorouter/src/privacy/mod.rs
/// CAPABILITY. Deliberately NO Ord: `max` over this type is always a bug.
pub enum ProviderTier { Public, Private }
impl ProviderTier { pub fn least(a: Self, b: Self) -> Self { … } }
/// CLASSIFICATION. Monotone in time. Public < Private.
#[derive(PartialOrd, Ord, …)]
pub enum Classification { Public, Private }
/// The ONE crossing. pub(crate); a repo-grep test asserts exactly two callers.
pub(crate) fn floor(t: ProviderTier) -> Classification { … }
There is no From/Into. A single Ord-derived tier would be simpler and would invite
max() where least() belongs.
-
Storage level. Capability is Agent::capability_tier(), a method reading
self.provider.lock().await.tier(). No field, no column, no cache that can go stale.
-
Scope level. Of the five gates, exactly one reads both (Gate A/B, the bind). Gate C reads
capability and extension.tier. Gate D reads capability and the target's classification,
never the caller's.
Invariant, and it should be a property test: for any sequence of legal binds,
capability(S) ≥ classification(S). The bind admits P only if tier(P) ≥ classification(S); the
ratchet then sets classification := max(old, floor(tier(P))) ≤ floor(tier(P)). By induction, the
naive failure — "the very next turn feeds private history to a public model" — has no code path.
4.1 The mixed configuration, worked through
Private lead + public worker. tier = least(Private, Public) = Public.
- Capability is Public: cannot call a private MCP server, cannot read a private session.
- Classification does not ratchet, because
floor(Public) = Public. The transcript has already
gone to the public worker, so there is nothing left on that axis to protect — and marking it
Private would make the bind gate refuse that same composite on the next resume, bricking a
working configuration.
This is the one place the design reads R3 in spirit rather than letter (R3 says "switched to a
private model even once → private permanently", and a composite contains a private model). It
needs an explicit ruling — see §17, question 1.
LeadWorkerProvider::tier() returning least(lead, worker) is the only override needed, and
it is the reason tier() is an instance method rather than a lookup on get_name():
get_name() on a composite returns the lead's name (verified,
providers/lead_worker.rs:332-334), which would badge a private-lead/public-worker session Private
— the exact inverse of R2.
4.2 The residual state
classification = Private, capability = Public is representable but unreachable through any legal
bind. It arises only from legacy rows, a pre-fix copy path, an LRU-evicted agent rehydrated with
the process default, or a scheduled job built from global config. Gate B owns it, and in that
state both properties hold at once: the session cannot run a turn until repaired or declassified,
and it stays invisible to every public reader, because Gate D keys on the target's classification
against the caller's capability, independent of the target's own capability. A corrupted private
session cannot leak while it is broken.
7. The capability matrix
Inputs.
- C =
capability(caller) = least over the components of the caller's currently-bound
provider. Pub | Priv.
- T =
target.privacy_tier, the stored classification. Pub | Priv.
- L = lineage of target relative to caller:
self · child
(target.parent_session_id == caller_session_id) · other (includes NULL parent and every
transitive descendant).
Lineage is one hop. A grandchild is other: R6 says "sessions the caller did spawn", and a
grandchild was spawned by the child. BR-71's workspace_list { parent_session_id: "<me>" } filter
already yields exactly the one-hop set, so no recursive CTE and no new "control my subtree" surface
is invented. A leader that needs deeper control asks its child.
The three rules.
VIS(T) ⇔ T ≤ C // a public caller sees public only
READ ⇔ VIS // any lineage — R6's read-only floor
WRITE ⇔ VIS ∧ L ∈ {self, child}
BIND(P→T) ⇔ WRITE ∧ tier(P) ≥ T // Gate A, evaluated on the target
A downgrade write (C=Priv, T=Pub) is permitted, with a first-use approval. R4 explicitly
permits a private session to spawn public children, and a rule that lets you spawn a public child
but never send it a prompt makes the permission useless — it would forbid exactly the
private-leader/public-worker arrangement R2 names. But the prompt text is private-origin content
crossing into a public model, so the first workspace_send_prompt / workspace_set_tools from a
given caller into a given public target raises an approval showing the exact payload.
The matrix. ✓ allowed · ✓! allowed, first crossing requires approval showing the payload ·
✗ refused with a teaching message · ∅ omitted from results entirely. self and child behave
identically under every rule and are merged; columns D and F prove it.
| BR-71 tool |
Class |
A C=Pub T=Pub self/child |
B C=Pub T=Pub other |
C C=Pub T=Priv any L |
D C=Priv T=Pub self/child |
E C=Priv T=Pub other |
F C=Priv T=Priv self/child |
G C=Priv T=Priv other |
workspace_list |
read |
✓ |
✓ |
∅ row omitted |
✓ |
✓ |
✓ |
✓ |
workspace_read_conversation |
read |
✓ |
✓ |
✗ |
✓ |
✓ |
✓ |
✓ |
workspace_watch |
read |
✓ |
✓ |
✗ |
✓ |
✓ |
✓ |
✓ |
workspace_open (existing session) |
read |
✓ |
✓ |
✗ |
✓ |
✓ |
✓ |
✓ |
workspace_send_prompt (turn / steer / note) |
write |
✓ |
✗ R6 |
✗ |
✓! |
✗ R6 |
✓ |
✗ R6 |
workspace_set_tools — extensions / skills / KBs |
write |
✓ |
✗ R6 |
✗ |
✓! |
✗ R6 |
✓ |
✗ R6 |
workspace_set_tools — add_extensions naming a private extension |
write |
✗ target is public-capability |
✗ |
✗ |
✗ |
✗ |
✓ |
✗ R6 |
workspace_set_tools — { provider, model } |
bind |
✓ if tier(P) ≥ Pub (always) |
✗ R6 |
✗ |
✓! if tier(P) ≥ Pub |
✗ R6 |
✓ only if tier(P)=Priv |
✗ R6 |
workspace_close |
write |
✓ |
✗ R6 |
✗ |
✓ |
✗ R6 |
✓ |
✗ R6 |
workspace_spawn_subagent / workspace_open { new: … } |
spawn |
see §8.2 |
|
|
|
|
|
|
workspace_list omits private rows rather than redacting them. The operator ruled existence
leaks acceptable, not required, and omission is strictly simpler: a workspace_list row
carries a title, and a session title in this product is LLM-generated from the conversation,
i.e. content. Redacting would mean a redaction pass to review; omitting is one WHERE clause and
removes the temptation to then call workspace_read_conversation on the id.
7.1 Non-BR-71 surfaces the same matrix governs
| Surface |
Rule |
chatrecall SEARCH |
VIS as a SQL predicate (Gate D) |
chatrecall LOAD (session_id) |
VIS, refused before any text is built |
| Any tool on a private MCP extension |
extension.tier ≤ C (Gate C) |
MCP read_resource, read_resource_tool, list_resources, get_ui_resources |
Same as Gate C |
MCP list_prompts, list_prompts_from_extension, get_prompt |
Same as Gate C |
Tool discovery (filter_tools) |
Private extensions absent from a public model's tool list |
active_work registry (ActiveWorkItem.title is built from a subagent's task prompt) |
VIS |
GET /sessions, the History list |
Not filtered — this is the user's own UI, not a model. Badges instead. Conditional on §9.1 being fixed; see the bypass. |
| Agent Drafter route / worker admission |
The BIND predicate, replacing provider_class |
7.2 Delegation is not amplification
A private parent may spawn a public child (R4). The child is Public, sees only public sessions, and
cannot call private extensions — because VIS is evaluated against the child's own capability,
never its parent's. A public parent cannot spawn a private child at all, so it can never mint a
private-capability agent to read from; and even if it held one, workspace_read_conversation on
that child would be C=Pub, T=Priv → refused.
Enforcement, MCP badges, chatrecall, declassification, migration
See §9–§15 of the design document. In outline:
- Five gates, each covering a bypass the others miss. The ratchet fires on the first turn and on a permitted private-extension dispatch — deliberately not on the bind, because binding is not when content appears, and ratcheting there would privatise a chat on a mis-click while still missing
POST /agent/call_tool, which dispatches straight into the extension manager without touching the reply path.
- The BAAM registry is the sole grantor of a private badge. Anything not on BAAM is public. The private set is exactly
ucsfomopagent and cdwagent. Built-ins are public; skills carry no classification.
chatrecall obeys the barrier — private models recall from private and public chats, public models from public only. Side channels (existence, counts, timing) are explicitly out of scope; only content must not cross.
- Declassification is the user's alone — an explicit deprivatise action in history settings with a graded confirmation. Nothing automatic, nothing an agent can invoke.
- Migration is fail-open for existing sessions, which is stated as a risk rather than hidden.
16. The honest cost
Private is the default state on a real machine, not the exception. Measured directly from
~/.local/share/biorouter/sessions/sessions.db (aggregate provider_name counts only, no message
content):
| session_type |
would backfill private |
public |
NULL provider |
total |
| user |
934 |
358 |
29 |
1,321 |
| scheduled |
121 |
76 |
0 |
197 |
| hidden |
52 |
459 |
0 |
511 |
| sub_agent |
11 |
33 |
0 |
44 |
934 of 1,321 user conversations — 70.7% — go private on first launch. 848 of those are
versa_azure (706) or versa_bedrock (142); 86 are llamacpp (48) or ollama (38).
And it is not an accident of one machine. ProviderGuard.tsx:177-186 orders the onboarding cards
Llama Server → Ollama → Institutional → Commercial — three of the four first-run cards are
private-tier — and CLAUDE.md states the ordering is deliberate. A new UCSF user's default,
zero-setup path produces private sessions. Every friction below should be re-costed at that
multiplier before it ships.
Where this annoys someone who has done nothing wrong:
- You can never move a private conversation to a public model. Not with a warning. A researcher
who used Versa once and now wants Opus on the same thread must stay on Versa, start a new chat,
or declassify. This is the design's central cost and it is deliberate. The
ratchet-at-first-turn correction (§6.1) removes the mis-click version, and the graded
confirmation (§12.4) removes the friction from the turn:-only majority.
- Turning on lead/worker with a public lead locks you out of your private chats.
least()
makes the composite Public, so one global setting one click from the composer affects unrelated
conversations, which reads as a bug unless the panel pre-flight-warns. At a 71% private base rate
this is not a footnote.
- Right after upgrade, an orchestration loses control of its existing children.
parent_session_id only exists going forward, so every pre-upgrade subagent is other and
read-only to its own parent. No clean mitigation; it resolves as new work is spawned.
- A remote llama.cpp or Ollama badges Private with no way to say otherwise — the one place this
design over-trusts. See §17.
- A private extension simply vanishes from a public model's tool list (Gate E). Without the
pairing-aware state in §14.5 this reads as "the OMOP tool is broken".
- Declassification is one chat at a time for
mcp:-reason sessions.
- A shared workflow pinning a public provider stops working in private sessions with nothing
explaining why, unless §14.7(d) ships.
If these are not budgeted, the honest prediction is that the first user with 900 private chats and
a commercial subscription tries to turn the feature off — and discovers the R7 opt-out covers only
Gate C, i.e. not the part annoying them. They file a bug instead.
17. Open questions needing an operator ruling
- Does a mixed lead/worker composite ratchet the session? R3 says "switched to a private model
even once → private permanently", and a private-lead/public-worker composite contains a private
model. This design says it does not ratchet, because tier = least and the transcript has
already gone to the public worker, and because ratcheting on max would make the bind gate
refuse that same composite on the next resume. Using one reduction for both the gate and the
ratchet is what makes capability ≥ classification provable by induction. This is the single
place the letter of a requirement was not followed.
- Is the spawn-downgrade an approval or a refusal? R4 permits it, so it is an approval showing
the task prompt. But the prompt is written by a private-context model and is the only leak
vector, and it is the one control a planted PermissionRequest hook could bypass — hooks load
from ~/.config/biorouter/config.yaml and, with allow_project_hooks, from
.biorouter/hooks.yaml in the working directory, both writable by an agent with text_editor.
An operator wanting zero risk makes it a Deny.
- Does the R7 opt-out really stop at Gate C? The operator wrote "opt out of the entire
protection layer", but R3/R4/R6/R13 are stated as invariants without a "by default". Scoping it
to the MCP gate is a materially different design from scoping it to everything.
- Is the first cross-tier write approval remembered per (caller, target) or per call?
Per-pair-per-session-lifetime was chosen because a confirmation on every steer of a public worker
is miserable and would be clicked through.
- Institutional Ollama versus hosted Ollama SaaS. R1 says self-hosted or institution-hosted
is private, and config cannot tell a lab GPU box at OLLAMA_HOST=gpu.lab.ucsf.edu from a hosted
SaaS. "Non-loopback stays Private, the badge names the resolved host" is a false-private — the
one place this design is permissive rather than restrictive. Certainty needs a
BIOROUTER_PRIVATE_HOSTS allowlist, a new concept deliberately not added (it would need its own
protection and settings UI).
- Should
versa_azure get its own config keys? It shares all three AZURE_OPENAI_* keys with
the public azure_openai provider, whose shipped default endpoint is the same UCSF gateway. The
demotion rule catches the dangerous direction, but it means a user can lose their private tier
by configuring an unrelated provider.
- Should the compiled-in private baseline be a signed registry snapshot? Signing would let a
downgrade be trusted offline. Today the union rule means an extension can only ever gain a
private badge without a fresh fetch — safe, but a genuine reclassification-to-public needs
connectivity.
- Who is "who" in the declassification record? The app is single-user, so the local OS username
is recorded. On a shared lab machine that is right; in a multi-account setup it is not, and there
is no user identity in the product to record instead.
- Skills (R12) carry no classification, which leaves three gaps. (a) A skill authored while a
private chat was open can embed pasted private text and is then readable by every session and
publishable to the marketplace. (b) A skill can instruct the model to call ucsfomopagent —
harmless in effect because Gate C refuses at dispatch, but the steering is unblocked and produces
confusing refusals. (c) BR-71 Task 15 lets one session add skills to another. The v1 mitigation
is a line in the skill-creation UI; closing (a) needs skills to carry a classification, which
contradicts R12.
ActiveWorkItem.title is cross-session content and predates all of this — derived from a
subagent's task prompt and surfaced process-wide with a session id. The visibility rule is
applied to it, but it is exposed only via GET /active_work for the GUI (the model-facing
subagent_status is session-scoped), so it may deserve its own fix rather than riding this one.
POST /agent/call_tool remains inspector-free. This design is correct either way because
the barrier is in the extension manager, but the route is a standing hazard for every future
inspector-based control, including BR-71's.
Relationship to BR-71 (#30)
BR-71 introduces exactly the tools this policy must govern — workspace_list, workspace_read_conversation, workspace_send_prompt, workspace_set_tools (which can change a session's provider and model mid-flight), workspace_close, workspace_watch, workspace_open, and the merged subagent. It also adds sessions.parent_session_id, which is the lineage this design needs, and a mutation inspector that is the closest existing precedent for a dispatch-time gate.
Nothing here is structural to BR-71: the additions are per-task, and the schema column can ride BR-71's own migration. See §18 of the design document.
Summary
Two independent lattices, one column, one predicate, five gates. The full design — capability matrix, enforcement points, migration, and the eleven rulings it needs — is committed at
docs/security/privacy-tiers.md(Status: Proposed). This issue is the summary and the tracking point.A public model must never reach a private session — not once, not read-only, not indirectly. The converse is unrestricted: a private model may read anything.
A session can therefore be classified private while holding only public capability. Those are genuinely independent properties, and conflating them is the mistake the design is built to avoid.
2. The problem, and why today's code cannot express it
2.1 There is no tier anywhere in the backend
ProviderMetadatahas exactly eight fields (name,display_name,description,default_model,known_models,model_doc_link,config_keys,allows_unlisted_models) and no tier.crates/biorouter/src/providers/base.rs:145-164const INSTITUTIONAL = new Set(['versa_azure','versa_bedrock']); const LOCAL = new Set(['llamacpp','ollama']);— presentation only, unreachable from the CLI, the daemon or any gate.ui/desktop/src/components/settings/providers/providerOrdering.ts:4-5sessionsDDL endsprovider_name, model_config_json, diverged_from, external_key, branch_point_msg_uid.CURRENT_SCHEMA_VERSIONis 16.session_manager.rs:29,:1865-1889struct Extension(config,client,server_info,_temp_dir,inprocess,_pooled) and no classification field in the marketplace registry.extension_manager.rs:56-72;landing/registry.json(version 1, 37 extensions, 129 skills, keysid name organization version description tags github download filename license)2.2 The one existing approximation is backwards
provider_class(routes/apps.rs:2089) classifies a provider name intoLocal/Institutional/Externaland gates three real sites (provider_allowed_for_app,resolve_route, worker-profile admission). Membership is exact equality:versa_azureandversa_bedrockmatch neither list nor either substring, so they fall through toExternal— whileazure,azure_openai,aws_bedrock,bedrock,databricks,vertexare listed
Institutional. Agent Drafter today refuses a Versa route for a sensitive app andpermits a direct Bedrock one. The function's own test table (
:6447) never exercises aversa_*name, which is why the inversion is green.
2.3 Five present-tense paths put private history in front of a public model
Each verified by reading the code, each fixed as a by-product of this design:
Agent::update_providerperforms no check at all (agent.rs:4936-4956) and swaps thein-memory provider before it persists. It is nevertheless the sole writer of both
Agent::providerandsessions.provider_name— a grep for.provider_name(outsidesession_manager.rsreturns exactly one hit, atagent.rs:4951. That is what makes a singlebind gate sufficient.
chatrecallLOAD mode has no filter of any kind (chatrecall_extension.rs:91-158). Givenany session id it calls
get_session(&sid, true)and emits the session name, workingdirectory, message count and six verbatim messages. It does not even carry SEARCH's
exclude_session_idguard. This is the only fully-open cross-session read in the product today.copy_session(session_manager.rs:4138-4168),diverge_session(:4204-4265— the primaryGUI diverge, and it does not call
copy_session) andimport_session(:4096-4135) eachhand-roll their own update builder. None sets
provider_nameormodel_config, so a branch ofa private chat resolves through
restore_provider_from_session'sConfig::global()fallback(
agent.rs:4963-4978) and runs private history on the user's default public model, with noprompt.
ClientFrame::ModelSelect(routes/apps.rs:3409-3428) callscreate_providerthenagent.update_providerwith no check whatsoever, over theGET /apps/{id}/agentWebSocket, whichauth.rs:52-77exempts from secret-key auth.POST /agent/call_tool(routes/agent.rs:1140-1163) dispatches straight intoExtensionManager::dispatch_tool_call, skippingAgent::dispatch_tool_calland everyToolInspector. Any control expressed as an inspector is invisible to it.4. The two lattices
They are not two reductions over one set. They are reductions over different domains, which is
what makes them provably consistent rather than contradictory.
Capability has no memory. Classification has no reference to live state. The independence is
enforced three ways:
Type level. Two Rust types that do not interconvert.
There is no
From/Into. A singleOrd-derived tier would be simpler and would invitemax()whereleast()belongs.Storage level. Capability is
Agent::capability_tier(), a method readingself.provider.lock().await.tier(). No field, no column, no cache that can go stale.Scope level. Of the five gates, exactly one reads both (Gate A/B, the bind). Gate C reads
capability and
extension.tier. Gate D reads capability and the target's classification,never the caller's.
Invariant, and it should be a property test: for any sequence of legal binds,
capability(S) ≥ classification(S). The bind admitsPonly iftier(P) ≥ classification(S); theratchet then sets
classification := max(old, floor(tier(P))) ≤ floor(tier(P)). By induction, thenaive failure — "the very next turn feeds private history to a public model" — has no code path.
4.1 The mixed configuration, worked through
Private lead + public worker.
tier = least(Private, Public) = Public.floor(Public) = Public. The transcript has alreadygone to the public worker, so there is nothing left on that axis to protect — and marking it
Private would make the bind gate refuse that same composite on the next resume, bricking a
working configuration.
This is the one place the design reads R3 in spirit rather than letter (R3 says "switched to a
private model even once → private permanently", and a composite contains a private model). It
needs an explicit ruling — see §17, question 1.
LeadWorkerProvider::tier()returningleast(lead, worker)is the only override needed, andit is the reason
tier()is an instance method rather than a lookup onget_name():get_name()on a composite returns the lead's name (verified,providers/lead_worker.rs:332-334), which would badge a private-lead/public-worker session Private— the exact inverse of R2.
4.2 The residual state
classification = Private, capability = Publicis representable but unreachable through any legalbind. It arises only from legacy rows, a pre-fix copy path, an LRU-evicted agent rehydrated with
the process default, or a scheduled job built from global config. Gate B owns it, and in that
state both properties hold at once: the session cannot run a turn until repaired or declassified,
and it stays invisible to every public reader, because Gate D keys on the target's classification
against the caller's capability, independent of the target's own capability. A corrupted private
session cannot leak while it is broken.
7. The capability matrix
Inputs.
capability(caller)=leastover the components of the caller's currently-boundprovider.
Pub|Priv.target.privacy_tier, the stored classification.Pub|Priv.self·child(
target.parent_session_id == caller_session_id) ·other(includes NULL parent and everytransitive descendant).
Lineage is one hop. A grandchild is
other: R6 says "sessions the caller did spawn", and agrandchild was spawned by the child. BR-71's
workspace_list { parent_session_id: "<me>" }filteralready yields exactly the one-hop set, so no recursive CTE and no new "control my subtree" surface
is invented. A leader that needs deeper control asks its child.
The three rules.
A downgrade write (C=Priv, T=Pub) is permitted, with a first-use approval. R4 explicitly
permits a private session to spawn public children, and a rule that lets you spawn a public child
but never send it a prompt makes the permission useless — it would forbid exactly the
private-leader/public-worker arrangement R2 names. But the prompt text is private-origin content
crossing into a public model, so the first
workspace_send_prompt/workspace_set_toolsfrom agiven caller into a given public target raises an approval showing the exact payload.
The matrix.
✓allowed ·✓!allowed, first crossing requires approval showing the payload ·✗refused with a teaching message ·∅omitted from results entirely.selfandchildbehaveidentically under every rule and are merged; columns D and F prove it.
C=Pub T=Pub
self/child
C=Pub T=Pub
other
C=Pub T=Priv
any L
C=Priv T=Pub
self/child
C=Priv T=Pub
other
C=Priv T=Priv
self/child
C=Priv T=Priv
other
workspace_listworkspace_read_conversationworkspace_watchworkspace_open(existing session)workspace_send_prompt(turn / steer / note)workspace_set_tools— extensions / skills / KBsworkspace_set_tools—add_extensionsnaming a private extensionworkspace_set_tools—{ provider, model }tier(P) ≥ Pub(always)tier(P) ≥ Pubtier(P)=Privworkspace_closeworkspace_spawn_subagent/workspace_open { new: … }workspace_listomits private rows rather than redacting them. The operator ruled existenceleaks acceptable, not required, and omission is strictly simpler: a
workspace_listrowcarries a title, and a session title in this product is LLM-generated from the conversation,
i.e. content. Redacting would mean a redaction pass to review; omitting is one
WHEREclause andremoves the temptation to then call
workspace_read_conversationon the id.7.1 Non-BR-71 surfaces the same matrix governs
chatrecallSEARCHchatrecallLOAD (session_id)extension.tier ≤ C(Gate C)read_resource,read_resource_tool,list_resources,get_ui_resourceslist_prompts,list_prompts_from_extension,get_promptfilter_tools)active_workregistry (ActiveWorkItem.titleis built from a subagent's task prompt)GET /sessions, the History listprovider_class7.2 Delegation is not amplification
A private parent may spawn a public child (R4). The child is Public, sees only public sessions, and
cannot call private extensions — because VIS is evaluated against the child's own capability,
never its parent's. A public parent cannot spawn a private child at all, so it can never mint a
private-capability agent to read from; and even if it held one,
workspace_read_conversationonthat child would be C=Pub, T=Priv → refused.
Enforcement, MCP badges, chatrecall, declassification, migration
See §9–§15 of the design document. In outline:
POST /agent/call_tool, which dispatches straight into the extension manager without touching the reply path.ucsfomopagentandcdwagent. Built-ins are public; skills carry no classification.chatrecallobeys the barrier — private models recall from private and public chats, public models from public only. Side channels (existence, counts, timing) are explicitly out of scope; only content must not cross.16. The honest cost
Private is the default state on a real machine, not the exception. Measured directly from
~/.local/share/biorouter/sessions/sessions.db(aggregateprovider_namecounts only, no messagecontent):
934 of 1,321 user conversations — 70.7% — go private on first launch. 848 of those are
versa_azure(706) orversa_bedrock(142); 86 arellamacpp(48) orollama(38).And it is not an accident of one machine.
ProviderGuard.tsx:177-186orders the onboarding cardsLlama Server → Ollama → Institutional → Commercial — three of the four first-run cards are
private-tier — and CLAUDE.md states the ordering is deliberate. A new UCSF user's default,
zero-setup path produces private sessions. Every friction below should be re-costed at that
multiplier before it ships.
Where this annoys someone who has done nothing wrong:
who used Versa once and now wants Opus on the same thread must stay on Versa, start a new chat,
or declassify. This is the design's central cost and it is deliberate. The
ratchet-at-first-turn correction (§6.1) removes the mis-click version, and the graded
confirmation (§12.4) removes the friction from the
turn:-only majority.least()makes the composite Public, so one global setting one click from the composer affects unrelated
conversations, which reads as a bug unless the panel pre-flight-warns. At a 71% private base rate
this is not a footnote.
parent_session_idonly exists going forward, so every pre-upgrade subagent isotherandread-only to its own parent. No clean mitigation; it resolves as new work is spawned.
design over-trusts. See §17.
pairing-aware state in §14.5 this reads as "the OMOP tool is broken".
mcp:-reason sessions.explaining why, unless §14.7(d) ships.
If these are not budgeted, the honest prediction is that the first user with 900 private chats and
a commercial subscription tries to turn the feature off — and discovers the R7 opt-out covers only
Gate C, i.e. not the part annoying them. They file a bug instead.
17. Open questions needing an operator ruling
even once → private permanently", and a private-lead/public-worker composite contains a private
model. This design says it does not ratchet, because
tier = leastand the transcript hasalready gone to the public worker, and because ratcheting on
maxwould make the bind gaterefuse that same composite on the next resume. Using one reduction for both the gate and the
ratchet is what makes
capability ≥ classificationprovable by induction. This is the singleplace the letter of a requirement was not followed.
the task prompt. But the prompt is written by a private-context model and is the only leak
vector, and it is the one control a planted
PermissionRequesthook could bypass — hooks loadfrom
~/.config/biorouter/config.yamland, withallow_project_hooks, from.biorouter/hooks.yamlin the working directory, both writable by an agent withtext_editor.An operator wanting zero risk makes it a
Deny.protection layer", but R3/R4/R6/R13 are stated as invariants without a "by default". Scoping it
to the MCP gate is a materially different design from scoping it to everything.
Per-pair-per-session-lifetime was chosen because a confirmation on every steer of a public worker
is miserable and would be clicked through.
is private, and config cannot tell a lab GPU box at
OLLAMA_HOST=gpu.lab.ucsf.edufrom a hostedSaaS. "Non-loopback stays Private, the badge names the resolved host" is a false-private — the
one place this design is permissive rather than restrictive. Certainty needs a
BIOROUTER_PRIVATE_HOSTSallowlist, a new concept deliberately not added (it would need its ownprotection and settings UI).
versa_azureget its own config keys? It shares all threeAZURE_OPENAI_*keys withthe public
azure_openaiprovider, whose shipped default endpoint is the same UCSF gateway. Thedemotion rule catches the dangerous direction, but it means a user can lose their private tier
by configuring an unrelated provider.
downgrade be trusted offline. Today the union rule means an extension can only ever gain a
private badge without a fresh fetch — safe, but a genuine reclassification-to-public needs
connectivity.
is recorded. On a shared lab machine that is right; in a multi-account setup it is not, and there
is no user identity in the product to record instead.
private chat was open can embed pasted private text and is then readable by every session and
publishable to the marketplace. (b) A skill can instruct the model to call
ucsfomopagent—harmless in effect because Gate C refuses at dispatch, but the steering is unblocked and produces
confusing refusals. (c) BR-71 Task 15 lets one session add skills to another. The v1 mitigation
is a line in the skill-creation UI; closing (a) needs skills to carry a classification, which
contradicts R12.
ActiveWorkItem.titleis cross-session content and predates all of this — derived from asubagent's task prompt and surfaced process-wide with a session id. The visibility rule is
applied to it, but it is exposed only via
GET /active_workfor the GUI (the model-facingsubagent_statusis session-scoped), so it may deserve its own fix rather than riding this one.POST /agent/call_toolremains inspector-free. This design is correct either way becausethe barrier is in the extension manager, but the route is a standing hazard for every future
inspector-based control, including BR-71's.
Relationship to BR-71 (#30)
BR-71 introduces exactly the tools this policy must govern —
workspace_list,workspace_read_conversation,workspace_send_prompt,workspace_set_tools(which can change a session's provider and model mid-flight),workspace_close,workspace_watch,workspace_open, and the mergedsubagent. It also addssessions.parent_session_id, which is the lineage this design needs, and a mutation inspector that is the closest existing precedent for a dispatch-time gate.Nothing here is structural to BR-71: the additions are per-task, and the schema column can ride BR-71's own migration. See §18 of the design document.