Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 61 additions & 2 deletions packages/core/src/filters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
*
Expand All @@ -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) {
Expand Down Expand Up @@ -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,
Expand Down
44 changes: 22 additions & 22 deletions packages/core/src/pack.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand All @@ -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,
Expand Down Expand Up @@ -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);
};
Expand Down Expand Up @@ -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(
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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);

Expand All @@ -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);

Expand Down
Loading