From 8e150d4921384e09962653a422d00cabab7dda95 Mon Sep 17 00:00:00 2001 From: Tehan Date: Wed, 12 Aug 2026 14:16:32 +0200 Subject: [PATCH 1/2] fix(inject-compartments): scope the injection cache to its source database `injectionCache` is process-global and keyed on `sessionId` alone, but the value it holds is rendered FROM a database. Two independent stores that share a session id therefore see each other's blocks: the second store's defer pass replays the first store's `` block instead of rendering its own. Reproduced on clean master @ 885e93dc with a two-database probe -- store A seeds a memory and renders, store B is empty and shares the session id: PROBE A: injected=true hasMarker=true PROBE B: injected=false LEAKED_FROM_A=true Cache entries now carry their `Database` handle; a cached entry whose `db` is not the current one is discarded rather than replayed, and every write records the db. Where this bites today is test isolation: two suites that both use `ses-1` with their own temp database poison each other, so a failure appears only when they co-run and the pair passes in isolation. That is a nasty shape to debug -- the failing assertion is in a file that never touched the cache. Production sessions are unique per store, so this is not a live-session bug I can demonstrate; the invariant is simply that a cache keyed by session id must not serve content derived from a different database. Anywhere one process opens more than one store -- explicit database paths, a migration or maintenance pass over a second file, a harness driving several -- the same replay applies. Added a regression test that RED-checks: with the fix reverted it fails with the first store's marker present in the second store's block. Gates on this branch: plugin 3758/0, pi-plugin 0 fail, typecheck 0 across three packages. The one `bun run lint` error is pre-existing on clean master (`latch-permanence-guard.test.ts`, byte-identical here, fails there too). --- .../magic-context/inject-compartments.test.ts | 35 +++++++++++++++++++ .../magic-context/inject-compartments.ts | 25 ++++++++----- 2 files changed, 52 insertions(+), 8 deletions(-) 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..b2b35060 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,19 @@ 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. + injectionCache.delete(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 +426,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 +471,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 +536,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 +623,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; } From c6375bae0633ff2589b9c827b998a259f0aa2d15 Mon Sep 17 00:00:00 2001 From: Tehan Date: Wed, 12 Aug 2026 14:37:39 +0200 Subject: [PATCH 2/2] fix(inject-compartments): drop degraded re-anchor state on the db mismatch too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review catch (#308): scoping only `injectionCache` left the degraded-mode bookkeeping — `degradedRebuildCountBySession` and `reAnchorLoggedBySession` — keyed by session id alone, so two stores sharing a session id still leaked into each other through that path. That path is the worse half. `degradedCount` gates the layer-B re-anchor, which CHANGES the injected bytes: a store inheriting another's count can re-anchor on its own first degraded pass, one pass early, and the log-once latch can swallow the announcement that it happened. The mismatch branch now calls `clearInjectionCache` rather than deleting the map entry directly. That function already resets the degraded state alongside the cache entry — the two are coupled deliberately (a cache clear means compartment state moved, so an in-flight degraded episode is stale), and the same reasoning applies here. Regression test RED-checks: with a bare `injectionCache.delete`, store B re-anchors to `msg_c1_end` on its first degraded pass instead of staying degraded. Plugin 3759/0, pi-plugin 0 fail, typecheck 0 across three packages. --- .../magic-context/degraded-reanchor.test.ts | 32 +++++++++++++++++++ .../magic-context/inject-compartments.ts | 7 +++- 2 files changed, 38 insertions(+), 1 deletion(-) 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.ts b/packages/plugin/src/hooks/magic-context/inject-compartments.ts index b2b35060..5d2cf484 100644 --- a/packages/plugin/src/hooks/magic-context/inject-compartments.ts +++ b/packages/plugin/src/hooks/magic-context/inject-compartments.ts @@ -323,7 +323,12 @@ export function prepareCompartmentInjection( // 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. - injectionCache.delete(sessionId); + // + // 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;