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) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-12 07:43:29 -07:00
parent 0ed0e53d56
commit 52facd1461
22 changed files with 472 additions and 121 deletions

View File

@@ -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.

View File

@@ -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.

View File

@@ -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<typeof getArtifact>;
const updateArtifact = updateArtifactRow as unknown as (layer: AsyncDataLayer, id: string, patch: { title?: string }, projectId?: string) => ReturnType<typeof updateArtifactRow>;
const artifacts = listArtifacts as unknown as (db: typeof a.db, options: { authorId: string }, projectId?: string) => ReturnType<typeof listArtifacts>;
const secret = getSecretMetadata as unknown as (db: typeof a.db, id: string, scope: "project", projectId?: string) => ReturnType<typeof getSecretMetadata>;
const secrets = listSecrets as unknown as (db: typeof a.db, scope: "project", projectId?: string) => ReturnType<typeof listSecrets>;
const branch = getBranchGroup as unknown as (db: typeof a.db, id: string, projectId?: string) => ReturnType<typeof getBranchGroup>;
const branches = listBranchGroups as unknown as (db: typeof a.db, options: undefined, projectId?: string) => ReturnType<typeof listBranchGroups>;
const updateBranch = updateBranchGroup as unknown as (db: typeof a.db, id: string, patch: { status: "finalized" }, projectId?: string) => ReturnType<typeof updateBranchGroup>;
const chatMessage = getChatMessage as unknown as (db: typeof a.db, id: string, projectId?: string) => ReturnType<typeof getChatMessage>;
const chatMessages = getChatMessages as unknown as (db: typeof a.db, sessionId: string, filter: undefined, projectId?: string) => ReturnType<typeof getChatMessages>;
const audits = queryRunAuditEvents as unknown as (db: typeof a.db, filter: { runId: string; limit?: number }, projectId?: string) => ReturnType<typeof queryRunAuditEvents>;
const approvals = getApprovalRequest as unknown as (db: typeof a.db, id: string, projectId?: string) => ReturnType<typeof getApprovalRequest>;
const approvalList = listApprovalRequests as unknown as (db: typeof a.db, input: object, projectId?: string) => ReturnType<typeof listApprovalRequests>;
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);
});
});

View File

@@ -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<ApprovalRequest | null> {
return asyncApprovalRequestStore.getApprovalRequest(this.asyncLayer!.db, id);
return asyncApprovalRequestStore.getApprovalRequest(this.asyncLayer!.db, id, this.asyncLayer!.projectId);
}
async list(input: ApprovalRequestListInput = {}): Promise<ApprovalRequest[]> {
return asyncApprovalRequestStore.listApprovalRequests(this.asyncLayer!.db, input);
return asyncApprovalRequestStore.listApprovalRequests(this.asyncLayer!.db, input, this.asyncLayer!.projectId);
}
async getPendingCountsByActor(): Promise<Map<string, number>> {
@@ -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<ApprovalRequestAuditEvent[]> {
return asyncApprovalRequestStore.getApprovalAuditHistory(this.asyncLayer!.db, requestId);
return asyncApprovalRequestStore.getApprovalAuditHistory(this.asyncLayer!.db, requestId, this.asyncLayer!.projectId);
}
}

View File

@@ -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<ApprovalRequest | null> {
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<ApprovalRequest[]> {
const conditions: ReturnType<typeof eq>[] = [];
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<ApprovalRequest> {
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<ApprovalRequest> {
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<ApprovalRequestAuditEvent[]> {
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`,
);

View File

@@ -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<string, unknown>): 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<string, unknown>): 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<ChatMessage> {
// 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<ChatMessage | undefined> {
export async function getChatMessage(
handle: QueryHandle,
id: string,
projectId?: string,
): Promise<ChatMessage | undefined> {
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<ChatMessage[]> {
const conditions: ReturnType<typeof eq>[] = [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<Map<string, ChatMessage>> {
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<ChatMessage> {
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<boolean> {
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<Map<string, string>> {
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<string, unknown> | null,
options?: { merge?: boolean },
projectId?: string,
): Promise<ChatMessage> {
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}`);
}

View File

@@ -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<SecretRecord | null> {
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<SecretRecord[]> {
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<SecretRecord> {
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<SecretRecord> {
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<void> {
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<SecretRecord[]> {
return listSecrets(this.layer.db, scope);
return listSecrets(this.layer.db, scope, this.layer.projectId);
}
async getSecretMetadata(id: string, scope: SecretScope): Promise<SecretRecord | null> {
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<SecretRecord> {
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<SecretRecord> {
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<void> {
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;
}

View File

@@ -373,7 +373,7 @@ export class ChatStore extends EventEmitter<ChatStoreEvents> {
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<ChatStoreEvents> {
* Append a file attachment metadata record to an existing message.
*/
async addMessageAttachment(sessionId: string, messageId: string, attachment: ChatAttachment): Promise<ChatMessage> {
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<ChatStoreEvents> {
* @returns Array of messages ordered by createdAt ASC (default) or DESC
*/
async getMessages(sessionId: string, filter?: ChatMessagesFilter): Promise<ChatMessage[]> {
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<ChatStoreEvents> {
* @returns The message, or undefined if not found
*/
async getMessage(id: string): Promise<ChatMessage | undefined> {
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<ChatStoreEvents> {
* @returns Map of sessionId -> latest ChatMessage for that session
*/
async getLastMessageForSessions(sessionIds: string[]): Promise<Map<string, ChatMessage>> {
return asyncChatStore.getLastMessageForSessions(this.asyncLayer.db, sessionIds);
return asyncChatStore.getLastMessageForSessions(this.asyncLayer.db, sessionIds, this.asyncLayer.projectId);
}
async hasMessages(sessionId: string): Promise<boolean> {
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<ChatStoreEvents> {
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<ChatStoreEvents> {
* @returns true if deleted, false if not found
*/
async deleteMessage(id: string): Promise<boolean> {
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<ChatStoreEvents> {
* @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<ChatStoreEvents> {
* is what lets a later edit rewind losslessly via SessionManager.branch()/resetLeaf().
*/
async updateMessageMetadata(messageId: string, metadata: Record<string, unknown> | null, options?: { merge?: boolean }): Promise<ChatMessage> {
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;
}

View File

@@ -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<typeof condition> => 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;

View File

@@ -147,7 +147,7 @@ export class SecretsStore {
}
async listSecrets(scope?: SecretScope): Promise<SecretRecord[]> {
return asyncSecretsStore.listSecrets(this.asyncLayer!.db, scope);
return asyncSecretsStore.listSecrets(this.asyncLayer!.db, scope, this.asyncLayer!.projectId);
}
async listEnvExportable(opts?: { keyPrefix?: string }): Promise<EnvExportableSecret[]> {
@@ -195,7 +195,7 @@ export class SecretsStore {
}
async getSecretMetadata(id: string, scope: SecretScope): Promise<SecretRecord | null> {
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<SecretRecord> {
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;
}

View File

@@ -2544,7 +2544,11 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
/** PostgreSQL-authoritative audit reader; sync fallback remains for test doubles. */
async getRunAuditEventsAsync(options: RunAuditEventFilter = {}): Promise<RunAuditEvent[]> {
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,

View File

@@ -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<Record<string, unknown>[]> {
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<Record<string, unknown>[]> {
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<string, unknown>[];
}

View File

@@ -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<string, unknown> | null {
export async function queryRunAuditEvents(
db: AsyncDataLayer["db"] | DbTransaction,
filter: RunAuditEventFilter = {},
projectId?: string,
): Promise<RunAuditEvent[]> {
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<typeof condition> => 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<number> {
const conditions = [];
const conditions = [projectScopeFor(schema.project.runAuditEvents.projectId, projectId)].filter(
(condition): condition is NonNullable<typeof condition> => condition !== undefined,
);
if (filter.runId) {
conditions.push(eq(schema.project.runAuditEvents.runId, filter.runId));
}

View File

@@ -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<BranchGroup> {
// 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<BranchGroup | null> {
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<BranchGroup | null> {
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<BranchGroup | null> {
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<BranchGroupCreateInput, "sourceType" | "sourceId">,
projectId?: string,
): Promise<BranchGroup> {
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<BranchGroup[]> {
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<BranchGroup> {
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;
}

View File

@@ -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<string>,): Promise<Artifact> {
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<Artifact | null> {
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<ArtifactWithTask[]> {
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))

View File

@@ -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<BranchGroup | null> {
// 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<BranchGroup | null> {
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<BranchGroup | null> {
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<BranchGroup[]> {
const layer = store.asyncLayer!;
return listBranchGroupsAsync(layer.db, options);
return listBranchGroupsAsync(layer.db, options, layer.projectId);
}
/*

View File

@@ -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);
}
/*

View File

@@ -835,7 +835,7 @@ export function insertArtifactRowImpl(store: TaskStore, input: ArtifactCreateInp
export async function getArtifactImpl(store: TaskStore, id: string): Promise<Artifact | null> {
const layer = store.asyncLayer!;
return getArtifactAsync(layer.db, id);
return getArtifactAsync(layer.db, id, layer.projectId);
}
/**

View File

@@ -888,5 +888,5 @@ export async function invokeTaskCreatedHookImpl(store: TaskStore, task: Task): P
export async function createBranchGroupImpl(store: TaskStore, input: BranchGroupCreateInput): Promise<BranchGroup> {
const layer = store.asyncLayer!;
return createBranchGroupAsync(layer.db, input);
return createBranchGroupAsync(layer.db, input, layer.projectId);
}

View File

@@ -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<BranchGroup> {
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<PrEntity> {
@@ -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[];
}

View File

@@ -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),

View File

@@ -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<RunAuditEvent[]> = 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"],