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",