From b51de02a54d89cd3175b5177905155d3bbf36973 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 15 Jul 2026 22:14:14 -0700 Subject: [PATCH] Revert "fix(core): resolve unbound project ids to a real partition or no filter" This reverts commit a048a619fcfb8c5256d3962c7e2bdce905481d93. --- 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, 14 insertions(+), 92 deletions(-) diff --git a/packages/core/src/async-agent-store.ts b/packages/core/src/async-agent-store.ts index 214b9f06c2..5e9fa640e2 100644 --- a/packages/core/src/async-agent-store.ts +++ b/packages/core/src/async-agent-store.ts @@ -45,7 +45,6 @@ */ 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, @@ -375,19 +374,6 @@ 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. */ @@ -402,7 +388,7 @@ export async function getRunDetail( .from(schema.project.agentRuns) .where( and( - projectScopeFor(schema.project.agentRuns.projectId, projectId), + eq(schema.project.agentRuns.projectId, projectId), eq(schema.project.agentRuns.agentId, agentId), eq(schema.project.agentRuns.id, runId), ), @@ -426,7 +412,7 @@ export async function getRunById( data: schema.project.agentRuns.data, }) .from(schema.project.agentRuns) - .where(and(projectScopeFor(schema.project.agentRuns.projectId, projectId), eq(schema.project.agentRuns.id, runId))); + .where(and(eq(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 }; @@ -444,7 +430,7 @@ export async function getRecentRuns( const rows = await handle .select({ data: schema.project.agentRuns.data }) .from(schema.project.agentRuns) - .where(and(projectScopeFor(schema.project.agentRuns.projectId, projectId), eq(schema.project.agentRuns.agentId, agentId))) + .where(and(eq(schema.project.agentRuns.projectId, projectId), eq(schema.project.agentRuns.agentId, agentId))) .orderBy(desc(schema.project.agentRuns.startedAt)) .limit(limit); return rows @@ -461,7 +447,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(projectScopeFor(schema.project.agentRuns.projectId, projectId), eq(schema.project.agentRuns.status, "active"))) + .where(and(eq(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) @@ -486,13 +472,13 @@ export async function listAllAgentRuns( ? await handle .select({ data: schema.project.agentRuns.data }) .from(schema.project.agentRuns) - .where(projectScopeFor(schema.project.agentRuns.projectId, projectId)) + .where(eq(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(projectScopeFor(schema.project.agentRuns.projectId, projectId)) + .where(eq(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) @@ -517,7 +503,7 @@ export async function getRunStatusCounts( count: sql`count(*)::int`, }) .from(schema.project.agentRuns) - .where(and(projectScopeFor(schema.project.agentRuns.projectId, projectId), inArray(schema.project.agentRuns.agentId, [...agentIds]))) + .where(and(eq(schema.project.agentRuns.projectId, projectId), inArray(schema.project.agentRuns.agentId, [...agentIds]))) .groupBy(schema.project.agentRuns.status); } else { rows = await handle @@ -526,7 +512,7 @@ export async function getRunStatusCounts( count: sql`count(*)::int`, }) .from(schema.project.agentRuns) - .where(projectScopeFor(schema.project.agentRuns.projectId, projectId)) + .where(eq(schema.project.agentRuns.projectId, projectId)) .groupBy(schema.project.agentRuns.status); } @@ -913,18 +899,11 @@ 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, projectPartitionId(projectId)), + eq(schema.project.projectMeta.projectId, projectId), eq(schema.project.projectMeta.key, key), )); return rows[0]?.value ?? undefined; @@ -942,16 +921,10 @@ 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: projectPartitionId(projectId), key, value }) + .values({ 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 09c070c328..b2da6e36cd 100644 --- a/packages/core/src/async-research-store.ts +++ b/packages/core/src/async-research-store.ts @@ -21,7 +21,6 @@ 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, @@ -252,8 +251,7 @@ export async function getActiveResearchRun( .from(schema.project.researchRuns) .where( and( - // 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.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 9b207d87a4..568849a4af 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, LEGACY_UNSCOPED_PROJECT_ID } from "./schema/_shared.js"; +import { PROJECT_SCHEMA } from "./schema/_shared.js"; /** * FNXC:AsyncDataLayer 2026-06-24-09:00: @@ -436,28 +436,3 @@ 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 2e5a302452..d72b62537e 100644 --- a/packages/core/src/postgres/schema/_shared.ts +++ b/packages/core/src/postgres/schema/_shared.ts @@ -37,17 +37,6 @@ 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 0b583293e2..92486e1f89 100644 --- a/packages/core/src/project-identity.ts +++ b/packages/core/src/project-identity.ts @@ -4,7 +4,6 @@ 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"); @@ -137,14 +136,7 @@ export function hasProjectIdentity(fusionDir: string): boolean { // ───────────────────────────────────────────────────────────────────── async function readMetaAsync(layer: AsyncDataLayer, key: string): Promise { - /* - 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 projectId = layer.projectId ?? ""; const rows = await layer.db .select({ value: schema.project.projectMeta.value }) .from(schema.project.projectMeta) @@ -156,16 +148,11 @@ 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 })