From b6839f4f03ea4993dc16d057be5ca0e6a552d8a5 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 12 Aug 2026 09:13:04 -0700 Subject: [PATCH] FN-9004: reconcile PostgreSQL project ownership defaults Reconcile PostgreSQL project ownership defaults and schema declarations. - Add an idempotent migration restoring the GitHub check-state project default. - Align Drizzle project ownership defaults and composite keys with PostgreSQL. - Cover schema declarations and upgrade migration behavior. Files changed: .changeset/fn-9004-project-ownership-defaults.md | 7 + docs/storage.md | 1 + ...ema-ownership-declarations-remaining.pg.test.ts | 170 +++++++++++++++++++++ .../src/__tests__/postgres/schema-applier.test.ts | 9 +- ...04_project_ownership_default_reconciliation.sql | 13 ++ packages/core/src/postgres/schema-applier.ts | 13 +- packages/core/src/postgres/schema/project.ts | 47 ++++-- 7 files changed, 248 insertions(+), 12 deletions(-) Fusion-Task-Id: FN-9004 Fusion-Task-Lineage: 43bb14f6-01b7-42be-a47c-7da1128f465e Co-authored-by: Fusion (runfusion.ai) --- .../fn-9004-project-ownership-defaults.md | 7 + docs/storage.md | 1 + ...wnership-declarations-remaining.pg.test.ts | 170 ++++++++++++++++++ .../__tests__/postgres/schema-applier.test.ts | 9 +- ...oject_ownership_default_reconciliation.sql | 13 ++ packages/core/src/postgres/schema-applier.ts | 13 +- packages/core/src/postgres/schema/project.ts | 47 +++-- 7 files changed, 248 insertions(+), 12 deletions(-) create mode 100644 .changeset/fn-9004-project-ownership-defaults.md create mode 100644 packages/core/src/__tests__/postgres/project-schema-ownership-declarations-remaining.pg.test.ts create mode 100644 packages/core/src/postgres/migrations/0057_fn_9004_project_ownership_default_reconciliation.sql diff --git a/.changeset/fn-9004-project-ownership-defaults.md b/.changeset/fn-9004-project-ownership-defaults.md new file mode 100644 index 0000000000..9afe89f8f2 --- /dev/null +++ b/.changeset/fn-9004-project-ownership-defaults.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Reconcile PostgreSQL GitHub check-state ownership defaults during upgrades. +category: fix +dev: Migration 0057 restores the project_id ownership default for github_check_states. diff --git a/docs/storage.md b/docs/storage.md index 945e85228f..40c7cf1a11 100644 --- a/docs/storage.md +++ b/docs/storage.md @@ -37,6 +37,7 @@ See the [2026-07-14 PostgreSQL runtime cutover review](./postgres-migration-revi - The `0000_initial.sql` baseline defines the table and indexes only. The later `0025_symbol_locks.sql` migration enables and forces RLS, creates `fusion_project_isolation`, and attaches `fusion_assign_project_id` after `0006_project_ownership.sql` creates that function/policy machinery. Both fresh full-applier and upgrade paths therefore end with the same project-isolation contract. - `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. +- FN-9004 reconciles the post-0006 `project.github_check_states` ownership default. Migration `0057_fn_9004_project_ownership_default_reconciliation.sql` idempotently restores the `0006` GUC/legacy-fallback default without rewriting rows, keys, RLS, or triggers; the `config`, `automations`, `deployments`, and `incidents` Drizzle declarations now also model their catalog-proven project-leading 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). - 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. diff --git a/packages/core/src/__tests__/postgres/project-schema-ownership-declarations-remaining.pg.test.ts b/packages/core/src/__tests__/postgres/project-schema-ownership-declarations-remaining.pg.test.ts new file mode 100644 index 0000000000..10e89ec99e --- /dev/null +++ b/packages/core/src/__tests__/postgres/project-schema-ownership-declarations-remaining.pg.test.ts @@ -0,0 +1,170 @@ +import { afterAll, afterEach, beforeAll, beforeEach, expect, it } from "vitest"; +import { getTableConfig } from "drizzle-orm/pg-core"; +import * as schema from "../../postgres/schema/index.js"; +import type { AsyncDataLayer } from "../../postgres/data-layer.js"; +import { createScheduleRow, listSchedules } from "../../async-stores/async-automation-store.js"; +import { + getIncidentAsync, + ingestIncidentSignalAsync, + recordDeploymentAsync, +} from "../../task-store/async/async-monitor.js"; +import { + listGitHubCheckStatesAsync, + recordGitHubCheckStateAsync, +} from "../../task-store/async/async-ci-checks.js"; +import { + patchProjectSettings, + readProjectConfig, + writeProjectConfig, +} from "../../task-store/async/async-settings.js"; +import { + createSharedPgTaskStoreTestHarness, + pgDescribe, + type SharedPgTaskStoreHarness, +} from "../../__test-utils__/pg-test-harness.js"; + +const tableNames = ["config", "automations", "deployments", "github_check_states", "incidents"] as const; +const declaredTables = { + config: schema.project.config, + automations: schema.project.automations, + deployments: schema.project.deployments, + github_check_states: schema.project.githubCheckStates, + incidents: schema.project.incidents, +} as const; +const expectedPrimaryKeys: Record<(typeof tableNames)[number], string[]> = { + config: ["project_id"], automations: ["project_id", "id"], deployments: ["project_id", "id"], + github_check_states: ["project_id", "id"], incidents: ["project_id", "id"], +}; + +pgDescribe("remaining project schema ownership declarations", () => { + const h: SharedPgTaskStoreHarness = createSharedPgTaskStoreTestHarness({ + prefix: "fusion_fn9004_schema", projectId: "fn9004-runtime-bound", + }); + const unboundHarness: SharedPgTaskStoreHarness = createSharedPgTaskStoreTestHarness({ + prefix: "fusion_fn9004_schema_unbound", + }); + beforeAll(async () => { await h.beforeAll(); await unboundHarness.beforeAll(); }); + afterAll(async () => { await unboundHarness.afterAll(); await h.afterAll(); }); + beforeEach(async () => { await h.beforeEach(); await unboundHarness.beforeEach(); }); + afterEach(async () => { await unboundHarness.afterEach(); await h.afterEach(); }); + + it("matches the post-0006 catalog default and project-leading keys", async () => { + /* + FNXC:MultiProjectIsolation 2026-08-12-15:43: + The declaration sweep must query PostgreSQL rather than infer physical keys from source + migrations. This catches post-0006 tables such as github_check_states that missed its default. + */ + for (const tableName of tableNames) { + const columns = await h.adminSql()>` + SELECT data_type, is_nullable, column_default FROM information_schema.columns + WHERE table_schema = 'project' AND table_name = ${tableName} AND column_name = 'project_id' + `; + expect(columns).toEqual([expect.objectContaining({ data_type: "text", is_nullable: "NO", column_default: expect.stringContaining("current_setting") })]); + const constraints = await h.adminSql()>` + SELECT c.conname, c.contype, array_agg(a.attname ORDER BY k.ordinality) AS columns + FROM pg_constraint c CROSS JOIN LATERAL unnest(c.conkey) WITH ORDINALITY k(attnum, ordinality) + JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = k.attnum + WHERE c.conrelid = ('project.' || ${tableName})::regclass AND c.contype IN ('p', 'u') + GROUP BY c.conname, c.contype ORDER BY c.contype, c.conname + `; + expect(constraints.find((constraint) => constraint.contype === "p")?.columns).toEqual(expectedPrimaryKeys[tableName]); + for (const constraint of constraints) expect(constraint.columns[0]).toBe("project_id"); + const declaration = getTableConfig(declaredTables[tableName]); + expect(declaration.columns.find((column) => column.name === "project_id")?.hasDefault).toBe(true); + const declaredPrimaryKey = tableName === "config" + ? declaration.columns.filter((column) => column.primary).map((column) => column.name) + : declaration.primaryKeys[0]?.columns.map((column) => column.name); + expect(declaredPrimaryKey).toEqual(expectedPrimaryKeys[tableName]); + expect(declaration.uniqueConstraints.map((constraint) => ({ name: constraint.getName(), columns: constraint.columns.map((column) => column.name) }))) + .toEqual(expect.arrayContaining(constraints.filter((constraint) => constraint.contype === "u").map((constraint) => ({ name: constraint.conname, columns: constraint.columns })))); + } + }); + + it("preserves production writer ownership, conflict targets, and scoped reads", async () => { + /* + FNXC:MultiProjectIsolation 2026-08-12-15:57: + Declaration introspection alone cannot prove Drizzle's composite conflict targets or public + mappers. Exercise each owner-writing production helper through bound and unbound layers. + */ + const bind = (projectId: string): AsyncDataLayer => ({ ...h.layer(), projectId }); + const projectA = bind("fn9004-writer-a"); + const projectB = bind("fn9004-writer-b"); + const unbound = unboundHarness.layer(); + const schedule = (id: string) => ({ + id, name: "Shared schedule", scheduleType: "custom" as const, cronExpression: "* * * * *", + command: "echo shared", enabled: true, runCount: 0, runHistory: [], + createdAt: "2026-08-12T00:00:00.000Z", updatedAt: "2026-08-12T00:00:00.000Z", + }); + await createScheduleRow(projectA, schedule("shared")); + await createScheduleRow(projectB, schedule("shared")); + expect(await listSchedules(projectA)).toMatchObject([{ id: "shared" }]); + expect(await listSchedules(projectB)).toMatchObject([{ id: "shared" }]); + await expect(createScheduleRow(unbound, schedule("unbound"))).rejects.toThrow("require asyncLayer.projectId"); + + await recordDeploymentAsync(projectA.db, { deploymentId: "shared", status: "started" }, projectA.projectId); + await expect(recordDeploymentAsync(projectA.db, { deploymentId: "shared", status: "complete" }, projectA.projectId)) + .resolves.toMatchObject({ deploymentId: "shared", status: "complete" }); + await recordDeploymentAsync(projectB.db, { deploymentId: "shared", status: "started" }, projectB.projectId); + await recordDeploymentAsync(unbound.db, { deploymentId: "unbound" }, ""); + + const incidentA = await ingestIncidentSignalAsync(projectA.db, { + groupingKey: "shared", title: "Project A incident", at: "2026-08-12T00:00:00.000Z", + }, projectA.projectId); + const incidentB = await ingestIncidentSignalAsync(projectB.db, { + groupingKey: "shared", title: "Project B incident", at: "2026-08-12T00:00:00.000Z", + }, projectB.projectId); + const unboundIncident = await ingestIncidentSignalAsync(unbound.db, { + groupingKey: "unbound", title: "Legacy incident", at: "2026-08-12T00:00:00.000Z", + }, ""); + expect(await getIncidentAsync(projectA.db, incidentA.incident.incidentId, projectA.projectId)).toMatchObject({ title: "Project A incident" }); + expect(await getIncidentAsync(projectB.db, incidentA.incident.incidentId, projectB.projectId)).toBeNull(); + expect(await getIncidentAsync(unbound.db, unboundIncident.incident.incidentId, "")).toMatchObject({ title: "Legacy incident" }); + expect(incidentB.incident.incidentId).not.toBe(incidentA.incident.incidentId); + + const check = { repo: "Owner/Repo", headSha: "shared", checkName: "ci/build", state: "success", reportedAt: "2026-08-12T00:00:00.000Z" }; + await recordGitHubCheckStateAsync(projectA, check, projectA.projectId!); + await recordGitHubCheckStateAsync(projectB, check, projectB.projectId!); + expect(await listGitHubCheckStatesAsync(projectA, { repo: check.repo, headSha: check.headSha }, projectA.projectId!)).toHaveLength(1); + expect(await listGitHubCheckStatesAsync(projectB, { repo: check.repo, headSha: check.headSha }, projectB.projectId!)).toHaveLength(1); + await expect(recordGitHubCheckStateAsync(unbound, check, " ")).rejects.toThrow("require asyncLayer.projectId"); + + await writeProjectConfig(projectA, { owner: "a" }); + await patchProjectSettings(projectA, { patched: true }); + await writeProjectConfig(projectB, { owner: "b" }); + expect(await readProjectConfig(projectA)).toMatchObject({ settings: { owner: "a", patched: true } }); + expect(await readProjectConfig(projectB)).toMatchObject({ settings: { owner: "b" } }); + await writeProjectConfig(unbound, { owner: "legacy" }); + expect(await readProjectConfig(unbound)).toMatchObject({ settings: { owner: "legacy" } }); + + expect(await h.adminSql()>` + SELECT project_id, deployment_id FROM project.deployments + WHERE deployment_id = 'shared' ORDER BY project_id + `).toEqual([ + { project_id: "fn9004-writer-a", deployment_id: "shared" }, + { project_id: "fn9004-writer-b", deployment_id: "shared" }, + ]); + expect(await unboundHarness.adminSql()>` + SELECT project_id FROM project.deployments WHERE deployment_id = 'unbound' + `).toEqual([{ project_id: "__legacy_unscoped__" }]); + }); + + it("stamps omitted owners and permits duplicate natural identities per partition", async () => { + const insertRows = async (projectId: string): Promise => { + await h.adminSql()`SELECT set_config('fusion.project_id', ${projectId}, false)`; + await h.adminSql()`INSERT INTO project.config (id, updated_at) VALUES (1, '2026-08-12')`; + await h.adminSql()`INSERT INTO project.automations (id, name, schedule_type, cron_expression, command, created_at, updated_at) VALUES ('shared', 'Shared', 'cron', '* * * * *', 'echo shared', '2026-08-12', '2026-08-12')`; + await h.adminSql()`INSERT INTO project.deployments (deployment_id, deployed_at, created_at) VALUES ('shared', '2026-08-12', '2026-08-12')`; + await h.adminSql()`INSERT INTO project.github_check_states (repo, head_sha, check_name, state, reported_at, received_at, created_at, updated_at) VALUES ('repo', 'sha', 'check', 'success', '2026-08-12', '2026-08-12', '2026-08-12', '2026-08-12')`; + await h.adminSql()`INSERT INTO project.incidents (incident_id, grouping_key, title, status, opened_at, created_at, updated_at) VALUES ('shared', 'group', 'Shared', 'open', '2026-08-12', '2026-08-12', '2026-08-12')`; + }; + await insertRows("fn9004-a"); await insertRows("fn9004-b"); + for (const tableName of tableNames) { + const rows = await h.adminSql()>`SELECT count(*)::text AS count FROM project.${h.adminSql().unsafe(tableName)} WHERE project_id IN ('fn9004-a', 'fn9004-b')`; + expect(rows).toEqual([{ count: "2" }]); + } + await h.adminSql()`SELECT set_config('fusion.project_id', '', false)`; + await h.adminSql()`INSERT INTO project.deployments (deployment_id, deployed_at, created_at) VALUES ('unbound', '2026-08-12', '2026-08-12')`; + expect(await h.adminSql()>`SELECT project_id FROM project.deployments WHERE deployment_id = 'unbound'`) + .toEqual([{ project_id: "__legacy_unscoped__" }]); + }); +}); diff --git a/packages/core/src/__tests__/postgres/schema-applier.test.ts b/packages/core/src/__tests__/postgres/schema-applier.test.ts index a96282f9c9..05e3a9d762 100644 --- a/packages/core/src/__tests__/postgres/schema-applier.test.ts +++ b/packages/core/src/__tests__/postgres/schema-applier.test.ts @@ -99,6 +99,7 @@ import { AGENT_RATING_PROJECT_ISOLATION_VERSION, AGENT_RATINGS_PROJECT_PARTITION_VERSION, PROJECT_OWNERSHIP_DECLARATION_DRIFT_VERSION, + PROJECT_OWNERSHIP_DEFAULT_RECONCILIATION_VERSION, } from "../../postgres/schema-applier.js"; import { ProjectPartitionRekeyError, rekeyFallbackProjectPartition } from "../../postgres/migration-stamping.js"; import type { PluginSchemaInitHook } from "../../postgres/plugin-schema-hook.js"; @@ -131,7 +132,8 @@ describe("schema-applier: immutable migration identities", () => { expect(AGENT_RATING_PROJECT_ISOLATION_VERSION).toBe("0054"); expect(AGENT_RATINGS_PROJECT_PARTITION_VERSION).toBe("0055"); expect(PROJECT_OWNERSHIP_DECLARATION_DRIFT_VERSION).toBe("0056"); - expect(SCHEMA_BASELINE_VERSION).toBe("0056"); + expect(PROJECT_OWNERSHIP_DEFAULT_RECONCILIATION_VERSION).toBe("0057"); + expect(SCHEMA_BASELINE_VERSION).toBe("0057"); }); it("keeps monitor and approval isolation assigned to version 0003", () => { @@ -1803,6 +1805,7 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => { AGENT_RATING_PROJECT_ISOLATION_VERSION, AGENT_RATINGS_PROJECT_PARTITION_VERSION, PROJECT_OWNERSHIP_DECLARATION_DRIFT_VERSION, + PROJECT_OWNERSHIP_DEFAULT_RECONCILIATION_VERSION, ]); expect((await applySchemaBaseline(ctx.db, { pluginHooks: [] })).applied).toBe(false); }); @@ -1885,6 +1888,7 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => { AGENT_RATING_PROJECT_ISOLATION_VERSION, AGENT_RATINGS_PROJECT_PARTITION_VERSION, PROJECT_OWNERSHIP_DECLARATION_DRIFT_VERSION, + PROJECT_OWNERSHIP_DEFAULT_RECONCILIATION_VERSION, ]); }); @@ -2100,6 +2104,7 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => { AGENT_RATING_PROJECT_ISOLATION_VERSION, AGENT_RATINGS_PROJECT_PARTITION_VERSION, PROJECT_OWNERSHIP_DECLARATION_DRIFT_VERSION, + PROJECT_OWNERSHIP_DEFAULT_RECONCILIATION_VERSION, ]); }); @@ -2196,6 +2201,7 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => { AGENT_RATING_PROJECT_ISOLATION_VERSION, AGENT_RATINGS_PROJECT_PARTITION_VERSION, PROJECT_OWNERSHIP_DECLARATION_DRIFT_VERSION, + PROJECT_OWNERSHIP_DEFAULT_RECONCILIATION_VERSION, ]); }); @@ -2292,6 +2298,7 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => { AGENT_RATING_PROJECT_ISOLATION_VERSION, AGENT_RATINGS_PROJECT_PARTITION_VERSION, PROJECT_OWNERSHIP_DECLARATION_DRIFT_VERSION, + PROJECT_OWNERSHIP_DEFAULT_RECONCILIATION_VERSION, ]); }); }); diff --git a/packages/core/src/postgres/migrations/0057_fn_9004_project_ownership_default_reconciliation.sql b/packages/core/src/postgres/migrations/0057_fn_9004_project_ownership_default_reconciliation.sql new file mode 100644 index 0000000000..2c8c50ef01 --- /dev/null +++ b/packages/core/src/postgres/migrations/0057_fn_9004_project_ownership_default_reconciliation.sql @@ -0,0 +1,13 @@ +/* +FNXC:MultiProjectIsolation 2026-08-12-15:43: +Migration 0048 created github_check_states after ownership migration 0006 and retained its +pre-isolation empty project_id default. Reconcile only that default so omitted ownership values +continue through the database GUC/trigger path without rewriting rows, keys, RLS, or triggers. +*/ +DO $$ +BEGIN + IF to_regclass('project.github_check_states') IS NOT NULL THEN + ALTER TABLE project.github_check_states + ALTER COLUMN project_id SET DEFAULT COALESCE(NULLIF(current_setting('fusion.project_id', true), ''), '__legacy_unscoped__'); + END IF; +END $$; diff --git a/packages/core/src/postgres/schema-applier.ts b/packages/core/src/postgres/schema-applier.ts index f091df117f..3f3673cedd 100644 --- a/packages/core/src/postgres/schema-applier.ts +++ b/packages/core/src/postgres/schema-applier.ts @@ -62,7 +62,8 @@ capacity-model table drop that landed while this PR was open. /* FNXC:MemoryRecall 2026-08-10-11:03: Explicit baseline registration prevents the recall migration from being silently skipped. */ /* FNXC:SpecLockMissionAlignment 2026-08-10-16:17: advance the schema ceiling so SQLite and PostgreSQL feature projections retain reconciled drift alignment. */ /* FNXC:MultiProjectIsolation 2026-08-11-10:25: schema startup must register project-local agent ratings before bound stores scope their mutations. */ -export const SCHEMA_BASELINE_VERSION = "0056"; +/* FNXC:MultiProjectIsolation 2026-08-12-15:43: 0057 reconciles the post-0006 GitHub check-state ownership default on upgrades. */ +export const SCHEMA_BASELINE_VERSION = "0057"; /** FNXC:SymbolLock 2026-07-20-10:00: upgrades need durable task declarations before admission resolves symbols. */ export const TASK_DECLARED_SYMBOLS_VERSION = "0028"; const INITIAL_SCHEMA_VERSION = "0000"; @@ -217,6 +218,8 @@ export const AGENT_RATING_PROJECT_ISOLATION_VERSION = "0054"; export const AGENT_RATINGS_PROJECT_PARTITION_VERSION = "0055"; /** FNXC:MultiProjectIsolation 2026-08-12-02:12: register FN-8997 predicate indexes explicitly; migration files are never auto-discovered. */ export const PROJECT_OWNERSHIP_DECLARATION_DRIFT_VERSION = "0056"; +/** FNXC:MultiProjectIsolation 2026-08-12-15:43: register the 0048 default reconciliation explicitly for upgrades. */ +export const PROJECT_OWNERSHIP_DEFAULT_RECONCILIATION_VERSION = "0057"; /** SECURITY DEFINER helper that only inserts LEGACY_ADOPTION_DRAINED_MARKER. */ export const LEGACY_ADOPTION_DRAINED_MARKER_FUNCTION = "fusion_mark_legacy_adoption_drained"; @@ -447,6 +450,7 @@ const MISSION_FEATURE_SPEC_ALIGNMENT_MIGRATION_PATH = join(MIGRATIONS_DIR, "0053 const AGENT_RATING_PROJECT_ISOLATION_MIGRATION_PATH = join(MIGRATIONS_DIR, "0054_fn_8957_agent_rating_project_isolation.sql"); const AGENT_RATINGS_PROJECT_PARTITION_MIGRATION_PATH = join(MIGRATIONS_DIR, "0055_fn_8988_agent_ratings_project_partition.sql"); const PROJECT_OWNERSHIP_DECLARATION_DRIFT_MIGRATION_PATH = join(MIGRATIONS_DIR, "0056_fn_8997_project_ownership_declaration_drift.sql"); +const PROJECT_OWNERSHIP_DEFAULT_RECONCILIATION_MIGRATION_PATH = join(MIGRATIONS_DIR, "0057_fn_9004_project_ownership_default_reconciliation.sql"); /** * Ensure the migration bookkeeping table exists. Lives in the public schema so @@ -573,6 +577,7 @@ export async function applySchemaBaseline( const agentRatingProjectIsolationAlreadyApplied = applied.includes(AGENT_RATING_PROJECT_ISOLATION_VERSION); const agentRatingsProjectPartitionAlreadyApplied = applied.includes(AGENT_RATINGS_PROJECT_PARTITION_VERSION); const projectOwnershipDeclarationDriftAlreadyApplied = applied.includes(PROJECT_OWNERSHIP_DECLARATION_DRIFT_VERSION); + const projectOwnershipDefaultReconciliationAlreadyApplied = applied.includes(PROJECT_OWNERSHIP_DEFAULT_RECONCILIATION_VERSION); assertBinaryNotOlderThanDatabase(applied); let schemaChanged = false; @@ -1261,6 +1266,12 @@ export async function applySchemaBaseline( await tx.execute(sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${PROJECT_OWNERSHIP_DECLARATION_DRIFT_VERSION}) ON CONFLICT (version) DO NOTHING`); schemaChanged = true; } + if (!projectOwnershipDefaultReconciliationAlreadyApplied) { + const migrationSql = await readFile(PROJECT_OWNERSHIP_DEFAULT_RECONCILIATION_MIGRATION_PATH, "utf8"); + await tx.execute(sql.raw(migrationSql)); + await tx.execute(sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${PROJECT_OWNERSHIP_DEFAULT_RECONCILIATION_VERSION}) ON CONFLICT (version) DO NOTHING`); + schemaChanged = true; + } return { applied: schemaChanged, pluginHooksRun: pluginHooks.length }; }); } diff --git a/packages/core/src/postgres/schema/project.ts b/packages/core/src/postgres/schema/project.ts index ee8223ab65..d6fd31d7ae 100644 --- a/packages/core/src/postgres/schema/project.ts +++ b/packages/core/src/postgres/schema/project.ts @@ -373,9 +373,14 @@ export const config = projectSchema.table("config", { // now keyed per-project on `project_id` (the effective PK). `id` is retained // for column-shape parity (always 1) but is no longer the PK and no longer // CHECK-constrained. Single-project / SQLite-parity callers leave project_id - // at its '' default (one row), preserving the pre-isolation behavior. + // through the database ownership trigger, preserving the pre-isolation behavior. + /* + FNXC:MultiProjectIsolation 2026-08-12-15:43: + Migration 0006 owns config's project_id default and leaves its catalog PK as (project_id). + Drizzle emits DEFAULT while the database GUC/trigger selects the effective ownership partition. + */ id: integer("id").default(1), - projectId: text("project_id").notNull().default("").primaryKey(), + projectId: text("project_id").notNull().default(sql`current_setting('fusion.project_id', true)`).primaryKey(), nextId: integer("next_id").default(1), nextWorkflowStepId: integer("next_workflow_step_id").default(1), // FNXC:SqliteFinalRemoval 2026-06-28: @@ -832,9 +837,13 @@ export const taskCommitAssociations = projectSchema.table("task_commit_associati export const automations = projectSchema.table("automations", { /* * FNXC:AutomationIsolation 2026-07-13-22:37: - * Automations are partitioned by the AsyncDataLayer's project ID because embedded PostgreSQL consolidates the per-project SQLite files into one table. The composite key deliberately permits the same automation ID in two projects without allowing either project's CRUD or cron-claim path to address the other row. The empty default preserves an explicit partition for legacy and project-agnostic callers until startup stamps migrated rows. + * Automations are partitioned by the AsyncDataLayer's project ID because embedded PostgreSQL consolidates the per-project SQLite files into one table. The composite key deliberately permits the same automation ID in two projects without allowing either project's CRUD or cron-claim path to address the other row. + * + * FNXC:MultiProjectIsolation 2026-08-12-15:43: + * Migration 0006 owns the default through the database GUC/trigger; Drizzle emits DEFAULT + * without replacing the physical legacy fallback or the fail-closed bound writer contract. */ - projectId: text("project_id").notNull().default(""), + projectId: text("project_id").notNull().default(sql`current_setting('fusion.project_id', true)`), id: text("id").notNull(), name: text("name").notNull(), description: text("description"), @@ -1952,8 +1961,13 @@ export const knowledgePages = projectSchema.table("knowledge_pages", { ]); export const deployments = projectSchema.table("deployments", { - id: integer("id").generatedAlwaysAsIdentity().primaryKey(), - projectId: text("project_id").notNull().default(""), + id: integer("id").generatedAlwaysAsIdentity().notNull(), + /* + FNXC:MultiProjectIsolation 2026-08-12-15:43: + Migration 0006 rebuilt deployments_pkey as (project_id, id). Drizzle emits DEFAULT while the + database GUC/trigger keeps ownership stamping and the physical legacy fallback authoritative. + */ + projectId: text("project_id").notNull().default(sql`current_setting('fusion.project_id', true)`), deploymentId: text("deployment_id").notNull(), service: text("service"), environment: text("environment"), @@ -1964,6 +1978,7 @@ export const deployments = projectSchema.table("deployments", { meta: jsonb("meta"), createdAt: text("created_at").notNull(), }, (t) => [ + primaryKey({ columns: [t.projectId, t.id], name: "deployments_pkey" }), uniqueIndex("idxDeploymentsProjectDeploymentId").on(t.projectId, t.deploymentId), index("idxDeploymentsProjectDeployedAt").on(t.projectId, t.deployedAt), index("idxDeploymentsDeployedAt").on(t.deployedAt), @@ -1977,7 +1992,12 @@ required checks cannot admit stale or cross-project results; received_at support */ export const githubCheckStates = projectSchema.table("github_check_states", { id: integer("id").generatedAlwaysAsIdentity().notNull(), - projectId: text("project_id").notNull().default(""), + /* + FNXC:MultiProjectIsolation 2026-08-12-15:43: + Migration 0048 post-dated 0006, so FN-9004 reconciles its physical default to the ownership + expression. Drizzle emits DEFAULT while the database GUC/trigger remains the stamping authority. + */ + projectId: text("project_id").notNull().default(sql`current_setting('fusion.project_id', true)`), repo: text("repo").notNull(), headSha: text("head_sha").notNull(), checkName: text("check_name").notNull(), @@ -1998,9 +2018,14 @@ export const githubCheckStates = projectSchema.table("github_check_states", { ]); export const incidents = projectSchema.table("incidents", { - id: integer("id").generatedAlwaysAsIdentity().primaryKey(), - projectId: text("project_id").notNull().default(""), - incidentId: text("incident_id").notNull().unique(), + id: integer("id").generatedAlwaysAsIdentity().notNull(), + /* + FNXC:MultiProjectIsolation 2026-08-12-15:43: + Migration 0006 rebuilt incident keys with project_id first. Drizzle emits DEFAULT while the + database GUC/trigger retains ownership stamping and its physical legacy fallback. + */ + projectId: text("project_id").notNull().default(sql`current_setting('fusion.project_id', true)`), + incidentId: text("incident_id").notNull(), groupingKey: text("grouping_key").notNull(), title: text("title").notNull(), severity: text("severity"), @@ -2014,6 +2039,8 @@ export const incidents = projectSchema.table("incidents", { createdAt: text("created_at").notNull(), updatedAt: text("updated_at").notNull(), }, (t) => [ + primaryKey({ columns: [t.projectId, t.id], name: "incidents_pkey" }), + unique("incidents_incident_id_key").on(t.projectId, t.incidentId), index("idxIncidentsProjectOpenedAt").on(t.projectId, t.openedAt), index("idxIncidentsProjectStatus").on(t.projectId, t.status), index("idxIncidentsGroupingKey").on(t.groupingKey),