diff --git a/packages/core/src/filters.ts b/packages/core/src/filters.ts index 7633d113..77792ec1 100644 --- a/packages/core/src/filters.ts +++ b/packages/core/src/filters.ts @@ -6,11 +6,19 @@ */ import { projectClause } from "./project.js"; +import { LOCAL_DEFAULT_SCOPE_ID } from "./scope-resolution.js"; import type { MemoryFilters } from "./types.js"; export interface OwnershipFilterContext { actorId: string; deviceId: string; + /** + * Apply the local read boundary for replication scopes. This is opt-in so + * low-level filter unit tests and non-memory callers can keep using the pure + * filter builder, while store/search paths can make scope visibility a hard + * invariant. + */ + enforceScopeVisibility?: boolean; } export interface FilterResult { @@ -84,6 +92,46 @@ function addMultiValueFilter( } } +const LEGACY_SHARED_REVIEW_SCOPE_ID = "legacy-shared-review"; + +function addScopeVisibilityFilter( + clauses: string[], + params: unknown[], + context: OwnershipFilterContext | undefined, +): void { + if (!context?.enforceScopeVisibility) return; + const deviceId = context.deviceId.trim(); + if (!deviceId) { + clauses.push("0 = 1"); + return; + } + // Blank/NULL scope_id is treated as local-default for read visibility. + // Migration backfill promotes legacy rows to explicit scopes asynchronously, + // but until that completes those rows must remain visible to their owning + // device exactly the way local-default rows are. + clauses.push(`( + COALESCE(TRIM(memory_items.scope_id), '') IN ('', ?, ?) + OR EXISTS ( + SELECT 1 + FROM replication_scopes rs + WHERE rs.scope_id = memory_items.scope_id + AND rs.status = 'active' + AND rs.authority_type = 'local' + ) + OR EXISTS ( + SELECT 1 + FROM scope_memberships sm + JOIN replication_scopes rs ON rs.scope_id = sm.scope_id + WHERE sm.scope_id = memory_items.scope_id + AND sm.device_id = ? + AND sm.status = 'active' + AND rs.status = 'active' + AND sm.membership_epoch >= rs.membership_epoch + ) + )`); + params.push(LOCAL_DEFAULT_SCOPE_ID, LEGACY_SHARED_REVIEW_SCOPE_ID, deviceId); +} + /** * Build WHERE clause fragments from a MemoryFilters object. * @@ -100,9 +148,9 @@ export function buildFilterClausesWithContext( ownership?: OwnershipFilterContext, ): FilterResult { const result: FilterResult = { clauses: [], params: [], joinSessions: false }; - if (!filters) return result; - const { clauses, params } = result; + addScopeVisibilityFilter(clauses, params, ownership); + if (!filters) return result; // Single kind filter if (filters.kind) { @@ -153,6 +201,17 @@ export function buildFilterClausesWithContext( normalizeVisibilityValues(filters.exclude_visibility), ); + // Replication scope filters. These are an explicit narrowing layer and are + // always intersected with the central scope-visibility gate above when the + // caller enables it. + addMultiValueFilter( + clauses, + params, + "memory_items.scope_id", + normalizeFilterStrings(filters.include_scope_ids ?? filters.scope_id), + normalizeFilterStrings(filters.exclude_scope_ids), + ); + // Workspace IDs addMultiValueFilter( clauses, diff --git a/packages/core/src/pack.test.ts b/packages/core/src/pack.test.ts index b41a6d61..76c36798 100644 --- a/packages/core/src/pack.test.ts +++ b/packages/core/src/pack.test.ts @@ -339,8 +339,8 @@ describe("buildMemoryPack", () => { .prepare( `INSERT INTO memory_items( session_id, kind, title, body_text, confidence, tags_text, active, - created_at, updated_at, metadata_json, rev - ) VALUES (?, 'session_summary', ?, ?, 0.8, '', 1, ?, ?, '{}', 1)`, + created_at, updated_at, metadata_json, rev, scope_id + ) VALUES (?, 'session_summary', ?, ?, 0.8, '', 1, ?, ?, '{}', 1, 'local-default')`, ) .run(sessionId, "Session recap", "Wrap-up of recent work", now, now); @@ -409,8 +409,8 @@ describe("buildMemoryPack", () => { .prepare( `INSERT INTO memory_items( session_id, kind, title, body_text, confidence, tags_text, active, - created_at, updated_at, metadata_json, rev, visibility, workspace_id, dedup_key - ) VALUES (?, 'decision', ?, ?, ?, '', 1, ?, ?, '{}', 1, 'shared', 'shared:default', NULL)`, + created_at, updated_at, metadata_json, rev, visibility, workspace_id, dedup_key, scope_id + ) VALUES (?, 'decision', ?, ?, ?, '', 1, ?, ?, '{}', 1, 'shared', 'shared:default', NULL, 'local-default')`, ) .run(sessionId, "Tracing duplicate title", "Tracing duplicate body", 0.8, now, now); const duplicateId = Number(info.lastInsertRowid); @@ -453,8 +453,8 @@ describe("buildMemoryPack", () => { .prepare( `INSERT INTO memory_items( session_id, kind, title, body_text, confidence, tags_text, active, - created_at, updated_at, metadata_json, rev, visibility, workspace_id, dedup_key - ) VALUES (?, 'decision', ?, ?, ?, '', 1, ?, ?, '{}', 1, 'shared', 'shared:default', NULL)`, + created_at, updated_at, metadata_json, rev, visibility, workspace_id, dedup_key, scope_id + ) VALUES (?, 'decision', ?, ?, ?, '', 1, ?, ?, '{}', 1, 'shared', 'shared:default', NULL, 'local-default')`, ) .run( sessionId, @@ -573,8 +573,8 @@ describe("buildMemoryPack", () => { .prepare( `INSERT INTO memory_items( session_id, kind, title, body_text, confidence, tags_text, active, - created_at, updated_at, metadata_json, rev - ) VALUES (?, 'session_summary', ?, ?, 0.9, '', 1, ?, ?, '{}', 1)`, + created_at, updated_at, metadata_json, rev, scope_id + ) VALUES (?, 'session_summary', ?, ?, 0.9, '', 1, ?, ?, '{}', 1, 'local-default')`, ) .run(sessionId, "Budget summary", "Short summary body", now, now); for (let i = 0; i < 10; i += 1) { @@ -599,8 +599,8 @@ describe("buildMemoryPack", () => { .prepare( `INSERT INTO memory_items( session_id, kind, title, body_text, confidence, tags_text, active, - created_at, updated_at, metadata_json, rev - ) VALUES (?, 'session_summary', ?, ?, 0.8, '', 1, ?, ?, '{}', 1)`, + created_at, updated_at, metadata_json, rev, scope_id + ) VALUES (?, 'session_summary', ?, ?, 0.8, '', 1, ?, ?, '{}', 1, 'local-default')`, ) .run( sessionId, @@ -708,8 +708,8 @@ describe("buildMemoryPack", () => { store.db .prepare( `INSERT INTO memory_items( - session_id, kind, title, body_text, confidence, tags_text, active, created_at, updated_at, metadata_json, rev - ) VALUES (?, 'session_summary', ?, ?, 0.8, '', 1, ?, ?, '{}', 1)`, + session_id, kind, title, body_text, confidence, tags_text, active, created_at, updated_at, metadata_json, rev, scope_id + ) VALUES (?, 'session_summary', ?, ?, 0.8, '', 1, ?, ?, '{}', 1, 'local-default')`, ) .run(targetSessionId, title, body, now, now); }; @@ -738,8 +738,8 @@ describe("buildMemoryPack", () => { store.db .prepare( `INSERT INTO memory_items( - session_id, kind, title, body_text, confidence, tags_text, active, created_at, updated_at, metadata_json, rev - ) VALUES (?, 'session_summary', ?, ?, 0.8, '', 1, ?, ?, '{}', 1)`, + session_id, kind, title, body_text, confidence, tags_text, active, created_at, updated_at, metadata_json, rev, scope_id + ) VALUES (?, 'session_summary', ?, ?, 0.8, '', 1, ?, ?, '{}', 1, 'local-default')`, ) .run(sessionId, "Recent summary", "Catch-up recap for current work", now, now); const decisionId = store.remember( @@ -762,8 +762,8 @@ describe("buildMemoryPack", () => { store.db .prepare( `INSERT INTO memory_items( - session_id, kind, title, body_text, confidence, tags_text, active, created_at, updated_at, metadata_json, rev - ) VALUES (?, 'change', ?, ?, 0.3, '', 1, ?, ?, ?, 1)`, + session_id, kind, title, body_text, confidence, tags_text, active, created_at, updated_at, metadata_json, rev, scope_id + ) VALUES (?, 'change', ?, ?, 0.3, '', 1, ?, ?, ?, 1, 'local-default')`, ) .run( sessionId, @@ -793,8 +793,8 @@ describe("buildMemoryPack", () => { store.db .prepare( `INSERT INTO memory_items( - session_id, kind, title, body_text, confidence, tags_text, active, created_at, updated_at, metadata_json, rev - ) VALUES (?, 'change', ?, ?, 0.3, '', 1, ?, ?, ?, 1)`, + session_id, kind, title, body_text, confidence, tags_text, active, created_at, updated_at, metadata_json, rev, scope_id + ) VALUES (?, 'change', ?, ?, 0.3, '', 1, ?, ?, ?, 1, 'local-default')`, ) .run( sessionId, @@ -816,8 +816,8 @@ describe("buildMemoryPack", () => { store.db .prepare( `INSERT INTO memory_items( - session_id, kind, title, body_text, confidence, tags_text, active, created_at, updated_at, metadata_json, rev - ) VALUES (?, 'session_summary', ?, ?, 0.8, '', 1, ?, ?, '{}', 1)`, + session_id, kind, title, body_text, confidence, tags_text, active, created_at, updated_at, metadata_json, rev, scope_id + ) VALUES (?, 'session_summary', ?, ?, 0.8, '', 1, ?, ?, '{}', 1, 'local-default')`, ) .run(sessionId, "Older summary", "Still the latest available summary", older, older); @@ -836,8 +836,8 @@ describe("buildMemoryPack", () => { store.db .prepare( `INSERT INTO memory_items( - session_id, kind, title, body_text, confidence, tags_text, active, created_at, updated_at, metadata_json, rev - ) VALUES (?, 'session_summary', ?, ?, 0.8, '', 1, ?, ?, '{}', 1)`, + session_id, kind, title, body_text, confidence, tags_text, active, created_at, updated_at, metadata_json, rev, scope_id + ) VALUES (?, 'session_summary', ?, ?, 0.8, '', 1, ?, ?, '{}', 1, 'local-default')`, ) .run(sessionId, "Recent summary", "Generic recap text", now, now); diff --git a/packages/core/src/search.test.ts b/packages/core/src/search.test.ts index 33c7d309..08288697 100644 --- a/packages/core/src/search.test.ts +++ b/packages/core/src/search.test.ts @@ -9,6 +9,64 @@ import { MemoryStore } from "./store.js"; import { initTestSchema, insertTestSession } from "./test-utils.js"; import type { MemoryResult } from "./types.js"; +function insertCoordinatorScope(store: MemoryStore, scopeId: string): void { + const now = new Date().toISOString(); + store.db + .prepare( + `INSERT OR REPLACE INTO replication_scopes( + scope_id, label, kind, authority_type, coordinator_id, group_id, + membership_epoch, status, created_at, updated_at + ) VALUES (?, ?, 'team', 'coordinator', 'coord-test', 'group-test', 0, 'active', ?, ?)`, + ) + .run(scopeId, scopeId, now, now); +} + +function grantScopeToLocalDevice(store: MemoryStore, scopeId: string): void { + insertCoordinatorScope(store, scopeId); + store.db + .prepare( + `INSERT OR REPLACE INTO scope_memberships( + scope_id, device_id, role, status, membership_epoch, + coordinator_id, group_id, updated_at + ) VALUES (?, ?, 'member', 'active', 0, 'coord-test', 'group-test', ?)`, + ) + .run(scopeId, store.deviceId, new Date().toISOString()); +} + +function insertScopedMemory( + store: MemoryStore, + input: { + scopeId: string; + sessionId?: number; + title: string; + body: string; + kind?: string; + createdAt?: string; + visibility?: string; + }, +): number { + const sessionId = input.sessionId ?? insertTestSession(store.db); + const timestamp = input.createdAt ?? new Date().toISOString(); + const info = store.db + .prepare( + `INSERT INTO memory_items( + session_id, kind, title, body_text, confidence, tags_text, active, + created_at, updated_at, metadata_json, rev, visibility, scope_id + ) VALUES (?, ?, ?, ?, 0.5, '', 1, ?, ?, '{}', 1, ?, ?)`, + ) + .run( + sessionId, + input.kind ?? "discovery", + input.title, + input.body, + timestamp, + timestamp, + input.visibility ?? "shared", + input.scopeId, + ); + return Number(info.lastInsertRowid); +} + // --------------------------------------------------------------------------- // Unit tests: expandQuery // --------------------------------------------------------------------------- @@ -709,6 +767,32 @@ describe("MemoryStore.search", () => { } }); + it("filters by local scope authorization before ranking", () => { + grantScopeToLocalDevice(store, "authorized-team"); + insertCoordinatorScope(store, "unauthorized-team"); + const visibleId = insertScopedMemory(store, { + scopeId: "authorized-team", + title: "Database scoped note", + body: "database detail", + }); + const hiddenId = insertScopedMemory(store, { + scopeId: "unauthorized-team", + title: "Database forbidden note", + body: "database database database secret", + }); + + const results = store.search("database", 10); + const resultIds = results.map((item) => item.id); + expect(resultIds).toContain(visibleId); + expect(resultIds).not.toContain(hiddenId); + + expect(store.search("database", 10, { include_scope_ids: ["unauthorized-team"] })).toEqual([]); + expect(store.search("database", 10, { scope_id: "unauthorized-team" })).toEqual([]); + expect( + store.search("database", 10, { scope_id: "authorized-team" }).map((item) => item.id), + ).toContain(visibleId); + }); + it("preserves explicit raw kind filters in emitted search results", () => { const sessionId = insertTestSession(store.db); const legacySummaryId = store.remember( @@ -858,22 +942,22 @@ describe("MemoryStore.search", () => { store.db .prepare( `INSERT INTO memory_items(session_id, kind, title, body_text, confidence, - tags_text, active, created_at, updated_at, metadata_json, rev, visibility) - VALUES (?, 'bugfix', 'Database bugfix shared', 'Shared fix for DB', 0.5, '', 1, ?, ?, '{}', 1, 'shared')`, + tags_text, active, created_at, updated_at, metadata_json, rev, visibility, scope_id) + VALUES (?, 'bugfix', 'Database bugfix shared', 'Shared fix for DB', 0.5, '', 1, ?, ?, '{}', 1, 'shared', 'local-default')`, ) .run(sessionId, ts, ts); store.db .prepare( `INSERT INTO memory_items(session_id, kind, title, body_text, confidence, - tags_text, active, created_at, updated_at, metadata_json, rev, visibility) - VALUES (?, 'bugfix', 'Database bugfix private', 'Private fix for DB', 0.5, '', 1, ?, ?, '{}', 1, 'private')`, + tags_text, active, created_at, updated_at, metadata_json, rev, visibility, scope_id) + VALUES (?, 'bugfix', 'Database bugfix private', 'Private fix for DB', 0.5, '', 1, ?, ?, '{}', 1, 'private', 'local-default')`, ) .run(sessionId, ts, ts); store.db .prepare( `INSERT INTO memory_items(session_id, kind, title, body_text, confidence, - tags_text, active, created_at, updated_at, metadata_json, rev, visibility) - VALUES (?, 'feature', 'Database feature shared', 'Shared feature for DB', 0.5, '', 1, ?, ?, '{}', 1, 'shared')`, + tags_text, active, created_at, updated_at, metadata_json, rev, visibility, scope_id) + VALUES (?, 'feature', 'Database feature shared', 'Shared feature for DB', 0.5, '', 1, ?, ?, '{}', 1, 'shared', 'local-default')`, ) .run(sessionId, ts, ts); @@ -1081,15 +1165,15 @@ describe("MemoryStore.search cross-project widening", () => { store.db .prepare( `INSERT INTO memory_items(session_id, kind, title, body_text, confidence, - tags_text, active, created_at, updated_at, metadata_json, rev, visibility) - VALUES (?, 'decision', 'Other private auth', 'private authentication decision', 0.9, '', 1, ?, ?, '{}', 1, 'private')`, + tags_text, active, created_at, updated_at, metadata_json, rev, visibility, scope_id) + VALUES (?, 'decision', 'Other private auth', 'private authentication decision', 0.9, '', 1, ?, ?, '{}', 1, 'private', 'local-default')`, ) .run(otherSessionId, ts, ts); store.db .prepare( `INSERT INTO memory_items(session_id, kind, title, body_text, confidence, - tags_text, active, created_at, updated_at, metadata_json, rev, visibility) - VALUES (?, 'decision', 'Other shared auth', 'shared authentication decision', 0.9, '', 1, ?, ?, '{}', 1, 'shared')`, + tags_text, active, created_at, updated_at, metadata_json, rev, visibility, scope_id) + VALUES (?, 'decision', 'Other shared auth', 'shared authentication decision', 0.9, '', 1, ?, ?, '{}', 1, 'shared', 'local-default')`, ) .run(otherSessionId, ts, ts); @@ -1189,8 +1273,8 @@ describe("MemoryStore.timeline", () => { const info = store.db .prepare( `INSERT INTO memory_items(session_id, kind, title, body_text, confidence, - tags_text, active, created_at, updated_at, metadata_json, rev) - VALUES (?, 'feature', ?, 'body text', 0.5, '', 1, ?, ?, '{}', 1)`, + tags_text, active, created_at, updated_at, metadata_json, rev, scope_id) + VALUES (?, 'feature', ?, 'body text', 0.5, '', 1, ?, ?, '{}', 1, 'local-default')`, ) .run(sessionId, titles[i], ts, ts); ids.push(Number(info.lastInsertRowid)); @@ -1220,6 +1304,67 @@ describe("MemoryStore.timeline", () => { expect(results.length).toBeLessThanOrEqual(5); }); + it("excludes unauthorized scoped neighbors from memoryId timelines", () => { + grantScopeToLocalDevice(store, "authorized-team"); + insertCoordinatorScope(store, "unauthorized-team"); + const sessionId = insertTestSession(store.db); + const hiddenBeforeId = insertScopedMemory(store, { + sessionId, + scopeId: "unauthorized-team", + title: "Hidden before", + body: "timeline scope body", + createdAt: "2025-01-01T00:00:00.000Z", + }); + const anchorId = insertScopedMemory(store, { + sessionId, + scopeId: "authorized-team", + title: "Visible anchor", + body: "timeline scope body", + createdAt: "2025-01-01T01:00:00.000Z", + }); + const visibleAfterId = insertScopedMemory(store, { + sessionId, + scopeId: "authorized-team", + title: "Visible after", + body: "timeline scope body", + createdAt: "2025-01-01T02:00:00.000Z", + }); + const hiddenAfterId = insertScopedMemory(store, { + sessionId, + scopeId: "unauthorized-team", + title: "Hidden after", + body: "timeline scope body", + createdAt: "2025-01-01T03:00:00.000Z", + }); + + const resultIds = store.timeline(null, anchorId, 3, 3).map((item) => item.id); + expect(resultIds).toContain(anchorId); + expect(resultIds).toContain(visibleAfterId); + expect(resultIds).not.toContain(hiddenBeforeId); + expect(resultIds).not.toContain(hiddenAfterId); + }); + + it("returns empty when an explicit memoryId anchor fails caller scope filters", () => { + grantScopeToLocalDevice(store, "authorized-team"); + const sessionId = insertTestSession(store.db); + const anchorId = insertScopedMemory(store, { + sessionId, + scopeId: "local-default", + title: "Local anchor", + body: "timeline scope body", + createdAt: "2025-01-01T01:00:00.000Z", + }); + insertScopedMemory(store, { + sessionId, + scopeId: "authorized-team", + title: "Filtered neighbor", + body: "timeline scope body", + createdAt: "2025-01-01T02:00:00.000Z", + }); + + expect(store.timeline(null, anchorId, 1, 1, { scope_id: "authorized-team" })).toEqual([]); + }); + it("returns empty for no match", () => { seedTimeline(); const results = store.timeline("xyznonexistent"); @@ -1249,8 +1394,8 @@ describe("MemoryStore.timeline", () => { const info = store.db .prepare( `INSERT INTO memory_items(session_id, kind, title, body_text, confidence, - tags_text, active, created_at, updated_at, metadata_json, rev) - VALUES (?, 'feature', ?, 'body text', 0.5, '', 1, ?, ?, '{}', 1)`, + tags_text, active, created_at, updated_at, metadata_json, rev, scope_id) + VALUES (?, 'feature', ?, 'body text', 0.5, '', 1, ?, ?, '{}', 1, 'local-default')`, ) .run(sessionId, title, ts, ts); ids.push(Number(info.lastInsertRowid)); @@ -1398,8 +1543,8 @@ describe("MemoryStore.explain", () => { .prepare( `INSERT INTO memory_items( session_id, kind, title, body_text, confidence, tags_text, active, - created_at, updated_at, metadata_json, rev - ) VALUES (?, 'session_summary', ?, ?, 0.8, '', 1, ?, ?, '{}', 1)`, + created_at, updated_at, metadata_json, rev, scope_id + ) VALUES (?, 'session_summary', ?, ?, 0.8, '', 1, ?, ?, '{}', 1, 'local-default')`, ) .run(sessionId, "Session recap", "Wrap-up of recent auth work", now, now); @@ -1429,6 +1574,41 @@ describe("MemoryStore.explain", () => { } }); + it("excludes unauthorized scoped memories from query and id explain results", () => { + grantScopeToLocalDevice(store, "authorized-team"); + insertCoordinatorScope(store, "unauthorized-team"); + const visibleId = insertScopedMemory(store, { + scopeId: "authorized-team", + title: "Scope visible auth note", + body: "scopeleak authorization detail", + }); + const hiddenId = insertScopedMemory(store, { + scopeId: "unauthorized-team", + title: "Scope hidden auth note", + body: "scopeleak forbidden detail", + }); + + const queryResultIds = store.explain("scopeleak").items.map((item) => item.id); + expect(queryResultIds).toContain(visibleId); + expect(queryResultIds).not.toContain(hiddenId); + + const missingId = 999_999; + const idResult = store.explain(null, [visibleId, hiddenId, missingId]); + const idResultIds = idResult.items.map((item) => item.id); + expect(idResultIds).toEqual([visibleId]); + expect(idResult.missing_ids).toContain(hiddenId); + expect(idResult.missing_ids).toContain(missingId); + expect(idResult.errors.find((e) => e.code === "NOT_FOUND")?.ids ?? []).toEqual( + expect.arrayContaining([hiddenId, missingId]), + ); + expect(idResult.errors.find((e) => e.code === "PROJECT_MISMATCH")?.ids ?? []).not.toContain( + hiddenId, + ); + expect(idResult.errors.find((e) => e.code === "FILTER_MISMATCH")?.ids ?? []).not.toContain( + hiddenId, + ); + }); + it("merges query and id results", () => { const { id1, id2 } = seedMemories(); // id1 is "Database migration guide" — should match the query @@ -1541,8 +1721,8 @@ describe("MemoryStore.explain", () => { store.db .prepare( `INSERT INTO memory_items(session_id, kind, title, body_text, confidence, - tags_text, active, created_at, updated_at, metadata_json, rev, workspace_id) - VALUES (?, 'discovery', 'WS memory', 'workspace body', 0.5, '', 1, ?, ?, '{}', 1, 'ws:team-alpha')`, + tags_text, active, created_at, updated_at, metadata_json, rev, workspace_id, scope_id) + VALUES (?, 'discovery', 'WS memory', 'workspace body', 0.5, '', 1, ?, ?, '{}', 1, 'ws:team-alpha', 'local-default')`, ) .run(sessionId, ts, ts); const memId = Number( diff --git a/packages/core/src/search.ts b/packages/core/src/search.ts index 5d29f2c8..f53a18c8 100644 --- a/packages/core/src/search.ts +++ b/packages/core/src/search.ts @@ -6,7 +6,7 @@ * Dynamic filter queries use raw SQL since the filter builder returns SQL strings. */ -import { and, eq, inArray } from "drizzle-orm"; +import { inArray } from "drizzle-orm"; import { drizzle } from "drizzle-orm/better-sqlite3"; import type { Database } from "./db.js"; import { fromJson } from "./db.js"; @@ -94,6 +94,7 @@ const DURABLE_ROLE_BONUS = 0.12; const GENERAL_ROLE_BONUS = 0.05; const EPHEMERAL_ROLE_PENALTY = 0.45; const EXPLICIT_RECAP_ROLE_BONUS = 0.08; +const MAX_TIMELINE_DEPTH = 200; const PERSONAL_QUERY_PATTERNS = [ /\bwhat did i\b/i, /\bmy notes\b/i, @@ -200,6 +201,12 @@ function trustBiasMode(filters: MemoryFilters | undefined): "off" | "soft" { return value === "soft" ? "soft" : "off"; } +function clampTimelineDepth(value: number): number { + if (value === Number.POSITIVE_INFINITY) return MAX_TIMELINE_DEPTH; + if (!Number.isFinite(value)) return 0; + return Math.min(Math.max(0, Math.trunc(value)), MAX_TIMELINE_DEPTH); +} + function widenSharedWhenWeakEnabled(filters: MemoryFilters | undefined): boolean { if (!filters || filters.widen_shared_when_weak === undefined) return false; const value = filters.widen_shared_when_weak; @@ -698,6 +705,7 @@ function fetchResultsByIds( const filterResult = buildFilterClausesWithContext(filters, { actorId: store.actorId, deviceId: store.deviceId, + enforceScopeVisibility: true, }); whereClauses.push(...filterResult.clauses); params.push(...filterResult.params); @@ -995,6 +1003,7 @@ function searchOnce( const filterResult = buildFilterClausesWithContext(filters, { actorId: store.actorId, deviceId: store.deviceId, + enforceScopeVisibility: true, }); whereClauses.push(...filterResult.clauses); params.push(...filterResult.params); @@ -1102,6 +1111,8 @@ function timelineAround( const anchorId = anchor.id; const anchorCreatedAt = anchor.created_at; const anchorSessionId = anchor.session_id; + const effectiveDepthBefore = clampTimelineDepth(depthBefore); + const effectiveDepthAfter = clampTimelineDepth(depthAfter); if (!anchorId || !anchorCreatedAt) { return []; @@ -1110,6 +1121,7 @@ function timelineAround( const filterResult = buildFilterClausesWithContext(filters, { actorId: store.actorId, deviceId: store.deviceId, + enforceScopeVisibility: true, }); const whereParts = ["memory_items.active = 1", ...filterResult.clauses]; const baseParams = [...filterResult.params]; @@ -1124,6 +1136,21 @@ function timelineAround( ? "JOIN sessions ON sessions.id = memory_items.session_id" : ""; + // Re-fetch anchor row to get full columns (anchor from search may be partial), + // but keep it behind the same scope/filter gate as the surrounding window. + const anchorRow = store.db + .prepare( + `SELECT memory_items.* + FROM memory_items + ${joinClause} + WHERE ${whereClause} + AND memory_items.id = ? + LIMIT 1`, + ) + .get(...baseParams, anchorId) as MemoryItem | undefined; + + if (!anchorRow) return []; + // Before: older memories, descending (we'll reverse later) const beforeRows = store.db .prepare( @@ -1138,7 +1165,13 @@ function timelineAround( ORDER BY memory_items.created_at DESC, memory_items.id DESC LIMIT ?`, ) - .all(...baseParams, anchorCreatedAt, anchorCreatedAt, anchorId, depthBefore) as MemoryItem[]; + .all( + ...baseParams, + anchorCreatedAt, + anchorCreatedAt, + anchorId, + effectiveDepthBefore, + ) as MemoryItem[]; // After: newer memories, ascending const afterRows = store.db @@ -1154,21 +1187,17 @@ function timelineAround( ORDER BY memory_items.created_at ASC, memory_items.id ASC LIMIT ?`, ) - .all(...baseParams, anchorCreatedAt, anchorCreatedAt, anchorId, depthAfter) as MemoryItem[]; - - // Re-fetch anchor row to get full columns (anchor from search may be partial) - const d = getDrizzle(store.db); - const anchorRow = d - .select() - .from(schema.memoryItems) - .where(and(eq(schema.memoryItems.id, anchorId), eq(schema.memoryItems.active, 1))) - .get() as MemoryItem | undefined; + .all( + ...baseParams, + anchorCreatedAt, + anchorCreatedAt, + anchorId, + effectiveDepthAfter, + ) as MemoryItem[]; // Combine: reversed(before) + anchor + after const rows: MemoryItem[] = [...beforeRows.reverse()]; - if (anchorRow) { - rows.push(anchorRow); - } + rows.push(anchorRow); rows.push(...afterRows); return rows.map((row) => { @@ -1285,25 +1314,28 @@ function loadItemsByIdsForExplain( }; } - const d = getDrizzle(store.db); - const allRows = d - .select() - .from(schema.memoryItems) - .where(and(eq(schema.memoryItems.active, 1), inArray(schema.memoryItems.id, ids))) - .all() as MemoryItem[]; - const allFoundIds = new Set(allRows.map((item) => item.id)); - // Placeholders for the dynamic-filter raw SQL queries below const placeholders = ids.map(() => "?").join(", "); + const scopeContext = { + actorId: store.actorId, + deviceId: store.deviceId, + enforceScopeVisibility: true, + }; + const visibilityFilter = buildFilterClausesWithContext(null, scopeContext); + const visibleRows = store.db + .prepare( + `SELECT memory_items.* + FROM memory_items + WHERE ${["memory_items.active = 1", `memory_items.id IN (${placeholders})`, ...visibilityFilter.clauses].join(" AND ")}`, + ) + .all(...ids, ...visibilityFilter.params) as MemoryItem[]; + const visibleFoundIds = new Set(visibleRows.map((item) => item.id)); - let projectScopedRows = allRows; - let projectScopedIds = new Set(allFoundIds); + let projectScopedRows = visibleRows; + let projectScopedIds = new Set(visibleFoundIds); if (filters.project) { const projectFiltersOnly: MemoryFilters = { project: filters.project }; - const projectFilterResult = buildFilterClausesWithContext(projectFiltersOnly, { - actorId: store.actorId, - deviceId: store.deviceId, - }); + const projectFilterResult = buildFilterClausesWithContext(projectFiltersOnly, scopeContext); if (projectFilterResult.clauses.length > 0) { const projectJoin = projectFilterResult.joinSessions ? "JOIN sessions ON sessions.id = memory_items.session_id" @@ -1322,10 +1354,7 @@ function loadItemsByIdsForExplain( } } - const filterResult = buildFilterClausesWithContext(filters, { - actorId: store.actorId, - deviceId: store.deviceId, - }); + const filterResult = buildFilterClausesWithContext(filters, scopeContext); const joinClause = filterResult.joinSessions ? "JOIN sessions ON sessions.id = memory_items.session_id" : ""; @@ -1339,9 +1368,9 @@ function loadItemsByIdsForExplain( .all(...ids, ...filterResult.params) as MemoryItem[]; const scopedIds = new Set(scopedRows.map((item) => item.id)); - const missingNotFound = ids.filter((memoryId) => !allFoundIds.has(memoryId)); + const missingNotFound = ids.filter((memoryId) => !visibleFoundIds.has(memoryId)); const missingProjectMismatch = ids.filter( - (memoryId) => allFoundIds.has(memoryId) && !projectScopedIds.has(memoryId), + (memoryId) => visibleFoundIds.has(memoryId) && !projectScopedIds.has(memoryId), ); const missingFilterMismatch = ids.filter( (memoryId) => projectScopedIds.has(memoryId) && !scopedIds.has(memoryId), diff --git a/packages/core/src/store.test.ts b/packages/core/src/store.test.ts index 600f89f6..e0675788 100644 --- a/packages/core/src/store.test.ts +++ b/packages/core/src/store.test.ts @@ -66,6 +66,44 @@ describe("MemoryStore", () => { rmSync(tmpDir, { recursive: true, force: true }); }); + function insertCoordinatorScope(scopeId: string): void { + const now = new Date().toISOString(); + store.db + .prepare( + `INSERT OR REPLACE INTO replication_scopes( + scope_id, label, kind, authority_type, coordinator_id, group_id, + membership_epoch, status, created_at, updated_at + ) VALUES (?, ?, 'team', 'coordinator', 'coord-test', 'group-test', 0, 'active', ?, ?)`, + ) + .run(scopeId, scopeId, now, now); + } + + function grantScopeToLocalDevice(scopeId: string): void { + insertCoordinatorScope(scopeId); + store.db + .prepare( + `INSERT OR REPLACE INTO scope_memberships( + scope_id, device_id, role, status, membership_epoch, + coordinator_id, group_id, updated_at + ) VALUES (?, ?, 'member', 'active', 0, 'coord-test', 'group-test', ?)`, + ) + .run(scopeId, store.deviceId, new Date().toISOString()); + } + + function insertScopedMemory(scopeId: string, title: string): number { + const sessionId = insertTestSession(store.db); + const now = new Date().toISOString(); + const info = store.db + .prepare( + `INSERT INTO memory_items( + session_id, kind, title, body_text, confidence, tags_text, active, + created_at, updated_at, metadata_json, rev, scope_id + ) VALUES (?, 'discovery', ?, 'scope body', 0.5, '', 1, ?, ?, '{}', 1, ?)`, + ) + .run(sessionId, title, now, now, scopeId); + return Number(info.lastInsertRowid); + } + // -- get ---------------------------------------------------------------- describe("get", () => { @@ -86,6 +124,16 @@ describe("MemoryStore", () => { // metadata_json should be parsed into an object expect(typeof result?.metadata_json).toBe("object"); }); + + it("hides direct ID reads outside locally authorized scopes", () => { + grantScopeToLocalDevice("authorized-team"); + insertCoordinatorScope("unauthorized-team"); + const visibleId = insertScopedMemory("authorized-team", "Authorized memory"); + const hiddenId = insertScopedMemory("unauthorized-team", "Unauthorized memory"); + + expect(store.get(visibleId)?.title).toBe("Authorized memory"); + expect(store.get(hiddenId)).toBeNull(); + }); }); // -- remember ----------------------------------------------------------- @@ -1178,8 +1226,8 @@ describe("MemoryStore", () => { store.db .prepare( `INSERT INTO memory_items(session_id, kind, title, body_text, confidence, - tags_text, active, created_at, updated_at, metadata_json, rev) - VALUES (?, ?, ?, ?, 0.5, '', 1, ?, ?, '{}', 1)`, + tags_text, active, created_at, updated_at, metadata_json, rev, scope_id) + VALUES (?, ?, ?, ?, 0.5, '', 1, ?, ?, '{}', 1, 'local-default')`, ) .run(sessionId, kind, `Item ${i}`, `Body ${i}`, `${base}${i}Z`, `${base}${i}Z`); } @@ -1227,6 +1275,55 @@ describe("MemoryStore", () => { expect(r.kind).toBe("discovery"); } }); + + it("intersects explicit scope filters with local authorization", () => { + grantScopeToLocalDevice("authorized-team"); + insertCoordinatorScope("unauthorized-team"); + const visibleId = insertScopedMemory("authorized-team", "Authorized recent"); + const hiddenId = insertScopedMemory("unauthorized-team", "Unauthorized recent"); + + const defaultRecentIds = store.recent(10).map((item) => item.id); + expect(defaultRecentIds).toContain(visibleId); + expect(defaultRecentIds).not.toContain(hiddenId); + + expect(store.recent(10, { include_scope_ids: ["unauthorized-team"] })).toEqual([]); + expect(store.recent(10, { scope_id: "unauthorized-team" })).toEqual([]); + expect( + store.recent(10, { include_scope_ids: ["authorized-team"] }).map((item) => item.id), + ).toContain(visibleId); + expect(store.recent(10, { scope_id: "authorized-team" }).map((item) => item.id)).toContain( + visibleId, + ); + }); + + it("treats legacy null/blank scope rows as locally visible", () => { + const sessionId = insertTestSession(store.db); + const now = "2026-01-01T00:00:00Z"; + const insertWithScope = (title: string, scopeValue: string | null): number => { + const info = store.db + .prepare( + `INSERT INTO memory_items( + session_id, kind, title, body_text, confidence, tags_text, active, + created_at, updated_at, metadata_json, rev, scope_id + ) VALUES (?, 'discovery', ?, 'legacy body', 0.5, '', 1, ?, ?, '{}', 1, ?)`, + ) + .run(sessionId, title, now, now, scopeValue); + return Number(info.lastInsertRowid); + }; + const nullScopeId = insertWithScope("Legacy null scope", null); + const blankScopeId = insertWithScope("Legacy blank scope", ""); + + const recentIds = store.recent(10).map((item) => item.id); + expect(recentIds).toContain(nullScopeId); + expect(recentIds).toContain(blankScopeId); + + const searchIds = store.search("legacy", 10).map((item) => item.id); + expect(searchIds).toContain(nullScopeId); + expect(searchIds).toContain(blankScopeId); + + expect(store.get(nullScopeId)?.title).toBe("Legacy null scope"); + expect(store.get(blankScopeId)?.title).toBe("Legacy blank scope"); + }); }); // -- recentByKinds ------------------------------------------------------- @@ -1250,6 +1347,24 @@ describe("MemoryStore", () => { const results = store.recentByKinds([]); expect(results).toEqual([]); }); + + it("intersects scope filters with local authorization", () => { + grantScopeToLocalDevice("authorized-team"); + insertCoordinatorScope("unauthorized-team"); + const visibleId = insertScopedMemory("authorized-team", "Authorized by-kind"); + const hiddenId = insertScopedMemory("unauthorized-team", "Unauthorized by-kind"); + + const defaultIds = store.recentByKinds(["discovery"], 10).map((item) => item.id); + expect(defaultIds).toContain(visibleId); + expect(defaultIds).not.toContain(hiddenId); + + expect(store.recentByKinds(["discovery"], 10, { scope_id: "unauthorized-team" })).toEqual([]); + expect( + store + .recentByKinds(["discovery"], 10, { include_scope_ids: ["authorized-team"] }) + .map((item) => item.id), + ).toContain(visibleId); + }); }); // -- stats --------------------------------------------------------------- @@ -1374,8 +1489,8 @@ describe("MemoryStore", () => { .prepare( `INSERT INTO memory_items(session_id, kind, title, body_text, confidence, tags_text, active, created_at, updated_at, metadata_json, - origin_device_id, rev) - VALUES (?, 'discovery', 'Foreign', 'Body', 0.5, '', 1, ?, ?, '{}', 'other-device', 1)`, + origin_device_id, rev, scope_id) + VALUES (?, 'discovery', 'Foreign', 'Body', 0.5, '', 1, ?, ?, '{}', 'other-device', 1, 'local-default')`, ) .run(sessionId, new Date().toISOString(), new Date().toISOString()); const foreignId = Number( diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index 0e54b32e..eac7afd1 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -28,7 +28,7 @@ import { toJson, toJsonNullable, } from "./db.js"; -import { buildFilterClauses } from "./filters.js"; +import { buildFilterClausesWithContext, type OwnershipFilterContext } from "./filters.js"; import { buildMemoryDedupKey, normalizeMemoryDedupTitle } from "./memory-dedup.js"; import { readCodememConfigFile } from "./observer-config.js"; import { @@ -414,6 +414,14 @@ export class MemoryStore { await Promise.allSettled([...this.pendingVectorWrites]); } + private scopeVisibleFilterContext(): OwnershipFilterContext { + return { + actorId: this.actorId, + deviceId: this.deviceId, + enforceScopeVisibility: true, + }; + } + // get /** @@ -421,11 +429,14 @@ export class MemoryStore { * Returns null if not found (does not filter by active status). */ get(memoryId: number): MemoryItemResponse | null { - const row = this.d - .select() - .from(schema.memoryItems) - .where(eq(schema.memoryItems.id, memoryId)) - .get() as MemoryItem | undefined; + const filterResult = buildFilterClausesWithContext(null, this.scopeVisibleFilterContext()); + const whereSql = buildWhereSql( + ["memory_items.id = ?", ...filterResult.clauses], + [memoryId, ...filterResult.params], + ); + const row = this.d.get( + sql`SELECT memory_items.* FROM memory_items WHERE ${whereSql}`, + ); if (!row) return null; return parseMetadata(row); } @@ -935,7 +946,7 @@ export class MemoryStore { */ recent(limit = 10, filters?: MemoryFilters | null, offset = 0): MemoryItemResponse[] { const baseClauses = ["memory_items.active = 1"]; - const filterResult = buildFilterClauses(filters); + const filterResult = buildFilterClausesWithContext(filters, this.scopeVisibleFilterContext()); const allClauses = [...baseClauses, ...filterResult.clauses]; const whereSql = buildWhereSql(allClauses, filterResult.params); @@ -971,7 +982,7 @@ export class MemoryStore { const kindPlaceholders = kindsList.map(() => "?").join(", "); const baseClauses = ["memory_items.active = 1", `memory_items.kind IN (${kindPlaceholders})`]; - const filterResult = buildFilterClauses(filters); + const filterResult = buildFilterClausesWithContext(filters, this.scopeVisibleFilterContext()); const allClauses = [...baseClauses, ...filterResult.clauses]; const params = [...kindsList, ...filterResult.params]; const whereSql = buildWhereSql(allClauses, params); diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 49624d79..4401ccff 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -688,6 +688,10 @@ export interface MemoryFilters { working_set_paths?: string[]; /** Project scope — matches sessions.project. Triggers session JOIN. */ project?: string; + /** Replication scope / Sharing domain filters. These narrow authorization; they never widen it. */ + scope_id?: string | string[]; + include_scope_ids?: string[]; + exclude_scope_ids?: string[]; visibility?: string | string[]; include_visibility?: string[]; exclude_visibility?: string[];