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) <noreply@anthropic.com>
This commit is contained in:
@@ -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<string, unknown> | 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<number>`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<number>`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<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, 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 },
|
||||
|
||||
@@ -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"]),
|
||||
),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<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
|
||||
.select({ value: schema.project.projectMeta.value })
|
||||
.from(schema.project.projectMeta)
|
||||
@@ -148,11 +156,16 @@ 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 })
|
||||
|
||||
Reference in New Issue
Block a user