From 52facd1461ea575a0c37a5926d1cdcbfb862acf3 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 12 Aug 2026 07:43:29 -0700 Subject: [PATCH] FN-9000: scope runtime records to their projects Scope runtime data access to the owning project while preserving explicit unbound compatibility. - Thread optional project identifiers through project-table reads and mutations. - Isolate chat, approvals, artifacts, secrets, audit, branch groups, plugin analytics, and verification cache records. - Add PostgreSQL coverage and a patch changeset documenting multi-project isolation. Files changed: .../fn-9000-project-ownership-runtime-scope.md | 7 + docs/storage.md | 3 +- .../project-ownership-runtime-scope.pg.test.ts | 155 +++++++++++++++++++++ packages/core/src/agents/approval-request-store.ts | 20 ++- .../async-stores/async-approval-request-store.ts | 30 ++-- packages/core/src/async-stores/async-chat-store.ts | 105 +++++++++++--- .../core/src/async-stores/async-secrets-store.ts | 58 +++++--- packages/core/src/chat/chat-store.ts | 22 +-- .../src/plugins/plugin-activation-analytics.ts | 12 +- packages/core/src/secrets/secrets-store.ts | 12 +- packages/core/src/store.ts | 6 +- .../src/task-store/async/async-archive-lineage.ts | 16 ++- packages/core/src/task-store/async/async-audit.ts | 18 ++- .../src/task-store/async/async-branch-groups.ts | 50 ++++--- .../task-store/async/async-comments-attachments.ts | 35 +++-- .../core/src/task-store/branch-and-pr-entities.ts | 15 +- packages/core/src/task-store/branch-group-ops.ts | 2 +- packages/core/src/task-store/task-artifacts-ops.ts | 2 +- packages/core/src/task-store/task-id-integrity.ts | 2 +- packages/core/src/task-store/task-mutation-ops.ts | 14 +- .../core/src/task-store/workflow-definitions.ts | 7 + packages/dashboard/src/server.ts | 2 +- 22 files changed, 472 insertions(+), 121 deletions(-) Fusion-Task-Id: FN-9000 Fusion-Task-Lineage: 9842e9e9-ec50-4459-8534-dfdaa2cdf35e Co-authored-by: Fusion (runfusion.ai) --- ...fn-9000-project-ownership-runtime-scope.md | 7 + docs/storage.md | 3 +- ...project-ownership-runtime-scope.pg.test.ts | 155 ++++++++++++++++++ .../core/src/agents/approval-request-store.ts | 20 ++- .../async-approval-request-store.ts | 30 ++-- .../core/src/async-stores/async-chat-store.ts | 105 +++++++++--- .../src/async-stores/async-secrets-store.ts | 58 ++++--- packages/core/src/chat/chat-store.ts | 22 +-- .../plugins/plugin-activation-analytics.ts | 12 +- packages/core/src/secrets/secrets-store.ts | 12 +- packages/core/src/store.ts | 6 +- .../task-store/async/async-archive-lineage.ts | 16 +- .../core/src/task-store/async/async-audit.ts | 18 +- .../task-store/async/async-branch-groups.ts | 50 ++++-- .../async/async-comments-attachments.ts | 35 +++- .../src/task-store/branch-and-pr-entities.ts | 15 +- .../core/src/task-store/branch-group-ops.ts | 2 +- .../core/src/task-store/task-artifacts-ops.ts | 2 +- .../core/src/task-store/task-id-integrity.ts | 2 +- .../core/src/task-store/task-mutation-ops.ts | 14 +- .../src/task-store/workflow-definitions.ts | 7 + packages/dashboard/src/server.ts | 2 +- 22 files changed, 472 insertions(+), 121 deletions(-) create mode 100644 .changeset/fn-9000-project-ownership-runtime-scope.md create mode 100644 packages/core/src/__tests__/postgres/project-ownership-runtime-scope.pg.test.ts diff --git a/.changeset/fn-9000-project-ownership-runtime-scope.md b/.changeset/fn-9000-project-ownership-runtime-scope.md new file mode 100644 index 0000000000..e561abb3d7 --- /dev/null +++ b/.changeset/fn-9000-project-ownership-runtime-scope.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Keep verification-cache results and project records isolated per project. +category: fix +dev: Runtime predicates now scope eight project tables; verification-cache results are no longer shared across projects. diff --git a/docs/storage.md b/docs/storage.md index a52ab5f521..9f713ba15f 100644 --- a/docs/storage.md +++ b/docs/storage.md @@ -38,7 +38,8 @@ See the [2026-07-14 PostgreSQL runtime cutover review](./postgres-migration-revi - `project.agent_ratings` is project-owned with composite `(project_id, id)` identity, allowing the same rating id in separate projects without cross-project reads or deletes. The dynamic `0006_project_ownership.sql` migration reconciles the physical table; `0055_fn_8988_agent_ratings_project_partition.sql` repeats that guarantee idempotently for historical drift. Bound `addRating`, `getRatings`, and `deleteRating` apply the project ownership partition, while unbound compatibility layers retain trigger-stamped writes and unscoped reads/deletes. - FN-8997 audited `workflow_steps`, `chat_room_members`, and `chat_room_messages`: their Drizzle declarations now model 0006's `project_id` and composite keys, and bound workflow/chat helpers scope reads and mutations on that partition. Chat isolation requires both membership/message predicates **and** the parent `chat_rooms.project_id` predicate; either leg alone can resolve a foreign row when room IDs collide. `plugins` remains an intentionally unmodeled compatibility table because it has no runtime Drizzle path. Migration `0056_fn_8997_project_ownership_declaration_drift.sql` is idempotent and adds only partition-prefixed predicate indexes; it does not rewrite healthy 0006 ownership columns or keys. - `project.workflows` has project-local `(project_id, id)` identity. Bound definition reads, updates, deletes, companion workflow settings/prompt-override deletes, and analytics name prefetches use `projectScopeFor`; blank/unbound layers deliberately retain cross-project compatibility reads. The per-project workflow-id counter intentionally scans occupancy across every partition before allocation, because burning a colliding ID is safer than reusing a legacy or stale-counter ID held elsewhere. -- FN-9002 makes the `0006_project_ownership.sql` partition expressible for `artifacts`, `secrets`, `branch_groups`, `plugin_activations`, `chat_messages`, `run_audit_events`, `verification_cache`, and `approval_requests`: each declaration retains the database default and models its project-leading identity (plus the artifacts task FK and secrets/branch-name unique keys). Their runtime owners are respectively async attachments/archive/task mutation; async secrets; async branch groups; activation analytics/events; async chat; async audit/merge coordination/data layer; workflow definitions; and async approval requests. This is declaration-only: existing inserts keep omitting `projectId`, public record mappers keep omitting it, and all project predicates are deliberately deferred to FN-9000. +- FN-9002 makes the `0006_project_ownership.sql` partition expressible for `artifacts`, `secrets`, `branch_groups`, `plugin_activations`, `chat_messages`, `run_audit_events`, `verification_cache`, and `approval_requests`: each declaration retains the database default and models its project-leading identity (plus the artifacts task FK and secrets/branch-name unique keys). +- FN-9000 scopes every load-bearing runtime Drizzle read, update, and delete for those eight tables with the bound `projectId`; blank/unbound layers intentionally retain cross-project compatibility reads. Chat message operations scope both `chat_messages` and their parent `chat_sessions` row. The `central.secrets_global` dispatch remains global, while `project.plugins` has no runtime Drizzle path and pre-cutover SQLite compatibility paths remain unchanged. Verification-cache entries now remain inside their owning project rather than being shared across projects. - Startup and Batch 1 self-healing expire locks when their lease elapsed or the owner task is terminal/missing. They never move a task or alter scheduler, worktree, semaphore, or verification state. Run-audit events are `symbol-lock:acquired`, `symbol-lock:acquire-conflict`, `symbol-lock:renewed`, `symbol-lock:released`, `symbol-lock:reconcile-stale`, and deduplicated `symbol-lock:reconcile-stale-no-action`; metadata uses only counts/outcomes and normalized opaque keys. - FN-8405 adds `Task.declaredSymbols` as the durable, normalized task declaration source. `## Declared Symbols` in PROMPT.md is parsed only on create/update writes: an absent key may hydrate from the prompt, while a present `undefined`, `null` (update), or `[]` clears and suppresses hydration; a non-empty explicit array wins. Store resolution (`resolveTaskSymbols` and `resolveTaskSymbolsForWorkItem({ taskId })`) reads only the durable field, and slim projections plus archive/restore retain it. Scheduler admission remains a separate FN-8306 consumer; File Scope is never treated as a symbol source. diff --git a/packages/core/src/__tests__/postgres/project-ownership-runtime-scope.pg.test.ts b/packages/core/src/__tests__/postgres/project-ownership-runtime-scope.pg.test.ts new file mode 100644 index 0000000000..f2e3ff0151 --- /dev/null +++ b/packages/core/src/__tests__/postgres/project-ownership-runtime-scope.pg.test.ts @@ -0,0 +1,155 @@ +import { afterAll, afterEach, beforeAll, beforeEach, expect, it } from "vitest"; +import { and, eq } from "drizzle-orm"; +import { + createSharedPgTaskStoreTestHarness, + pgDescribe, + type SharedPgTaskStoreHarness, +} from "../../__test-utils__/pg-test-harness.js"; +import type { AsyncDataLayer } from "../../postgres/data-layer.js"; +import * as schema from "../../postgres/schema/index.js"; +import { + decideApprovalRequest, + getApprovalRequest, + listApprovalRequests, +} from "../../async-stores/async-approval-request-store.js"; +import { getChatMessage, getChatMessages } from "../../async-stores/async-chat-store.js"; +import { getSecretMetadata, listSecrets } from "../../async-stores/async-secrets-store.js"; +import { aggregatePluginActivations } from "../../plugins/plugin-activation-analytics.js"; +import { getArtifact, listArtifacts, updateArtifactRow } from "../../task-store/async/async-comments-attachments.js"; +import { queryRunAuditEvents } from "../../task-store/async/async-audit.js"; +import { getBranchGroup, listBranchGroups, updateBranchGroup } from "../../task-store/async/async-branch-groups.js"; +import { TaskStore } from "../../store.js"; + +pgDescribe("project ownership runtime scope", () => { + const h: SharedPgTaskStoreHarness = createSharedPgTaskStoreTestHarness({ + prefix: "fusion_project_ownership_runtime_scope", + }); + const now = "2026-08-12T14:15:00.000Z"; + const bind = (projectId: string): AsyncDataLayer => ({ ...h.layer(), projectId }); + + beforeAll(h.beforeAll); + afterAll(h.afterAll); + beforeEach(h.beforeEach); + afterEach(h.afterEach); + + it("isolates all drifted project tables for bound callers while preserving unbound reads", async () => { + /* + FNXC:MultiProjectIsolation 2026-08-12-14:15: + FN-9000 requires QueryHandle helpers to accept a trailing optional projectId while layer helpers read layer.projectId. Owner-connected PostgreSQL bypasses RLS, so this two-project fixture makes every runtime predicate load-bearing: bound reads and mutations must keep the colliding foreign row untouched, while empty-string and undefined callers retain deliberate unbound compatibility. + */ + const a = bind("ownership-a"); + const b = bind("ownership-b"); + const unbound = { ...h.layer(), projectId: undefined }; + const empty = { ...h.layer(), projectId: "" }; + + // Seed the physical composite-key shape directly so every public read sees a colliding natural identity. + await h.adminDb().insert(schema.project.artifacts).values([ + { projectId: a.projectId!, id: "art-1", type: "document", title: "Artifact A", authorId: "owner", authorType: "agent", createdAt: now, updatedAt: now }, + { projectId: b.projectId!, id: "art-1", type: "document", title: "Artifact B", authorId: "owner", authorType: "agent", createdAt: now, updatedAt: now }, + ]); + await h.adminDb().insert(schema.project.secrets).values([ + { projectId: a.projectId!, id: "sec-1", key: "SHARED_SECRET", valueCiphertext: Buffer.from([1]), nonce: Buffer.from([1]), createdAt: now, updatedAt: now }, + { projectId: b.projectId!, id: "sec-1", key: "SHARED_SECRET", valueCiphertext: Buffer.from([2]), nonce: Buffer.from([2]), createdAt: now, updatedAt: now }, + ]); + await h.adminDb().insert(schema.project.branchGroups).values([ + { projectId: a.projectId!, id: "bg-1", sourceType: "planning", sourceId: "shared-source", branchName: "feature/shared", createdAt: 1, updatedAt: 1 }, + { projectId: b.projectId!, id: "bg-1", sourceType: "planning", sourceId: "shared-source", branchName: "feature/shared", createdAt: 1, updatedAt: 1 }, + ]); + await h.adminDb().insert(schema.project.pluginActivations).values([ + { projectId: a.projectId!, pluginId: "plugin-shared", source: "test", activatedAt: now }, + { projectId: b.projectId!, pluginId: "plugin-shared", source: "test", activatedAt: now }, + ]); + /* + FNXC:ChatProjectIsolation 2026-08-12-14:15: + Keep the parent only in A: a message predicate alone would still expose B's + colliding message, while the mandatory parent-session predicate rejects it. + */ + await h.adminDb().insert(schema.project.chatSessions).values({ + id: "sess-1", agentId: "agent", projectId: a.projectId!, createdAt: now, updatedAt: now, + }); + await h.adminDb().insert(schema.project.chatMessages).values([ + { projectId: a.projectId!, id: "msg-1", sessionId: "sess-1", role: "user", content: "Message A", createdAt: now }, + { projectId: b.projectId!, id: "msg-1", sessionId: "sess-1", role: "user", content: "Message B", createdAt: now }, + ]); + await h.adminDb().insert(schema.project.runAuditEvents).values([ + { projectId: a.projectId!, id: "rae-older", timestamp: "2026-08-12T14:13:00.000Z", agentId: "agent", runId: "run-shared", domain: "test", mutationType: "shared", target: "A older" }, + { projectId: a.projectId!, id: "rae-1", timestamp: "2026-08-12T14:14:00.000Z", agentId: "agent", runId: "run-shared", domain: "test", mutationType: "shared", target: "A newest" }, + { projectId: b.projectId!, id: "rae-1", timestamp: "2026-08-12T14:16:00.000Z", agentId: "agent", runId: "run-shared", domain: "test", mutationType: "shared", target: "B newest" }, + ]); + await h.adminDb().insert(schema.project.verificationCache).values([ + { projectId: a.projectId!, treeSha: "tree-shared", testCommand: "test", buildCommand: "build", recordedAt: now, taskId: "cache-a" }, + { projectId: b.projectId!, treeSha: "tree-shared", testCommand: "test", buildCommand: "build", recordedAt: now, taskId: "cache-b" }, + ]); + await h.adminDb().insert(schema.project.approvalRequests).values([ + { projectId: a.projectId!, id: "ar-1", status: "pending", requesterActorId: "agent", requesterActorType: "agent", requesterActorName: "Agent A", targetActionCategory: "test", targetActionOperation: "shared", targetActionSummary: "A", targetResourceType: "task", targetResourceId: "shared", requestedAt: now, createdAt: now, updatedAt: now }, + { projectId: b.projectId!, id: "ar-1", status: "pending", requesterActorId: "agent", requesterActorType: "agent", requesterActorName: "Agent B", targetActionCategory: "test", targetActionOperation: "shared", targetActionSummary: "B", targetResourceType: "task", targetResourceId: "shared", requestedAt: now, createdAt: now, updatedAt: now }, + ]); + + // Cast only bridges Step 1's pre-threading signatures; the runtime calls intentionally carry the future trailing projectId. + const artifact = getArtifact as unknown as (db: typeof a.db, id: string, projectId?: string) => ReturnType; + const updateArtifact = updateArtifactRow as unknown as (layer: AsyncDataLayer, id: string, patch: { title?: string }, projectId?: string) => ReturnType; + const artifacts = listArtifacts as unknown as (db: typeof a.db, options: { authorId: string }, projectId?: string) => ReturnType; + const secret = getSecretMetadata as unknown as (db: typeof a.db, id: string, scope: "project", projectId?: string) => ReturnType; + const secrets = listSecrets as unknown as (db: typeof a.db, scope: "project", projectId?: string) => ReturnType; + const branch = getBranchGroup as unknown as (db: typeof a.db, id: string, projectId?: string) => ReturnType; + const branches = listBranchGroups as unknown as (db: typeof a.db, options: undefined, projectId?: string) => ReturnType; + const updateBranch = updateBranchGroup as unknown as (db: typeof a.db, id: string, patch: { status: "finalized" }, projectId?: string) => ReturnType; + const chatMessage = getChatMessage as unknown as (db: typeof a.db, id: string, projectId?: string) => ReturnType; + const chatMessages = getChatMessages as unknown as (db: typeof a.db, sessionId: string, filter: undefined, projectId?: string) => ReturnType; + const audits = queryRunAuditEvents as unknown as (db: typeof a.db, filter: { runId: string; limit?: number }, projectId?: string) => ReturnType; + const approvals = getApprovalRequest as unknown as (db: typeof a.db, id: string, projectId?: string) => ReturnType; + const approvalList = listApprovalRequests as unknown as (db: typeof a.db, input: object, projectId?: string) => ReturnType; + + expect((await artifact(a.db, "art-1", a.projectId))?.title).toBe("Artifact A"); + await updateArtifact(a, "art-1", { title: "Artifact A updated" }, a.projectId); + expect((await h.adminDb().select({ title: schema.project.artifacts.title }).from(schema.project.artifacts).where(and(eq(schema.project.artifacts.projectId, b.projectId!), eq(schema.project.artifacts.id, "art-1"))))[0]?.title).toBe("Artifact B"); + expect((await artifacts(a.db, { authorId: "owner" }, a.projectId)).map((row) => row.id)).toEqual(["art-1"]); + expect((await artifacts(unbound.db, { authorId: "owner" }, unbound.projectId)).map((row) => row.id)).toHaveLength(2); + + expect((await secret(a.db, "sec-1", "project", a.projectId))?.key).toBe("SHARED_SECRET"); + expect((await secrets(a.db, "project", a.projectId)).map((row) => row.id)).toEqual(["sec-1"]); + expect((await secrets(empty.db, "project", empty.projectId)).map((row) => row.id)).toHaveLength(2); + + expect((await branch(a.db, "bg-1", a.projectId))?.branchName).toBe("feature/shared"); + await updateBranch(a.db, "bg-1", { status: "finalized" }, a.projectId); + expect((await h.adminDb().select({ status: schema.project.branchGroups.status }).from(schema.project.branchGroups).where(and(eq(schema.project.branchGroups.projectId, b.projectId!), eq(schema.project.branchGroups.id, "bg-1"))))[0]?.status).toBe("open"); + expect((await branches(a.db, undefined, a.projectId)).map((row) => row.id)).toEqual(["bg-1"]); + expect((await branches(unbound.db, undefined, unbound.projectId)).map((row) => row.id)).toHaveLength(2); + + expect((await chatMessage(a.db, "msg-1", a.projectId))?.content).toBe("Message A"); + expect((await chatMessages(a.db, "sess-1", undefined, a.projectId)).map((row) => row.content)).toEqual(["Message A"]); + expect((await chatMessages(empty.db, "sess-1", undefined, empty.projectId)).map((row) => row.content)).toEqual(["Message A", "Message B"]); + + expect((await audits(a.db, { runId: "run-shared", limit: 1 }, a.projectId)).map((row) => row.target)).toEqual(["A newest"]); + expect((await audits(unbound.db, { runId: "run-shared" }, unbound.projectId)).map((row) => row.target)).toHaveLength(3); + + expect((await aggregatePluginActivations(a)).activations).toBe(1); + expect((await aggregatePluginActivations(bind("ownership-empty"))).unavailable).toBe(true); + expect((await aggregatePluginActivations(unbound)).activations).toBe(2); + + expect((await approvals(a.db, "ar-1", a.projectId))?.requester.actorName).toBe("Agent A"); + await decideApprovalRequest(a, "ar-1", "approved", { actor: { actorId: "operator", actorType: "user", actorName: "Operator" } }); + expect((await approvals(b.db, "ar-1", b.projectId))?.status).toBe("pending"); + expect((await approvalList(a.db, {}, a.projectId)).map((row) => row.id)).toEqual(["ar-1"]); + expect((await approvalList(unbound.db, {}, unbound.projectId)).map((row) => row.id)).toHaveLength(2); + + const storeA = new TaskStore(h.rootDir(), undefined, { asyncLayer: a }); + const storeB = new TaskStore(h.rootDir(), undefined, { asyncLayer: b }); + /* + FNXC:BranchGroupProjectIsolation 2026-08-12-14:30: + TaskStore wrappers are the load-bearing production path: helper-only coverage would not catch a missing layer.projectId argument at this boundary. + */ + expect((await storeA.getBranchGroup("bg-1"))?.sourceId).toBe("shared-source"); + expect((await storeA.getBranchGroupBySource("planning", "shared-source"))?.id).toBe("bg-1"); + expect((await storeA.getBranchGroupByBranchName("feature/shared"))?.id).toBe("bg-1"); + expect((await storeA.listBranchGroups()).map((row) => row.id)).toEqual(["bg-1"]); + await storeA.updateBranchGroup("bg-1", { branchName: "feature/a-runtime" }); + expect((await storeB.getBranchGroup("bg-1"))?.branchName).toBe("feature/shared"); + expect((await storeA.ensureBranchGroupForSource("planning", "shared-source", { branchName: "feature/a-runtime" })).id).toBe("bg-1"); + expect((await storeA.getVerificationCacheHit("tree-shared", "test", "build"))?.taskId).toBe("cache-a"); + expect((await storeB.getVerificationCacheHit("tree-shared", "test", "build"))?.taskId).toBe("cache-b"); + expect((await storeB.getVerificationCacheHit("tree-shared", "test", "build"))?.taskId).toBe("cache-b"); + expect(await h.adminDb().select({ taskId: schema.project.verificationCache.taskId }).from(schema.project.verificationCache).where(eq(schema.project.verificationCache.treeSha, "tree-shared"))).toHaveLength(2); + + }); +}); diff --git a/packages/core/src/agents/approval-request-store.ts b/packages/core/src/agents/approval-request-store.ts index 30b87b5000..2b2c06ca23 100644 --- a/packages/core/src/agents/approval-request-store.ts +++ b/packages/core/src/agents/approval-request-store.ts @@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto"; import { count, eq, desc, and } from "drizzle-orm"; import type { Database } from "../db/db.js"; import { fromJson } from "../db/db.js"; -import type { AsyncDataLayer } from "../postgres/data-layer.js"; +import { projectScopeFor, type AsyncDataLayer } from "../postgres/data-layer.js"; import * as asyncApprovalRequestStore from "../async-stores/async-approval-request-store.js"; import * as schema from "../postgres/schema/index.js"; import { appendAgentActivityEvent } from "../task-store/async/async-agent-activity.js"; @@ -204,11 +204,11 @@ export class ApprovalRequestStore { } async get(id: string): Promise { - return asyncApprovalRequestStore.getApprovalRequest(this.asyncLayer!.db, id); + return asyncApprovalRequestStore.getApprovalRequest(this.asyncLayer!.db, id, this.asyncLayer!.projectId); } async list(input: ApprovalRequestListInput = {}): Promise { - return asyncApprovalRequestStore.listApprovalRequests(this.asyncLayer!.db, input); + return asyncApprovalRequestStore.listApprovalRequests(this.asyncLayer!.db, input, this.asyncLayer!.projectId); } async getPendingCountsByActor(): Promise> { @@ -219,7 +219,7 @@ export class ApprovalRequestStore { requestCount: count(), }) .from(table) - .where(eq(table.status, "pending")) + .where(and(eq(table.status, "pending"), projectScopeFor(table.projectId, this.asyncLayer!.projectId))) .groupBy(table.requesterActorId); return new Map(rows.map((row) => [row.actorId, Number(row.requestCount)])); } @@ -231,7 +231,15 @@ export class ApprovalRequestStore { */ if (this.backendMode) { const table = schema.project.approvalRequests; - const conditions = [eq(table.requesterActorId, input.requesterActorId)]; + /* + FNXC:ApprovalProjectIsolation 2026-08-12-14:15: + Approval request IDs and dedupe keys can repeat in another project after + the composite-key migration, so every runtime lookup stays on this layer's project. + */ + const conditions = [ + eq(table.requesterActorId, input.requesterActorId), + projectScopeFor(table.projectId, this.asyncLayer!.projectId), + ]; if (input.taskId !== undefined) { conditions.push(eq(table.taskId, input.taskId)); } @@ -281,6 +289,6 @@ export class ApprovalRequestStore { } async getAuditHistory(requestId: string): Promise { - return asyncApprovalRequestStore.getApprovalAuditHistory(this.asyncLayer!.db, requestId); + return asyncApprovalRequestStore.getApprovalAuditHistory(this.asyncLayer!.db, requestId, this.asyncLayer!.projectId); } } diff --git a/packages/core/src/async-stores/async-approval-request-store.ts b/packages/core/src/async-stores/async-approval-request-store.ts index 6ac70a3d74..323a0b8cd0 100644 --- a/packages/core/src/async-stores/async-approval-request-store.ts +++ b/packages/core/src/async-stores/async-approval-request-store.ts @@ -20,7 +20,7 @@ */ import { and, desc, eq, sql } from "drizzle-orm"; import * as schema from "../postgres/schema/index.js"; -import type { AsyncDataLayer, DbTransaction } from "../postgres/data-layer.js"; +import { projectScopeFor, type AsyncDataLayer, type DbTransaction } from "../postgres/data-layer.js"; // FNXC:ApprovalLifecycleSecurity 2026-07-26-12:25: import { isApprovalRequestExpired } from "../types/agents/agents.js"; import { @@ -182,6 +182,7 @@ export async function createApprovalRequest( }; await layer.transactionImmediate(async (tx) => { await tx.insert(schema.project.approvalRequests).values({ + ...(layer.projectId?.trim() ? { projectId: layer.projectId } : {}), id: request.id, status: request.status, requesterActorId: request.requester.actorId, @@ -207,16 +208,19 @@ export async function createApprovalRequest( } /** - * Get a single approval request by id. + * FNXC:ApprovalProjectIsolation 2026-08-12-14:15: + * approval_requests now has a composite project/id identity, so id-only reads + * and the guarded decision updates must constrain the bound runtime project. */ export async function getApprovalRequest( handle: QueryHandle, id: string, + projectId?: string, ): Promise { const rows = await handle .select() .from(schema.project.approvalRequests) - .where(eq(schema.project.approvalRequests.id, id)); + .where(and(eq(schema.project.approvalRequests.id, id), projectScopeFor(schema.project.approvalRequests.projectId, projectId))); return rows[0] ? rowToRequest(rows[0] as ApprovalRequestRow) : null; } @@ -227,8 +231,9 @@ export async function getApprovalRequest( export async function listApprovalRequests( handle: QueryHandle, input: ApprovalRequestListInput = {}, + projectId?: string, ): Promise { - const conditions: ReturnType[] = []; + const conditions = [projectScopeFor(schema.project.approvalRequests.projectId, projectId)]; if (input.status) conditions.push(eq(schema.project.approvalRequests.status, input.status)); if (input.requesterActorId) conditions.push(eq(schema.project.approvalRequests.requesterActorId, input.requesterActorId)); if (input.taskId) conditions.push(eq(schema.project.approvalRequests.taskId, input.taskId)); @@ -266,7 +271,7 @@ export async function decideApprovalRequest( ): Promise { const now = new Date().toISOString(); return layer.transactionImmediate(async (tx) => { - const existing = await getApprovalRequest(tx, requestId); + const existing = await getApprovalRequest(tx, requestId, layer.projectId); if (!existing) throw new Error(`Approval request ${requestId} not found`); if (!isValidApprovalRequestTransition(existing.status, status)) { throw new Error(`Invalid approval request transition: ${existing.status} -> ${status}`); @@ -281,17 +286,18 @@ export async function decideApprovalRequest( and( eq(schema.project.approvalRequests.id, requestId), eq(schema.project.approvalRequests.status, existing.status), + projectScopeFor(schema.project.approvalRequests.projectId, layer.projectId), ), ) .returning({ id: schema.project.approvalRequests.id }); if (updatedRows.length === 0) { - const raced = await getApprovalRequest(tx, requestId); + const raced = await getApprovalRequest(tx, requestId, layer.projectId); throw new Error( `Invalid approval request transition: ${raced?.status ?? existing.status} -> ${status}`, ); } await appendAuditEvent(tx, layer.projectId ?? "", requestId, status, input.actor, now, input.note); - return (await getApprovalRequest(tx, requestId))!; + return (await getApprovalRequest(tx, requestId, layer.projectId))!; }); } @@ -314,7 +320,7 @@ export async function markApprovalRequestCompleted( ): Promise { const now = new Date().toISOString(); return layer.transactionImmediate(async (tx) => { - const existing = await getApprovalRequest(tx, requestId); + const existing = await getApprovalRequest(tx, requestId, layer.projectId); if (!existing) throw new Error(`Approval request ${requestId} not found`); if (!isValidApprovalRequestTransition(existing.status, "completed")) { throw new Error(`Invalid approval request transition: ${existing.status} -> completed`); @@ -335,17 +341,18 @@ export async function markApprovalRequestCompleted( and( eq(schema.project.approvalRequests.id, requestId), eq(schema.project.approvalRequests.status, existing.status), + projectScopeFor(schema.project.approvalRequests.projectId, layer.projectId), ), ) .returning({ id: schema.project.approvalRequests.id }); if (updatedRows.length === 0) { - const raced = await getApprovalRequest(tx, requestId); + const raced = await getApprovalRequest(tx, requestId, layer.projectId); throw new Error( `Invalid approval request transition: ${raced?.status ?? existing.status} -> completed`, ); } await appendAuditEvent(tx, layer.projectId ?? "", requestId, "completed", input.actor, now, input.note); - return (await getApprovalRequest(tx, requestId))!; + return (await getApprovalRequest(tx, requestId, layer.projectId))!; }); } @@ -355,11 +362,12 @@ export async function markApprovalRequestCompleted( export async function getApprovalAuditHistory( handle: QueryHandle, requestId: string, + projectId?: string, ): Promise { const rows = await handle .select() .from(schema.project.approvalRequestAuditEvents) - .where(eq(schema.project.approvalRequestAuditEvents.requestId, requestId)) + .where(and(eq(schema.project.approvalRequestAuditEvents.requestId, requestId), projectScopeFor(schema.project.approvalRequestAuditEvents.projectId, projectId))) .orderBy( sql`${schema.project.approvalRequestAuditEvents.createdAt} ASC, ${schema.project.approvalRequestAuditEvents.id} ASC`, ); diff --git a/packages/core/src/async-stores/async-chat-store.ts b/packages/core/src/async-stores/async-chat-store.ts index e2eba1a306..647f8682d3 100644 --- a/packages/core/src/async-stores/async-chat-store.ts +++ b/packages/core/src/async-stores/async-chat-store.ts @@ -17,7 +17,7 @@ * consume. */ import { randomUUID } from "node:crypto"; -import { and, asc, desc, eq, gt, ilike, inArray, isNull, lte, ne, or as orFn, sql as drizzleSql } from "drizzle-orm"; +import { and, asc, desc, eq, exists, gt, ilike, inArray, isNull, lte, ne, or as orFn, sql as drizzleSql } from "drizzle-orm"; import * as schema from "../postgres/schema/index.js"; import { projectScopeFor, type AsyncDataLayer, type DbTransaction } from "../postgres/data-layer.js"; import { sanitizeTextValue, sanitizeJsonbValue } from "../postgres/nul-sanitize.js"; @@ -81,6 +81,27 @@ function rowToMessage(row: Record): ChatMessage { }; } +/* +FNXC:ChatProjectIsolation 2026-08-12-14:16: +Messages and their parent sessions must resolve within the same bound partition. +A message-only predicate is insufficient when session IDs collide across projects; +unbound handles deliberately keep their existing cross-project behavior. +*/ +function chatMessageProjectConditions(handle: QueryHandle, projectId?: string) { + const messageScope = projectScopeFor(schema.project.chatMessages.projectId, projectId); + const sessionScope = projectScopeFor(schema.project.chatSessions.projectId, projectId); + if (!messageScope || !sessionScope) return []; + return [ + messageScope, + exists(handle.select({ id: schema.project.chatSessions.id }) + .from(schema.project.chatSessions) + .where(and( + eq(schema.project.chatSessions.id, schema.project.chatMessages.sessionId), + sessionScope, + ))), + ]; +} + function rowToRoom(row: Record): ChatRoom { return { id: row.id as string, @@ -311,6 +332,7 @@ export async function replaceChatSessionTags(layer: AsyncDataLayer, sessionId: s export async function addChatMessage( handle: QueryHandle, message: ChatMessage, + projectId?: string, ): Promise { // FNXC:PostgresMigrationNulSanitize 2026-07-20: agent/tool output persisted // here can contain a raw NUL byte (e.g. piped-through Windows CLI dumps), @@ -341,18 +363,28 @@ export async function addChatMessage( await handle .update(schema.project.chatSessions) .set({ updatedAt: sanitized.createdAt }) - .where(eq(schema.project.chatSessions.id, sanitized.sessionId)); + .where(and( + eq(schema.project.chatSessions.id, sanitized.sessionId), + projectScopeFor(schema.project.chatSessions.projectId, projectId), + )); return sanitized; } /** * Get a chat message by id. */ -export async function getChatMessage(handle: QueryHandle, id: string): Promise { +export async function getChatMessage( + handle: QueryHandle, + id: string, + projectId?: string, +): Promise { const rows = await handle .select() .from(schema.project.chatMessages) - .where(eq(schema.project.chatMessages.id, id)); + .where(and( + eq(schema.project.chatMessages.id, id), + ...chatMessageProjectConditions(handle, projectId), + )); return rows[0] ? rowToMessage(rows[0]) : undefined; } @@ -363,8 +395,12 @@ export async function getChatMessages( handle: QueryHandle, sessionId: string, filter?: { limit?: number; offset?: number; before?: string; order?: "asc" | "desc" }, + projectId?: string, ): Promise { - const conditions: ReturnType[] = [eq(schema.project.chatMessages.sessionId, sessionId)]; + const conditions = [ + eq(schema.project.chatMessages.sessionId, sessionId), + ...chatMessageProjectConditions(handle, projectId), + ]; if (filter?.before) { conditions.push(lte(schema.project.chatMessages.createdAt, filter.before)); } @@ -388,12 +424,16 @@ export async function getChatMessages( export async function getLastMessageForSessions( handle: QueryHandle, sessionIds: string[], + projectId?: string, ): Promise> { if (sessionIds.length === 0) return new Map(); const rows = await handle .select() .from(schema.project.chatMessages) - .where(inArray(schema.project.chatMessages.sessionId, sessionIds)) + .where(and( + inArray(schema.project.chatMessages.sessionId, sessionIds), + ...chatMessageProjectConditions(handle, projectId), + )) .orderBy( desc(schema.project.chatMessages.createdAt), desc(schema.project.chatMessages.id), @@ -812,8 +852,9 @@ export async function addChatMessageAttachment( sessionId: string, messageId: string, attachment: ChatAttachment, + projectId?: string, ): Promise { - const message = await getChatMessage(handle, messageId); + const message = await getChatMessage(handle, messageId, projectId); if (!message || message.sessionId !== sessionId) { throw new Error(`Message ${messageId} not found in session ${sessionId}`); } @@ -824,8 +865,11 @@ export async function addChatMessageAttachment( await handle .update(schema.project.chatMessages) .set({ attachments: updatedAttachments }) - .where(eq(schema.project.chatMessages.id, messageId)); - const updated = await getChatMessage(handle, messageId); + .where(and( + eq(schema.project.chatMessages.id, messageId), + ...chatMessageProjectConditions(handle, projectId), + )); + const updated = await getChatMessage(handle, messageId, projectId); if (!updated) throw new Error(`Failed to update message ${messageId}`); return updated; } @@ -838,14 +882,21 @@ export async function addChatMessageAttachment( export async function deleteChatMessage( handle: QueryHandle, id: string, + projectId?: string, ): Promise { - const existing = await getChatMessage(handle, id); + const existing = await getChatMessage(handle, id, projectId); if (!existing) return false; - await handle.delete(schema.project.chatMessages).where(eq(schema.project.chatMessages.id, id)); + await handle.delete(schema.project.chatMessages).where(and( + eq(schema.project.chatMessages.id, id), + ...chatMessageProjectConditions(handle, projectId), + )); await handle .update(schema.project.chatSessions) .set({ updatedAt: new Date().toISOString() }) - .where(eq(schema.project.chatSessions.id, existing.sessionId)); + .where(and( + eq(schema.project.chatSessions.id, existing.sessionId), + projectScopeFor(schema.project.chatSessions.projectId, projectId), + )); return true; } @@ -861,6 +912,7 @@ export async function searchChatSessionsByMessageContent( handle: QueryHandle, query: string, sessionIds: string[], + projectId?: string, ): Promise> { const trimmed = query.trim(); if (!trimmed || sessionIds.length === 0) return new Map(); @@ -871,6 +923,7 @@ export async function searchChatSessionsByMessageContent( .where(and( inArray(schema.project.chatMessages.sessionId, sessionIds), ilike(schema.project.chatMessages.content, `%${escaped}%`), + ...chatMessageProjectConditions(handle, projectId), )) .orderBy( desc(schema.project.chatMessages.createdAt), @@ -898,18 +951,22 @@ export async function deleteChatMessagesFrom( handle: QueryHandle, sessionId: string, fromMessageId: string, + projectId?: string, ): Promise<{ deletedIds: string[]; retained: ChatMessage[] }> { const orderedRows = await handle .select() .from(schema.project.chatMessages) - .where(eq(schema.project.chatMessages.sessionId, sessionId)) + .where(and( + eq(schema.project.chatMessages.sessionId, sessionId), + ...chatMessageProjectConditions(handle, projectId), + )) .orderBy( asc(schema.project.chatMessages.createdAt), asc(schema.project.chatMessages.id), ); const ordered = orderedRows.map(rowToMessage); - const target = await getChatMessage(handle, fromMessageId); + const target = await getChatMessage(handle, fromMessageId, projectId); if (!target || target.sessionId !== sessionId) { return { deletedIds: [], retained: ordered }; } @@ -927,11 +984,17 @@ export async function deleteChatMessagesFrom( await handle .delete(schema.project.chatMessages) - .where(inArray(schema.project.chatMessages.id, deletedIds)); + .where(and( + inArray(schema.project.chatMessages.id, deletedIds), + ...chatMessageProjectConditions(handle, projectId), + )); await handle .update(schema.project.chatSessions) .set({ updatedAt: new Date().toISOString() }) - .where(eq(schema.project.chatSessions.id, sessionId)); + .where(and( + eq(schema.project.chatSessions.id, sessionId), + projectScopeFor(schema.project.chatSessions.projectId, projectId), + )); return { deletedIds, retained }; } @@ -947,8 +1010,9 @@ export async function updateChatMessageMetadata( messageId: string, metadata: Record | null, options?: { merge?: boolean }, + projectId?: string, ): Promise { - const existing = await getChatMessage(handle, messageId); + const existing = await getChatMessage(handle, messageId, projectId); if (!existing) { throw new Error(`Message ${messageId} not found`); } @@ -961,9 +1025,12 @@ export async function updateChatMessageMetadata( await handle .update(schema.project.chatMessages) .set({ metadata: sanitizeJsonbValue(nextMetadata) ?? null }) - .where(eq(schema.project.chatMessages.id, messageId)); + .where(and( + eq(schema.project.chatMessages.id, messageId), + ...chatMessageProjectConditions(handle, projectId), + )); - const updated = await getChatMessage(handle, messageId); + const updated = await getChatMessage(handle, messageId, projectId); if (!updated) { throw new Error(`Failed to update message ${messageId}`); } diff --git a/packages/core/src/async-stores/async-secrets-store.ts b/packages/core/src/async-stores/async-secrets-store.ts index ae47e21260..fe81167299 100644 --- a/packages/core/src/async-stores/async-secrets-store.ts +++ b/packages/core/src/async-stores/async-secrets-store.ts @@ -43,10 +43,10 @@ const severityAuditLog = createLogger("core-async-secrets-store"); * it in place of the sync store at the flip. */ import { randomUUID } from "node:crypto"; -import { asc, eq, sql } from "drizzle-orm"; +import { and, asc, eq, sql } from "drizzle-orm"; import { isPostgresUniqueError } from "../db/postgres-errors.js"; import * as schema from "../postgres/schema/index.js"; -import type { AsyncDataLayer, DbTransaction } from "../postgres/data-layer.js"; +import { projectScopeFor, type AsyncDataLayer, type DbTransaction } from "../postgres/data-layer.js"; import { createSecretCipher, SecretCryptoError, @@ -141,6 +141,16 @@ function tableForScope(scope: SecretScope): ProjectSecretsTable { : (schema.central.secretsGlobal as unknown as ProjectSecretsTable); } +/** + * FNXC:ProjectSecretsIsolation 2026-08-12-14:15: + * project.secrets IDs are only unique within their owning project after the + * composite-key migration. Keep the central/global leg predicate-free because + * central.secrets_global is intentionally shared across projects. + */ +function scopeForSecretTable(scope: SecretScope, table: ProjectSecretsTable, projectId?: string) { + return scope === "project" ? projectScopeFor(table.projectId, projectId) : undefined; +} + function isAccessPolicy(value: string): value is SecretAccessPolicy { return value === "auto" || value === "prompt" || value === "deny"; } @@ -188,12 +198,13 @@ export async function getSecretMetadata( handle: QueryHandle, id: string, scope: SecretScope, + projectId?: string, ): Promise { const table = tableForScope(scope); const rows = await handle .select(metadataColumns(table)) .from(table) - .where(eq(table.id, id)) + .where(and(eq(table.id, id), scopeForSecretTable(scope, table, projectId))) .limit(1); const row = rows[0] as SecretRow | undefined; return row ? rowToRecord(row, scope) : null; @@ -207,17 +218,19 @@ export async function getSecretMetadata( export async function listSecrets( handle: QueryHandle, scope?: SecretScope, + projectId?: string, ): Promise { if (scope) { const table = tableForScope(scope); const rows = await handle .select(metadataColumns(table)) .from(table) + .where(scopeForSecretTable(scope, table, projectId)) .orderBy(asc(sql`lower(${table.key})`)); return (rows as SecretRow[]).map((row) => rowToRecord(row, scope)); } - const project = await listSecrets(handle, "project"); - const global = await listSecrets(handle, "global"); + const project = await listSecrets(handle, "project", projectId); + const global = await listSecrets(handle, "global", projectId); return [...project, ...global]; } @@ -239,6 +252,7 @@ export async function createSecret( envExportable?: boolean; envExportKey?: string | null; }, + projectId?: string, ): Promise { const key = input.key.trim(); if (!key) { @@ -255,6 +269,7 @@ export async function createSecret( try { await handle.insert(table).values({ + ...(input.scope === "project" && projectId?.trim() ? { projectId } : {}), id, key, valueCiphertext: encrypted.ciphertext, @@ -275,7 +290,7 @@ export async function createSecret( throw error; } - const created = await getSecretMetadata(handle, id, input.scope); + const created = await getSecretMetadata(handle, id, input.scope, projectId); if (!created) { throw new SecretsStoreError({ code: "not-found", message: "Secret insert succeeded but row could not be read back" }); } @@ -302,8 +317,9 @@ export async function updateSecret( envExportable?: boolean; envExportKey?: string | null; }, + projectId?: string, ): Promise { - const existing = await getSecretMetadata(handle, id, scope); + const existing = await getSecretMetadata(handle, id, scope, projectId); if (!existing) { throw new SecretsStoreError({ code: "not-found", message: "Secret not found" }); } @@ -340,7 +356,7 @@ export async function updateSecret( } try { - await handle.update(table).set(updates).where(eq(table.id, id)); + await handle.update(table).set(updates).where(and(eq(table.id, id), scopeForSecretTable(scope, table, projectId))); } catch (error) { if (isPostgresUniqueError(error)) { throw new SecretsStoreError({ code: "duplicate-key", message: "Secret key already exists" }); @@ -348,7 +364,7 @@ export async function updateSecret( throw error; } - const updated = await getSecretMetadata(handle, id, scope); + const updated = await getSecretMetadata(handle, id, scope, projectId); if (!updated) { throw new SecretsStoreError({ code: "not-found", message: "Secret update succeeded but row could not be read back" }); } @@ -364,13 +380,14 @@ export async function deleteSecret( handle: QueryHandle, id: string, scope: SecretScope, + projectId?: string, ): Promise { - const existing = await getSecretMetadata(handle, id, scope); + const existing = await getSecretMetadata(handle, id, scope, projectId); if (!existing) { throw new SecretsStoreError({ code: "not-found", message: "Secret not found" }); } const table = tableForScope(scope); - await handle.delete(table).where(eq(table.id, id)); + await handle.delete(table).where(and(eq(table.id, id), scopeForSecretTable(scope, table, projectId))); } /** @@ -385,6 +402,7 @@ export async function revealSecret( id: string, scope: SecretScope, reader: { agentId?: string | null; userId?: string | null }, + projectId?: string, ): Promise<{ key: string; plaintextValue: string }> { const table = tableForScope(scope); const rows = await handle @@ -394,7 +412,7 @@ export async function revealSecret( nonce: table.nonce, }) .from(table) - .where(eq(table.id, id)) + .where(and(eq(table.id, id), scopeForSecretTable(scope, table, projectId))) .limit(1); const row = rows[0] as SecretCipherRow | undefined; if (!row) { @@ -419,7 +437,7 @@ export async function revealSecret( await handle .update(table) .set({ lastReadAt: now, lastReadBy, updatedAt: now }) - .where(eq(table.id, id)); + .where(and(eq(table.id, id), scopeForSecretTable(scope, table, projectId))); return { key: row.key, plaintextValue }; } @@ -472,11 +490,11 @@ export class AsyncSecretsStore { } listSecrets(scope?: SecretScope): Promise { - return listSecrets(this.layer.db, scope); + return listSecrets(this.layer.db, scope, this.layer.projectId); } async getSecretMetadata(id: string, scope: SecretScope): Promise { - return getSecretMetadata(this.layer.db, id, scope); + return getSecretMetadata(this.layer.db, id, scope, this.layer.projectId); } async createSecret(input: { @@ -488,7 +506,7 @@ export class AsyncSecretsStore { envExportable?: boolean; envExportKey?: string | null; }): Promise { - const created = await createSecret(this.layer.db, this.cipher, input); + const created = await createSecret(this.layer.db, this.cipher, input, this.layer.projectId); this.emitAudit({ mutationType: "secret:create", scope: input.scope, secretId: created.id, key: created.key }); return created; } @@ -505,17 +523,17 @@ export class AsyncSecretsStore { envExportKey?: string | null; }, ): Promise { - const updated = await updateSecret(this.layer.db, this.cipher, id, scope, patch); + const updated = await updateSecret(this.layer.db, this.cipher, id, scope, patch, this.layer.projectId); this.emitAudit({ mutationType: "secret:update", scope, secretId: updated.id, key: updated.key }); return updated; } async deleteSecret(id: string, scope: SecretScope): Promise { - const existing = await getSecretMetadata(this.layer.db, id, scope); + const existing = await getSecretMetadata(this.layer.db, id, scope, this.layer.projectId); if (!existing) { throw new SecretsStoreError({ code: "not-found", message: "Secret not found" }); } - await deleteSecret(this.layer.db, id, scope); + await deleteSecret(this.layer.db, id, scope, this.layer.projectId); this.emitAudit({ mutationType: "secret:delete", scope, secretId: id, key: existing.key }); } @@ -524,7 +542,7 @@ export class AsyncSecretsStore { scope: SecretScope, reader: { agentId?: string | null; userId?: string | null }, ): Promise<{ key: string; plaintextValue: string }> { - const revealed = await revealSecret(this.layer.db, this.cipher, id, scope, reader); + const revealed = await revealSecret(this.layer.db, this.cipher, id, scope, reader, this.layer.projectId); this.emitAudit({ mutationType: "secret:read", scope, secretId: id, key: revealed.key, actor: reader }); return revealed; } diff --git a/packages/core/src/chat/chat-store.ts b/packages/core/src/chat/chat-store.ts index 176f5745e4..1b36498aed 100644 --- a/packages/core/src/chat/chat-store.ts +++ b/packages/core/src/chat/chat-store.ts @@ -373,7 +373,7 @@ export class ChatStore extends EventEmitter { attachments: input.attachments, createdAt: now, }; - const created = await asyncChatStore.addChatMessage(this.asyncLayer.db, message); + const created = await asyncChatStore.addChatMessage(this.asyncLayer.db, message, this.asyncLayer.projectId); this.emit("chat:message:added", created); return created; } @@ -382,7 +382,7 @@ export class ChatStore extends EventEmitter { * Append a file attachment metadata record to an existing message. */ async addMessageAttachment(sessionId: string, messageId: string, attachment: ChatAttachment): Promise { - const updated = await asyncChatStore.addChatMessageAttachment(this.asyncLayer.db, sessionId, messageId, attachment); + const updated = await asyncChatStore.addChatMessageAttachment(this.asyncLayer.db, sessionId, messageId, attachment, this.asyncLayer.projectId); this.emit("chat:message:updated", updated); return updated; } @@ -395,7 +395,7 @@ export class ChatStore extends EventEmitter { * @returns Array of messages ordered by createdAt ASC (default) or DESC */ async getMessages(sessionId: string, filter?: ChatMessagesFilter): Promise { - return asyncChatStore.getChatMessages(this.asyncLayer.db, sessionId, filter); + return asyncChatStore.getChatMessages(this.asyncLayer.db, sessionId, filter, this.asyncLayer.projectId); } /** @@ -405,7 +405,7 @@ export class ChatStore extends EventEmitter { * @returns The message, or undefined if not found */ async getMessage(id: string): Promise { - return asyncChatStore.getChatMessage(this.asyncLayer.db, id); + return asyncChatStore.getChatMessage(this.asyncLayer.db, id, this.asyncLayer.projectId); } /** @@ -416,11 +416,11 @@ export class ChatStore extends EventEmitter { * @returns Map of sessionId -> latest ChatMessage for that session */ async getLastMessageForSessions(sessionIds: string[]): Promise> { - return asyncChatStore.getLastMessageForSessions(this.asyncLayer.db, sessionIds); + return asyncChatStore.getLastMessageForSessions(this.asyncLayer.db, sessionIds, this.asyncLayer.projectId); } async hasMessages(sessionId: string): Promise { - return (await asyncChatStore.getChatMessages(this.asyncLayer.db, sessionId, { limit: 1 })).length > 0; + return (await asyncChatStore.getChatMessages(this.asyncLayer.db, sessionId, { limit: 1 }, this.asyncLayer.projectId)).length > 0; } /** @@ -449,7 +449,7 @@ export class ChatStore extends EventEmitter { if (!trimmed || !sessionIds || sessionIds.length === 0) { return new Map(); } - return asyncChatStore.searchChatSessionsByMessageContent(this.asyncLayer.db, trimmed, sessionIds); + return asyncChatStore.searchChatSessionsByMessageContent(this.asyncLayer.db, trimmed, sessionIds, this.asyncLayer.projectId); } /** @@ -459,9 +459,9 @@ export class ChatStore extends EventEmitter { * @returns true if deleted, false if not found */ async deleteMessage(id: string): Promise { - const existing = await asyncChatStore.getChatMessage(this.asyncLayer.db, id); + const existing = await asyncChatStore.getChatMessage(this.asyncLayer.db, id, this.asyncLayer.projectId); if (!existing) return false; - const deleted = await asyncChatStore.deleteChatMessage(this.asyncLayer.db, id); + const deleted = await asyncChatStore.deleteChatMessage(this.asyncLayer.db, id, this.asyncLayer.projectId); if (deleted) { this.emit("chat:message:deleted", id); const updatedSession = await this.getSession(existing.sessionId); @@ -485,7 +485,7 @@ export class ChatStore extends EventEmitter { * @returns deletedIds (in ASC order) and retained messages (pre-edit history, ASC order) */ async deleteMessagesFrom(sessionId: string, fromMessageId: string): Promise<{ deletedIds: string[]; retained: ChatMessage[] }> { - const result = await asyncChatStore.deleteChatMessagesFrom(this.asyncLayer.db, sessionId, fromMessageId); + const result = await asyncChatStore.deleteChatMessagesFrom(this.asyncLayer.db, sessionId, fromMessageId, this.asyncLayer.projectId); if (result.deletedIds.length > 0) { for (const id of result.deletedIds) { this.emit("chat:message:deleted", id); @@ -504,7 +504,7 @@ export class ChatStore extends EventEmitter { * is what lets a later edit rewind losslessly via SessionManager.branch()/resetLeaf(). */ async updateMessageMetadata(messageId: string, metadata: Record | null, options?: { merge?: boolean }): Promise { - const updated = await asyncChatStore.updateChatMessageMetadata(this.asyncLayer.db, messageId, metadata, options); + const updated = await asyncChatStore.updateChatMessageMetadata(this.asyncLayer.db, messageId, metadata, options, this.asyncLayer.projectId); this.emit("chat:message:updated", updated); return updated; } diff --git a/packages/core/src/plugins/plugin-activation-analytics.ts b/packages/core/src/plugins/plugin-activation-analytics.ts index 9cc4f5de12..76522dccc6 100644 --- a/packages/core/src/plugins/plugin-activation-analytics.ts +++ b/packages/core/src/plugins/plugin-activation-analytics.ts @@ -1,5 +1,5 @@ import type { Database } from "../db/db.js"; -import type { AsyncDataLayer } from "../postgres/data-layer.js"; +import { projectScopeFor, type AsyncDataLayer } from "../postgres/data-layer.js"; import { and, gte, lte, sql } from "drizzle-orm"; import * as schema from "../postgres/schema/index.js"; @@ -91,7 +91,15 @@ export async function aggregatePluginActivations( // instead of the broken `"execute" in dbOrLayer || ("transactionImmediate" in dbOrLayer)`. if ("ping" in dbOrLayer) { const layer = dbOrLayer as AsyncDataLayer; - const conditions = []; + /* + FNXC:ProjectSchemaOwnership 2026-08-12-14:14: + Ecosystem analytics may share one PostgreSQL schema across projects. Scope both + aggregate reads to the bound layer so another project's activation history is + never presented as this dashboard's metric; unbound analytics remain global. + */ + const conditions = [projectScopeFor(schema.project.pluginActivations.projectId, layer.projectId)].filter( + (condition): condition is NonNullable => condition !== undefined, + ); if (query.from !== undefined) conditions.push(gte(schema.project.pluginActivations.activatedAt, query.from)); if (query.to !== undefined) conditions.push(lte(schema.project.pluginActivations.activatedAt, query.to)); const where = conditions.length > 0 ? and(...conditions) : undefined; diff --git a/packages/core/src/secrets/secrets-store.ts b/packages/core/src/secrets/secrets-store.ts index 764c8915ea..d00b5e194a 100644 --- a/packages/core/src/secrets/secrets-store.ts +++ b/packages/core/src/secrets/secrets-store.ts @@ -147,7 +147,7 @@ export class SecretsStore { } async listSecrets(scope?: SecretScope): Promise { - return asyncSecretsStore.listSecrets(this.asyncLayer!.db, scope); + return asyncSecretsStore.listSecrets(this.asyncLayer!.db, scope, this.asyncLayer!.projectId); } async listEnvExportable(opts?: { keyPrefix?: string }): Promise { @@ -195,7 +195,7 @@ export class SecretsStore { } async getSecretMetadata(id: string, scope: SecretScope): Promise { - return asyncSecretsStore.getSecretMetadata(this.asyncLayer!.db, id, scope); + return asyncSecretsStore.getSecretMetadata(this.asyncLayer!.db, id, scope, this.asyncLayer!.projectId); } async createSecret(input: { @@ -215,7 +215,7 @@ export class SecretsStore { throw new SecretsStoreError({ code: "invalid-policy", message: "Invalid access policy" }); } - const created = await asyncSecretsStore.createSecret(this.asyncLayer!.db, this.cipher, input); + const created = await asyncSecretsStore.createSecret(this.asyncLayer!.db, this.cipher, input, this.asyncLayer!.projectId); this.emitAudit({ mutationType: "secret:create", scope: input.scope, secretId: created.id, key: created.key }); return created; } @@ -228,7 +228,7 @@ export class SecretsStore { envExportable?: boolean; envExportKey?: string | null; }): Promise { - const updated = await asyncSecretsStore.updateSecret(this.asyncLayer!.db, this.cipher, id, scope, patch); + const updated = await asyncSecretsStore.updateSecret(this.asyncLayer!.db, this.cipher, id, scope, patch, this.asyncLayer!.projectId); this.emitAudit({ mutationType: "secret:update", scope, secretId: updated.id, key: updated.key }); return updated; } @@ -238,7 +238,7 @@ export class SecretsStore { if (!existing) { throw new SecretsStoreError({ code: "not-found", message: "Secret not found" }); } - await asyncSecretsStore.deleteSecret(this.asyncLayer!.db, id, scope); + await asyncSecretsStore.deleteSecret(this.asyncLayer!.db, id, scope, this.asyncLayer!.projectId); this.emitAudit({ mutationType: "secret:delete", scope, secretId: id, key: existing.key }); return; } @@ -248,7 +248,7 @@ export class SecretsStore { scope: SecretScope, reader: { agentId?: string | null; userId?: string | null }, ): Promise<{ key: string; plaintextValue: string }> { - const revealed = await asyncSecretsStore.revealSecret(this.asyncLayer!.db, this.cipher, id, scope, reader); + const revealed = await asyncSecretsStore.revealSecret(this.asyncLayer!.db, this.cipher, id, scope, reader, this.asyncLayer!.projectId); this.emitAudit({ mutationType: "secret:read", scope, secretId: id, key: revealed.key, actor: reader }); return revealed; } diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index 39f8877579..6dc0c7d392 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -2544,7 +2544,11 @@ export class TaskStore extends EventEmitter { /** PostgreSQL-authoritative audit reader; sync fallback remains for test doubles. */ async getRunAuditEventsAsync(options: RunAuditEventFilter = {}): Promise { if (this.asyncLayer) { - const events = await queryRunAuditEvents(this.asyncLayer.db, options); + const events = await queryRunAuditEvents( + this.asyncLayer.db, + options, + this.asyncLayer.projectId, + ); return events.map((event) => ({ ...event, taskId: event.taskId ?? undefined, diff --git a/packages/core/src/task-store/async/async-archive-lineage.ts b/packages/core/src/task-store/async/async-archive-lineage.ts index 9eff02fbf8..f71754e5d8 100644 --- a/packages/core/src/task-store/async/async-archive-lineage.ts +++ b/packages/core/src/task-store/async/async-archive-lineage.ts @@ -30,7 +30,7 @@ */ import { and, desc, eq, inArray, sql } from "drizzle-orm"; import * as schema from "../../postgres/schema/index.js"; -import type { AsyncDataLayer, DbTransaction } from "../../postgres/data-layer.js"; +import { projectScopeFor, type AsyncDataLayer, type DbTransaction } from "../../postgres/data-layer.js"; import { ACTIVE_TASK_FILTER } from "./async-persistence.js"; import { findLiveLineageChildren, projectPartition, removeLineageReferences, type LineageRemovalOutcome } from "./async-lifecycle.js"; import { assertLineageCandidatesUnchanged } from "../lineage-approval-invalidation.js"; @@ -419,6 +419,7 @@ export async function listLiveTaskDocuments( export async function listLiveArtifacts( db: AsyncDataLayer["db"] | DbTransaction, taskId: string, + projectId?: string, ): Promise[]> { const rows = await db .select({ @@ -440,11 +441,16 @@ export async function listLiveArtifacts( .from(schema.project.artifacts) .innerJoin( schema.project.tasks, - eq(schema.project.tasks.id, schema.project.artifacts.taskId), + and( + eq(schema.project.tasks.id, schema.project.artifacts.taskId), + eq(schema.project.tasks.projectId, schema.project.artifacts.projectId), + ), ) .where( and( eq(schema.project.artifacts.taskId, taskId), + projectScopeFor(schema.project.artifacts.projectId, projectId), + projectScopeFor(schema.project.tasks.projectId, projectId), ACTIVE_TASK_FILTER, sql`${schema.project.tasks.column} != 'archived'`, ), @@ -481,10 +487,14 @@ export async function listAllTaskDocuments( export async function listAllArtifacts( db: AsyncDataLayer["db"] | DbTransaction, taskId: string, + projectId?: string, ): Promise[]> { const rows = await db .select() .from(schema.project.artifacts) - .where(eq(schema.project.artifacts.taskId, taskId)); + .where(and( + eq(schema.project.artifacts.taskId, taskId), + projectScopeFor(schema.project.artifacts.projectId, projectId), + )); return rows as unknown as Record[]; } diff --git a/packages/core/src/task-store/async/async-audit.ts b/packages/core/src/task-store/async/async-audit.ts index 5b9d70457c..ee329d1688 100644 --- a/packages/core/src/task-store/async/async-audit.ts +++ b/packages/core/src/task-store/async/async-audit.ts @@ -25,7 +25,7 @@ */ import { and, asc, count, desc, eq, gt, gte, isNotNull, lte, or, sql } from "drizzle-orm"; import * as schema from "../../postgres/schema/index.js"; -import type { AsyncDataLayer, DbTransaction } from "../../postgres/data-layer.js"; +import { projectScopeFor, type AsyncDataLayer, type DbTransaction } from "../../postgres/data-layer.js"; import { recordRunAuditEventWithinTransaction, recordRunAuditEvent, @@ -89,8 +89,17 @@ function safeJsonParse(value: string | null): Record | null { export async function queryRunAuditEvents( db: AsyncDataLayer["db"] | DbTransaction, filter: RunAuditEventFilter = {}, + projectId?: string, ): Promise { - const conditions = []; + /* + FNXC:ProjectSchemaOwnership 2026-08-12-14:14: + Run-audit events share the flat project schema, so bound runtime readers must + classify rows by their owning project before applying audit filters. An absent + binding deliberately remains an analytics-wide read for compatibility layers. + */ + const conditions = [projectScopeFor(schema.project.runAuditEvents.projectId, projectId)].filter( + (condition): condition is NonNullable => condition !== undefined, + ); if (filter.runId) { conditions.push(eq(schema.project.runAuditEvents.runId, filter.runId)); } @@ -136,8 +145,11 @@ export async function queryRunAuditEvents( export async function countRunAuditEvents( db: AsyncDataLayer["db"] | DbTransaction, filter: RunAuditEventFilter = {}, + projectId?: string, ): Promise { - const conditions = []; + const conditions = [projectScopeFor(schema.project.runAuditEvents.projectId, projectId)].filter( + (condition): condition is NonNullable => condition !== undefined, + ); if (filter.runId) { conditions.push(eq(schema.project.runAuditEvents.runId, filter.runId)); } diff --git a/packages/core/src/task-store/async/async-branch-groups.ts b/packages/core/src/task-store/async/async-branch-groups.ts index 46a87c9fa0..3c9983080e 100644 --- a/packages/core/src/task-store/async/async-branch-groups.ts +++ b/packages/core/src/task-store/async/async-branch-groups.ts @@ -23,7 +23,7 @@ */ import { and, asc, eq, notInArray, sql } from "drizzle-orm"; import * as schema from "../../postgres/schema/index.js"; -import type { AsyncDataLayer, DbTransaction } from "../../postgres/data-layer.js"; +import { projectScopeFor, type AsyncDataLayer, type DbTransaction } from "../../postgres/data-layer.js"; import { validateBranchGroupBranchName, } from "../../branch/branch-assignment.js"; @@ -97,6 +97,7 @@ export function rowToBranchGroup(row: BranchGroupRow): BranchGroup { export async function createBranchGroup( db: AsyncDataLayer["db"] | DbTransaction, input: BranchGroupCreateInput, + projectId?: string, ): Promise { // Fix #11: reject injection-shaped branch names at the persistence boundary. validateBranchGroupBranchName(input.branchName); @@ -117,7 +118,7 @@ export async function createBranchGroup( updatedAt: now, closedAt: input.closedAt ?? null, }); - const created = await getBranchGroup(db, id); + const created = await getBranchGroup(db, id, projectId); if (!created) throw new Error(`Failed to read branch group ${id} after create`); return created; } @@ -128,11 +129,15 @@ export async function createBranchGroup( export async function getBranchGroup( db: AsyncDataLayer["db"] | DbTransaction, id: string, + projectId?: string, ): Promise { const rows = await db .select() .from(schema.project.branchGroups) - .where(eq(schema.project.branchGroups.id, id)) + .where(and( + eq(schema.project.branchGroups.id, id), + projectScopeFor(schema.project.branchGroups.projectId, projectId), + )) .limit(1); const row = rows[0] as BranchGroupRow | undefined; return row ? rowToBranchGroup(row) : null; @@ -146,6 +151,7 @@ export async function getBranchGroupBySource( db: AsyncDataLayer["db"] | DbTransaction, sourceType: BranchGroup["sourceType"], sourceId: string, + projectId?: string, ): Promise { const rows = await db .select() @@ -154,6 +160,7 @@ export async function getBranchGroupBySource( and( eq(schema.project.branchGroups.sourceType, sourceType), eq(schema.project.branchGroups.sourceId, sourceId), + projectScopeFor(schema.project.branchGroups.projectId, projectId), ), ) .limit(1); @@ -168,11 +175,15 @@ export async function getBranchGroupBySource( export async function getBranchGroupByBranchName( db: AsyncDataLayer["db"] | DbTransaction, branchName: string, + projectId?: string, ): Promise { const rows = await db .select() .from(schema.project.branchGroups) - .where(eq(schema.project.branchGroups.branchName, branchName)) + .where(and( + eq(schema.project.branchGroups.branchName, branchName), + projectScopeFor(schema.project.branchGroups.projectId, projectId), + )) .orderBy(sql`${schema.project.branchGroups.createdAt} DESC`) .limit(1); const row = rows[0] as BranchGroupRow | undefined; @@ -191,16 +202,18 @@ export async function ensureBranchGroupForSource( sourceType: BranchGroup["sourceType"], sourceId: string, init: Omit, + projectId?: string, ): Promise { - const existing = await getBranchGroupBySource(db, sourceType, sourceId); + const existing = await getBranchGroupBySource(db, sourceType, sourceId, projectId); if (existing) return existing; - // branch_groups.branchName is globally UNIQUE — reuse an existing open group - // for this branch rather than colliding on insert. - const existingByBranch = await getBranchGroupByBranchName(db, init.branchName); + // FNXC:BranchGroupProjectIsolation 2026-08-12-14:16: Branch names are unique + // per physical project partition, so only this project's existing group may + // be reused; matching names in another project must not suppress creation. + const existingByBranch = await getBranchGroupByBranchName(db, init.branchName, projectId); if (existingByBranch) return existingByBranch; - return createBranchGroup(db, { sourceType, sourceId, ...init }); + return createBranchGroup(db, { sourceType, sourceId, ...init }, projectId); } /** @@ -209,14 +222,15 @@ export async function ensureBranchGroupForSource( export async function listBranchGroups( db: AsyncDataLayer["db"] | DbTransaction, options?: { status?: BranchGroup["status"] }, + projectId?: string, ): Promise { - const query = db + const conditions = [projectScopeFor(schema.project.branchGroups.projectId, projectId)]; + if (options?.status) conditions.push(eq(schema.project.branchGroups.status, options.status)); + const rows = await db .select() .from(schema.project.branchGroups) + .where(and(...conditions)) .orderBy(asc(schema.project.branchGroups.createdAt)); - const rows = options?.status - ? await query.where(eq(schema.project.branchGroups.status, options.status)) - : await query; return (rows as BranchGroupRow[]).map((row) => rowToBranchGroup(row)); } @@ -231,8 +245,9 @@ export async function updateBranchGroup( db: AsyncDataLayer["db"] | DbTransaction, id: string, patch: BranchGroupUpdate, + projectId?: string, ): Promise { - const current = await getBranchGroup(db, id); + const current = await getBranchGroup(db, id, projectId); if (!current) throw new Error(`Branch group ${id} not found`); // Fix #11: a rename must reject injection-shaped names. @@ -261,9 +276,12 @@ export async function updateBranchGroup( updatedAt: now, closedAt: nextClosedAt, }) - .where(eq(schema.project.branchGroups.id, id)); + .where(and( + eq(schema.project.branchGroups.id, id), + projectScopeFor(schema.project.branchGroups.projectId, projectId), + )); - const updated = await getBranchGroup(db, id); + const updated = await getBranchGroup(db, id, projectId); if (!updated) throw new Error(`Branch group ${id} disappeared after update`); return updated; } diff --git a/packages/core/src/task-store/async/async-comments-attachments.ts b/packages/core/src/task-store/async/async-comments-attachments.ts index 3e7c4691f8..eca1d6d053 100644 --- a/packages/core/src/task-store/async/async-comments-attachments.ts +++ b/packages/core/src/task-store/async/async-comments-attachments.ts @@ -28,7 +28,7 @@ import { and, desc, eq, ilike, isNull, or } from "drizzle-orm"; import { randomUUID } from "node:crypto"; import * as schema from "../../postgres/schema/index.js"; -import { recordRunAuditEventWithinTransaction, type AsyncDataLayer, type DbTransaction } from "../../postgres/data-layer.js"; +import { projectScopeFor, recordRunAuditEventWithinTransaction, type AsyncDataLayer, type DbTransaction } from "../../postgres/data-layer.js"; import { ACTIVE_TASK_FILTER } from "./async-persistence.js"; import { projectPartition } from "./async-lifecycle.js"; import { @@ -685,7 +685,10 @@ export async function insertArtifactRow( const rows = await tx .select() .from(schema.project.artifacts) - .where(eq(schema.project.artifacts.id, id)) + .where(and( + eq(schema.project.artifacts.id, id), + projectScopeFor(schema.project.artifacts.projectId, layer.projectId), + )) .limit(1); const row = rows[0] as ArtifactRow | undefined; if (!row) throw new Error(`Failed to register artifact ${id}`); @@ -707,7 +710,7 @@ export async function updateArtifactRow( archivedColumns?: ReadonlySet,): Promise { return layer.transactionImmediate(async (tx) => { - const existing = await getArtifact(tx, id); + const existing = await getArtifact(tx, id, layer.projectId); if (!existing) { throw new Error(`Artifact ${id} not found`); } @@ -744,9 +747,12 @@ export async function updateArtifactRow( content: updates.content !== undefined ? updates.content : existing.content ?? null, updatedAt: now, }) - .where(eq(schema.project.artifacts.id, id)); + .where(and( + eq(schema.project.artifacts.id, id), + projectScopeFor(schema.project.artifacts.projectId, layer.projectId), + )); - const updated = await getArtifact(tx, id); + const updated = await getArtifact(tx, id, layer.projectId); if (!updated) { throw new Error(`Failed to update artifact ${id}`); } @@ -761,11 +767,15 @@ export async function updateArtifactRow( export async function getArtifact( db: AsyncDataLayer["db"] | DbTransaction, id: string, + projectId?: string, ): Promise { const rows = await db .select() .from(schema.project.artifacts) - .where(eq(schema.project.artifacts.id, id)) + .where(and( + eq(schema.project.artifacts.id, id), + projectScopeFor(schema.project.artifacts.projectId, projectId), + )) .limit(1); const row = rows[0] as ArtifactRow | undefined; return row ? rowToArtifact(row) : null; @@ -803,7 +813,10 @@ export async function getArtifacts( const rows = await db .select() .from(schema.project.artifacts) - .where(eq(schema.project.artifacts.taskId, taskId)) + .where(and( + eq(schema.project.artifacts.taskId, taskId), + projectScopeFor(schema.project.artifacts.projectId, projectId), + )) .orderBy(desc(schema.project.artifacts.createdAt)); return (rows as ArtifactRow[]).map((row) => rowToArtifact(row)); } @@ -838,11 +851,12 @@ export async function listArtifacts( offset?: number; search?: string; }, + projectId?: string, ): Promise { const limit = Math.min(Math.max(1, options?.limit ?? 200), 1000); const offset = Math.max(0, options?.offset ?? 0); - const conditions = []; + const conditions = [projectScopeFor(schema.project.artifacts.projectId, projectId)]; if (options?.type) { conditions.push(eq(schema.project.artifacts.type, options.type)); } @@ -888,7 +902,10 @@ export async function listArtifacts( .from(schema.project.artifacts) .leftJoin( schema.project.tasks, - eq(schema.project.artifacts.taskId, schema.project.tasks.id), + and( + eq(schema.project.artifacts.taskId, schema.project.tasks.id), + eq(schema.project.artifacts.projectId, schema.project.tasks.projectId), + ), ) .where(and(...conditions)) .orderBy(desc(schema.project.artifacts.createdAt)) diff --git a/packages/core/src/task-store/branch-and-pr-entities.ts b/packages/core/src/task-store/branch-and-pr-entities.ts index 8dc8881f1e..37674ed7ac 100644 --- a/packages/core/src/task-store/branch-and-pr-entities.ts +++ b/packages/core/src/task-store/branch-and-pr-entities.ts @@ -31,20 +31,24 @@ import { join } from "node:path"; import { MoveTaskInternalOptions, MoveTaskOptions, storeLog } from "../store.js"; import { resolveProjectColumnsForRoles } from "../project-lane-vocabulary.js"; +/* +FNXC:BranchGroupProjectIsolation 2026-08-12-14:30: +TaskStore is the production branch-group boundary, so every wrapper must forward its bound project id to the optional helper predicate. Omitting it makes owner-connected PostgreSQL reads and mutations cross project partitions despite scoped helper implementations. +*/ export async function getBranchGroupImpl(store: TaskStore, id: string): Promise { // FNXC:RuntimeWorkflowAsync 2026-06-24-16:21: const layer = store.asyncLayer!; - return getBranchGroupAsync(layer.db, id); + return getBranchGroupAsync(layer.db, id, layer.projectId); } export async function getBranchGroupBySourceImpl(store: TaskStore, sourceType: BranchGroup["sourceType"], sourceId: string): Promise { const layer = store.asyncLayer!; - return getBranchGroupBySourceAsync(layer.db, sourceType, sourceId); + return getBranchGroupBySourceAsync(layer.db, sourceType, sourceId, layer.projectId); } export async function getBranchGroupByBranchNameImpl(store: TaskStore, branchName: string): Promise { const layer = store.asyncLayer!; - return getBranchGroupByBranchNameAsync(layer.db, branchName); + return getBranchGroupByBranchNameAsync(layer.db, branchName, layer.projectId); } export async function ensureBranchGroupForSourceImpl(store: TaskStore, @@ -56,12 +60,13 @@ export async function ensureBranchGroupForSourceImpl(store: TaskStore, FNXC:SqliteDualPathCleanup 2026-07-26-14:07: Branch-group ensure is PostgreSQL-only via ensureBranchGroupForSourceAsync (UNIQUE branchName reuse lives in the async helper). */ - return ensureBranchGroupForSourceAsync(store.asyncLayer!.db, sourceType, sourceId, init); + const layer = store.asyncLayer!; + return ensureBranchGroupForSourceAsync(layer.db, sourceType, sourceId, init, layer.projectId); } export async function listBranchGroupsImpl(store: TaskStore, options?: { status?: BranchGroup["status"] }): Promise { const layer = store.asyncLayer!; - return listBranchGroupsAsync(layer.db, options); + return listBranchGroupsAsync(layer.db, options, layer.projectId); } /* diff --git a/packages/core/src/task-store/branch-group-ops.ts b/packages/core/src/task-store/branch-group-ops.ts index a6289deccd..cdc079a4b7 100644 --- a/packages/core/src/task-store/branch-group-ops.ts +++ b/packages/core/src/task-store/branch-group-ops.ts @@ -392,7 +392,7 @@ export async function listArtifactsImpl(store: TaskStore, options?: { type?: Art // PG backend mode: delegate to the AsyncDataLayer helper. The sync path // below dereferences store.db (no SQLite handle in backend mode) and 500'd // the dashboard /api/artifacts list. - return listArtifactsAsync(store.asyncLayer!.db, options); + return listArtifactsAsync(store.asyncLayer!.db, options, store.asyncLayer!.projectId); } /* diff --git a/packages/core/src/task-store/task-artifacts-ops.ts b/packages/core/src/task-store/task-artifacts-ops.ts index c0f1daf561..fa14a2c14f 100644 --- a/packages/core/src/task-store/task-artifacts-ops.ts +++ b/packages/core/src/task-store/task-artifacts-ops.ts @@ -835,7 +835,7 @@ export function insertArtifactRowImpl(store: TaskStore, input: ArtifactCreateInp export async function getArtifactImpl(store: TaskStore, id: string): Promise { const layer = store.asyncLayer!; - return getArtifactAsync(layer.db, id); + return getArtifactAsync(layer.db, id, layer.projectId); } /** diff --git a/packages/core/src/task-store/task-id-integrity.ts b/packages/core/src/task-store/task-id-integrity.ts index 6c0ec304b7..57ca5f9ee2 100644 --- a/packages/core/src/task-store/task-id-integrity.ts +++ b/packages/core/src/task-store/task-id-integrity.ts @@ -888,5 +888,5 @@ export async function invokeTaskCreatedHookImpl(store: TaskStore, task: Task): P export async function createBranchGroupImpl(store: TaskStore, input: BranchGroupCreateInput): Promise { const layer = store.asyncLayer!; - return createBranchGroupAsync(layer.db, input); + return createBranchGroupAsync(layer.db, input, layer.projectId); } diff --git a/packages/core/src/task-store/task-mutation-ops.ts b/packages/core/src/task-store/task-mutation-ops.ts index ecc362bf91..461fc0aa4d 100644 --- a/packages/core/src/task-store/task-mutation-ops.ts +++ b/packages/core/src/task-store/task-mutation-ops.ts @@ -36,7 +36,7 @@ import {and, asc, eq, inArray, isNotNull, isNull, sql} from "drizzle-orm"; import {recoverExpiredMergeQueueLeases as recoverExpiredMergeQueueLeasesAsync} from "../task-store/async/async-merge-coordination.js"; import {updateBranchGroup as updateBranchGroupAsync, updatePrEntity as updatePrEntityAsync} from "../task-store/async/async-branch-groups.js"; import {recordCompletionHandoff as recordCompletionHandoffAsync, getCompletionHandoffMarker as getCompletionHandoffMarkerAsync} from "../task-store/async/async-workflow-workitems.js"; -import { taskProjectScope } from "../postgres/data-layer.js"; +import { projectScopeFor, taskProjectScope } from "../postgres/data-layer.js"; import type { AsyncDataLayer, DbTransaction } from "../postgres/data-layer.js"; import {getActivityLog as getActivityLogAsync} from "../task-store/async/async-audit.js"; import {insertArtifactRow as insertArtifactRowAsync} from "../task-store/async/async-comments-attachments.js"; @@ -197,7 +197,7 @@ export async function _maybeAutoArchiveSameAgentDuplicateBackendImpl(store: Task export async function updateBranchGroupImpl(store: TaskStore, id: string, patch: BranchGroupUpdate): Promise { const layer = store.asyncLayer!; - return updateBranchGroupAsync(layer.db, id, patch); + return updateBranchGroupAsync(layer.db, id, patch, layer.projectId); } export async function updatePrEntityImpl(store: TaskStore, id: string, patch: PrEntityUpdate): Promise { @@ -982,7 +982,10 @@ async function deleteAttachmentArtifactRows(store: TaskStore, taskId: string, fi .map((artifact) => artifact.id); if (linkedArtifactIds.length === 0) return; for (const artifactId of linkedArtifactIds) { - await layer.db.delete(schema.project.artifacts).where(eq(schema.project.artifacts.id, artifactId)); + await layer.db.delete(schema.project.artifacts).where(and( + eq(schema.project.artifacts.id, artifactId), + projectScopeFor(schema.project.artifacts.projectId, layer.projectId), + )); } return; } @@ -994,7 +997,10 @@ async function getArtifactsForAttachmentCleanup(store: TaskStore, taskId: string const rows = await layer.db .select() .from(schema.project.artifacts) - .where(eq(schema.project.artifacts.taskId, taskId)); + .where(and( + eq(schema.project.artifacts.taskId, taskId), + projectScopeFor(schema.project.artifacts.projectId, layer.projectId), + )); return rows as unknown as Artifact[]; } diff --git a/packages/core/src/task-store/workflow-definitions.ts b/packages/core/src/task-store/workflow-definitions.ts index 2edc23e4aa..31ae09e43d 100644 --- a/packages/core/src/task-store/workflow-definitions.ts +++ b/packages/core/src/task-store/workflow-definitions.ts @@ -1177,7 +1177,14 @@ export async function getVerificationCacheHitImpl(store: TaskStore, const rows = await store.asyncLayer!.db .select({ recordedAt: table.recordedAt, taskId: table.taskId }) .from(table) + /* + FNXC:ProjectSchemaOwnership 2026-08-12-14:14: + Verification cache keys are only unique within their project partition. + Read through the owning layer's project scope so an identical tree and + command tuple from another project cannot skip this project's verification. + */ .where(and( + projectScopeFor(table.projectId, store.asyncLayer!.projectId), eq(table.treeSha, treeSha), eq(table.testCommand, normalizedTest), eq(table.buildCommand, normalizedBuild), diff --git a/packages/dashboard/src/server.ts b/packages/dashboard/src/server.ts index 451c0402cd..0b8a36759b 100644 --- a/packages/dashboard/src/server.ts +++ b/packages/dashboard/src/server.ts @@ -1905,7 +1905,7 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT Backend-mode Reliability must use the authoritative async run-audit reader. The synchronous reader is a SQLite/test compatibility surface and intentionally degrades to an empty result under PostgreSQL. */ const runAuditEventsPromise: Promise = asyncLayer - ? queryRunAuditEvents(asyncLayer.db, auditFilter).then((events) => events.map((event) => ({ + ? queryRunAuditEvents(asyncLayer.db, auditFilter, asyncLayer.projectId).then((events) => events.map((event) => ({ ...event, domain: event.domain as RunAuditEvent["domain"], mutationType: event.mutationType as RunAuditEvent["mutationType"],