diff --git a/packages/core/src/postgres/data-layer.ts b/packages/core/src/postgres/data-layer.ts index 7f91ad912a..568849a4af 100644 --- a/packages/core/src/postgres/data-layer.ts +++ b/packages/core/src/postgres/data-layer.ts @@ -411,3 +411,28 @@ export function archivedTaskProjectScope( ? eq(schema.project.archivedTasks.projectId, layer.projectId) : undefined; } + +/** + * FNXC:MultiProjectIsolation 2026-07-15-21:40: + * As {@link taskProjectScope}, but for any project-schema column and a raw project id rather + * than a bound layer. Same contract: a bound id scopes the read, an unbound one (undefined or + * blank) is a no-op that reads across projects. + * + * Exists because helpers that take `projectId: string` are called as `layer.projectId ?? ""`, + * which turns "no scope" into a literal `''` scope. That never matches anything: the + * `fusion_assign_project_id` BEFORE INSERT trigger (migration 0006) rewrites a written `''` to + * the session's `fusion.project_id` or `__legacy_unscoped__`, so reads filtering on `''` look + * for a value the database never stores. Writes normalize; reads must not invent a scope. + * + * Blank is treated as unbound rather than as the legacy sentinel deliberately: an unbound layer + * is documented as a project-agnostic / analytics reader, so it reads everything, exactly as the + * `taskProjectScope` no-op already does. Restricting it to `__legacy_unscoped__` rows would make + * an unscoped analytics read silently partial instead. + */ +export function projectScopeFor( + column: SQL | Parameters[0], + projectId: string | undefined, +): SQL | undefined { + const scope = projectId?.trim(); + return scope ? eq(column, scope) : undefined; +} diff --git a/packages/core/src/task-store/async-events.ts b/packages/core/src/task-store/async-events.ts index 82f3e05916..64da44e72f 100644 --- a/packages/core/src/task-store/async-events.ts +++ b/packages/core/src/task-store/async-events.ts @@ -25,8 +25,9 @@ * These helpers are the async target the migrating store and the PostgreSQL * integration tests consume. */ -import { and, desc, eq, gte, lte } from "drizzle-orm"; +import { and, desc, eq, gte, lte, type 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 type { GoalCitation, @@ -265,7 +266,17 @@ export async function queryUsageEvents( projectId: string, query: UsageEventRangeQuery = {}, ): Promise { - const conditions = [eq(schema.project.usageEvents.projectId, projectId)]; + /* + FNXC:MultiProjectIsolation 2026-07-15-21:40: + An unbound (project-agnostic / analytics) layer reaches here as `layer.projectId ?? ""`. Scoping + on that literal `''` returned nothing at all: the write side never stores it — the + fusion_assign_project_id trigger rewrites `''` to the session project or `__legacy_unscoped__` — + so every unscoped read silently found zero events it had just written. Blank means unscoped, so + read across projects, matching taskProjectScope's no-op. See projectScopeFor. + */ + const conditions: SQL[] = []; + const scope = projectScopeFor(schema.project.usageEvents.projectId, projectId); + if (scope) conditions.push(scope); if (query.from) { conditions.push(gte(schema.project.usageEvents.ts, query.from)); }