Evidence
src/_core/search/session.ts:42-52 RecallSession has no scope field; SessionStore.create (session.ts:76-99) stores query, results, budgets only. src/interfaces/shared/search.ts:195-235 refines an existing session with the scopes of the current call:
const response = await searchMultiSource(db, {
query: params.query, sources, mode: "hybrid", budget: remainingBudget,
scopes: params.scopes, // from this call, not the session
}, config);
store.addResults(params.sessionId, response.results, params.query);
drillRecallResult (search.ts:268-297) takes sessionId + resultIndex and returns the stored result with no scope check at all. server.ts:1568-1578 (recall_session) resolves scopes per call via resolveCallScoping(process.env, params); recall_drill (server.ts:1599) passes none.
Mechanism
Sessions live in one process-wide SessionStore per worker (session.ts:203), keyed by a random UUID, shared by every client of the HTTP daemon. Two drifts follow:
- A session opened with
read_scopes: ["hermes:career"] and refined with read_scopes omitted (env default → all scopes on the daemon) merges results from every tenant into the same session; the caller's later recall_drill then serves content it never asked to see.
- Any client that learns another tenant's
session_id can recall_drill its results — no scope is re-checked. (Low probability: UUIDs are random; but the check is free.)
Suggested fix
Pin scopes on the session: add scopes?: string[] to RecallSession, set it in createOrRefineRecallSession on create, and on refine use session.scopes instead of params.scopes (or throw when the caller supplies a different set). Optionally re-check scopeVisible(result.metadata.scope, session.scopes) in drillRecallResult.
Related: #25, ADR-010, Phase 6 RLM.
Evidence
src/_core/search/session.ts:42-52RecallSessionhas no scope field;SessionStore.create(session.ts:76-99) storesquery,results, budgets only.src/interfaces/shared/search.ts:195-235refines an existing session with the scopes of the current call:drillRecallResult(search.ts:268-297) takessessionId+resultIndexand returns the stored result with no scope check at all.server.ts:1568-1578(recall_session) resolvesscopesper call viaresolveCallScoping(process.env, params);recall_drill(server.ts:1599) passes none.Mechanism
Sessions live in one process-wide
SessionStoreper worker (session.ts:203), keyed by a random UUID, shared by every client of the HTTP daemon. Two drifts follow:read_scopes: ["hermes:career"]and refined withread_scopesomitted (env default → all scopes on the daemon) merges results from every tenant into the same session; the caller's laterrecall_drillthen serves content it never asked to see.session_idcanrecall_drillits results — no scope is re-checked. (Low probability: UUIDs are random; but the check is free.)Suggested fix
Pin scopes on the session: add
scopes?: string[]toRecallSession, set it increateOrRefineRecallSessionon create, and on refine usesession.scopesinstead ofparams.scopes(or throw when the caller supplies a different set). Optionally re-checkscopeVisible(result.metadata.scope, session.scopes)indrillRecallResult.Related: #25, ADR-010, Phase 6 RLM.