From a588c387849e6d681c1d429e66fded09bfc324cd Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 15 Jul 2026 21:37:44 -0700 Subject: [PATCH] fix(core): read usage events across projects when the layer is unbound An unbound (project-agnostic) data layer read zero usage events it had just written. AsyncDataLayer.projectId is optional by design -- undefined means a project-agnostic layer for single-project / global / analytics reads -- but helpers taking `projectId: string` are called as `layer.projectId ?? ""`, which turns "no scope" into a literal '' scope. '' never matches: the fusion_assign_project_id BEFORE INSERT trigger (migration 0006) rewrites a written '' to the session's fusion.project_id or '__legacy_unscoped__', so a read filtering on '' looks for a value the database never stores. Writes normalize, reads did not. Proven by probe: the row is present with project_id '__legacy_unscoped__', emitUsageEvent returns true, and queryUsageEvents returns [] even with no other filters. Treat blank as unbound and drop the scope predicate, matching the contract taskProjectScope already documents ("when undefined the scope filter is a no-op"). Restricting an unbound reader to '__legacy_unscoped__' rows instead would make an unscoped analytics read silently partial. Adds projectScopeFor() next to taskProjectScope so the convention has one home rather than a third open-coded variant. Note the write path is already live: remaining-ops-7.ts emits with `layer.projectId ?? ""` under backendMode, so unscoped events are accumulating under the sentinel today. The async reader has no production caller yet, which is why nothing user-facing broke. Fixes taskstore-remaining.test.ts (24/24). The remaining failures in that suite share this root cause but not this resolution -- see the follow-up. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/core/src/postgres/data-layer.ts | 25 ++++++++++++++++++++ packages/core/src/task-store/async-events.ts | 15 ++++++++++-- 2 files changed, 38 insertions(+), 2 deletions(-) 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)); }