Revert "fix(core): resolve unbound project ids to a real partition or no filter"

This reverts commit a048a619fc.
This commit is contained in:
gsxdsm
2026-07-15 22:14:14 -07:00
parent 959a7877c8
commit b51de02a54
5 changed files with 14 additions and 92 deletions

View File

@@ -45,7 +45,6 @@
*/ */
import { and, asc, desc, eq, inArray, sql } from "drizzle-orm"; import { and, asc, desc, eq, inArray, sql } from "drizzle-orm";
import * as schema from "./postgres/schema/index.js"; 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 { AsyncDataLayer, DbTransaction } from "./postgres/data-layer.js";
import type { import type {
Agent, 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. * Get a specific run by id, or null if not found.
*/ */
@@ -402,7 +388,7 @@ export async function getRunDetail(
.from(schema.project.agentRuns) .from(schema.project.agentRuns)
.where( .where(
and( and(
projectScopeFor(schema.project.agentRuns.projectId, projectId), eq(schema.project.agentRuns.projectId, projectId),
eq(schema.project.agentRuns.agentId, agentId), eq(schema.project.agentRuns.agentId, agentId),
eq(schema.project.agentRuns.id, runId), eq(schema.project.agentRuns.id, runId),
), ),
@@ -426,7 +412,7 @@ export async function getRunById(
data: schema.project.agentRuns.data, data: schema.project.agentRuns.data,
}) })
.from(schema.project.agentRuns) .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<string, unknown> | null } | undefined; const row = rows[0] as { agentId: string; data: Record<string, unknown> | null } | undefined;
if (!row) return null; if (!row) return null;
return { agentId: row.agentId, run: (row.data as AgentHeartbeatRun | null) ?? null }; return { agentId: row.agentId, run: (row.data as AgentHeartbeatRun | null) ?? null };
@@ -444,7 +430,7 @@ export async function getRecentRuns(
const rows = await handle const rows = await handle
.select({ data: schema.project.agentRuns.data }) .select({ data: schema.project.agentRuns.data })
.from(schema.project.agentRuns) .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)) .orderBy(desc(schema.project.agentRuns.startedAt))
.limit(limit); .limit(limit);
return rows return rows
@@ -461,7 +447,7 @@ export async function listActiveHeartbeatRuns(handle: QueryHandle, projectId: st
const rows = await handle const rows = await handle
.select({ data: schema.project.agentRuns.data }) .select({ data: schema.project.agentRuns.data })
.from(schema.project.agentRuns) .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)); .orderBy(asc(schema.project.agentRuns.startedAt));
return rows return rows
.map((row) => (row.data as AgentHeartbeatRun | null) ?? null) .map((row) => (row.data as AgentHeartbeatRun | null) ?? null)
@@ -486,13 +472,13 @@ export async function listAllAgentRuns(
? await handle ? await handle
.select({ data: schema.project.agentRuns.data }) .select({ data: schema.project.agentRuns.data })
.from(schema.project.agentRuns) .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)) .orderBy(desc(schema.project.agentRuns.startedAt), desc(schema.project.agentRuns.id))
.limit(normalizedLimit) .limit(normalizedLimit)
: await handle : await handle
.select({ data: schema.project.agentRuns.data }) .select({ data: schema.project.agentRuns.data })
.from(schema.project.agentRuns) .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)); .orderBy(asc(schema.project.agentRuns.startedAt), asc(schema.project.agentRuns.id));
return rows return rows
.map((row) => (row.data as AgentHeartbeatRun | null) ?? null) .map((row) => (row.data as AgentHeartbeatRun | null) ?? null)
@@ -517,7 +503,7 @@ export async function getRunStatusCounts(
count: sql<number>`count(*)::int`, count: sql<number>`count(*)::int`,
}) })
.from(schema.project.agentRuns) .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); .groupBy(schema.project.agentRuns.status);
} else { } else {
rows = await handle rows = await handle
@@ -526,7 +512,7 @@ export async function getRunStatusCounts(
count: sql<number>`count(*)::int`, count: sql<number>`count(*)::int`,
}) })
.from(schema.project.agentRuns) .from(schema.project.agentRuns)
.where(projectScopeFor(schema.project.agentRuns.projectId, projectId)) .where(eq(schema.project.agentRuns.projectId, projectId))
.groupBy(schema.project.agentRuns.status); .groupBy(schema.project.agentRuns.status);
} }
@@ -913,18 +899,11 @@ export async function getMetaValue(
key: string, key: string,
projectId = "", projectId = "",
): Promise<string | undefined> { ): Promise<string | undefined> {
/*
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 const rows = await handle
.select({ value: schema.project.projectMeta.value }) .select({ value: schema.project.projectMeta.value })
.from(schema.project.projectMeta) .from(schema.project.projectMeta)
.where(and( .where(and(
eq(schema.project.projectMeta.projectId, projectPartitionId(projectId)), eq(schema.project.projectMeta.projectId, projectId),
eq(schema.project.projectMeta.key, key), eq(schema.project.projectMeta.key, key),
)); ));
return rows[0]?.value ?? undefined; return rows[0]?.value ?? undefined;
@@ -942,16 +921,10 @@ export async function upsertMetaValue(
/* /*
FNXC:PostgresMultiProjectCutover 2026-07-14-11:18: 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. 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 await handle
.insert(schema.project.projectMeta) .insert(schema.project.projectMeta)
.values({ projectId: projectPartitionId(projectId), key, value }) .values({ projectId, key, value })
.onConflictDoUpdate({ .onConflictDoUpdate({
target: [schema.project.projectMeta.projectId, schema.project.projectMeta.key], target: [schema.project.projectMeta.projectId, schema.project.projectMeta.key],
set: { value }, set: { value },

View File

@@ -21,7 +21,6 @@ import { randomUUID } from "node:crypto";
import { EventEmitter } from "node:events"; import { EventEmitter } from "node:events";
import { and, asc, desc, eq, gte, inArray, lte, sql } from "drizzle-orm"; import { and, asc, desc, eq, gte, inArray, lte, sql } from "drizzle-orm";
import * as schema from "./postgres/schema/index.js"; 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 type { AsyncDataLayer, DbTransaction } from "./postgres/data-layer.js";
import { import {
ResearchLifecycleError, ResearchLifecycleError,
@@ -252,8 +251,7 @@ export async function getActiveResearchRun(
.from(schema.project.researchRuns) .from(schema.project.researchRuns)
.where( .where(
and( 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. eq(schema.project.researchRuns.projectId, projectId),
projectScopeFor(schema.project.researchRuns.projectId, projectId),
eq(schema.project.researchRuns.trigger, trigger), eq(schema.project.researchRuns.trigger, trigger),
inArray(schema.project.researchRuns.status, ["queued", "running", "cancelling", "retry_waiting"]), inArray(schema.project.researchRuns.status, ["queued", "running", "cancelling", "retry_waiting"]),
), ),

View File

@@ -56,7 +56,7 @@ import type { PostgresJsDatabase, PostgresJsTransaction } from "drizzle-orm/post
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import type { PostgresConnections } from "./connection.js"; import type { PostgresConnections } from "./connection.js";
import * as schema from "./schema/index.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: * FNXC:AsyncDataLayer 2026-06-24-09:00:
@@ -436,28 +436,3 @@ export function projectScopeFor(
const scope = projectId?.trim(); const scope = projectId?.trim();
return scope ? eq(column, scope) : undefined; 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;
}

View File

@@ -37,17 +37,6 @@ export const ARCHIVE_SCHEMA = "archive";
/** PostgreSQL schema where Drizzle's migration bookkeeping table lives. */ /** PostgreSQL schema where Drizzle's migration bookkeeping table lives. */
export const DRIZZLE_MIGRATION_SCHEMA = "public"; 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. * All application schemas, in the order the applier creates them.
* Plugin-owned tables are materialized separately via the schema-init hook * Plugin-owned tables are materialized separately via the schema-init hook

View File

@@ -4,7 +4,6 @@ import { and, eq } from "drizzle-orm";
import { DatabaseSync } from "./sqlite-adapter.js"; import { DatabaseSync } from "./sqlite-adapter.js";
import { createLogger } from "./logger.js"; import { createLogger } from "./logger.js";
import * as schema from "./postgres/schema/index.js"; import * as schema from "./postgres/schema/index.js";
import { projectPartitionId } from "./postgres/data-layer.js";
import type { AsyncDataLayer } from "./postgres/data-layer.js"; import type { AsyncDataLayer } from "./postgres/data-layer.js";
const log = createLogger("project-identity"); const log = createLogger("project-identity");
@@ -137,14 +136,7 @@ export function hasProjectIdentity(fusionDir: string): boolean {
// ───────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────
async function readMetaAsync(layer: AsyncDataLayer, key: string): Promise<string | null> { async function readMetaAsync(layer: AsyncDataLayer, key: string): Promise<string | null> {
/* 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 const rows = await layer.db
.select({ value: schema.project.projectMeta.value }) .select({ value: schema.project.projectMeta.value })
.from(schema.project.projectMeta) .from(schema.project.projectMeta)
@@ -156,16 +148,11 @@ async function readMetaAsync(layer: AsyncDataLayer, key: string): Promise<string
} }
async function upsertMetaAsync(layer: AsyncDataLayer, key: string, value: string): Promise<void> { async function upsertMetaAsync(layer: AsyncDataLayer, key: string, value: string): Promise<void> {
const projectId = layer.projectId ?? "";
/* /*
FNXC:PostgresMultiProjectCutover 2026-07-14-11:18: 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. 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 await layer.db
.insert(schema.project.projectMeta) .insert(schema.project.projectMeta)
.values({ projectId, key, value }) .values({ projectId, key, value })