diff --git a/src/intelligence/memory-cloude.js b/src/intelligence/memory-cloude.js index b50d1b8..e7879d3 100755 --- a/src/intelligence/memory-cloude.js +++ b/src/intelligence/memory-cloude.js @@ -11,11 +11,23 @@ import { resolveAiModel, extractAiText } from "../lib/ai-model.js"; +/** + * Instances that are NOT per-entity and must never be resolved from caller input. + * These are the pre-scoping shared buckets; reaching one is a cross-entity read. + */ +const MEMORY_INSTANCE_DENYLIST = new Set(["memory-cloude", "memory-context-embeddings"]); + export class MemoryCloude { constructor(env) { this.env = env; this.kv = env.MEMORY_KV || env.TOKEN_KV; // Fallback to TOKEN_KV for now this.searchNamespace = env.AI_SEARCH; // Cloudflare AI Search namespace binding + // Derived here, NOT in initialize(). src/index.js calls initialize() without + // awaiting it (`.initialize().catch(...)`), so a request arriving first would see + // this undefined and silently skip indexing — the same silent-skip that left + // storeEmbedding dead behind `this.hasVectorize`. It is a synchronous derivation + // from env; it has no reason to depend on an async call having completed. + this.hasAiSearch = !!this.searchNamespace; this.retention = { conversations: 90, // 90 days decisions: 365, // 1 year @@ -29,9 +41,8 @@ export class MemoryCloude { async initialize() { console.log("[MemoryCloude™] Initializing perpetual context system..."); - // Check for AI Search availability - this.hasAiSearch = !!this.searchNamespace; - + // hasAiSearch is set in the constructor; re-deriving here would reintroduce the + // ordering dependency this deliberately removed. if (!this.hasAiSearch) { console.warn( "[MemoryCloude™] AI Search not available, using KV-only mode", @@ -60,7 +71,7 @@ export class MemoryCloude { ); // 2. Generate and store embedding (if Vectorize available) - if (this.hasVectorize) { + if (this.hasAiSearch) { await this.storeEmbedding(interactionId, sessionId, interaction); } @@ -86,11 +97,54 @@ export class MemoryCloude { /** * Store interaction in AI Search */ + /** + * Resolve the AI Search instance for one primary synthetic entity. + * + * Scope is the instance, never a query filter — see + * chittysearch/docs/NAMESPACE-STRATEGY.md rule 1. Between entities is a real trust + * boundary and gets a separate instance; between sessions of one entity is the + * product (90-day continuity), so session narrowing is ranking, not scope. + * + * Returns null when no entity can be identified. Callers must then skip the AI + * Search path entirely rather than fall back to a shared instance — an unscoped + * write is how one entity's context becomes another's recall. + * + * THIS SCOPES; IT DOES NOT AUTHORIZE. It maps an entity id to that entity's + * instance and validates the id's shape. It cannot know whether the caller is + * entitled to that entity — the route must pass an entity from authenticated + * context, never straight from a request body. Validation here bounds the damage + * of a caller mistake; it is not a substitute for authorization at the edge. + * + * @param {{entityId?: string, userId?: string}} interaction + * @returns {string|null} instance id, or null when unscoped + */ + memoryInstanceFor(interaction) { + const entity = interaction?.entityId || interaction?.userId || this.env?.CHITTY_ENTITY_ID; + if (!entity || typeof entity !== "string") return null; + // Reject anything that is not a plain identifier. The value reaches here from + // request-shaped objects, so it is attacker-influenceable: unconstrained, a caller + // could pick a name that resolves to an instance it does not own. The `memory-` + // prefix already makes an evidence instance unreachable, but not a sibling one. + if (!/^[A-Za-z0-9][A-Za-z0-9_-]{2,63}$/.test(entity)) return null; + const instance = `memory-${entity}`; + // Legacy shared instances predate per-entity scoping and belong to no entity. + // Without this, entityId "cloude" resolves to the old everyone-bucket. + if (MEMORY_INSTANCE_DENYLIST.has(instance)) return null; + return instance; + } + async storeEmbedding(interactionId, sessionId, interaction) { try { if (this.hasAiSearch) { + const instanceId = this.memoryInstanceFor(interaction); + if (!instanceId) { + console.warn( + "[MemoryCloude™] No entity id on interaction; skipping AI Search index (unscoped write refused)", + ); + return; + } const text = this.extractTextContent(interaction); - const instance = this.searchNamespace.get("memory-cloude"); + const instance = this.searchNamespace.get(instanceId); await instance.items.upload( interactionId, text, @@ -216,24 +270,38 @@ export class MemoryCloude { */ async recallContext(sessionId, query, options = {}) { const limit = options.limit || 5; - const useSemanticSearch = options.semantic !== false && this.hasAiSearch; + // Semantic recall requires a resolvable entity, because the entity's instance IS + // the scope. Unscoped, the only safe answer is the KV-backed keyword path, which + // reads `session:{sessionId}:*` and therefore cannot cross an entity boundary. + const instanceId = this.memoryInstanceFor(options); + const useSemanticSearch = + options.semantic !== false && this.hasAiSearch && !!instanceId; if (useSemanticSearch) { - return await this.semanticRecall(sessionId, query, limit); + return await this.semanticRecall(sessionId, query, limit, instanceId); } else { return await this.keywordRecall(sessionId, query, limit); } } /** - * Semantic search using AI Search cross-instance capabilities + * Semantic search within ONE entity's memory instance. + * + * Federation is deliberately absent. An earlier revision passed + * `instance_ids: ["memory-cloude", "context-embeddings"]` and then isolated sessions + * with a post-hoc `chunk.item.metadata.sessionId === sessionId` filter. Both halves + * were wrong: chittysearch's CHARTER forbids fanning out across instances, and a + * caller-supplied filter is not a scope boundary because it fails open the moment it + * is omitted. The session filter below is retained only as *ranking* — every chunk it + * sees already belongs to this entity, so dropping it would widen recall within the + * entity, never across entities. */ - async semanticRecall(sessionId, query, limit) { + async semanticRecall(sessionId, query, limit, instanceId) { try { - const searchResults = await this.searchNamespace.search({ + const instance = this.searchNamespace.get(instanceId); + const searchResults = await instance.search({ messages: [{ role: "user", content: query }], ai_search_options: { - instance_ids: ["memory-cloude", "context-embeddings"], retrieval: { top_k: limit * 2 } } }); @@ -521,11 +589,17 @@ export class MemoryCloude { return []; } + // Same rule as semanticRecall: no entity, no instance, no search. Returning [] + // is correct here — a decomposition hint is an optimisation, and losing it costs + // nothing next to answering from another entity's memory. + const instanceId = this.memoryInstanceFor(subtask); + if (!instanceId) return []; + try { - const searchResults = await this.searchNamespace.search({ + const instance = this.searchNamespace.get(instanceId); + const searchResults = await instance.search({ messages: [{ role: "user", content: JSON.stringify(subtask) }], ai_search_options: { - instance_ids: ["memory-cloude"], retrieval: { top_k: 5, filters: { type: "task_decomposition" } diff --git a/tests/intelligence/memory-cloude.test.js b/tests/intelligence/memory-cloude.test.js index 4cfdd94..24c9d12 100644 --- a/tests/intelligence/memory-cloude.test.js +++ b/tests/intelligence/memory-cloude.test.js @@ -116,3 +116,75 @@ describe("MemoryCloude session summary — blank-envelope guard", () => { expect(cached).toBe("The session covered credential provisioning."); }); }); + +describe("MemoryCloude entity scoping", () => { + // Scope is the instance, never a filter. These assert the boundary itself, so they + // are written to FAIL if the resolver ever falls back to a shared instance. + const build = (env = {}) => new MemoryCloude({ TOKEN_KV: new MockKV(), ...env }); + + it("derives one instance per primary synthetic entity", () => { + const m = build(); + expect(m.memoryInstanceFor({ entityId: "03-1-USA-0650-P-2606-1-24" })).toBe( + "memory-03-1-USA-0650-P-2606-1-24", + ); + }); + + it("gives two entities two different instances", () => { + const m = build(); + const a = m.memoryInstanceFor({ entityId: "03-1-USA-0650-P-2606-1-24" }); + const b = m.memoryInstanceFor({ entityId: "03-1-USA-0651-P-2606-1-25" }); + expect(a).not.toBe(b); + }); + + it("returns null rather than a shared instance when no entity is identifiable", () => { + const m = build(); + for (const input of [{}, undefined, null, { entityId: "" }, { entityId: 42 }]) { + expect(m.memoryInstanceFor(input)).toBeNull(); + } + }); + + it("does not let a non-string entity id coerce into an instance name", () => { + const m = build(); + // `memory-[object Object]` would be a single shared bucket every caller lands in. + expect(m.memoryInstanceFor({ entityId: { toString: () => "x" } })).toBeNull(); + }); + + it("refuses the legacy shared instances a caller could name directly", () => { + const m = build(); + // entityId "cloude" would otherwise resolve to `memory-cloude`, the pre-scoping + // everyone-bucket. Naming it must not be a way back into shared memory. + expect(m.memoryInstanceFor({ entityId: "cloude" })).toBeNull(); + expect(m.memoryInstanceFor({ entityId: "context-embeddings" })).toBeNull(); + }); + + it("rejects entity ids that are not plain identifiers", () => { + const m = build(); + for (const bad of ["../evidence", "a/b", "x y", "a".repeat(80), "ab", "-lead", "has.dot"]) { + expect(m.memoryInstanceFor({ entityId: bad })).toBeNull(); + } + }); + + it("has AI Search state before initialize() is awaited", () => { + // src/index.js calls initialize() without awaiting it. If hasAiSearch were only + // set there, a request arriving first would silently skip indexing — exactly the + // dead-flag bug this file's fix removed. Derive it in the constructor. + const m = new MemoryCloude({ TOKEN_KV: new MockKV(), AI_SEARCH: {} }); + expect(m.hasAiSearch).toBe(true); + const off = new MemoryCloude({ TOKEN_KV: new MockKV() }); + expect(off.hasAiSearch).toBe(false); + }); + + it("falls back to keyword recall — not a shared instance — when unscoped", async () => { + const m = build(); + m.hasAiSearch = true; + m.searchNamespace = { + get() { + throw new Error("semantic path must not be reached without an entity"); + }, + }; + // No entityId in options => must take the KV keyword path, which cannot cross + // an entity boundary because it reads session:{id}:* directly. + const out = await m.recallContext("session-1", "anything", { limit: 1 }); + expect(Array.isArray(out)).toBe(true); + }); +});