diff --git a/packages/plugin/src/hooks/magic-context/degraded-reanchor.test.ts b/packages/plugin/src/hooks/magic-context/degraded-reanchor.test.ts index 0ea11409..4973e8bf 100644 --- a/packages/plugin/src/hooks/magic-context/degraded-reanchor.test.ts +++ b/packages/plugin/src/hooks/magic-context/degraded-reanchor.test.ts @@ -392,6 +392,38 @@ describe("Layer B — degraded-mode re-anchor (#264)", () => { expect(pass2Messages[1].info.id).toBe("msg_y"); }); + it("does not inherit another database's degraded count for the same session id", () => { + // The degraded count gates a byte-CHANGING re-anchor. If it leaks across + // stores sharing a session id, store B re-anchors on its FIRST degraded + // pass because it inherited store A's episode. + const makeVisible = (): MessageLike[] => [ + userMessage("msg_c1_end", "compartment one end"), + userMessage("msg_x", "x"), + ]; + + // Store A: one degraded bust pass (count = 1, below the threshold). + seedTwoCompartments(); + const storeAPass = prepareCompartmentInjection(db, SESSION_ID, makeVisible(), true); + expect(storeAPass?.compartmentEndMessageId).toBeNull(); + + // Store B: independent database, same session id, its FIRST degraded pass. + const storeA = db; + const storeB = makeContextDb(); + try { + db = storeB; + seedTwoCompartments(); + const messages = makeVisible(); + const storeBPass = prepareCompartmentInjection(storeB, SESSION_ID, messages, true); + // Still pass 1 for THIS store: no re-anchor, nothing spliced. + expect(storeBPass?.compartmentEndMessageId).toBeNull(); + expect(storeBPass?.skippedVisibleMessages).toBe(0); + expect(messages.length).toBe(2); + } finally { + db = storeA; + closeQuietly(storeB); + } + }); + it("does NOT re-anchor before the degraded-pass threshold", () => { seedTwoCompartments(); const makeVisible = (): MessageLike[] => [ diff --git a/packages/plugin/src/hooks/magic-context/inject-compartments.test.ts b/packages/plugin/src/hooks/magic-context/inject-compartments.test.ts index 159ec24b..91ce3e4c 100644 --- a/packages/plugin/src/hooks/magic-context/inject-compartments.test.ts +++ b/packages/plugin/src/hooks/magic-context/inject-compartments.test.ts @@ -322,6 +322,41 @@ describe("prepareCompartmentInjection — empty compartments fallback", () => { }); }); +describe("prepareCompartmentInjection — cross-database cache isolation", () => { + it("does not replay a block rendered from a different database", () => { + // The injection cache is process-global and keyed by session id alone, + // while the value it holds is rendered FROM a database. Two independent + // stores that share a session id must not see each other's blocks. + const first = makeDb(); + try { + insertMemory(first, { + projectPath: PROJECT_PATH, + category: "CONSTRAINTS", + content: "MEMORY-ONLY-IN-FIRST-DATABASE", + }); + const populated = prepareCompartmentInjection( + first, + SESSION_ID, + [userMessage("m1", "hi")], + true, + PROJECT_PATH, + ); + expect(populated?.block).toContain("MEMORY-ONLY-IN-FIRST-DATABASE"); + } finally { + closeQuietly(first); + } + + // Second store: same session id, no memories, and a DEFER pass — the + // path that replays the cached injection. + db = makeDb(); + const messages: MessageLike[] = [userMessage("m1", "hi")]; + const replayed = prepareCompartmentInjection(db, SESSION_ID, messages, false, PROJECT_PATH); + + expect(replayed?.block ?? "").not.toContain("MEMORY-ONLY-IN-FIRST-DATABASE"); + expect(replayed).toBeNull(); + }); +}); + describe("prepareCompartmentInjection — workspace memory sharing", () => { it("renders only explicitly shared foreign memory categories", () => { db = makeDb(); diff --git a/packages/plugin/src/hooks/magic-context/inject-compartments.ts b/packages/plugin/src/hooks/magic-context/inject-compartments.ts index 50c774bb..5d2cf484 100644 --- a/packages/plugin/src/hooks/magic-context/inject-compartments.ts +++ b/packages/plugin/src/hooks/magic-context/inject-compartments.ts @@ -93,8 +93,8 @@ export interface PreparedCompartmentInjection { */ const INJECTION_CACHE_MAX = 100; type InjectionCacheEntry = - | { kind: "empty"; compartmentEndMessageId: string; renderedBytes: number } - | { kind: "populated"; injection: PreparedCompartmentInjection }; + | { db: Database; kind: "empty"; compartmentEndMessageId: string; renderedBytes: number } + | { db: Database; kind: "populated"; injection: PreparedCompartmentInjection }; const injectionCache = new BoundedSessionMap(INJECTION_CACHE_MAX); @@ -319,11 +319,24 @@ export function prepareCompartmentInjection( // On defer (cache-safe) passes, replay the cached injection result so that // historian publications between passes do not bust the prompt-cache prefix. const cached = injectionCache.get(sessionId); - if (!isCacheBusting && cached) { - if (cached.kind === "empty") { + if (cached && cached.db !== db) { + // Session ids are unique in production, but tests and explicit database + // paths can reuse one across independent stores. Never replay a block + // rendered from a different database into the current session. + // + // clearInjectionCache (not a bare delete) so the degraded-mode re-anchor + // bookkeeping is dropped with it: that count gates a byte-CHANGING + // re-anchor, so inheriting another store's episode could re-anchor early + // — the same cross-store leak, on the path where it costs more. + clearInjectionCache(sessionId); + } + const usableCached = cached?.db === db ? cached : undefined; + + if (!isCacheBusting && usableCached) { + if (usableCached.kind === "empty") { return null; } - const prepared = cached.injection; + const prepared = usableCached.injection; if (prepared.compartmentEndMessageId === null) { sessionLog( sessionId, @@ -418,6 +431,7 @@ export function prepareCompartmentInjection( // Nothing to inject if we have no compartments, no facts, and no memories if (compartments.length === 0 && facts.length === 0 && !memoryBlock) { injectionCache.set(sessionId, { + db, kind: "empty", compartmentEndMessageId: "", renderedBytes: 0, @@ -462,7 +476,7 @@ export function prepareCompartmentInjection( memoryCount, rebuiltFromDb: true, }; - injectionCache.set(sessionId, { kind: "populated", injection: result }); + injectionCache.set(sessionId, { db, kind: "populated", injection: result }); return result; } @@ -527,7 +541,7 @@ export function prepareCompartmentInjection( memoryCount, rebuiltFromDb: true, }; - injectionCache.set(sessionId, { kind: "populated", injection: result }); + injectionCache.set(sessionId, { db, kind: "populated", injection: result }); return result; } @@ -614,7 +628,7 @@ export function prepareCompartmentInjection( if (needsFreshMaterialization) { result.needsFreshMaterialization = true; } - injectionCache.set(sessionId, { kind: "populated", injection: result }); + injectionCache.set(sessionId, { db, kind: "populated", injection: result }); return result; }