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 * 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<string, unknown> | 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<number>`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<number>`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<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
.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 },

View File

@@ -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"]),
),

View File

@@ -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;
}

View File

@@ -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

View File

@@ -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<string | null> {
/*
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<string
}
async function upsertMetaAsync(layer: AsyncDataLayer, key: string, value: string): Promise<void> {
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 })