From a048a619fcfb8c5256d3962c7e2bdce905481d93 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 15 Jul 2026 21:47:01 -0700 Subject: [PATCH] fix(core): resolve unbound project ids to a real partition or no filter Six of the eight postgres-suite failures shared one root cause: writes normalize project_id, reads did not. The fusion_assign_project_id trigger (migration 0006) rewrites a blank project_id to the session's fusion.project_id or '__legacy_unscoped__', but helpers reached as `layer.projectId ?? ""` then filtered on the literal '' -- a value the database never stores. Every unbound read missed rows it had just written. AsyncDataLayer.projectId is optional by design (undefined = project-agnostic), so `?? ""` is the bug: it turns "no scope" into a scope that matches nothing. The resolution differs by what the rows are, and conflating them corrupts data: - Data and analytics reads (usage events, agent runs, research runs) take projectScopeFor(): a bound id filters, an unbound one reads across projects. This matches the contract taskProjectScope already documents ("when undefined the scope filter is a no-op"). - __meta migration guards (project-identity stamps, agent-store markers) take projectPartitionId(): an unbound id resolves to the shared sentinel partition. projectScopeFor would be wrong here -- dropping the predicate lets an unbound getMetaValue return whichever project's marker it finds first, so on the shared cluster project A's "migration complete" marker would tell project B to skip a migration it never ran. upsertMetaValue already documented this: "the empty binding remains the explicit project-agnostic compatibility partition". Writing the sentinel explicitly also keeps the partition deterministic -- a blank write from a session carrying fusion.project_id would otherwise land in that project's stamp. Names the sentinel (LEGACY_UNSCOPED_PROJECT_ID) instead of open-coding it, and puts both helpers next to taskProjectScope so the convention has one home. Fixes taskstore-remaining (24/24), project-identity (6/6), and satellite-fusiondir-stores (16/16). The remaining two failures are a different bug and are NOT addressed here: the child tables research_run_events and experiment_session_records never declared project_id in schema-as-code, though migration 0006 added the column and rewrote their FKs to composite (project_id, parent_id). Drizzle therefore cannot write the parent's partition, the trigger stamps '__legacy_unscoped__', and the FK fails against a project-owned parent. That needs a schema-as-code change. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/core/src/async-agent-store.ts | 47 +++++++++++++++----- packages/core/src/async-research-store.ts | 4 +- packages/core/src/postgres/data-layer.ts | 27 ++++++++++- packages/core/src/postgres/schema/_shared.ts | 11 +++++ packages/core/src/project-identity.ts | 17 ++++++- 5 files changed, 92 insertions(+), 14 deletions(-) diff --git a/packages/core/src/async-agent-store.ts b/packages/core/src/async-agent-store.ts index 5e9fa640e2..214b9f06c2 100644 --- a/packages/core/src/async-agent-store.ts +++ b/packages/core/src/async-agent-store.ts @@ -45,6 +45,7 @@ */ import { and, asc, desc, eq, inArray, sql } from "drizzle-orm"; import * as schema from "./postgres/schema/index.js"; +import { projectPartitionId, projectScopeFor } from "./postgres/data-layer.js"; import type { AsyncDataLayer, DbTransaction } from "./postgres/data-layer.js"; import type { Agent, @@ -374,6 +375,19 @@ export async function saveRun(handle: QueryHandle, projectId: string, run: Agent }); } +/* +FNXC:MultiProjectIsolation 2026-07-15-22:05: +Agent-run reads scope through projectScopeFor: a bound project filters, an unbound one reads +across projects. Callers reach here as `layer.projectId ?? ""`, and a literal '' scope matched +nothing — the fusion_assign_project_id trigger (migration 0006) rewrites the '' saveRun writes, +so every unbound read missed the runs it had just saved. + +Runs are data, not migration guards: unbound means project-agnostic (single-project / analytics), +which reads everything, exactly as taskProjectScope already does for tasks. Contrast the +__meta helpers below, which resolve an unbound id to one partition via projectPartitionId — for +those, reading across projects would let one project's marker suppress another's migration. +*/ + /** * Get a specific run by id, or null if not found. */ @@ -388,7 +402,7 @@ export async function getRunDetail( .from(schema.project.agentRuns) .where( and( - eq(schema.project.agentRuns.projectId, projectId), + projectScopeFor(schema.project.agentRuns.projectId, projectId), eq(schema.project.agentRuns.agentId, agentId), eq(schema.project.agentRuns.id, runId), ), @@ -412,7 +426,7 @@ export async function getRunById( data: schema.project.agentRuns.data, }) .from(schema.project.agentRuns) - .where(and(eq(schema.project.agentRuns.projectId, projectId), eq(schema.project.agentRuns.id, runId))); + .where(and(projectScopeFor(schema.project.agentRuns.projectId, projectId), eq(schema.project.agentRuns.id, runId))); const row = rows[0] as { agentId: string; data: Record | null } | undefined; if (!row) return null; return { agentId: row.agentId, run: (row.data as AgentHeartbeatRun | null) ?? null }; @@ -430,7 +444,7 @@ export async function getRecentRuns( const rows = await handle .select({ data: schema.project.agentRuns.data }) .from(schema.project.agentRuns) - .where(and(eq(schema.project.agentRuns.projectId, projectId), eq(schema.project.agentRuns.agentId, agentId))) + .where(and(projectScopeFor(schema.project.agentRuns.projectId, projectId), eq(schema.project.agentRuns.agentId, agentId))) .orderBy(desc(schema.project.agentRuns.startedAt)) .limit(limit); return rows @@ -447,7 +461,7 @@ export async function listActiveHeartbeatRuns(handle: QueryHandle, projectId: st const rows = await handle .select({ data: schema.project.agentRuns.data }) .from(schema.project.agentRuns) - .where(and(eq(schema.project.agentRuns.projectId, projectId), eq(schema.project.agentRuns.status, "active"))) + .where(and(projectScopeFor(schema.project.agentRuns.projectId, projectId), eq(schema.project.agentRuns.status, "active"))) .orderBy(asc(schema.project.agentRuns.startedAt)); return rows .map((row) => (row.data as AgentHeartbeatRun | null) ?? null) @@ -472,13 +486,13 @@ export async function listAllAgentRuns( ? await handle .select({ data: schema.project.agentRuns.data }) .from(schema.project.agentRuns) - .where(eq(schema.project.agentRuns.projectId, projectId)) + .where(projectScopeFor(schema.project.agentRuns.projectId, projectId)) .orderBy(desc(schema.project.agentRuns.startedAt), desc(schema.project.agentRuns.id)) .limit(normalizedLimit) : await handle .select({ data: schema.project.agentRuns.data }) .from(schema.project.agentRuns) - .where(eq(schema.project.agentRuns.projectId, projectId)) + .where(projectScopeFor(schema.project.agentRuns.projectId, projectId)) .orderBy(asc(schema.project.agentRuns.startedAt), asc(schema.project.agentRuns.id)); return rows .map((row) => (row.data as AgentHeartbeatRun | null) ?? null) @@ -503,7 +517,7 @@ export async function getRunStatusCounts( count: sql`count(*)::int`, }) .from(schema.project.agentRuns) - .where(and(eq(schema.project.agentRuns.projectId, projectId), inArray(schema.project.agentRuns.agentId, [...agentIds]))) + .where(and(projectScopeFor(schema.project.agentRuns.projectId, projectId), inArray(schema.project.agentRuns.agentId, [...agentIds]))) .groupBy(schema.project.agentRuns.status); } else { rows = await handle @@ -512,7 +526,7 @@ export async function getRunStatusCounts( count: sql`count(*)::int`, }) .from(schema.project.agentRuns) - .where(eq(schema.project.agentRuns.projectId, projectId)) + .where(projectScopeFor(schema.project.agentRuns.projectId, projectId)) .groupBy(schema.project.agentRuns.status); } @@ -899,11 +913,18 @@ export async function getMetaValue( key: string, projectId = "", ): Promise { + /* + FNXC:MultiProjectIsolation 2026-07-15-22:05: + Resolve the unscoped default to the shared partition. A literal '' scope matched nothing — + the fusion_assign_project_id trigger (migration 0006) rewrites the '' this module writes to + the sentinel — so these migration guards read back undefined for markers they had just + written, and re-ran migrations they had already completed. Must match upsertMetaValue. + */ const rows = await handle .select({ value: schema.project.projectMeta.value }) .from(schema.project.projectMeta) .where(and( - eq(schema.project.projectMeta.projectId, projectId), + eq(schema.project.projectMeta.projectId, projectPartitionId(projectId)), eq(schema.project.projectMeta.key, key), )); return rows[0]?.value ?? undefined; @@ -921,10 +942,16 @@ export async function upsertMetaValue( /* FNXC:PostgresMultiProjectCutover 2026-07-14-11:18: Agent-store migration markers share the project schema but not project ownership. Include the bound project in their composite key; the empty binding remains the explicit project-agnostic compatibility partition. + + FNXC:MultiProjectIsolation 2026-07-15-22:05: + Resolve that "explicit compatibility partition" to a value the database actually stores. Writing + a blank left the partition at the trigger's mercy — it becomes the session's `fusion.project_id` + when one is set, so the marker lands in an arbitrary project's partition instead of the shared + one. Must match getMetaValue. */ await handle .insert(schema.project.projectMeta) - .values({ projectId, key, value }) + .values({ projectId: projectPartitionId(projectId), key, value }) .onConflictDoUpdate({ target: [schema.project.projectMeta.projectId, schema.project.projectMeta.key], set: { value }, diff --git a/packages/core/src/async-research-store.ts b/packages/core/src/async-research-store.ts index b2da6e36cd..09c070c328 100644 --- a/packages/core/src/async-research-store.ts +++ b/packages/core/src/async-research-store.ts @@ -21,6 +21,7 @@ import { randomUUID } from "node:crypto"; import { EventEmitter } from "node:events"; import { and, asc, desc, eq, gte, inArray, lte, sql } from "drizzle-orm"; import * as schema from "./postgres/schema/index.js"; +import { projectScopeFor } from "./postgres/data-layer.js"; import type { AsyncDataLayer, DbTransaction } from "./postgres/data-layer.js"; import { ResearchLifecycleError, @@ -251,7 +252,8 @@ export async function getActiveResearchRun( .from(schema.project.researchRuns) .where( and( - eq(schema.project.researchRuns.projectId, projectId), + // FNXC:MultiProjectIsolation 2026-07-15-22:05: unbound reads across projects; a literal '' scope matched nothing (the migration-0006 trigger never stores ''). See projectScopeFor. + projectScopeFor(schema.project.researchRuns.projectId, projectId), eq(schema.project.researchRuns.trigger, trigger), inArray(schema.project.researchRuns.status, ["queued", "running", "cancelling", "retry_waiting"]), ), diff --git a/packages/core/src/postgres/data-layer.ts b/packages/core/src/postgres/data-layer.ts index 568849a4af..9b207d87a4 100644 --- a/packages/core/src/postgres/data-layer.ts +++ b/packages/core/src/postgres/data-layer.ts @@ -56,7 +56,7 @@ import type { PostgresJsDatabase, PostgresJsTransaction } from "drizzle-orm/post import { randomUUID } from "node:crypto"; import type { PostgresConnections } from "./connection.js"; import * as schema from "./schema/index.js"; -import { PROJECT_SCHEMA } from "./schema/_shared.js"; +import { PROJECT_SCHEMA, LEGACY_UNSCOPED_PROJECT_ID } from "./schema/_shared.js"; /** * FNXC:AsyncDataLayer 2026-06-24-09:00: @@ -436,3 +436,28 @@ export function projectScopeFor( const scope = projectId?.trim(); return scope ? eq(column, scope) : undefined; } + +/** + * Resolve a project id to the partition a row is actually stored under. + * + * FNXC:MultiProjectIsolation 2026-07-15-22:05: + * The counterpart to {@link projectScopeFor}, for tables where an unbound id is a PLACE rather + * than an absence of scope — currently `project.__meta`, whose rows are migration guards + * ("legacy file import, terminated-state migration, heartbeat-procedure-path migration"). + * + * Those two readings are not interchangeable, and picking the wrong one for `__meta` corrupts + * data rather than hiding it. `projectScopeFor` drops the predicate when unbound, so an unbound + * `getMetaValue` would return whichever project's marker it found first — on the shared + * embedded-PG cluster (one database, one `project` schema, every project's rows in one table) + * project A's "migration complete" marker would tell project B to skip a migration it never ran. + * An unscoped marker must resolve to one specific partition, which is what + * upsertMetaValue documents: "the empty binding remains the explicit project-agnostic + * compatibility partition". + * + * Writing the sentinel explicitly (rather than writing '' and letting the trigger COALESCE) + * keeps the partition deterministic: a blank write from a session that happens to have + * `fusion.project_id` set would otherwise land in THAT project instead of the shared one. + */ +export function projectPartitionId(projectId: string | undefined): string { + return projectId?.trim() || LEGACY_UNSCOPED_PROJECT_ID; +} diff --git a/packages/core/src/postgres/schema/_shared.ts b/packages/core/src/postgres/schema/_shared.ts index d72b62537e..2e5a302452 100644 --- a/packages/core/src/postgres/schema/_shared.ts +++ b/packages/core/src/postgres/schema/_shared.ts @@ -37,6 +37,17 @@ export const ARCHIVE_SCHEMA = "archive"; /** PostgreSQL schema where Drizzle's migration bookkeeping table lives. */ export const DRIZZLE_MIGRATION_SCHEMA = "public"; +/** + * The `project_id` value for rows that belong to no specific project. + * + * FNXC:MultiProjectIsolation 2026-07-15-22:05: + * Written by the `fusion_assign_project_id` BEFORE INSERT trigger (migration 0006), which + * rewrites a blank `project_id` to the session's `fusion.project_id` or, failing that, this + * sentinel. Code that writes a blank id and then reads it back with `project_id = ''` finds + * nothing, because '' is never what lands. Named here so call sites stop open-coding it. + */ +export const LEGACY_UNSCOPED_PROJECT_ID = "__legacy_unscoped__"; + /** * All application schemas, in the order the applier creates them. * Plugin-owned tables are materialized separately via the schema-init hook diff --git a/packages/core/src/project-identity.ts b/packages/core/src/project-identity.ts index 92486e1f89..0b583293e2 100644 --- a/packages/core/src/project-identity.ts +++ b/packages/core/src/project-identity.ts @@ -4,6 +4,7 @@ import { and, eq } from "drizzle-orm"; import { DatabaseSync } from "./sqlite-adapter.js"; import { createLogger } from "./logger.js"; import * as schema from "./postgres/schema/index.js"; +import { projectPartitionId } from "./postgres/data-layer.js"; import type { AsyncDataLayer } from "./postgres/data-layer.js"; const log = createLogger("project-identity"); @@ -136,7 +137,14 @@ export function hasProjectIdentity(fusionDir: string): boolean { // ───────────────────────────────────────────────────────────────────── async function readMetaAsync(layer: AsyncDataLayer, key: string): Promise { - const projectId = layer.projectId ?? ""; + /* + FNXC:MultiProjectIsolation 2026-07-15-22:05: + An unbound layer must resolve to the shared unscoped PARTITION, not to a literal '' scope: the + fusion_assign_project_id trigger (migration 0006) never stores '', so reading it back found no + identity stamp this code had just written. projectPartitionId, not projectScopeFor — dropping + the predicate here would read another project's stamp. Must match upsertMetaAsync exactly. + */ + const projectId = projectPartitionId(layer.projectId); const rows = await layer.db .select({ value: schema.project.projectMeta.value }) .from(schema.project.projectMeta) @@ -148,11 +156,16 @@ async function readMetaAsync(layer: AsyncDataLayer, key: string): Promise { - const projectId = layer.projectId ?? ""; /* FNXC:PostgresMultiProjectCutover 2026-07-14-11:18: Backend identity reads and writes must use the data layer's project binding so one registered project cannot inherit or overwrite another project's PostgreSQL __meta stamp. + + FNXC:MultiProjectIsolation 2026-07-15-22:05: + Write the resolved partition explicitly rather than a blank the trigger rewrites. A blank write + from a session carrying `fusion.project_id` would land in THAT project's stamp — the exact + cross-project inheritance the note above forbids. Must match readMetaAsync exactly. */ + const projectId = projectPartitionId(layer.projectId); await layer.db .insert(schema.project.projectMeta) .values({ projectId, key, value })