From 261901343e5f51f5bead97843b5a1be38c9586b1 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 16 Jul 2026 00:30:59 -0700 Subject: [PATCH] fix(core): split the domain project field from the RLS partition column (#2165) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem Migration 0006 made `project_id` the RLS isolation partition on every `project`-schema table — stamped by a BEFORE INSERT trigger from the `fusion.project_id` session GUC, with every PK/unique/FK rewritten to composite `(project_id, …)`. Eleven tables **also** carried a caller-supplied domain `projectId` on their TS types and wrote that domain value into the same physical column. When the domain value differs from the session GUC, the parent row lands in the domain partition while child rows (`research_run_events`, `experiment_session_records`, `eval_task_results`, …) land in the session partition — and the composite FK fails with SQLSTATE 23503. Appending an event to a project-owned research run could not persist. ## Fix **Decision (operator): separate domain column; `project_id` stays the partition.** - **Migration `0011_owner_project_id.sql`** adds a nullable `owner_project_id` domain column to the 11 conflated tables (`research_runs`, `experiment_sessions`, `todo_lists`, `eval_runs`, `chat_sessions`, `chat_rooms`, `ai_sessions`, `chat_token_usage`, `project_insights`, `project_insight_runs`, `cli_sessions`), backfills it from `project_id` (identical in production, so exact; the `__legacy_unscoped__` sentinel backfills to NULL), and indexes it. Idempotent, `to_regclass`-guarded per the 0007 pattern. - **Stores** (`async-research-store`, `async-experiment-session-store`, `async-todo-store`, `async-chat-store`, `async-ai-session-store`, `async-eval-store`, `async-insight-store`, `cli-session-store`, …) stop writing `project_id` entirely — the trigger/GUC owns the partition — and map their domain `projectId` field to `owner_project_id` for both reads and filters. TS types unchanged. - **Applier** registers `OWNER_PROJECT_ID_SPLIT_VERSION = "0011"` and advances `SCHEMA_BASELINE_VERSION`. ## Verification (re-run independently of the implementing agent) - Core `tsc --noEmit`: exit 0 · `pnpm lint`: exit 0 · `pnpm check:changesets`: exit 0 · `pnpm test:gate`: 185/185 - Full postgres suite: **5 failed / 807 passed** vs a **7 / 804** baseline — the two conflation round-trips (`satellite-db-injected-stores` ResearchStore + ExperimentSessionStore) go green, zero new failures. The remaining 5 are pre-existing unbound-harness `__meta`/identity failures, unrelated to this change. 🤖 Generated with [Claude Code](https://claude.com/claude-code) ## Summary by CodeRabbit * **Bug Fixes** * Corrected project-scoped persistence and queries across AI sessions, chats (rooms + token usage), evaluations/experiments, insights, research, and todos by separating domain ownership from RLS partitioning. * Prevented foreign-key and row-level security violations when storing or retrieving project-scoped data, including legacy records. * **Database / New Features** * Added migration 0011 introducing `owner_project_id` and backfilling existing rows to preserve ownership while improving isolation. * **Tests** * Updated migration-parity coverage to include the new baseline step. --------- Co-authored-by: Claude Fable 5 --- .changeset/owner-project-id-split.md | 7 ++ .../__tests__/postgres/schema-applier.test.ts | 20 ++++-- packages/core/src/async-ai-session-store.ts | 16 +++-- packages/core/src/async-chat-store.ts | 23 ++++--- packages/core/src/async-eval-store.ts | 30 ++++++--- .../src/async-experiment-session-store.ts | 10 +-- packages/core/src/async-insight-store.ts | 43 ++++++++----- packages/core/src/async-research-store.ts | 10 +-- packages/core/src/async-todo-store.ts | 24 +++++-- packages/core/src/chat-store.ts | 5 +- packages/core/src/cli-session-store.ts | 15 +++-- .../migrations/0011_owner_project_id.sql | 64 +++++++++++++++++++ packages/core/src/postgres/schema-applier.ts | 38 ++++++++++- packages/core/src/postgres/schema/project.ts | 37 +++++++++-- packages/core/src/postgres/sqlite-migrator.ts | 14 +++- packages/core/src/token-analytics.ts | 2 +- 16 files changed, 278 insertions(+), 80 deletions(-) create mode 100644 .changeset/owner-project-id-split.md create mode 100644 packages/core/src/postgres/migrations/0011_owner_project_id.sql diff --git a/.changeset/owner-project-id-split.md b/.changeset/owner-project-id-split.md new file mode 100644 index 0000000000..eb35b29982 --- /dev/null +++ b/.changeset/owner-project-id-split.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Fix cross-project data mixups by separating a record's owning project from PostgreSQL isolation. +category: fix +dev: Migration 0011 adds `owner_project_id` to research_runs, experiment_sessions, todo_lists, eval_runs, chat_sessions, chat_rooms, ai_sessions, chat_token_usage, project_insights, project_insight_runs, and cli_sessions (backfilled from the previously conflated `project_id`, `__legacy_unscoped__` → NULL). Stores now write/read the domain project through `owner_project_id`; `project_id` stays the RLS partition owned by the `fusion_assign_project_id` trigger and the `fusion.project_id` GUC, fixing composite-FK 23503 failures when a caller's domain projectId differed from the session partition. diff --git a/packages/core/src/__tests__/postgres/schema-applier.test.ts b/packages/core/src/__tests__/postgres/schema-applier.test.ts index c947cd4ef1..4b6f36c500 100644 --- a/packages/core/src/__tests__/postgres/schema-applier.test.ts +++ b/packages/core/src/__tests__/postgres/schema-applier.test.ts @@ -41,6 +41,7 @@ import { MULTI_PROJECT_CUTOVER_SCHEMA_VERSION, MISSION_FIX_IDEMPOTENCY_VERSION, IMPORT_TRANSLATION_CACHE_VERSION, + OWNER_PROJECT_ID_SPLIT_VERSION, PROJECT_OWNERSHIP_SCHEMA_VERSION, SESSION_ADVISOR_ENABLED_SCHEMA_VERSION, SQLITE_SCHEMA_PARITY_VERSION, @@ -99,7 +100,14 @@ describe("schema-applier: immutable migration identities", () => { it("keeps the import translation cache assigned to version 0010", () => { expect(IMPORT_TRANSLATION_CACHE_VERSION).toBe("0010"); - expect(SCHEMA_BASELINE_VERSION).toBe(IMPORT_TRANSLATION_CACHE_VERSION); + // FNXC:MultiProjectIsolation 2026-07-15-23:40: the baseline marker advanced to + // 0011; 0010 keeps its immutable identity so its migration cannot be skipped. + expect(Number(SCHEMA_BASELINE_VERSION)).toBeGreaterThanOrEqual(Number(IMPORT_TRANSLATION_CACHE_VERSION)); + }); + + it("keeps the owner_project_id domain/partition split assigned to version 0011", () => { + expect(OWNER_PROJECT_ID_SPLIT_VERSION).toBe("0011"); + expect(SCHEMA_BASELINE_VERSION).toBe(OWNER_PROJECT_ID_SPLIT_VERSION); }); }); @@ -953,7 +961,7 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => { const versions = (await ctx.db.execute(sql` SELECT version FROM public.fusion_schema_migrations ORDER BY version `)) as unknown as Array<{ version: string }>; - expect(versions.map(({ version }) => version)).toEqual(["0000", "0001", "0002", "0003", "0004", "0005", PROJECT_OWNERSHIP_SCHEMA_VERSION, SQLITE_SCHEMA_PARITY_VERSION, SESSION_ADVISOR_ENABLED_SCHEMA_VERSION, MISSION_FIX_IDEMPOTENCY_VERSION, SCHEMA_BASELINE_VERSION]); + expect(versions.map(({ version }) => version)).toEqual(["0000", "0001", "0002", "0003", "0004", "0005", PROJECT_OWNERSHIP_SCHEMA_VERSION, SQLITE_SCHEMA_PARITY_VERSION, SESSION_ADVISOR_ENABLED_SCHEMA_VERSION, MISSION_FIX_IDEMPOTENCY_VERSION, IMPORT_TRANSLATION_CACHE_VERSION, SCHEMA_BASELINE_VERSION]); expect((await applySchemaBaseline(ctx.db, { pluginHooks: [] })).applied).toBe(false); }); @@ -977,7 +985,7 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => { applySchemaBaseline(ctx.db, { pluginHooks: [] }), ]); expect(results.filter(({ applied }) => applied)).toHaveLength(1); - expect(await getAppliedMigrations(ctx.db)).toEqual(["0000", "0001", "0002", "0003", "0004", "0005", PROJECT_OWNERSHIP_SCHEMA_VERSION, SQLITE_SCHEMA_PARITY_VERSION, SESSION_ADVISOR_ENABLED_SCHEMA_VERSION, MISSION_FIX_IDEMPOTENCY_VERSION, SCHEMA_BASELINE_VERSION]); + expect(await getAppliedMigrations(ctx.db)).toEqual(["0000", "0001", "0002", "0003", "0004", "0005", PROJECT_OWNERSHIP_SCHEMA_VERSION, SQLITE_SCHEMA_PARITY_VERSION, SESSION_ADVISOR_ENABLED_SCHEMA_VERSION, MISSION_FIX_IDEMPOTENCY_VERSION, IMPORT_TRANSLATION_CACHE_VERSION, SCHEMA_BASELINE_VERSION]); }); it("upgrades a 0001 database by backfilling analytics ownership", async () => { @@ -1013,7 +1021,7 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => { ))) as unknown as Array<{ project_id: string }>; expect(rows).toEqual([{ project_id: "project-a" }]); } - expect(await getAppliedMigrations(ctx.db)).toEqual(["0000", "0001", "0002", "0003", "0004", "0005", "0006", "0007", "0008", "0009", "0010"]); + expect(await getAppliedMigrations(ctx.db)).toEqual(["0000", "0001", "0002", "0003", "0004", "0005", "0006", "0007", "0008", "0009", "0010", "0011"]); }); /** @@ -1051,7 +1059,7 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => { ))) as unknown as Array<{ project_id: string }>; expect(rows).toEqual([{ project_id: "project-a" }]); } - expect(await getAppliedMigrations(ctx.db)).toEqual(["0000", "0001", "0002", "0003", "0004", "0005", "0006", "0007", "0008", "0009", "0010"]); + expect(await getAppliedMigrations(ctx.db)).toEqual(["0000", "0001", "0002", "0003", "0004", "0005", "0006", "0007", "0008", "0009", "0010", "0011"]); }); /* @@ -1089,7 +1097,7 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => { "project_auth_users", "task_reviewer_runs", ]); - expect(await getAppliedMigrations(ctx.db)).toEqual(["0000", "0001", "0002", "0003", "0004", "0005", "0006", "0007", "0008", "0009", "0010"]); + expect(await getAppliedMigrations(ctx.db)).toEqual(["0000", "0001", "0002", "0003", "0004", "0005", "0006", "0007", "0008", "0009", "0010", "0011"]); }); }); diff --git a/packages/core/src/async-ai-session-store.ts b/packages/core/src/async-ai-session-store.ts index f12ce2b29c..6ba8a789b8 100644 --- a/packages/core/src/async-ai-session-store.ts +++ b/packages/core/src/async-ai-session-store.ts @@ -103,7 +103,8 @@ function rowToSession(row: Record): AiSessionRow { result: result == null ? null : typeof result === "string" ? result : JSON.stringify(result), thinkingOutput: (row.thinkingOutput as string) ?? "", error: (row.error as string | null) ?? null, - projectId: (row.projectId as string | null) ?? null, + // FNXC:MultiProjectIsolation 2026-07-15-23:40: the domain projectId now maps to owner_project_id; project_id is the trigger/GUC-owned RLS partition (migration 0011). + projectId: (row.ownerProjectId as string | null) ?? null, createdAt: row.createdAt as string, updatedAt: row.updatedAt as string, archived: typeof row.archived === "number" ? row.archived : Number(row.archived ?? 0), @@ -153,7 +154,8 @@ export async function upsertAiSession(handle: QueryHandle, session: AiSessionRow result: resultValue, thinkingOutput: thinking, error: session.error ?? null, - ...(session.projectId ? { projectId: session.projectId } : {}), + // FNXC:MultiProjectIsolation 2026-07-15-23:40: write the caller's domain project to owner_project_id and never project_id — the trigger/GUC owns the partition (the composite (project_id, id) PK conflict target below is partition-scoped by design). + ...(session.projectId ? { ownerProjectId: session.projectId } : {}), createdAt: session.createdAt || now, updatedAt: now, }) @@ -200,14 +202,14 @@ export async function listActiveAiSessions( inArray(schema.project.aiSessions.status, ["generating", "awaiting_input", "error"]), eq(schema.project.aiSessions.archived, 0), ]; - if (projectId) conditions.push(eq(schema.project.aiSessions.projectId, projectId)); + if (projectId) conditions.push(eq(schema.project.aiSessions.ownerProjectId, projectId)); const rows = await handle .select({ id: schema.project.aiSessions.id, type: schema.project.aiSessions.type, status: schema.project.aiSessions.status, title: schema.project.aiSessions.title, - projectId: schema.project.aiSessions.projectId, + projectId: schema.project.aiSessions.ownerProjectId, updatedAt: schema.project.aiSessions.updatedAt, archived: schema.project.aiSessions.archived, }) @@ -234,7 +236,7 @@ export async function listAllAiSessions( if (!options?.includeArchived) { conditions.push(eq(schema.project.aiSessions.archived, 0)); } - if (projectId) conditions.push(eq(schema.project.aiSessions.projectId, projectId)); + if (projectId) conditions.push(eq(schema.project.aiSessions.ownerProjectId, projectId)); if (options?.type) conditions.push(eq(schema.project.aiSessions.type, options.type)); const query = handle .select({ @@ -243,7 +245,7 @@ export async function listAllAiSessions( status: schema.project.aiSessions.status, title: schema.project.aiSessions.title, inputPayload: schema.project.aiSessions.inputPayload, - projectId: schema.project.aiSessions.projectId, + projectId: schema.project.aiSessions.ownerProjectId, updatedAt: schema.project.aiSessions.updatedAt, archived: schema.project.aiSessions.archived, }) @@ -263,7 +265,7 @@ export async function listRecoverableAiSessions( const conditions = [ inArray(schema.project.aiSessions.status, ["generating", "awaiting_input"]), ]; - if (projectId) conditions.push(eq(schema.project.aiSessions.projectId, projectId)); + if (projectId) conditions.push(eq(schema.project.aiSessions.ownerProjectId, projectId)); const rows = await handle .select() .from(schema.project.aiSessions) diff --git a/packages/core/src/async-chat-store.ts b/packages/core/src/async-chat-store.ts index b037f7710a..32e040ffe7 100644 --- a/packages/core/src/async-chat-store.ts +++ b/packages/core/src/async-chat-store.ts @@ -44,7 +44,8 @@ function rowToSession(row: Record): ChatSession { agentId: row.agentId as string, title: (row.title as string | null) ?? null, status: row.status as ChatSessionStatus, - projectId: (row.projectId as string | null) ?? null, + // FNXC:MultiProjectIsolation 2026-07-15-23:40: the domain projectId now maps to owner_project_id; project_id is the trigger/GUC-owned RLS partition (migration 0011). + projectId: (row.ownerProjectId as string | null) ?? null, modelProvider: (row.modelProvider as string | null) ?? null, modelId: (row.modelId as string | null) ?? null, thinkingLevel: (row.thinkingLevel as string | null) ?? null, @@ -75,7 +76,8 @@ function rowToRoom(row: Record): ChatRoom { name: row.name as string, slug: row.slug as string, description: (row.description as string | null) ?? null, - projectId: (row.projectId as string | null) ?? null, + // FNXC:MultiProjectIsolation 2026-07-15-23:40: domain projectId reads from owner_project_id (see rowToSession). + projectId: (row.ownerProjectId as string | null) ?? null, createdBy: (row.createdBy as string | null) ?? null, status: row.status as ChatRoomStatus, // FNXC:Chat-ThinkingLevel 2026-07-13 (merge port): room-level reasoning-effort default. @@ -120,7 +122,8 @@ export async function createChatSession(handle: QueryHandle, session: ChatSessio agentId: session.agentId, title: session.title, status: session.status, - projectId: session.projectId, + // FNXC:MultiProjectIsolation 2026-07-15-23:40: write the caller's domain project to owner_project_id and never project_id — the trigger/GUC owns the partition. + ownerProjectId: session.projectId, modelProvider: session.modelProvider, modelId: session.modelId, thinkingLevel: session.thinkingLevel ?? null, @@ -153,7 +156,7 @@ export async function listChatSessions( options?: { projectId?: string; agentId?: string; status?: ChatSessionStatus }, ): Promise { const conditions: ReturnType[] = []; - if (options?.projectId) conditions.push(eq(schema.project.chatSessions.projectId, options.projectId)); + if (options?.projectId) conditions.push(eq(schema.project.chatSessions.ownerProjectId, options.projectId)); if (options?.agentId) conditions.push(eq(schema.project.chatSessions.agentId, options.agentId)); if (options?.status) conditions.push(eq(schema.project.chatSessions.status, options.status)); const query = handle @@ -283,7 +286,7 @@ export async function createChatRoom( name: room.name, slug: room.slug, description: room.description, - projectId: room.projectId, + ownerProjectId: room.projectId, createdBy: room.createdBy, status: room.status, thinkingLevel: room.thinkingLevel ?? null, @@ -325,9 +328,9 @@ export async function getChatRoomBySlug( ): Promise { const conditions = [eq(schema.project.chatRooms.slug, slug)]; if (projectId !== null) { - conditions.push(eq(schema.project.chatRooms.projectId, projectId)); + conditions.push(eq(schema.project.chatRooms.ownerProjectId, projectId)); } else { - conditions.push(isNull(schema.project.chatRooms.projectId)); + conditions.push(isNull(schema.project.chatRooms.ownerProjectId)); } const rows = await handle .select() @@ -344,7 +347,7 @@ export async function listChatRooms( options?: { projectId?: string; status?: ChatRoomStatus }, ): Promise { const conditions: ReturnType[] = []; - if (options?.projectId) conditions.push(eq(schema.project.chatRooms.projectId, options.projectId)); + if (options?.projectId) conditions.push(eq(schema.project.chatRooms.ownerProjectId, options.projectId)); if (options?.status) conditions.push(eq(schema.project.chatRooms.status, options.status)); const query = handle .select() @@ -857,7 +860,7 @@ export async function listChatRoomsForAgent( const conditions: ReturnType[] = [inArray(schema.project.chatRooms.id, memberRoomIds)]; if (options?.status) conditions.push(eq(schema.project.chatRooms.status, options.status)); - if (options?.projectId) conditions.push(eq(schema.project.chatRooms.projectId, options.projectId)); + if (options?.projectId) conditions.push(eq(schema.project.chatRooms.ownerProjectId, options.projectId)); const rows = await handle .select() @@ -986,7 +989,7 @@ export async function findLatestActiveChatSessionForTarget( eq(schema.project.chatSessions.agentId, normalizedAgentId), ]; if (options.projectId && options.projectId.trim()) { - baseConditions.push(eq(schema.project.chatSessions.projectId, options.projectId.trim())); + baseConditions.push(eq(schema.project.chatSessions.ownerProjectId, options.projectId.trim())); } // Model-targeted: exact provider+model match. diff --git a/packages/core/src/async-eval-store.ts b/packages/core/src/async-eval-store.ts index affd435a6b..04d686648a 100644 --- a/packages/core/src/async-eval-store.ts +++ b/packages/core/src/async-eval-store.ts @@ -55,7 +55,8 @@ type QueryHandle = AsyncDataLayer["db"] | DbTransaction; function rowToRun(row: Record): EvalRun { return { id: String(row.id), - projectId: String(row.projectId), + // FNXC:MultiProjectIsolation 2026-07-15-23:40: the domain projectId now maps to owner_project_id; project_id is the trigger/GUC-owned RLS partition (migration 0011). + projectId: String(row.ownerProjectId ?? ""), status: row.status as EvalRunStatus, trigger: row.trigger as EvalRun["trigger"], scope: String(row.scope), @@ -123,7 +124,8 @@ export async function createEvalRun( ): Promise { await handle.insert(schema.project.evalRuns).values({ id: run.id, - projectId: run.projectId, + // FNXC:MultiProjectIsolation 2026-07-15-23:40: write the caller's domain project to owner_project_id and never project_id — domain writes into the partition desynced parent/child partitions and broke composite FKs (23503). + ownerProjectId: run.projectId, status: "pending", trigger: run.trigger, scope: run.scope, @@ -144,7 +146,7 @@ export async function createEvalRun( }); return rowToRun({ id: run.id, - projectId: run.projectId, + ownerProjectId: run.projectId, status: "pending", trigger: run.trigger, scope: run.scope, @@ -181,7 +183,7 @@ export async function getEvalRun(handle: QueryHandle, id: string): Promise { const conditions: ReturnType[] = []; - if (options.projectId) conditions.push(eq(schema.project.evalRuns.projectId, options.projectId)); + if (options.projectId) conditions.push(eq(schema.project.evalRuns.ownerProjectId, options.projectId)); if (options.status) conditions.push(eq(schema.project.evalRuns.status, options.status)); if (options.trigger) conditions.push(eq(schema.project.evalRuns.trigger, options.trigger)); let query = handle @@ -232,17 +234,22 @@ export async function upsertEvalTaskResult( handle: QueryHandle, result: EvalTaskResult, ): Promise { + // FNXC:MultiProjectIsolation 2026-07-16-08:05: children inherit the parent run's + // project_id partition explicitly. The GUC-driven trigger only covers bound sessions; + // unbound/bypass handles (tests, admin, maintenance) or a GUC naming another partition + // would otherwise violate the composite (project_id, run_id) FK with SQLSTATE 23503. + // Inherit the PARTITION column (projectId), never the domain field (ownerProjectId). const runRows = await handle .select({ projectId: schema.project.evalRuns.projectId }) .from(schema.project.evalRuns) .where(eq(schema.project.evalRuns.id, result.runId)) .limit(1); - const projectId = runRows[0]?.projectId; - if (!projectId) throw new Error(`Eval run not found: ${result.runId}`); + const parentProjectId = runRows[0]?.projectId; + if (!parentProjectId) throw new Error(`Eval run not found: ${result.runId}`); await handle .insert(schema.project.evalTaskResults) .values({ - projectId, + projectId: parentProjectId, id: result.id, runId: result.runId, taskId: result.taskId, @@ -346,13 +353,16 @@ export async function appendEvalRunEvent( input: { id: string; runId: string; type: string; message: string; status?: EvalRunStatus; taskId?: string; metadata?: Record }, ): Promise { return layer.transactionImmediate(async (tx) => { + // FNXC:MultiProjectIsolation 2026-07-16-08:05: inherit the parent run's project_id + // partition explicitly (see upsertEvalTaskResult) — the ambient GUC is not guaranteed + // to match for unbound/bypass handles, and a mismatch breaks the composite parent FK. const runRows = await tx .select({ projectId: schema.project.evalRuns.projectId }) .from(schema.project.evalRuns) .where(eq(schema.project.evalRuns.id, input.runId)) .limit(1); - const projectId = runRows[0]?.projectId; - if (!projectId) throw new Error(`Eval run not found: ${input.runId}`); + const parentProjectId = runRows[0]?.projectId; + if (!parentProjectId) throw new Error(`Eval run not found: ${input.runId}`); const seqRows = await tx .select({ maxSeq: sql`max(${schema.project.evalRunEvents.seq})` }) .from(schema.project.evalRunEvents) @@ -360,7 +370,7 @@ export async function appendEvalRunEvent( const seq = (seqRows[0]?.maxSeq ?? 0) + 1; const createdAt = new Date().toISOString(); await tx.insert(schema.project.evalRunEvents).values({ - projectId, + projectId: parentProjectId, id: input.id, runId: input.runId, seq, diff --git a/packages/core/src/async-experiment-session-store.ts b/packages/core/src/async-experiment-session-store.ts index cebce53e25..6c6fe21c7e 100644 --- a/packages/core/src/async-experiment-session-store.ts +++ b/packages/core/src/async-experiment-session-store.ts @@ -35,7 +35,8 @@ function rowToSession(row: Record): ExperimentSession { return { id: row.id as string, name: row.name as string, - projectId: (row.projectId as string | null) ?? undefined, + // FNXC:MultiProjectIsolation 2026-07-15-23:40: the domain projectId now maps to owner_project_id; project_id is the trigger/GUC-owned RLS partition (migration 0011). + projectId: (row.ownerProjectId as string | null) ?? undefined, status: row.status as ExperimentSessionStatus, metric: (metricRaw as ExperimentSession["metric"]) ?? { name: "unknown", direction: "maximize" }, currentSegment: Number(row.currentSegment ?? 1), @@ -74,7 +75,8 @@ export async function createExperimentSession( await handle.insert(schema.project.experimentSessions).values({ id: session.id, name: session.name, - projectId: session.projectId ?? null, + // FNXC:MultiProjectIsolation 2026-07-15-23:40: domain project goes to owner_project_id; project_id (the RLS partition) is owned by the fusion_assign_project_id trigger/GUC. + ownerProjectId: session.projectId ?? null, status: session.status, metric: JSON.stringify(session.metric), currentSegment: session.currentSegment, @@ -109,7 +111,7 @@ export async function getExperimentSession(handle: QueryHandle, id: string): Pro export async function listExperimentSessions(handle: QueryHandle, options: ExperimentSessionListOptions = {}): Promise { const conditions: ReturnType[] = []; if (options.status) conditions.push(eq(schema.project.experimentSessions.status, options.status)); - if (options.projectId) conditions.push(eq(schema.project.experimentSessions.projectId, options.projectId)); + if (options.projectId) conditions.push(eq(schema.project.experimentSessions.ownerProjectId, options.projectId)); const query = handle .select() .from(schema.project.experimentSessions) @@ -126,7 +128,7 @@ export async function persistExperimentSession(handle: QueryHandle, session: Exp .update(schema.project.experimentSessions) .set({ name: session.name, - projectId: session.projectId ?? null, + ownerProjectId: session.projectId ?? null, status: session.status, metric: JSON.stringify(session.metric), currentSegment: session.currentSegment, diff --git a/packages/core/src/async-insight-store.ts b/packages/core/src/async-insight-store.ts index 7129396009..6762257c15 100644 --- a/packages/core/src/async-insight-store.ts +++ b/packages/core/src/async-insight-store.ts @@ -53,7 +53,8 @@ type QueryHandle = AsyncDataLayer["db"] | DbTransaction; function rowToInsight(row: Record): Insight { return { id: row.id as string, - projectId: row.projectId as string, + // FNXC:MultiProjectIsolation 2026-07-15-23:40: the domain projectId now maps to owner_project_id; project_id is the trigger/GUC-owned RLS partition (migration 0011). + projectId: (row.ownerProjectId as string | null) ?? "", title: row.title as string, content: (row.content as string | null) ?? null, category: row.category as InsightCategory, @@ -69,7 +70,7 @@ function rowToInsight(row: Record): Insight { function rowToRun(row: Record): InsightRun { return { id: row.id as string, - projectId: row.projectId as string, + projectId: (row.ownerProjectId as string | null) ?? "", trigger: row.trigger as InsightRunTrigger, status: row.status as InsightRunStatus, summary: (row.summary as string | null) ?? null, @@ -97,7 +98,8 @@ export async function createInsight( ): Promise { await handle.insert(schema.project.projectInsights).values({ id: insight.id, - projectId: insight.projectId, + // FNXC:MultiProjectIsolation 2026-07-15-23:40: write the caller's domain project to owner_project_id and never project_id — the trigger/GUC owns the partition, and domain writes into it desynced the composite FK partition of project_insight_run_events. + ownerProjectId: insight.projectId, title: insight.title, content: insight.content ?? null, category: insight.category, @@ -127,7 +129,7 @@ export async function getInsight(handle: QueryHandle, id: string): Promise { const conditions: ReturnType[] = []; - if (options.projectId !== undefined) conditions.push(eq(schema.project.projectInsights.projectId, options.projectId)); + if (options.projectId !== undefined) conditions.push(eq(schema.project.projectInsights.ownerProjectId, options.projectId)); if (options.category !== undefined) conditions.push(eq(schema.project.projectInsights.category, options.category)); if (options.status !== undefined) conditions.push(eq(schema.project.projectInsights.status, options.status)); if (options.runId !== undefined) conditions.push(eq(schema.project.projectInsights.lastRunId, options.runId)); @@ -154,7 +156,7 @@ export async function upsertInsight( .from(schema.project.projectInsights) .where( and( - eq(schema.project.projectInsights.projectId, projectId), + eq(schema.project.projectInsights.ownerProjectId, projectId), eq(schema.project.projectInsights.fingerprint, input.fingerprint), ), ); @@ -219,7 +221,7 @@ export async function createInsightRun( ): Promise { await handle.insert(schema.project.projectInsightRuns).values({ id: run.id, - projectId: run.projectId, + ownerProjectId: run.projectId, trigger: run.trigger, status: "pending", summary: null, @@ -270,7 +272,7 @@ export async function getInsightRun(handle: QueryHandle, id: string): Promise { const conditions: ReturnType[] = []; - if (options.projectId !== undefined) conditions.push(eq(schema.project.projectInsightRuns.projectId, options.projectId)); + if (options.projectId !== undefined) conditions.push(eq(schema.project.projectInsightRuns.ownerProjectId, options.projectId)); if (options.status !== undefined) conditions.push(eq(schema.project.projectInsightRuns.status, options.status)); if (options.trigger !== undefined) conditions.push(eq(schema.project.projectInsightRuns.trigger, options.trigger)); const query = handle @@ -295,7 +297,7 @@ export async function findActiveInsightRun( .from(schema.project.projectInsightRuns) .where( and( - eq(schema.project.projectInsightRuns.projectId, projectId), + eq(schema.project.projectInsightRuns.ownerProjectId, projectId), eq(schema.project.projectInsightRuns.trigger, trigger), inArray(schema.project.projectInsightRuns.status, ["pending", "running"]), ), @@ -315,21 +317,32 @@ export async function appendInsightRunEvent( let seq = 1; const createdAt = new Date().toISOString(); await layer.transactionImmediate(async (tx) => { + // FNXC:MultiProjectIsolation 2026-07-16-09:10: read the parent run's PARTITION column + // (projectId, not ownerProjectId) and stamp it explicitly on the child event insert. + // The ambient fusion.project_id GUC is not guaranteed to match the parent's partition — + // unbound/bypass handles (tests, admin, maintenance) would otherwise get the trigger's + // default (__legacy_unscoped__ or another ambient partition) and fail the composite + // (project_id, run_id) FK with SQLSTATE 23503. The trigger only rewrites blank values, + // so an explicit non-blank project_id is preserved, and RLS WITH CHECK still passes for + // bound sessions (they can only read their own partition's parent run anyway). const runRows = await tx - .select({ projectId: schema.project.projectInsightRuns.projectId }) + .select({ + id: schema.project.projectInsightRuns.id, + projectId: schema.project.projectInsightRuns.projectId, + }) .from(schema.project.projectInsightRuns) .where(eq(schema.project.projectInsightRuns.id, input.runId)) .limit(1); - const projectId = runRows[0]?.projectId; - if (!projectId) throw new Error(`Insight run not found: ${input.runId}`); + if (!runRows[0]) throw new Error(`Insight run not found: ${input.runId}`); + const parentPartitionId = runRows[0].projectId as string; const seqRows = await tx .select({ nextSeq: sql`coalesce(max(${schema.project.projectInsightRunEvents.seq}), 0) + 1` }) .from(schema.project.projectInsightRunEvents) .where(eq(schema.project.projectInsightRunEvents.runId, input.runId)); seq = Number(seqRows[0]?.nextSeq ?? 1); await tx.insert(schema.project.projectInsightRunEvents).values({ - projectId, id: input.id, + projectId: parentPartitionId, runId: input.runId, seq, type: input.type, @@ -465,7 +478,7 @@ export async function updateInsightRun( function buildInsightCountConditions(options: Pick): ReturnType[] { const conditions: ReturnType[] = []; - if (options.projectId !== undefined) conditions.push(eq(schema.project.projectInsights.projectId, options.projectId)); + if (options.projectId !== undefined) conditions.push(eq(schema.project.projectInsights.ownerProjectId, options.projectId)); if (options.category !== undefined) conditions.push(eq(schema.project.projectInsights.category, options.category)); if (options.status !== undefined) conditions.push(eq(schema.project.projectInsights.status, options.status)); if (options.runId !== undefined) conditions.push(eq(schema.project.projectInsights.lastRunId, options.runId)); @@ -485,7 +498,7 @@ export async function countInsights(handle: QueryHandle, options: Omit): ReturnType[] { const conditions: ReturnType[] = []; - if (options.projectId !== undefined) conditions.push(eq(schema.project.projectInsightRuns.projectId, options.projectId)); + if (options.projectId !== undefined) conditions.push(eq(schema.project.projectInsightRuns.ownerProjectId, options.projectId)); if (options.status !== undefined) conditions.push(eq(schema.project.projectInsightRuns.status, options.status)); if (options.trigger !== undefined) conditions.push(eq(schema.project.projectInsightRuns.trigger, options.trigger)); return conditions; @@ -517,7 +530,7 @@ export async function listStalePendingRuns( inArray(schema.project.projectInsightRuns.status, ["pending", "running"]), lte(sql`coalesce(${schema.project.projectInsightRuns.startedAt}, ${schema.project.projectInsightRuns.createdAt})`, olderThanIso), ]; - if (options.projectId) conditions.push(eq(schema.project.projectInsightRuns.projectId, options.projectId)); + if (options.projectId) conditions.push(eq(schema.project.projectInsightRuns.ownerProjectId, options.projectId)); const rows = await handle .select() .from(schema.project.projectInsightRuns) diff --git a/packages/core/src/async-research-store.ts b/packages/core/src/async-research-store.ts index b2da6e36cd..f5e22bc24e 100644 --- a/packages/core/src/async-research-store.ts +++ b/packages/core/src/async-research-store.ts @@ -57,7 +57,8 @@ function rowToRun(row: Record): ResearchRun { query: row.query as string, topic: (row.topic as string | null) ?? undefined, status: normalizeStatus((row.status as ResearchRunStatus | "pending") ?? "queued"), - projectId: (row.projectId as string | null) ?? undefined, + // FNXC:MultiProjectIsolation 2026-07-15-23:40: the domain projectId now maps to owner_project_id; project_id is the trigger/GUC-owned RLS partition (migration 0011). + projectId: (row.ownerProjectId as string | null) ?? undefined, trigger: (row.trigger as string | null) ?? undefined, providerConfig: row.providerConfig as ResearchRun["providerConfig"], sources: (row.sources as ResearchSource[]) ?? [], @@ -99,7 +100,8 @@ export async function createResearchRun( query: run.query, topic: run.topic ?? null, status: run.status, - projectId: run.projectId ?? null, + // FNXC:MultiProjectIsolation 2026-07-15-23:40: write the caller's domain project to owner_project_id and never project_id — writing domain data into the partition put parents and children in different partitions and broke the composite FKs (23503). + ownerProjectId: run.projectId ?? null, trigger: run.trigger ?? null, providerConfig: run.providerConfig ?? null, sources: run.sources, @@ -141,7 +143,7 @@ export async function persistResearchRun(handle: QueryHandle, run: ResearchRun): query: run.query, topic: run.topic ?? null, status: run.status, - projectId: run.projectId ?? null, + ownerProjectId: run.projectId ?? null, trigger: run.trigger ?? null, providerConfig: run.providerConfig ?? null, sources: run.sources, @@ -251,7 +253,7 @@ export async function getActiveResearchRun( .from(schema.project.researchRuns) .where( and( - eq(schema.project.researchRuns.projectId, projectId), + eq(schema.project.researchRuns.ownerProjectId, projectId), eq(schema.project.researchRuns.trigger, trigger), inArray(schema.project.researchRuns.status, ["queued", "running", "cancelling", "retry_waiting"]), ), diff --git a/packages/core/src/async-todo-store.ts b/packages/core/src/async-todo-store.ts index 4e911b53d2..e9e44a5fda 100644 --- a/packages/core/src/async-todo-store.ts +++ b/packages/core/src/async-todo-store.ts @@ -84,7 +84,8 @@ function rowToTodoItem(row: TodoItemRow): TodoItem { const todoListColumns = { id: schema.project.todoLists.id, - projectId: schema.project.todoLists.projectId, + // FNXC:MultiProjectIsolation 2026-07-15-23:40: the TodoList domain projectId reads from owner_project_id; project_id is the trigger/GUC-owned RLS partition (migration 0011). + projectId: schema.project.todoLists.ownerProjectId, title: schema.project.todoLists.title, createdAt: schema.project.todoLists.createdAt, updatedAt: schema.project.todoLists.updatedAt, @@ -111,7 +112,8 @@ export async function createTodoList( ): Promise { await handle.insert(schema.project.todoLists).values({ id: list.id, - projectId: list.projectId, + // FNXC:MultiProjectIsolation 2026-07-15-23:40: write the caller's domain project to owner_project_id and never project_id — the trigger/GUC owns the partition, and domain writes into it desynced the composite FK partitions of todo_items. + ownerProjectId: list.projectId, title: list.title, createdAt: list.createdAt, updatedAt: list.updatedAt, @@ -137,7 +139,7 @@ export async function listTodoLists(handle: QueryHandle, projectId: string): Pro const rows = await handle .select(todoListColumns) .from(schema.project.todoLists) - .where(eq(schema.project.todoLists.projectId, projectId)) + .where(eq(schema.project.todoLists.ownerProjectId, projectId)) .orderBy(asc(schema.project.todoLists.createdAt), asc(schema.project.todoLists.id)); return rows.map((row) => rowToTodoList(row as TodoListRow)); } @@ -181,13 +183,18 @@ export async function createTodoItem( handle: QueryHandle, item: { id: string; listId: string; text: string; completed: boolean; completedAt: string | null; sortOrder: number | undefined; createdAt: string; updatedAt: string }, ): Promise { + // FNXC:MultiProjectIsolation 2026-07-16-00:10: read the parent list's project_id + // partition (NOT owner_project_id) so the item explicitly inherits it. The ambient + // fusion.project_id GUC is not guaranteed to match the list's partition — unbound or + // RLS-bypass handles can read a list from any partition, and letting the trigger stamp + // the GUC would put the item in a different partition and break the composite + // (project_id, list_id) FK with SQLSTATE 23503. const listRows = await handle - .select({ projectId: schema.project.todoLists.projectId }) + .select({ id: schema.project.todoLists.id, projectId: schema.project.todoLists.projectId }) .from(schema.project.todoLists) .where(eq(schema.project.todoLists.id, item.listId)) .limit(1); - const projectId = listRows[0]?.projectId; - if (!projectId) throw new Error(`Todo list not found: ${item.listId}`); + if (!listRows[0]) throw new Error(`Todo list not found: ${item.listId}`); let sortOrder = item.sortOrder; if (sortOrder === undefined) { const maxRows = await handle @@ -197,7 +204,10 @@ export async function createTodoItem( sortOrder = (maxRows[0]?.maxSortOrder ?? -1) + 1; } await handle.insert(schema.project.todoItems).values({ - projectId, + // FNXC:MultiProjectIsolation 2026-07-16-00:10: explicit non-blank project_id is safe — + // the assign trigger only rewrites blanks, and RLS WITH CHECK holds because a bound + // session can only have read a parent list from its own partition. + projectId: listRows[0].projectId, id: item.id, listId: item.listId, text: item.text, diff --git a/packages/core/src/chat-store.ts b/packages/core/src/chat-store.ts index 8d99507a63..5773908658 100644 --- a/packages/core/src/chat-store.ts +++ b/packages/core/src/chat-store.ts @@ -621,8 +621,9 @@ export class ChatStore extends EventEmitter { }; const layer = this.asyncLayer; /* FNXC:PostgresChatUsage 2026-07-14-18:49: Token accounting is durable before a chat turn reports completion; callers await the insert so shutdown and immediate analytics cannot lose or race the record. */ + /* FNXC:MultiProjectIsolation 2026-07-15-23:40: the record's domain projectId is written to owner_project_id; project_id (the RLS partition) is omitted so the fusion_assign_project_id trigger/GUC owns it (migration 0011). */ await layer.db.execute(sql`INSERT INTO project.chat_token_usage ( - id, source_kind, chat_session_id, room_id, message_id, project_id, agent_id, + id, source_kind, chat_session_id, room_id, message_id, owner_project_id, agent_id, model_provider, model_id, input_tokens, output_tokens, cached_tokens, cache_write_tokens, total_tokens, created_at ) VALUES ( @@ -644,7 +645,7 @@ export class ChatStore extends EventEmitter { return rows.map((row) => ({ id: row.id, sourceKind: row.sourceKind as ChatTokenUsageSourceKind, chatSessionId: row.chatSessionId, roomId: row.roomId, messageId: row.messageId, - projectId: row.projectId, agentId: row.agentId, modelProvider: row.modelProvider, + projectId: row.ownerProjectId, agentId: row.agentId, modelProvider: row.modelProvider, modelId: row.modelId, inputTokens: row.inputTokens, outputTokens: row.outputTokens, cachedTokens: row.cachedTokens, cacheWriteTokens: row.cacheWriteTokens, totalTokens: row.totalTokens, createdAt: row.createdAt, diff --git a/packages/core/src/cli-session-store.ts b/packages/core/src/cli-session-store.ts index 0deb300fa4..f88569b001 100644 --- a/packages/core/src/cli-session-store.ts +++ b/packages/core/src/cli-session-store.ts @@ -45,7 +45,8 @@ function rowToSession(row: CliSessionRow): CliSession { taskId: row.taskId, chatSessionId: row.chatSessionId, purpose: row.purpose as CliSessionPurpose, - projectId: row.projectId, + // FNXC:MultiProjectIsolation 2026-07-15-23:40: the domain projectId now maps to owner_project_id; project_id is the trigger/GUC-owned RLS partition (migration 0011). The store always writes it, so hydrated rows are non-null. + projectId: row.ownerProjectId ?? "", adapterId: row.adapterId, agentState: row.agentState as CliAgentState, terminationReason: row.terminationReason as CliTerminationReason | null, @@ -77,7 +78,7 @@ export class CliSessionStore extends EventEmitter { const rows = await layer.db .select() .from(schema.project.cliSessions) - .where(eq(schema.project.cliSessions.projectId, projectId)) + .where(eq(schema.project.cliSessions.ownerProjectId, projectId)) .orderBy(desc(schema.project.cliSessions.updatedAt)); for (const row of rows) store.sessions.set(row.id, rowToSession(row)); return store; @@ -143,8 +144,12 @@ export class CliSessionStore extends EventEmitter { updatedAt: now, }; this.sessions.set(session.id, session); + // FNXC:MultiProjectIsolation 2026-07-15-23:40: write the session's domain project to + // owner_project_id and never project_id — the trigger/GUC owns the RLS partition. + const { projectId: ownerProjectId, ...columns } = session; this.enqueue(() => this.layer.db.insert(schema.project.cliSessions).values({ - ...session, + ...columns, + ownerProjectId, autonomyPosture: session.autonomyPosture ? JSON.stringify(session.autonomyPosture) : null, })); this.emit("cli-session:created", session); @@ -203,7 +208,7 @@ export class CliSessionStore extends EventEmitter { }) .where(and( eq(schema.project.cliSessions.id, id), - eq(schema.project.cliSessions.projectId, this.projectId), + eq(schema.project.cliSessions.ownerProjectId, this.projectId), ))); this.emit("cli-session:updated", updated); return updated; @@ -215,7 +220,7 @@ export class CliSessionStore extends EventEmitter { .delete(schema.project.cliSessions) .where(and( eq(schema.project.cliSessions.id, id), - eq(schema.project.cliSessions.projectId, this.projectId), + eq(schema.project.cliSessions.ownerProjectId, this.projectId), ))); this.emit("cli-session:deleted", id); return true; diff --git a/packages/core/src/postgres/migrations/0011_owner_project_id.sql b/packages/core/src/postgres/migrations/0011_owner_project_id.sql new file mode 100644 index 0000000000..631a052693 --- /dev/null +++ b/packages/core/src/postgres/migrations/0011_owner_project_id.sql @@ -0,0 +1,64 @@ +-- 0011_owner_project_id.sql +-- +-- FNXC:MultiProjectIsolation 2026-07-15-23:40: +-- Separate the DOMAIN "project" field from the PostgreSQL isolation partition. +-- Migration 0006 made `project_id` the RLS partition column on every project-schema +-- table (owned by the fusion_assign_project_id BEFORE INSERT trigger + the +-- fusion.project_id session GUC, with composite PK/unique/FK keys). A handful of +-- tables ALSO carried a caller-supplied domain "projectId" on their TS types and +-- wrote that domain value into the same physical `project_id` column. When the +-- domain value differed from the session GUC, the parent row landed in the domain +-- partition while child rows (research_run_events, experiment_session_records, +-- eval_task_results, ...) landed in the session partition, violating the composite +-- FKs with SQLSTATE 23503. +-- +-- This migration adds a separate nullable `owner_project_id` domain column to the +-- conflated tables. `project_id` remains the isolation partition and stays owned by +-- the trigger/GUC; stores stop writing it and read/write the domain field through +-- `owner_project_id` instead. +-- +-- Backfill: in production the domain value and the partition were written with the +-- same id, so `owner_project_id = project_id` is correct. `'__legacy_unscoped__'` +-- is the trigger's "no session project bound" sentinel and never a real domain +-- project, and the domain field was nullable, so the sentinel backfills to NULL. +-- +-- Idempotent and re-runnable: to_regclass-guarded (a failed early baseline can +-- leave a migration marker before every baseline table exists — absent tables +-- receive the column when the idempotent baseline is materialized on the next +-- recovery pass, matching the 0007 pattern), ADD COLUMN IF NOT EXISTS, and a +-- backfill scoped to owner_project_id IS NULL. + +DO $$ +DECLARE + tbl text; +BEGIN + FOREACH tbl IN ARRAY ARRAY[ + 'research_runs', + 'experiment_sessions', + 'todo_lists', + 'eval_runs', + 'chat_sessions', + 'chat_rooms', + 'ai_sessions', + 'chat_token_usage', + 'project_insights', + 'project_insight_runs', + 'cli_sessions' + ] LOOP + IF to_regclass(format('project.%I', tbl)) IS NOT NULL THEN + EXECUTE format('ALTER TABLE project.%I ADD COLUMN IF NOT EXISTS owner_project_id text', tbl); + EXECUTE format( + 'UPDATE project.%I SET owner_project_id = NULLIF(project_id, %L) WHERE owner_project_id IS NULL', + tbl, + '__legacy_unscoped__' + ); + -- Domain-filter index (stores list/filter by the domain project id). + EXECUTE format( + 'CREATE INDEX IF NOT EXISTS %I ON project.%I (owner_project_id)', + 'idx_' || tbl || '_owner_project_id', + tbl + ); + END IF; + END LOOP; +END +$$; diff --git a/packages/core/src/postgres/schema-applier.ts b/packages/core/src/postgres/schema-applier.ts index 341a68c453..bc00486748 100644 --- a/packages/core/src/postgres/schema-applier.ts +++ b/packages/core/src/postgres/schema-applier.ts @@ -28,10 +28,10 @@ import { runPluginSchemaInitHooks, DEFAULT_PLUGIN_SCHEMA_INIT_HOOKS, type Plugin /** The latest PostgreSQL schema version known to this applier. */ /* -FNXC:GitHubImportTranslate 2026-07-15-09:30: -Advances to 0010 with the import-translation cache. Per-migration identities above stay fixed; only this latest-version marker moves. +FNXC:MultiProjectIsolation 2026-07-15-23:40: +Advances to 0011 with the owner_project_id domain/partition split. Per-migration identities above stay fixed; only this latest-version marker moves. */ -export const SCHEMA_BASELINE_VERSION = "0010"; +export const SCHEMA_BASELINE_VERSION = "0011"; const INITIAL_SCHEMA_VERSION = "0000"; const AUTOMATION_ISOLATION_SCHEMA_VERSION = "0001"; const ANALYTICS_ISOLATION_SCHEMA_VERSION = "0002"; @@ -56,6 +56,16 @@ FNXC:GitHubImportTranslate 2026-07-15-09:30: Import-translation cache advances to 0010. Migrations are registered here explicitly (not auto-discovered from the migrations dir), so a new .sql file that is not wired through a version constant + bookkeeping check silently never runs. */ export const IMPORT_TRANSLATION_CACHE_VERSION = "0010"; +/* +FNXC:MultiProjectIsolation 2026-07-15-23:40: +Version 0011 splits the domain "project" field from the RLS partition on the tables +that conflated them: `project_id` stays the trigger/GUC-owned isolation partition, +`owner_project_id` becomes the caller-supplied domain field. Writing domain values +into the partition put parent rows and child rows in different partitions and broke +the composite FKs (SQLSTATE 23503). Keep this identity fixed when +SCHEMA_BASELINE_VERSION advances. +*/ +export const OWNER_PROJECT_ID_SPLIT_VERSION = "0011"; /** Bookkeeping table for the fresh Drizzle migration history. */ export const MIGRATION_BOOKKEEPING_TABLE = "fusion_schema_migrations"; @@ -112,6 +122,11 @@ const IMPORT_TRANSLATION_CACHE_MIGRATION_PATH = join( "migrations", "0010_import_translation_cache.sql", ); +const OWNER_PROJECT_ID_SPLIT_MIGRATION_PATH = join( + __dirname, + "migrations", + "0011_owner_project_id.sql", +); /** * Ensure the migration bookkeeping table exists. Lives in the public schema so @@ -179,6 +194,7 @@ export async function applySchemaBaseline( const sessionAdvisorEnabledAlreadyApplied = applied.includes(SESSION_ADVISOR_ENABLED_SCHEMA_VERSION); const missionFixIdempotencyAlreadyApplied = applied.includes(MISSION_FIX_IDEMPOTENCY_VERSION); const importTranslationCacheAlreadyApplied = applied.includes(IMPORT_TRANSLATION_CACHE_VERSION); + const ownerProjectIdSplitAlreadyApplied = applied.includes(OWNER_PROJECT_ID_SPLIT_VERSION); let schemaChanged = false; if (!baselineAlreadyApplied) { @@ -415,6 +431,22 @@ export async function applySchemaBaseline( schemaChanged = true; } + /* + FNXC:MultiProjectIsolation 2026-07-15-23:40: + Apply the owner_project_id domain/partition split independently of earlier + schema versions so existing databases gain the domain column (backfilled from + the previously conflated partition value) before any store read/write path + that now targets owner_project_id runs on boot. + */ + if (!ownerProjectIdSplitAlreadyApplied) { + const ownerProjectIdSplitSql = await readFile(OWNER_PROJECT_ID_SPLIT_MIGRATION_PATH, "utf8"); + await tx.execute(sql.raw(ownerProjectIdSplitSql)); + await tx.execute( + sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${OWNER_PROJECT_ID_SPLIT_VERSION}) ON CONFLICT (version) DO NOTHING`, + ); + schemaChanged = true; + } + return { applied: schemaChanged, pluginHooksRun: pluginHooks.length }; }); } diff --git a/packages/core/src/postgres/schema/project.ts b/packages/core/src/postgres/schema/project.ts index 09dcb713d0..78f4e36fba 100644 --- a/packages/core/src/postgres/schema/project.ts +++ b/packages/core/src/postgres/schema/project.ts @@ -931,6 +931,8 @@ export const researchRuns = projectSchema.table("research_runs", { topic: text("topic"), status: text("status").notNull(), projectId: text("project_id"), + // FNXC:MultiProjectIsolation 2026-07-15-23:40: domain "project" field, split from the trigger/GUC-owned project_id RLS partition (migration 0011). + ownerProjectId: text("owner_project_id"), trigger: text("trigger"), providerConfig: jsonb("provider_config"), sources: jsonb("sources").notNull().default([]), @@ -985,6 +987,8 @@ export const experimentSessions = projectSchema.table("experiment_sessions", { id: text("id").primaryKey(), name: text("name").notNull(), projectId: text("project_id"), + // FNXC:MultiProjectIsolation 2026-07-15-23:40: domain "project" field, split from the trigger/GUC-owned project_id RLS partition (migration 0011). + ownerProjectId: text("owner_project_id"), status: text("status").notNull(), metric: text("metric").notNull(), currentSegment: integer("current_segment").notNull().default(1), @@ -1022,7 +1026,10 @@ export const experimentSessionRecords = projectSchema.table("experiment_session_ // ── Eval runs ──────────────────────────────────────────────────────── export const evalRuns = projectSchema.table("eval_runs", { id: text("id").notNull(), - projectId: text("project_id").notNull(), + // FNXC:MultiProjectIsolation 2026-07-15-23:40: reflect the DB default installed by migration 0006 so insert types treat the trigger/GUC-owned partition as optional; stores must not write it. + projectId: text("project_id").notNull().default(sql`current_setting('fusion.project_id', true)`), + // FNXC:MultiProjectIsolation 2026-07-15-23:40: domain "project" field, split from the trigger/GUC-owned project_id RLS partition (migration 0011). + ownerProjectId: text("owner_project_id"), status: text("status").notNull(), trigger: text("trigger").notNull(), scope: text("scope").notNull(), @@ -1425,7 +1432,10 @@ export const routines = projectSchema.table("routines", { export const projectInsights = projectSchema.table("project_insights", { id: text("id").primaryKey(), - projectId: text("project_id").notNull(), + // FNXC:MultiProjectIsolation 2026-07-15-23:40: reflect the DB default installed by migration 0006 so insert types treat the trigger/GUC-owned partition as optional; stores must not write it. + projectId: text("project_id").notNull().default(sql`current_setting('fusion.project_id', true)`), + // FNXC:MultiProjectIsolation 2026-07-15-23:40: domain "project" field, split from the trigger/GUC-owned project_id RLS partition (migration 0011). + ownerProjectId: text("owner_project_id"), title: text("title").notNull(), content: text("content"), category: text("category").notNull(), @@ -1443,7 +1453,10 @@ export const projectInsights = projectSchema.table("project_insights", { export const projectInsightRuns = projectSchema.table("project_insight_runs", { id: text("id").notNull(), - projectId: text("project_id").notNull(), + // FNXC:MultiProjectIsolation 2026-07-15-23:40: reflect the DB default installed by migration 0006 so insert types treat the trigger/GUC-owned partition as optional; stores must not write it. + projectId: text("project_id").notNull().default(sql`current_setting('fusion.project_id', true)`), + // FNXC:MultiProjectIsolation 2026-07-15-23:40: domain "project" field, split from the trigger/GUC-owned project_id RLS partition (migration 0011). + ownerProjectId: text("owner_project_id"), trigger: text("trigger").notNull(), status: text("status").notNull(), summary: text("summary"), @@ -1483,7 +1496,10 @@ export const projectInsightRunEvents = projectSchema.table("project_insight_run_ // ── Todo lists ─────────────────────────────────────────────────────── export const todoLists = projectSchema.table("todo_lists", { id: text("id").notNull(), - projectId: text("project_id").notNull(), + // FNXC:MultiProjectIsolation 2026-07-15-23:40: reflect the DB default installed by migration 0006 so insert types treat the trigger/GUC-owned partition as optional; stores must not write it. + projectId: text("project_id").notNull().default(sql`current_setting('fusion.project_id', true)`), + // FNXC:MultiProjectIsolation 2026-07-15-23:40: domain "project" field, split from the trigger/GUC-owned project_id RLS partition (migration 0011). + ownerProjectId: text("owner_project_id"), title: text("title").notNull(), createdAt: text("created_at").notNull(), updatedAt: text("updated_at").notNull(), @@ -1618,6 +1634,8 @@ export const aiSessions = projectSchema.table("ai_sessions", { thinkingOutput: text("thinking_output").default(""), error: text("error"), projectId: text("project_id").notNull().default(sql`current_setting('fusion.project_id', true)`), + // FNXC:MultiProjectIsolation 2026-07-15-23:40: domain "project" field, split from the trigger/GUC-owned project_id RLS partition (migration 0011). + ownerProjectId: text("owner_project_id"), createdAt: text("created_at").notNull(), updatedAt: text("updated_at").notNull(), /* @@ -1685,6 +1703,8 @@ export const chatSessions = projectSchema.table("chat_sessions", { title: text("title"), status: text("status").notNull().default("active"), projectId: text("project_id"), + // FNXC:MultiProjectIsolation 2026-07-15-23:40: domain "project" field, split from the trigger/GUC-owned project_id RLS partition (migration 0011). + ownerProjectId: text("owner_project_id"), modelProvider: text("model_provider"), modelId: text("model_id"), // FNXC:ChatThinkingLevel 2026-07-10: FN-7775 per-chat thinking-level override @@ -1708,7 +1728,10 @@ export const cliSessions = projectSchema.table("cli_sessions", { taskId: text("task_id"), chatSessionId: text("chat_session_id"), purpose: text("purpose").notNull(), - projectId: text("project_id").notNull(), + // FNXC:MultiProjectIsolation 2026-07-15-23:40: reflect the DB default installed by migration 0006 so insert types treat the trigger/GUC-owned partition as optional; stores must not write it. + projectId: text("project_id").notNull().default(sql`current_setting('fusion.project_id', true)`), + // FNXC:MultiProjectIsolation 2026-07-15-23:40: domain "project" field, split from the trigger/GUC-owned project_id RLS partition (migration 0011). + ownerProjectId: text("owner_project_id"), adapterId: text("adapter_id").notNull(), agentState: text("agent_state").notNull().default("starting"), terminationReason: text("termination_reason"), @@ -1749,6 +1772,8 @@ export const chatTokenUsage = projectSchema.table("chat_token_usage", { roomId: text("room_id"), messageId: text("message_id"), projectId: text("project_id"), + // FNXC:MultiProjectIsolation 2026-07-15-23:40: domain "project" field, split from the trigger/GUC-owned project_id RLS partition (migration 0011). + ownerProjectId: text("owner_project_id"), agentId: text("agent_id"), modelProvider: text("model_provider"), modelId: text("model_id"), @@ -1951,6 +1976,8 @@ export const chatRooms = projectSchema.table("chat_rooms", { slug: text("slug").notNull(), description: text("description"), projectId: text("project_id"), + // FNXC:MultiProjectIsolation 2026-07-15-23:40: domain "project" field, split from the trigger/GUC-owned project_id RLS partition (migration 0011). + ownerProjectId: text("owner_project_id"), createdBy: text("created_by"), status: text("status").notNull().default("active"), // FNXC:Chat-ThinkingLevel 2026-07-13 (merge port): room-level reasoning-effort default. diff --git a/packages/core/src/postgres/sqlite-migrator.ts b/packages/core/src/postgres/sqlite-migrator.ts index cfaa311113..916aca0c56 100644 --- a/packages/core/src/postgres/sqlite-migrator.ts +++ b/packages/core/src/postgres/sqlite-migrator.ts @@ -1126,7 +1126,19 @@ function resolveColumnMapping( const mapping: ColumnMapping[] = []; const unmappedSourceColumns: string[] = []; for (const sc of sqliteCols) { - const pgName = toSnakeCase(sc.name); + /* + FNXC:MultiProjectIsolation 2026-07-15-23:40: + SQLite had no partition concept: a legacy `projectId` column is always the DOMAIN + project field. Since migration 0011 split that domain field (`owner_project_id`) + from the trigger/GUC-owned RLS partition (`project_id`), route the source value to + `owner_project_id` whenever the target declares it. The partition still receives + the registry-resolved project id through the existing unmapped-project_id insert + path, so cutover preserves the source's domain value instead of coercing it away. + */ + let pgName = toSnakeCase(sc.name); + if (pgName === "project_id" && pgByName.has("owner_project_id")) { + pgName = "owner_project_id"; + } const pgCol = pgByName.get(pgName); if (!pgCol) { /* diff --git a/packages/core/src/token-analytics.ts b/packages/core/src/token-analytics.ts index 5141743167..3eeb0cac23 100644 --- a/packages/core/src/token-analytics.ts +++ b/packages/core/src/token-analytics.ts @@ -387,7 +387,7 @@ export async function aggregateTokenAnalytics( chat_session_id AS "chatSessionId", room_id AS "roomId", message_id AS "messageId", - project_id AS "projectId", + owner_project_id AS "projectId", agent_id AS "agentId", input_tokens AS "inputTokens", output_tokens AS "outputTokens",