Skip to content

Commit eee3753

Browse files
committed
fix: dreamer merge honors workspace per-category sharing inside a workspace (D1)
The dreamer is the cross-PROJECT merge superuser (#5971) — its merge loop supersedes each source under its own identity and reconciles every affected project's m[1]. That power is correct OUTSIDE a workspace. But inside a workspace, per-category sharing is the user's explicit privacy boundary, and the visibility check sat only inside the non-dreamer branch — so the dreamer could merge (supersede) a FOREIGN member's memory in a NON-shared category (e.g. a workspace sharing only CONSTRAINTS, dreamer merges a foreign ARCHITECTURE memory), crossing that boundary. Fix: when the caller IS the dreamer AND it's operating in a workspace (identities.length > 1), gate merge sources on memoryVisibleToTool — own→always, foreign-shared-category→ok, foreign-non-shared→blocked. Outside a workspace the dreamer's cross-project merge is unchanged (#5971 intact; the existing "merging across identities" test still passes). Mirrored in Pi. +2 OpenCode tests (dreamer blocked on foreign non-shared; allowed on foreign shared, both inside a workspace). A4 in AUDIT-KNOWN-ISSUES updated with the workspace refinement. Gate: plugin ctx-memory 42/0 (+2), Pi ctx-memory 11/0, Pi tsc clean.
1 parent e5737f5 commit eee3753

4 files changed

Lines changed: 111 additions & 3 deletions

File tree

docs/AUDIT-KNOWN-ISSUES.md

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,13 +54,21 @@ path that seldom runs and whose bias is already the desired one. Accepted.
5454
held to the same visibility gate as `update`/`archive`: every source memory must
5555
pass `memoryVisibleToTool` (own project in any category, or a foreign workspace
5656
member only in a shared category). A primary agent cannot consolidate a memory it
57-
cannot see. The **dreamer** keeps the unrestricted cross-identity path — its merge
58-
loop supersedes each source under **its own** project identity and queues a
59-
per-project supersede-delta row, so every affected project's m[1] reconciles
57+
cannot see. The **dreamer** keeps the cross-identity path **outside a workspace**
58+
its merge loop supersedes each source under **its own** project identity and queues
59+
a per-project supersede-delta row, so every affected project's m[1] reconciles
6060
(see the "merging across identities" test). The gate is the same one
6161
update/archive use; do not weaken `merge` back to a bare project-ownership check —
6262
that reintroduces the foreign-non-shared-category mutation hole.
6363

64+
**Workspace refinement (D1):** *inside* a workspace, the dreamer ALSO honors the
65+
per-category sharing policy — a foreign member's memory in a non-shared category is
66+
off-limits even to the dreamer, because the policy is the user's explicit privacy
67+
boundary that the system's own consolidation worker must respect. Outside a
68+
workspace the dreamer's cross-project power is unchanged (#5971). The gate is
69+
`agent === DREAMER && workspaceIdentitySet.identities.length > 1 →
70+
memoryVisibleToTool(source)`. Mirrored in Pi `ctx-memory.ts`.
71+
6472
### A5. Re-observing a fact does not revive an archived memory
6573

6674
`getMemoryByHash` does not filter by status, so when a fact whose prior instance

packages/pi-plugin/src/tools/ctx-memory.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -484,6 +484,21 @@ export function createCtxMemoryTool(
484484
if (inactive) {
485485
return err(inactiveMemoryError(inactive.id, "merging"));
486486
}
487+
} else if (workspaceIdentitySet.identities.length > 1) {
488+
// The dreamer keeps cross-PROJECT merge power (#5971) OUTSIDE a
489+
// workspace, but INSIDE a workspace per-category sharing is the
490+
// user's explicit privacy boundary the dreamer honors too: a
491+
// foreign member's memory in a non-shared category is off-limits.
492+
// memoryVisibleToTool already encodes own→true,
493+
// foreign-shared→true, else→false. (Parity with OpenCode D1.)
494+
const blocked = sourceMemories.find(
495+
(memory) => !memoryVisibleToTool(memory),
496+
);
497+
if (blocked) {
498+
return err(
499+
`Error: Memory with ID ${blocked.id} is in a category not shared with this workspace member and cannot be merged.`,
500+
);
501+
}
487502
}
488503

489504
// Schema-validated literal union — no runtime re-check needed.

packages/plugin/src/tools/ctx-memory/tools.test.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -582,6 +582,77 @@ describe("createCtxMemoryTools", () => {
582582
expect(getMemoryById(db, foreignShared.id)?.status).toBe("archived");
583583
});
584584

585+
it("REFUSES a DREAMER merge of a foreign NON-shared-category memory INSIDE a workspace (D1)", async () => {
586+
// The dreamer keeps cross-project merge OUTSIDE a workspace (#5971), but
587+
// INSIDE a workspace the per-category sharing policy is the user's explicit
588+
// privacy boundary the dreamer must honor too.
589+
db.exec(`
590+
INSERT INTO workspaces (id, name, created_at, updated_at, share_categories)
591+
VALUES (1, 'ws', 1, 1, '["CONSTRAINTS"]');
592+
INSERT INTO workspace_members (workspace_id, project_path, display_name, display_path, added_at)
593+
VALUES (1, '/repo/project', 'Own', '/repo/project', 1),
594+
(1, '/repo/foreign', 'Foreign', '/repo/foreign', 1);
595+
`);
596+
const own = insertMemory(db, {
597+
projectPath: "/repo/project",
598+
category: "ARCHITECTURE",
599+
content: "Own architecture detail D1.",
600+
});
601+
const foreignHidden = insertMemory(db, {
602+
projectPath: "/repo/foreign",
603+
category: "ARCHITECTURE", // foreign, NON-shared category
604+
content: "Foreign architecture not shared with this workspace member.",
605+
});
606+
607+
const result = await tools.ctx_memory.execute(
608+
{
609+
action: "merge",
610+
ids: [own.id, foreignHidden.id],
611+
content: "Merged architecture detail D1.",
612+
category: "ARCHITECTURE",
613+
},
614+
toolContext("ses-dreamer", DREAMER_AGENT),
615+
);
616+
617+
expect(result).toContain("not shared with this workspace member");
618+
expect(getMemoryById(db, own.id)?.status).toBe("active");
619+
expect(getMemoryById(db, foreignHidden.id)?.status).toBe("active");
620+
});
621+
622+
it("ALLOWS a DREAMER merge of a foreign SHARED-category memory INSIDE a workspace (D1)", async () => {
623+
db.exec(`
624+
INSERT INTO workspaces (id, name, created_at, updated_at, share_categories)
625+
VALUES (1, 'ws', 1, 1, '["CONSTRAINTS"]');
626+
INSERT INTO workspace_members (workspace_id, project_path, display_name, display_path, added_at)
627+
VALUES (1, '/repo/project', 'Own', '/repo/project', 1),
628+
(1, '/repo/foreign', 'Foreign', '/repo/foreign', 1);
629+
`);
630+
const own = insertMemory(db, {
631+
projectPath: "/repo/project",
632+
category: "CONSTRAINTS",
633+
content: "Own constraint D1.",
634+
});
635+
const foreignShared = insertMemory(db, {
636+
projectPath: "/repo/foreign",
637+
category: "CONSTRAINTS", // shared
638+
content: "Foreign constraint shared with the workspace.",
639+
});
640+
641+
const result = await tools.ctx_memory.execute(
642+
{
643+
action: "merge",
644+
ids: [own.id, foreignShared.id],
645+
content: "Merged shared constraint D1.",
646+
category: "CONSTRAINTS",
647+
},
648+
toolContext("ses-dreamer", DREAMER_AGENT),
649+
);
650+
651+
expect(result).not.toContain("not shared");
652+
expect(getMemoryById(db, own.id)?.status).toBe("archived");
653+
expect(getMemoryById(db, foreignShared.id)?.status).toBe("archived");
654+
});
655+
585656
describe("#given update action", () => {
586657
it("updates a foreign workspace memory with duplicate checks and mutations under the target identity", async () => {
587658
db.exec(`

packages/plugin/src/tools/ctx-memory/tools.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -503,6 +503,20 @@ function createCtxMemoryTool(deps: CtxMemoryToolDeps): ToolDefinition {
503503
if (inactive) {
504504
return inactiveMemoryError(inactive.id, "merging");
505505
}
506+
} else if (workspaceIdentitySet.identities.length > 1) {
507+
// The dreamer keeps its cross-PROJECT merge power (#5971) OUTSIDE
508+
// a workspace (the branch above leaves non-workspace dreamer
509+
// merges unrestricted). But INSIDE a workspace, per-category
510+
// sharing is the user's explicit privacy boundary that even the
511+
// system's own consolidation worker honors: a FOREIGN member's
512+
// memory in a non-shared category (or a non-member project's
513+
// memory) is off-limits. memoryVisibleToTool already encodes
514+
// exactly that for the workspace case (own → true,
515+
// foreign-shared-category → true, else → false).
516+
const blocked = sourceMemories.find((memory) => !memoryVisibleToTool(memory));
517+
if (blocked) {
518+
return `Error: Memory with ID ${blocked.id} is in a category not shared with this workspace member and cannot be merged.`;
519+
}
506520
}
507521

508522
const category =

0 commit comments

Comments
 (0)