diff --git a/packages/core/src/__test-utils__/pg-test-harness.ts b/packages/core/src/__test-utils__/pg-test-harness.ts index 763aec1f4..6461c6d6b 100644 --- a/packages/core/src/__test-utils__/pg-test-harness.ts +++ b/packages/core/src/__test-utils__/pg-test-harness.ts @@ -546,9 +546,12 @@ export function createSharedPgTaskStoreTestHarness(options?: { // not user input, so interpolation is safe here). await harness.adminDb.execute( sql.raw( - `INSERT INTO ${PROJECT_SCHEMA}.config (id, next_id, next_workflow_step_id, settings, workflow_steps, updated_at) - VALUES (1, 1, 1, '${defaultsJson.replace(/'/g, "''")}'::jsonb, '[]'::jsonb, now()) - ON CONFLICT (id) DO UPDATE SET next_id = 1, next_workflow_step_id = 1, settings = EXCLUDED.settings, workflow_steps = '[]'::jsonb, updated_at = now()`, + // FNXC:MultiProjectIsolation 2026-07-11: config is keyed per-project on + // project_id (the PK) — id is no longer unique, so the upsert arbiter + // must be project_id. Harness stores run project-agnostic (projectId ''). + `INSERT INTO ${PROJECT_SCHEMA}.config (id, project_id, next_id, next_workflow_step_id, settings, workflow_steps, updated_at) + VALUES (1, '', 1, 1, '${defaultsJson.replace(/'/g, "''")}'::jsonb, '[]'::jsonb, now()) + ON CONFLICT (project_id) DO UPDATE SET next_id = 1, next_workflow_step_id = 1, settings = EXCLUDED.settings, workflow_steps = '[]'::jsonb, updated_at = now()`, ), ); // Drop any in-memory caches so the store doesn't serve stale rows. diff --git a/packages/core/src/postgres/data-layer.ts b/packages/core/src/postgres/data-layer.ts index 10561e935..7f91ad912 100644 --- a/packages/core/src/postgres/data-layer.ts +++ b/packages/core/src/postgres/data-layer.ts @@ -51,7 +51,7 @@ * Drizzle transaction callback wrapper. */ -import { sql, type SQL } from "drizzle-orm"; +import { sql, eq, type SQL } from "drizzle-orm"; import type { PostgresJsDatabase, PostgresJsTransaction } from "drizzle-orm/postgres-js"; import { randomUUID } from "node:crypto"; import type { PostgresConnections } from "./connection.js"; @@ -137,6 +137,22 @@ export interface TransactionOptions { export interface AsyncDataLayer { /** Schema-typed runtime Drizzle instance for non-transactional queries. */ readonly db: DrizzleDb; + /** + * FNXC:MultiProjectIsolation 2026-07-10: + * The central-registry project ID this data layer is bound to, or undefined + * for a project-agnostic layer (single-project / global / analytics reads). + * + * In embedded-PG mode every per-project TaskStore gets its OWN AsyncDataLayer + * instance (constructed by the startup factory per projectId) but they all + * connect to the SAME shared `fusion` database + `project` schema. This field + * lets the task-store helpers scope every read/claim/insert on the flat + * `project.tasks` / `project.archived_tasks` tables to a single project so + * per-project engines cannot poll/claim/execute each other's tasks. When + * undefined the scope filter is a no-op (back-compat: single-project stores, + * cross-project analytics, and the SQLite path — which isolates by file — are + * unaffected). + */ + readonly projectId?: string; /** * Run an async callback inside a PostgreSQL transaction. All writes inside * the callback commit atomically; a thrown error rolls back every write @@ -203,8 +219,13 @@ export interface RunAuditEvent { * data-layer contract plugin stores consume. * * @param connections The resolved PostgreSQL connection set (runtime + migration). + * @param options Optional binding: `projectId` scopes task-table reads/writes + * to a single project (embedded-PG multi-project isolation, FNXC:MultiProjectIsolation). */ -export function createAsyncDataLayer(connections: PostgresConnections): AsyncDataLayer { +export function createAsyncDataLayer( + connections: PostgresConnections, + options?: { projectId?: string }, +): AsyncDataLayer { // The runtime Drizzle instance is schema-less at the connection layer // (connection.ts constructs it without a schema binding so it works for // any caller). We cast to the schema-typed view so callers get @@ -213,6 +234,7 @@ export function createAsyncDataLayer(connections: PostgresConnections): AsyncDat return { db, + projectId: options?.projectId, async transaction(fn: (tx: DbTransaction) => Promise, options?: TransactionOptions): Promise { return runInTransaction(db, fn, options); }, @@ -357,3 +379,35 @@ export async function recordRunAuditEvent( export function projectTable(tableName: string): SQL { return sql.raw(`${PROJECT_SCHEMA}."${tableName}"`); } + +/** + * FNXC:MultiProjectIsolation 2026-07-10: + * The per-project scope predicate for the flat `project.tasks` table. Returns + * `project_id = ` when the data layer is bound to a project, + * or `undefined` (a no-op inside Drizzle's `and(...)`) when it is not. + * + * Every backend-mode task READ / CLAIM / LIST / COUNT path folds this into its + * WHERE clause so a per-project engine only ever sees its own project's rows on + * the shared embedded-PG cluster. `undefined` preserves the pre-isolation + * behavior for project-agnostic layers (single-project stores, cross-project + * analytics) and is safe to pass through `and()` (Drizzle drops undefined + * operands). + * + * NOTE: this operand is passed to `and(...)` so it is only enforced where a + * caller actually threads it in. The load-bearing sites are the row-scan + * readers (readLiveTaskRows, readTaskRow, countLiveTasks), the merge-lease + * candidate scan, and the search scans — see the FNXC:MultiProjectIsolation + * markers in the task-store helpers. + */ +export function taskProjectScope(layer: Pick): SQL | undefined { + return layer.projectId ? eq(schema.project.tasks.projectId, layer.projectId) : undefined; +} + +/** As {@link taskProjectScope} but for the `project.archived_tasks` table. */ +export function archivedTaskProjectScope( + layer: Pick, +): SQL | undefined { + return layer.projectId + ? eq(schema.project.archivedTasks.projectId, layer.projectId) + : undefined; +} diff --git a/packages/core/src/postgres/migrations/0000_initial.sql b/packages/core/src/postgres/migrations/0000_initial.sql index a82cd4487..95737775b 100644 --- a/packages/core/src/postgres/migrations/0000_initial.sql +++ b/packages/core/src/postgres/migrations/0000_initial.sql @@ -35,6 +35,11 @@ CREATE SCHEMA IF NOT EXISTS archive; CREATE TABLE IF NOT EXISTS project.tasks ( id text PRIMARY KEY, + -- FNXC:MultiProjectIsolation 2026-07-10: per-project partition key for + -- embedded-PG multi-project isolation (shared cluster/db/schema). Populated + -- from the store's bound projectId on insert; filtered on every backend-mode + -- read/claim/list. Nullable so SQLite mode + legacy rows are unaffected. + project_id text, lineage_id text, title text, description text NOT NULL, @@ -174,8 +179,14 @@ CREATE TABLE IF NOT EXISTS project.tasks ( ) STORED ); +-- FNXC:MultiProjectIsolation 2026-07-11: per-project config isolation. The old +-- singleton row (id = 1, CHECK-enforced) forced every project in the shared +-- `project` schema to share one taskPrefix / maxConcurrent / maxWorktrees. The +-- row is now keyed per-project on project_id (the PK); `id` stays for column +-- parity (always 1) but is no longer the PK / no longer CHECK-constrained. CREATE TABLE IF NOT EXISTS project.config ( - id integer PRIMARY KEY, + id integer DEFAULT 1, + project_id text NOT NULL DEFAULT '' PRIMARY KEY, next_id integer DEFAULT 1, next_workflow_step_id integer DEFAULT 1, -- FNXC:SqliteFinalRemoval 2026-06-28: WF-id counter for createWorkflowDefinition @@ -183,8 +194,7 @@ CREATE TABLE IF NOT EXISTS project.config ( next_workflow_definition_id integer DEFAULT 1, settings jsonb DEFAULT '{}', workflow_steps jsonb DEFAULT '[]', - updated_at text, - CONSTRAINT config_id_check CHECK (id = 1) + updated_at text ); CREATE TABLE IF NOT EXISTS project.distributed_task_id_state ( @@ -276,10 +286,13 @@ CREATE INDEX IF NOT EXISTS "idxActivityLogTaskId" ON project.activity_log(task_i CREATE TABLE IF NOT EXISTS project.archived_tasks ( id text PRIMARY KEY, + -- FNXC:MultiProjectIsolation 2026-07-10: per-project partition key (see project.tasks.project_id). + project_id text, data text NOT NULL, archived_at text NOT NULL ); CREATE INDEX IF NOT EXISTS "idxArchivedTasksId" ON project.archived_tasks(id); +CREATE INDEX IF NOT EXISTS "idxArchivedTasksProjectId" ON project.archived_tasks(project_id); CREATE TABLE IF NOT EXISTS project.task_commit_associations ( id text PRIMARY KEY, @@ -1465,6 +1478,9 @@ CREATE INDEX IF NOT EXISTS "idxTasksSourceParentTaskId" ON project.tasks(source_ -- The partial predicate shrinks the index to live rows only so the planner -- can serve the most common board filter without a bitmap-AND over two indexes. CREATE INDEX IF NOT EXISTS "idxTasksLiveColumn" ON project.tasks("column") WHERE deleted_at IS NULL; +-- FNXC:MultiProjectIsolation 2026-07-10: composite (project_id, column) partial +-- index for the per-project board scan + scheduler poll. +CREATE INDEX IF NOT EXISTS "idxTasksProjectLiveColumn" ON project.tasks(project_id, "column") WHERE deleted_at IS NULL; -- FNXC:TaskStoreSearch 2026-06-24-12:35: -- GIN index on the tasks search_vector for full-text search (VAL-SEARCH-001). -- Replaces the FTS5 index. REINDEX restores search after bloat (VAL-SEARCH-007). diff --git a/packages/core/src/postgres/postgres-health.ts b/packages/core/src/postgres/postgres-health.ts index 300ac132d..019d87485 100644 --- a/packages/core/src/postgres/postgres-health.ts +++ b/packages/core/src/postgres/postgres-health.ts @@ -153,6 +153,11 @@ export const EXPECTED_PROJECT_COLUMNS: ReadonlyArray<{ table: string; column: st { table: "tasks", column: "created_at", type: "text" }, { table: "tasks", column: "updated_at", type: "text" }, { table: "tasks", column: "deleted_at", type: "text" }, + // FNXC:MultiProjectIsolation 2026-07-11: per-project partition key (PR #2007). + // Listed so existing embedded-PG databases self-heal the column on boot — + // the baseline's CREATE TABLE IF NOT EXISTS never upgrades an existing + // table, and every scoped task read/claim now folds project_id into WHERE. + { table: "tasks", column: "project_id", type: "text" }, // distributed_task_id_state { table: "distributed_task_id_state", column: "prefix", type: "text" }, { table: "distributed_task_id_state", column: "next_sequence", type: "integer" }, @@ -163,6 +168,8 @@ export const EXPECTED_PROJECT_COLUMNS: ReadonlyArray<{ table: string; column: st { table: "archived_tasks", column: "id", type: "text" }, { table: "archived_tasks", column: "data", type: "text" }, { table: "archived_tasks", column: "archived_at", type: "text" }, + // FNXC:MultiProjectIsolation 2026-07-11: see tasks.project_id above. + { table: "archived_tasks", column: "project_id", type: "text" }, // chat_sessions — FN-7775 per-chat thinking level (added 2026-07-10); listed // so existing embedded-PG databases self-heal the column via ALTER TABLE // ADD COLUMN IF NOT EXISTS on boot (CREATE TABLE IF NOT EXISTS alone never diff --git a/packages/core/src/postgres/schema/project.ts b/packages/core/src/postgres/schema/project.ts index ae10f1d0d..56877059c 100644 --- a/packages/core/src/postgres/schema/project.ts +++ b/packages/core/src/postgres/schema/project.ts @@ -54,6 +54,20 @@ export const projectSchema = pgSchema(PROJECT_SCHEMA); // ── Tasks ──────────────────────────────────────────────────────────── export const tasks = projectSchema.table("tasks", { id: text("id").primaryKey(), + /* + FNXC:MultiProjectIsolation 2026-07-10: + Partition key for embedded-PG multi-project isolation. In embedded mode every + project's per-project TaskStore connects its AsyncDataLayer to ONE shared + `fusion` database + ONE `project` schema, so this flat tasks table is shared + across all projects. Without a project_id, per-project engines poll the same + unfiltered table and claim/execute each other's tasks in the wrong repo. + This column (populated from the store's bound projectId on every insert and + filtered on every read/claim/list in backend mode) re-adds the partition key + the SQLite per-file storage provided implicitly. Nullable so SQLite mode (which + isolates via per-file storage) and legacy rows are unaffected; the filter is + a no-op when the layer has no bound projectId (single-project / global reads). + */ + projectId: text("project_id"), lineageId: text("lineage_id"), title: text("title"), description: text("description").notNull(), @@ -257,6 +271,17 @@ export const tasks = projectSchema.table("tasks", { .on(t.column) .where(sql`${t.deletedAt} IS NULL`), /* + FNXC:MultiProjectIsolation 2026-07-10: + Composite index for the per-project isolation filter. Every backend-mode task + read/claim/list adds `project_id = $current`; the hottest board query shape is + `WHERE deleted_at IS NULL AND project_id = ? AND "column" = ?`. Leading with + project_id (then column) serves the per-project board scan and the scheduler + poll from one index. Partial on live rows keeps it small. + */ + index("idxTasksProjectLiveColumn") + .on(t.projectId, t.column) + .where(sql`${t.deletedAt} IS NULL`), + /* FNXC:TaskStoreSearch 2026-06-24-12:15: GIN index on the search_vector tsvector for full-text search (VAL-SEARCH-001). This is the PostgreSQL replacement for the FTS5 index. @@ -270,7 +295,16 @@ export const tasks = projectSchema.table("tasks", { // ── Config ─────────────────────────────────────────────────────────── export const config = projectSchema.table("config", { - id: integer("id").primaryKey(), + // FNXC:MultiProjectIsolation 2026-07-11: + // In embedded-PG mode every project shares this `project` schema, so the old + // singleton config row (id = 1, enforced by a CHECK constraint) forced ALL + // projects to share one taskPrefix / maxConcurrent / maxWorktrees. The row is + // now keyed per-project on `project_id` (the effective PK). `id` is retained + // for column-shape parity (always 1) but is no longer the PK and no longer + // CHECK-constrained. Single-project / SQLite-parity callers leave project_id + // at its '' default (one row), preserving the pre-isolation behavior. + id: integer("id").default(1), + projectId: text("project_id").notNull().default("").primaryKey(), nextId: integer("next_id").default(1), nextWorkflowStepId: integer("next_workflow_step_id").default(1), // FNXC:SqliteFinalRemoval 2026-06-28: @@ -281,7 +315,7 @@ export const config = projectSchema.table("config", { settings: jsonb("settings").default({}), workflowSteps: jsonb("workflow_steps").default([]), updatedAt: text("updated_at"), -}, (t) => [check("config_id_check", sql`${t.id} = 1`)]); +}); // ── Distributed task ID allocator ──────────────────────────────────── export const distributedTaskIdState = projectSchema.table("distributed_task_id_state", { @@ -381,9 +415,14 @@ export const activityLog = projectSchema.table("activity_log", { // ── Archived tasks (project-side legacy copy) ──────────────────────── export const archivedTasks = projectSchema.table("archived_tasks", { id: text("id").primaryKey(), + // FNXC:MultiProjectIsolation 2026-07-10: per-project partition key (see tasks.projectId). + projectId: text("project_id"), data: text("data").notNull(), archivedAt: text("archived_at").notNull(), -}, (t) => [index("idxArchivedTasksId").on(t.id)]); +}, (t) => [ + index("idxArchivedTasksId").on(t.id), + index("idxArchivedTasksProjectId").on(t.projectId), +]); // ── Task commit associations ───────────────────────────────────────── export const taskCommitAssociations = projectSchema.table("task_commit_associations", { diff --git a/packages/core/src/postgres/startup-factory.ts b/packages/core/src/postgres/startup-factory.ts index 2bfa808b3..43f0cfcb9 100644 --- a/packages/core/src/postgres/startup-factory.ts +++ b/packages/core/src/postgres/startup-factory.ts @@ -370,8 +370,21 @@ export async function createTaskStoreForBackend( const fusionDir = join(rootDir, ".fusion"); const legacySqlitePath = join(fusionDir, "fusion.db"); if (existsSync(legacySqlitePath) && isValidSqliteDatabaseFile(legacySqlitePath)) { + /* + FNXC:MultiProjectIsolation 2026-07-11: + With per-project task partitioning (project_id on project.tasks), the + first-boot emptiness check must be scoped to THIS project — otherwise + the second project booting against the shared embedded cluster sees the + first project's rows and silently skips migrating its own legacy + fusion.db (the exact data-loss trap Step 5.5 exists to close). NULL + project_id rows are counted as blocking: they may be this project's + pre-isolation data, and migrating on top of them risks id collisions. + Without a bound projectId the pre-isolation whole-table check applies. + */ const countRows = (await connections.migration.execute( - drizzleSql`SELECT count(*)::int AS count FROM project.tasks`, + options.projectId + ? drizzleSql`SELECT count(*)::int AS count FROM project.tasks WHERE project_id = ${options.projectId} OR project_id IS NULL` + : drizzleSql`SELECT count(*)::int AS count FROM project.tasks`, )) as Array<{ count: number }>; const pgTaskCount = Number(countRows[0]?.count ?? 0); if (pgTaskCount === 0) { @@ -394,6 +407,24 @@ export async function createTaskStoreForBackend( log.log(`startup-factory: empty PostgreSQL database with legacy SQLite data present — auto-migrating ${sources.length} source(s) (SQLite files are kept as backups)`); const report = await migrateSqliteToPostgres(connections.migration, sources, { skipBaseline: true }); const migratedRows = report.tables.reduce((sum, table) => sum + table.insertedRows, 0); + /* + FNXC:MultiProjectIsolation 2026-07-11: + The SQLite migrator predates partitioning and leaves project_id + NULL — rows the strict taskProjectScope filter (project_id = $bound) + would never surface, so the scheduler/board would show an empty + project right after a "successful" migration. Stamp the + just-migrated rows with the booting project's id. Safe because the + scoped emptiness check above guarantees every NULL-project_id row + in tasks/archived_tasks was written by THIS migration pass. + */ + if (options.projectId) { + await connections.migration.execute( + drizzleSql`UPDATE project.tasks SET project_id = ${options.projectId} WHERE project_id IS NULL`, + ); + await connections.migration.execute( + drizzleSql`UPDATE project.archived_tasks SET project_id = ${options.projectId} WHERE project_id IS NULL`, + ); + } log.log(`startup-factory: SQLite → PostgreSQL auto-migration complete (${migratedRows} row(s) across ${report.tables.length} table(s))`); } } @@ -412,7 +443,13 @@ export async function createTaskStoreForBackend( } // Step 6: construct the AsyncDataLayer. - const asyncLayer = createAsyncDataLayer(connections); + // FNXC:MultiProjectIsolation 2026-07-10: + // Bind the layer to this project so the task-store helpers scope every + // read/claim/insert on the shared embedded-PG `project.tasks` table to a + // single project. options.projectId is the central-registry ID both the + // dashboard (getOrCreateProjectStore) and the engine (InProcessRuntime) pass, + // so a task's row is stamped and filtered under one consistent partition key. + const asyncLayer = createAsyncDataLayer(connections, { projectId: options.projectId }); // Step 7: construct the TaskStore in backend mode. /* diff --git a/packages/core/src/task-store/async-allocator.ts b/packages/core/src/task-store/async-allocator.ts index 68acd02e1..5c6b7fbc8 100644 --- a/packages/core/src/task-store/async-allocator.ts +++ b/packages/core/src/task-store/async-allocator.ts @@ -74,12 +74,16 @@ interface ConfiguredPrefixRow { */ export async function getConfiguredPrefixAndLegacyNextId( db: AsyncDataLayer["db"] | DbTransaction, + projectId?: string, ): Promise { try { const rows = await db .select({ nextId: schema.project.config.nextId, settings: schema.project.config.settings }) .from(schema.project.config) - .where(eq(schema.project.config.id, 1)); + // FNXC:MultiProjectIsolation 2026-07-11: the config row is now keyed + // per-project. Scope by project_id when bound to a project, else fall back + // to the legacy singleton id = 1 row (single-project / SQLite parity). + .where(projectId ? eq(schema.project.config.projectId, projectId) : eq(schema.project.config.id, 1)); const row = rows[0]; if (!row) { return { prefix: "KB", legacyNextId: null }; @@ -167,8 +171,9 @@ async function getMaxReservationSequence( export async function computeNextSequenceFloor( db: AsyncDataLayer["db"] | DbTransaction, prefix: string, + projectId?: string, ): Promise { - const configured = await getConfiguredPrefixAndLegacyNextId(db); + const configured = await getConfiguredPrefixAndLegacyNextId(db, projectId); let nextSequence = 1; if (configured.prefix === prefix && configured.legacyNextId && configured.legacyNextId > nextSequence) { nextSequence = configured.legacyNextId; @@ -188,9 +193,10 @@ export async function computeNextSequenceFloor( */ export async function getKnownPrefixes( db: AsyncDataLayer["db"] | DbTransaction, + projectId?: string, ): Promise> { const prefixes = new Set(); - const configured = await getConfiguredPrefixAndLegacyNextId(db); + const configured = await getConfiguredPrefixAndLegacyNextId(db, projectId); if (configured.prefix) { prefixes.add(configured.prefix); } @@ -297,9 +303,9 @@ export async function reconcileTaskIdStateAsync( const nowIso = new Date().toISOString(); return layer.transactionImmediate(async (tx) => { const reconciled: string[] = []; - const prefixes = await getKnownPrefixes(tx); + const prefixes = await getKnownPrefixes(tx, layer.projectId); for (const prefix of prefixes) { - const floor = await computeNextSequenceFloor(tx, prefix); + const floor = await computeNextSequenceFloor(tx, prefix, layer.projectId); // Read the current nextSequence so we can detect a change. const beforeRows = await tx @@ -434,7 +440,7 @@ export function createAsyncDistributedTaskIdAllocator( } // Ensure the state row exists with the correct floor. - const floor = await computeNextSequenceFloor(tx, prefix); + const floor = await computeNextSequenceFloor(tx, prefix, layer.projectId); await ensureStateRow(tx, prefix, floor, nowIso); // Read the current nextSequence. @@ -518,7 +524,7 @@ export function createAsyncDistributedTaskIdAllocator( .where(eq(schema.project.distributedTaskIdReservations.reservationId, row.reservationId)); // Ensure state row exists and bump committed count. - const floor = await computeNextSequenceFloor(tx, row.prefix); + const floor = await computeNextSequenceFloor(tx, row.prefix, layer.projectId); await ensureStateRow(tx, row.prefix, floor, nowIso); await tx .update(schema.project.distributedTaskIdState) @@ -573,7 +579,7 @@ export function createAsyncDistributedTaskIdAllocator( .where(eq(schema.project.distributedTaskIdReservations.reservationId, row.reservationId)); } - const floor = await computeNextSequenceFloor(tx, row.prefix); + const floor = await computeNextSequenceFloor(tx, row.prefix, layer.projectId); await ensureStateRow(tx, row.prefix, floor, nowIso); const stateRows = await tx .select({ committedClusterTaskCount: schema.project.distributedTaskIdState.committedClusterTaskCount }) @@ -600,7 +606,7 @@ export function createAsyncDistributedTaskIdAllocator( if (!prefix) { throw new Error("prefix is required"); } - const floor = await computeNextSequenceFloor(tx, prefix); + const floor = await computeNextSequenceFloor(tx, prefix, layer.projectId); await ensureStateRow(tx, prefix, floor, nowIso); const stateRows = await tx diff --git a/packages/core/src/task-store/async-merge-coordination.ts b/packages/core/src/task-store/async-merge-coordination.ts index 3b534cf82..5ceeff110 100644 --- a/packages/core/src/task-store/async-merge-coordination.ts +++ b/packages/core/src/task-store/async-merge-coordination.ts @@ -39,7 +39,7 @@ import { and, eq, inArray, isNull, lte, or, sql } from "drizzle-orm"; import { randomUUID } from "node:crypto"; import * as schema from "../postgres/schema/index.js"; import type { AsyncDataLayer, DbTransaction } from "../postgres/data-layer.js"; -import { recordRunAuditEventWithinTransaction } from "../postgres/data-layer.js"; +import { recordRunAuditEventWithinTransaction, taskProjectScope } from "../postgres/data-layer.js"; import { normalizeTaskPriority } from "../task-priority.js"; import type { MergeQueueAcquireOptions, @@ -96,13 +96,24 @@ function leaseAvailable(now: string) { ); } -/** Predicate: the queue row's task is still in the `in-review` column. */ -function taskStillInReview() { +/** + * Predicate: the queue row's task is still in the `in-review` column. + * + * FNXC:MultiProjectIsolation 2026-07-10: when `projectId` is bound, the EXISTS + * additionally requires the task to belong to this project so a project's + * merger can only lease its OWN queue rows (merge_queue has no project_id, so + * it is scoped transitively through its task on the shared embedded-PG cluster). + */ +function taskStillInReview(projectId?: string) { + const projectClause = projectId + ? sql`AND ${schema.project.tasks.projectId} = ${projectId}` + : sql``; return sql` EXISTS ( SELECT 1 FROM ${schema.project.tasks} WHERE ${schema.project.tasks.id} = ${schema.project.mergeQueue.taskId} AND ${schema.project.tasks.column} = 'in-review' + ${projectClause} ) `; } @@ -325,6 +336,10 @@ export async function acquireMergeQueueLease( throw new InvalidMergeQueueLeaseDurationError(opts.leaseDurationMs); } + // FNXC:MultiProjectIsolation 2026-07-10: the merger's lease candidate scans + // must be scoped to this project so a project's merger can never lease (and + // then merge in the wrong repo) another project's in-review task. + const projectId = layer.projectId; return layer.transactionImmediate(async (tx) => { const now = opts.now ?? new Date().toISOString(); const leaseExpiresAt = new Date(Date.parse(now) + opts.leaseDurationMs).toISOString(); @@ -338,7 +353,7 @@ export async function acquireMergeQueueLease( .where( and( eq(schema.project.mergeQueue.taskId, opts.targetTaskId), - taskStillInReview(), + taskStillInReview(projectId), leaseAvailable(now), ), ) @@ -391,7 +406,7 @@ export async function acquireMergeQueueLease( .where( and( eq(schema.project.mergeQueue.taskId, opts.targetTaskId), - taskStillInReview(), + taskStillInReview(projectId), leaseAvailable(now), ), ) @@ -433,6 +448,8 @@ export async function acquireMergeQueueLease( .where( and( eq(schema.project.tasks.column, "in-review"), + // FNXC:MultiProjectIsolation 2026-07-10: only this project's tasks. + taskProjectScope(layer), leaseAvailable(now), ), ) diff --git a/packages/core/src/task-store/async-persistence.ts b/packages/core/src/task-store/async-persistence.ts index ae081b9ae..893efb1c5 100644 --- a/packages/core/src/task-store/async-persistence.ts +++ b/packages/core/src/task-store/async-persistence.ts @@ -33,6 +33,7 @@ import { and, Column, eq, is, isNull, sql, type SQL } from "drizzle-orm"; import type { PgColumn } from "drizzle-orm/pg-core"; import * as schema from "../postgres/schema/index.js"; import type { AsyncDataLayer, DbTransaction } from "../postgres/data-layer.js"; +import { taskProjectScope } from "../postgres/data-layer.js"; import { TASK_COLUMN_DESCRIPTORS, type TaskPersistSerializationContext, @@ -123,6 +124,7 @@ const TASK_JSONB_COLUMNS: ReadonlySet = new Set([ export function buildTaskInsertValues( taskRecord: Record, context: TaskPersistSerializationContext, + projectId?: string, ): Record { const values: Record = {}; for (const descriptor of TASK_COLUMN_DESCRIPTORS) { @@ -138,6 +140,15 @@ export function buildTaskInsertValues( } values[descriptor.column] = value; } + // FNXC:MultiProjectIsolation 2026-07-10: + // Stamp the per-project partition key so every task row is attributed to the + // project whose store wrote it. The task-store descriptors don't include + // project_id (it isn't a Task field), so it is set here from the bound layer + // projectId. When undefined (single-project store / SQLite path), the column + // stays NULL and the scope filter is a no-op — behavior-preserving. + if (projectId !== undefined) { + values.projectId = projectId; + } return values; } @@ -158,7 +169,7 @@ export async function insertTaskRow( taskRecord: Record, context: TaskPersistSerializationContext, ): Promise { - const values = buildTaskInsertValues(taskRecord, context); + const values = buildTaskInsertValues(taskRecord, context, layer.projectId); await layer.db.insert(schema.project.tasks).values(values as never); } @@ -171,8 +182,9 @@ export async function insertTaskRowInTransaction( tx: DbTransaction, taskRecord: Record, context: TaskPersistSerializationContext, + projectId?: string, ): Promise { - const values = buildTaskInsertValues(taskRecord, context); + const values = buildTaskInsertValues(taskRecord, context, projectId); await tx.insert(schema.project.tasks).values(values as never); } @@ -260,6 +272,12 @@ export async function readTaskRow( if (!options?.includeDeleted) { conditions.push(ACTIVE_TASK_FILTER); } + // FNXC:MultiProjectIsolation 2026-07-10: scope the by-id read to the bound + // project so one project's store can never resolve another project's task + // (defence-in-depth; also protects the merger, which loads each merge-queue + // entry's task via getTask -> readTaskRow and skips when not found). + const projectScope = taskProjectScope(layer); + if (projectScope) conditions.push(projectScope); const rows = await layer.db .select() .from(schema.project.tasks) @@ -329,6 +347,15 @@ export async function readLiveTaskRows( // applied so soft-deleted tasks never appear on the board (VAL-DATA-005). // When includeDeleted is true the filter is dropped entirely, exposing // tombstoned rows for admin/forensic surfaces (e.g. GET /api/tasks?includeDeleted=true). + // FNXC:MultiProjectIsolation 2026-07-10: + // THE load-bearing isolation filter. readLiveTaskRows backs store.listTasks(), + // which the engine scheduler/executor uses to decide what to run, plus the + // board/kanban/count reads and the /api/tasks list. Scoping it to the bound + // project is what stops a per-project engine from ever seeing — and therefore + // claiming/executing in the wrong repo — another project's tasks on the shared + // embedded-PG cluster. `and(...)` drops undefined operands, so the scope + // collapses to just the live filter when the layer is project-agnostic. + const projectScope = taskProjectScope(layer); /* FNXC:TaskStoreReadsPerf 2026-07-11 (PR #1793 review): Push the board filters and pagination into SQL. The previous shape read the @@ -344,8 +371,8 @@ export async function readLiveTaskRows( ? sql`${schema.project.tasks.column} IS DISTINCT FROM ${options.excludeColumn}` : undefined; const liveFilter = options?.includeDeleted - ? columnScope - : (columnScope ? and(ACTIVE_TASK_FILTER, columnScope) : ACTIVE_TASK_FILTER); + ? and(projectScope, columnScope) + : and(ACTIVE_TASK_FILTER, projectScope, columnScope); const paginate = options?.limit !== undefined || (options?.offset ?? 0) > 0; // Mirrors the JS comparator: createdAt ASC, then the numeric suffix of the // task id ("FN-12" → 12; no trailing digits → 0). substring() returns NULL @@ -386,10 +413,11 @@ export async function readLiveTaskRows( * board count never includes tombstoned tasks (VAL-DATA-005). */ export async function countLiveTasks(layer: AsyncDataLayer): Promise { + // FNXC:MultiProjectIsolation 2026-07-10: scope live counts to the bound project. const rows = await layer.db .select({ count: sql`count(*)::int` }) .from(schema.project.tasks) - .where(ACTIVE_TASK_FILTER); + .where(and(ACTIVE_TASK_FILTER, taskProjectScope(layer))); return rows[0]?.count ?? 0; } @@ -412,11 +440,14 @@ export async function upsertTaskRowInTransaction( tx: DbTransaction, taskRecord: Record, context: TaskPersistSerializationContext, + projectId?: string, ): Promise { - const values = buildTaskInsertValues(taskRecord, context); + const values = buildTaskInsertValues(taskRecord, context, projectId); const updateValues: Record = {}; for (const [key, value] of Object.entries(values)) { - if (key === "id") continue; + // Never rewrite the primary key or the per-project partition key on update + // (FNXC:MultiProjectIsolation — project_id is stable for a task's lifetime). + if (key === "id" || key === "projectId") continue; updateValues[key] = value; } await tx diff --git a/packages/core/src/task-store/async-search.ts b/packages/core/src/task-store/async-search.ts index 5184a7593..5b7885438 100644 --- a/packages/core/src/task-store/async-search.ts +++ b/packages/core/src/task-store/async-search.ts @@ -69,11 +69,18 @@ export function sanitizeSearchTokens(query: string): string[] { * @param includeArchived Whether to include archived tasks in the results. * @returns The composed SQL predicate. */ -export function liveSearchPredicate(includeArchived: boolean): SQL { - if (includeArchived) { - return ACTIVE_TASK_FILTER; - } - return and(ACTIVE_TASK_FILTER, ne(schema.project.tasks.column, "archived")) as SQL; +export function liveSearchPredicate(includeArchived: boolean, projectId?: string): SQL { + // FNXC:MultiProjectIsolation 2026-07-10: + // Fold the per-project partition key into the shared search predicate so BOTH + // full-text (tsvector) and LIKE search paths are scoped to the bound project. + // This is load-bearing for the CREATE-time near-duplicate check, which calls + // scopedStore.searchTasks(): without it a task in project B is rejected as a + // duplicate of a same-titled task in project A on the shared embedded-PG table. + const projectScope = projectId ? eq(schema.project.tasks.projectId, projectId) : undefined; + const base = includeArchived + ? ACTIVE_TASK_FILTER + : and(ACTIVE_TASK_FILTER, ne(schema.project.tasks.column, "archived")); + return (projectScope ? and(base, projectScope) : base) as SQL; } /** @@ -139,7 +146,7 @@ export function buildLikeSearchPredicate(tokens: readonly string[]): SQL | undef export async function searchTasksLike( db: AsyncDataLayer["db"] | DbTransaction, query: string, - options?: { limit?: number; offset?: number; includeArchived?: boolean }, + options?: { limit?: number; offset?: number; includeArchived?: boolean; projectId?: string }, ): Promise[]> { const tokens = sanitizeSearchTokens(query); if (tokens.length === 0) return []; @@ -148,7 +155,7 @@ export async function searchTasksLike( const textPredicate = buildLikeSearchPredicate(tokens); if (!textPredicate) return []; - const conditions = [textPredicate, liveSearchPredicate(includeArchived)]; + const conditions = [textPredicate, liveSearchPredicate(includeArchived, options?.projectId)]; const baseQuery = db .select() @@ -170,7 +177,7 @@ export async function searchTasksLike( export async function countSearchTasksLike( db: AsyncDataLayer["db"] | DbTransaction, query: string, - options?: { includeArchived?: boolean }, + options?: { includeArchived?: boolean; projectId?: string }, ): Promise { const tokens = sanitizeSearchTokens(query); if (tokens.length === 0) return 0; @@ -179,7 +186,7 @@ export async function countSearchTasksLike( const textPredicate = buildLikeSearchPredicate(tokens); if (!textPredicate) return 0; - const conditions = [textPredicate, liveSearchPredicate(includeArchived)]; + const conditions = [textPredicate, liveSearchPredicate(includeArchived, options?.projectId)]; const rows = await db .select({ count: sql`count(*)::int` }) .from(schema.project.tasks) @@ -319,7 +326,7 @@ function buildTsqueryFragment(query: string): SQL | undefined { export async function searchTasksTsvector( db: AsyncDataLayer["db"] | DbTransaction, query: string, - options?: { limit?: number; offset?: number; includeArchived?: boolean }, + options?: { limit?: number; offset?: number; includeArchived?: boolean; projectId?: string }, ): Promise[]> { const tokens = sanitizeSearchTokens(query); if (tokens.length === 0) return []; @@ -334,7 +341,7 @@ export async function searchTasksTsvector( const includeArchived = options?.includeArchived ?? true; const conditions = [ sql`${schema.project.tasks.searchVector} @@ ${tsquery}`, - liveSearchPredicate(includeArchived), + liveSearchPredicate(includeArchived, options?.projectId), ]; const baseQuery = db @@ -360,7 +367,7 @@ export async function searchTasksTsvector( export async function countSearchTasksTsvector( db: AsyncDataLayer["db"] | DbTransaction, query: string, - options?: { includeArchived?: boolean }, + options?: { includeArchived?: boolean; projectId?: string }, ): Promise { const tokens = sanitizeSearchTokens(query); if (tokens.length === 0) return 0; @@ -372,7 +379,7 @@ export async function countSearchTasksTsvector( const includeArchived = options?.includeArchived ?? true; const conditions = [ sql`${schema.project.tasks.searchVector} @@ ${tsquery}`, - liveSearchPredicate(includeArchived), + liveSearchPredicate(includeArchived, options?.projectId), ]; const rows = await db .select({ count: sql`count(*)::int` }) diff --git a/packages/core/src/task-store/async-settings.ts b/packages/core/src/task-store/async-settings.ts index 785cbb69f..47d96c39d 100644 --- a/packages/core/src/task-store/async-settings.ts +++ b/packages/core/src/task-store/async-settings.ts @@ -23,7 +23,7 @@ * The sync `readConfig()`/`writeConfig()` remain the live path until U15. * These helpers are the PostgreSQL target the integration tests exercise. */ -import { eq, sql } from "drizzle-orm"; +import { eq, sql, type SQL } from "drizzle-orm"; import * as schema from "../postgres/schema/index.js"; import type { AsyncDataLayer } from "../postgres/data-layer.js"; @@ -40,9 +40,24 @@ export interface ProjectConfigRow { settings: Record | null; } -/** Sentinel config id (singleton row). */ +/** Sentinel config id (legacy singleton-row id; still written for column parity). */ export const CONFIG_ROW_ID = 1; +/** + * FNXC:MultiProjectIsolation 2026-07-11: + * Per-project scope predicate for the config row. Embedded-PG mode consolidated + * every project's config into the shared `project.config` table, so the row is + * now keyed on `project_id`. When the layer is bound to a project we scope by + * `project_id`; otherwise (single-project store, SQLite parity, project-agnostic + * reads) we fall back to the legacy `id = CONFIG_ROW_ID` singleton row so the + * pre-isolation behavior is preserved. + */ +function configScope(layer: Pick): SQL { + return layer.projectId + ? eq(schema.project.config.projectId, layer.projectId) + : eq(schema.project.config.id, CONFIG_ROW_ID); +} + /** * Read the project config row. Returns a default empty config when the row is * absent (mirrors the sync `readConfig()` fallback to `{ nextId: 1 }`). @@ -61,7 +76,7 @@ export async function readProjectConfig( settings: schema.project.config.settings, }) .from(schema.project.config) - .where(eq(schema.project.config.id, CONFIG_ROW_ID)); + .where(configScope(layer)); const row = rows[0]; if (!row) { return { nextId: 1, nextWorkflowStepId: 1, nextWorkflowDefinitionId: 1, settings: null }; @@ -84,7 +99,7 @@ export async function readProjectSettings( const rows = await layer.db .select({ settings: schema.project.config.settings }) .from(schema.project.config) - .where(eq(schema.project.config.id, CONFIG_ROW_ID)); + .where(configScope(layer)); const row = rows[0]; if (!row) { return null; @@ -127,7 +142,9 @@ export async function writeProjectConfig( .insert(schema.project.config) .values({ id: CONFIG_ROW_ID, - nextId: sql`COALESCE((SELECT next_id FROM ${schema.project.config} WHERE id = ${CONFIG_ROW_ID} LIMIT 1), 1)`, + // FNXC:MultiProjectIsolation 2026-07-11: key the row per-project. + projectId: layer.projectId ?? "", + nextId: sql`COALESCE((SELECT next_id FROM ${schema.project.config} WHERE ${configScope(layer)} LIMIT 1), 1)`, nextWorkflowStepId, nextWorkflowDefinitionId, settings, @@ -135,7 +152,7 @@ export async function writeProjectConfig( updatedAt: nowIso, }) .onConflictDoUpdate({ - target: schema.project.config.id, + target: schema.project.config.projectId, set: { nextWorkflowStepId, nextWorkflowDefinitionId, @@ -168,6 +185,8 @@ export async function patchProjectSettings( .insert(schema.project.config) .values({ id: CONFIG_ROW_ID, + // FNXC:MultiProjectIsolation 2026-07-11: key the row per-project. + projectId: layer.projectId ?? "", nextId: 1, nextWorkflowStepId: 1, settings: patch, @@ -175,7 +194,7 @@ export async function patchProjectSettings( updatedAt: nowIso, }) .onConflictDoUpdate({ - target: schema.project.config.id, + target: schema.project.config.projectId, set: { settings: sql`COALESCE(${schema.project.config.settings}, '{}'::jsonb) || (${patchJson}::jsonb)`, updatedAt: nowIso, diff --git a/packages/core/src/task-store/moves.ts b/packages/core/src/task-store/moves.ts index cfad88c19..68517829a 100644 --- a/packages/core/src/task-store/moves.ts +++ b/packages/core/src/task-store/moves.ts @@ -716,7 +716,9 @@ export async function moveTaskInternalImpl(store: TaskStore, id: string, toColum } // Upsert the task row (update column + all mutated fields). - await upsertTaskRowInTransaction(tx, task as unknown as Record, context); + // FNXC:MultiProjectIsolation 2026-07-10: pass the bound projectId (stamped + // on insert, preserved on update) so partitioning survives moves. + await upsertTaskRowInTransaction(tx, task as unknown as Record, context, layer.projectId); // U4 (flag-ON) parity with the SQLite branch below: write the // crash-safe transitionPending marker in the SAME transaction as the diff --git a/packages/core/src/task-store/reads.ts b/packages/core/src/task-store/reads.ts index 2e8cc8cf4..49cf62974 100644 --- a/packages/core/src/task-store/reads.ts +++ b/packages/core/src/task-store/reads.ts @@ -581,7 +581,7 @@ export async function listTasksModifiedSinceImpl(store: TaskStore, since: string let disableAgeStalenessHydration = false; if (store.backendMode) { - const { and, asc, gt, sql } = await import("drizzle-orm"); + const { and, asc, eq, gt, sql } = await import("drizzle-orm"); const schema = await import("../postgres/schema/index.js"); const conditions = [ sql`(${schema.project.tasks.deletedAt} IS NULL)`, @@ -591,6 +591,12 @@ export async function listTasksModifiedSinceImpl(store: TaskStore, since: string conditions.push(sql`${schema.project.tasks.column} != 'archived'`); } const layer = store.asyncLayer!; + // FNXC:MultiProjectIsolation 2026-07-10: scope the incremental-sync scan + // (backs the SSE watcher / modified-since polling) to the bound project so + // one project's dashboard never receives another project's task updates. + if (layer.projectId) { + conditions.push(eq(schema.project.tasks.projectId, layer.projectId)); + } const pgRows = await layer.db .select() .from(schema.project.tasks) @@ -775,12 +781,16 @@ export async function searchTasksImpl(store: TaskStore, query: string, options?: limit, offset, includeArchived, + // FNXC:MultiProjectIsolation 2026-07-10: scope search to the bound project + // (load-bearing for the CREATE-time near-duplicate check via searchTasks). + projectId: layer.projectId, }); if (pgRows.length === 0) { pgRows = await searchTasksLike(layer.db, trimmedQuery, { limit, offset, includeArchived, + projectId: layer.projectId, }); } const now = Date.now(); diff --git a/packages/core/src/task-store/remaining-ops-1.ts b/packages/core/src/task-store/remaining-ops-1.ts index c8a0e44ee..c56ed0b40 100644 --- a/packages/core/src/task-store/remaining-ops-1.ts +++ b/packages/core/src/task-store/remaining-ops-1.ts @@ -174,8 +174,9 @@ export async function atomicWriteTaskJsonWithAuditImpl(store: TaskStore, dir: st .where(eq(schema.project.tasks.id, id)); } } else { + // FNXC:MultiProjectIsolation 2026-07-10: preserve the bound projectId partition key. const context = store.createTaskPersistSerializationContext(task); - await upsertTaskRowInTransaction(tx, task as unknown as Record, context); + await upsertTaskRowInTransaction(tx, task as unknown as Record, context, layer.projectId); } if (auditInput) { await recordRunAuditEventWithinTransaction(tx, auditInput); diff --git a/packages/core/src/task-store/remaining-ops-4.ts b/packages/core/src/task-store/remaining-ops-4.ts index 645b113df..07b887736 100644 --- a/packages/core/src/task-store/remaining-ops-4.ts +++ b/packages/core/src/task-store/remaining-ops-4.ts @@ -127,9 +127,10 @@ export async function atomicWriteTaskJsonImpl2(store: TaskStore, dir: string, ta // Update-only path: never resurrect a soft-deleted row; a missing row // falls through to the legacy full upsert (matches sqlite's // upsertTaskWithFtsRecovery fallback for vanished rows). + // FNXC:MultiProjectIsolation 2026-07-10: preserve the bound projectId partition key. if (!pgRow) { const context = store.createTaskPersistSerializationContext(task); - await upsertTaskRowInTransaction(tx, task as unknown as Record, context); + await upsertTaskRowInTransaction(tx, task as unknown as Record, context, layer.projectId); } return; } diff --git a/packages/core/src/task-store/task-creation.ts b/packages/core/src/task-store/task-creation.ts index 2d9fdfbd7..8163f5e1b 100644 --- a/packages/core/src/task-store/task-creation.ts +++ b/packages/core/src/task-store/task-creation.ts @@ -346,7 +346,9 @@ export async function _createTaskInternalBackendImpl(store: TaskStore, input: Ta const context = store.createTaskPersistSerializationContext(task); try { await layer.transactionImmediate(async (tx) => { - await insertTaskRowInTransaction(tx, task as unknown as Record, context); + // FNXC:MultiProjectIsolation 2026-07-10: stamp the bound projectId so the + // new task row is attributed to (and later filtered under) this project. + await insertTaskRowInTransaction(tx, task as unknown as Record, context, layer.projectId); }); } catch (error) { if (isTaskIdConflictError(error)) { diff --git a/packages/engine/src/project-manager.ts b/packages/engine/src/project-manager.ts index fc98b7fab..cb04b97f5 100644 --- a/packages/engine/src/project-manager.ts +++ b/packages/engine/src/project-manager.ts @@ -115,6 +115,23 @@ export class ProjectManager extends EventEmitter { /** Mutable limit read by the shared semaphore's getter function. */ private currentGlobalLimit = 4; private globalLimitRefreshInterval: ReturnType; + /** + * FNXC:LiveGlobalConcurrency 2026-07-11: + * Listener that applies a live global-concurrency change to the shared + * semaphore's limit immediately, so a PUT /api/global-concurrency takes effect + * without waiting for the 30s refresh poll (or a process restart). This is the + * binding semaphore: it is injected into every InProcessRuntime (which only + * installs its OWN concurrency:changed listener when it has to create a local + * semaphore, i.e. NOT in the shared-semaphore path), so the subscription must + * live here. Mirrors project-engine-manager.ts. + */ + private concurrencyListener = (state: unknown): void => { + const s = state as { globalMaxConcurrent?: number }; + if (typeof s.globalMaxConcurrent === "number") { + this.currentGlobalLimit = s.globalMaxConcurrent; + projectManagerLog.log(`Global concurrency limit updated to ${this.currentGlobalLimit}`); + } + }; /** * @param centralCore - CentralCore reference for global coordination @@ -128,7 +145,13 @@ export class ProjectManager extends EventEmitter { // cross-project concurrency is enforced correctly. this.globalSemaphore = new AgentSemaphore(() => this.currentGlobalLimit); - // Refresh the global limit periodically + // Subscribe to live concurrency changes so limit updates take effect + // immediately (no restart / no 30s poll wait) on the shared semaphore. + if (typeof centralCore.on === "function") { + centralCore.on("concurrency:changed", this.concurrencyListener); + } + + // Refresh the global limit periodically (fallback reconciliation). this.refreshGlobalLimit(); this.globalLimitRefreshInterval = setInterval(() => this.refreshGlobalLimit(), 30000); this.globalLimitRefreshInterval.unref?.(); @@ -537,6 +560,9 @@ export class ProjectManager extends EventEmitter { await Promise.all(stopPromises); clearInterval(this.globalLimitRefreshInterval); + if (typeof this.centralCore.off === "function") { + this.centralCore.off("concurrency:changed", this.concurrencyListener); + } projectManagerLog.log("All project runtimes stopped"); this.removeAllListeners(); }