FN-8988: partition agent ratings by project
Isolate PostgreSQL agent ratings within each project while preserving compatibility behavior. - Add migration 0055 to enforce the (project_id, id) rating identity key and repair drifted composite keys. - Scope rating CRUD operations to bound projects and document the storage contract. - Cover cross-project rating isolation and drifted-key migration repair. Files changed: .changeset/fn-8988-agent-ratings-project-partition.md | 7 ++ docs/storage.md | 1 + packages/core/src/__tests__/postgres/agent-ratings-project-isolation.pg.test.ts | 105 +++++++++++++++++++++ packages/core/src/__tests__/postgres/schema-applier.test.ts | 66 ++++++++++++- packages/core/src/agents/agent-store.ts | 18 +++- packages/core/src/async-stores/async-agent-store.ts | 11 ++- packages/core/src/postgres/migrations/0055_fn_8988_agent_ratings_project_partition.sql | 74 +++++++++++++++ packages/core/src/postgres/schema-applier.ts | 12 ++- packages/core/src/postgres/schema/project.ts | 5 + 9 files changed, 293 insertions(+), 6 deletions(-) Fusion-Task-Id: FN-8988 Fusion-Task-Lineage: 5d3e5e60-1589-4641-8695-1786f1c15c5d Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-8988-agent-ratings-project-partition.md
Normal file
7
.changeset/fn-8988-agent-ratings-project-partition.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Agent ratings are now isolated per project on shared PostgreSQL databases.
|
||||
category: fix
|
||||
dev: Migration 0055 and SCHEMA_BASELINE_VERSION protect the composite partition; addRating/getRatings/deleteRating use bound project scope.
|
||||
@@ -35,6 +35,7 @@ See the [2026-07-14 PostgreSQL runtime cutover review](./postgres-migration-revi
|
||||
- `project.symbol_locks` is the project-scoped, lease-based admission seam for later mission-lineage scheduling. Its composite `(project_id, symbol_key)` identity permits only one current lock row per normalized symbol in a project; ownership records task ID plus optional mission, feature, lineage, node, and agent IDs.
|
||||
- Lock acquire is all-or-nothing over normalized keys. Held unexpired rows owned by another task return their owner as a conflict, while expired/released rows may be reclaimed. Renewal and release are owner-scoped and release is idempotent.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, expect, it } from "vitest";
|
||||
import {
|
||||
createSharedPgTaskStoreTestHarness,
|
||||
pgDescribe,
|
||||
type SharedPgTaskStoreHarness,
|
||||
} from "../../__test-utils__/pg-test-harness.js";
|
||||
import { AgentStore } from "../../agents/agent-store.js";
|
||||
import {
|
||||
addRating,
|
||||
deleteRating,
|
||||
getRatings,
|
||||
writeAgent,
|
||||
} from "../../async-stores/async-agent-store.js";
|
||||
import type { AsyncDataLayer } from "../../postgres/data-layer.js";
|
||||
import type { Agent, AgentRating } from "../../types.js";
|
||||
|
||||
pgDescribe("agent ratings project isolation", () => {
|
||||
const h: SharedPgTaskStoreHarness = createSharedPgTaskStoreTestHarness({
|
||||
prefix: "fusion_agent_ratings_isolation",
|
||||
});
|
||||
|
||||
beforeAll(h.beforeAll);
|
||||
afterAll(h.afterAll);
|
||||
beforeEach(h.beforeEach);
|
||||
afterEach(h.afterEach);
|
||||
|
||||
it("isolates duplicate agent and rating identities while preserving unbound compatibility", async () => {
|
||||
/*
|
||||
FNXC:AgentRatingsProjectIsolation 2026-08-12-01:00:
|
||||
Owner-connected PostgreSQL deployments bypass RLS, so application predicates must keep duplicate agent and rating IDs in their bound project. Blank bindings remain intentionally unscoped for compatibility while their writes are trigger-stamped.
|
||||
*/
|
||||
const bind = (projectId: string): AsyncDataLayer => ({ ...h.layer(), projectId });
|
||||
const projectA = bind("ratings-project-a");
|
||||
const projectB = bind("ratings-project-b");
|
||||
const agentId = "shared-agent";
|
||||
const now = "2026-08-12T01:00:00.000Z";
|
||||
const agent = (name: string): Agent => ({
|
||||
id: agentId,
|
||||
name,
|
||||
role: "executor",
|
||||
roles: ["executor"],
|
||||
state: "idle",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
const rating = (id: string, score: number, category: string, comment: string): AgentRating => ({
|
||||
id,
|
||||
agentId,
|
||||
raterType: "user",
|
||||
score,
|
||||
category,
|
||||
comment,
|
||||
createdAt: now,
|
||||
});
|
||||
|
||||
await writeAgent(projectA.db, agent("Project A"), projectA.projectId);
|
||||
await writeAgent(projectB.db, agent("Project B"), projectB.projectId);
|
||||
await addRating(projectA.db, rating("shared-rating", 5, "quality", "A shared"), projectA.projectId);
|
||||
await addRating(projectA.db, rating("a-only", 4, "delivery", "A only"), projectA.projectId);
|
||||
await addRating(projectB.db, rating("shared-rating", 1, "quality", "B shared"), projectB.projectId);
|
||||
await addRating(projectB.db, rating("b-only", 2, "delivery", "B only"), projectB.projectId);
|
||||
|
||||
expect((await getRatings(projectA.db, agentId, undefined, projectA.projectId)).map(({ id, score, comment }) => ({ id, score, comment })))
|
||||
.toEqual([
|
||||
{ id: "shared-rating", score: 5, comment: "A shared" },
|
||||
{ id: "a-only", score: 4, comment: "A only" },
|
||||
]);
|
||||
expect((await getRatings(projectA.db, agentId, { category: "quality" }, projectA.projectId)).map((row) => row.comment))
|
||||
.toEqual(["A shared"]);
|
||||
expect((await getRatings(projectA.db, agentId, { limit: 1 }, projectA.projectId)).map((row) => row.id))
|
||||
.toEqual(["shared-rating"]);
|
||||
|
||||
const agentStoreA = new AgentStore({ rootDir: h.rootDir(), asyncLayer: projectA });
|
||||
expect(await agentStoreA.getRatingSummary(agentId)).toMatchObject({
|
||||
totalRatings: 2,
|
||||
averageScore: 4.5,
|
||||
categoryAverages: { quality: 5, delivery: 4 },
|
||||
});
|
||||
expect(await agentStoreA.getRatingSummary("other-project-agent")).toMatchObject({
|
||||
totalRatings: 0,
|
||||
averageScore: 0,
|
||||
trend: "insufficient-data",
|
||||
});
|
||||
|
||||
expect(await deleteRating(projectA.db, "b-only", projectA.projectId)).toBe(false);
|
||||
expect((await getRatings(projectB.db, agentId, undefined, projectB.projectId)).map((row) => row.id))
|
||||
.toEqual(["shared-rating", "b-only"]);
|
||||
expect(await deleteRating(projectA.db, "shared-rating", projectA.projectId)).toBe(true);
|
||||
expect((await getRatings(projectB.db, agentId, undefined, projectB.projectId)).map((row) => row.id))
|
||||
.toEqual(["shared-rating", "b-only"]);
|
||||
|
||||
const unbound = { ...h.layer(), projectId: "" } satisfies AsyncDataLayer;
|
||||
await addRating(unbound.db, {
|
||||
id: "unbound-rating",
|
||||
agentId: "unbound-agent",
|
||||
raterType: "user",
|
||||
score: 3,
|
||||
createdAt: now,
|
||||
}, unbound.projectId);
|
||||
expect((await getRatings(unbound.db, "unbound-agent", undefined, unbound.projectId)).map((row) => row.id))
|
||||
.toEqual(["unbound-rating"]);
|
||||
expect(await deleteRating(unbound.db, "unbound-rating", unbound.projectId)).toBe(true);
|
||||
expect(await getRatings(unbound.db, "unbound-agent", undefined, unbound.projectId)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -97,6 +97,7 @@ import {
|
||||
MEMORY_RECALL_RECORDS_VERSION,
|
||||
MISSION_FEATURE_SPEC_ALIGNMENT_VERSION,
|
||||
AGENT_RATING_PROJECT_ISOLATION_VERSION,
|
||||
AGENT_RATINGS_PROJECT_PARTITION_VERSION,
|
||||
} from "../../postgres/schema-applier.js";
|
||||
import { ProjectPartitionRekeyError, rekeyFallbackProjectPartition } from "../../postgres/migration-stamping.js";
|
||||
import type { PluginSchemaInitHook } from "../../postgres/plugin-schema-hook.js";
|
||||
@@ -127,7 +128,8 @@ describe("schema-applier: immutable migration identities", () => {
|
||||
expect(MEMORY_RECALL_RECORDS_VERSION).toBe("0052");
|
||||
expect(MISSION_FEATURE_SPEC_ALIGNMENT_VERSION).toBe("0053");
|
||||
expect(AGENT_RATING_PROJECT_ISOLATION_VERSION).toBe("0054");
|
||||
expect(SCHEMA_BASELINE_VERSION).toBe("0054");
|
||||
expect(AGENT_RATINGS_PROJECT_PARTITION_VERSION).toBe("0055");
|
||||
expect(SCHEMA_BASELINE_VERSION).toBe("0055");
|
||||
});
|
||||
|
||||
it("keeps monitor and approval isolation assigned to version 0003", () => {
|
||||
@@ -1797,6 +1799,7 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => {
|
||||
MEMORY_RECALL_RECORDS_VERSION,
|
||||
MISSION_FEATURE_SPEC_ALIGNMENT_VERSION,
|
||||
AGENT_RATING_PROJECT_ISOLATION_VERSION,
|
||||
AGENT_RATINGS_PROJECT_PARTITION_VERSION,
|
||||
]);
|
||||
expect((await applySchemaBaseline(ctx.db, { pluginHooks: [] })).applied).toBe(false);
|
||||
});
|
||||
@@ -1877,6 +1880,7 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => {
|
||||
MEMORY_RECALL_RECORDS_VERSION,
|
||||
MISSION_FEATURE_SPEC_ALIGNMENT_VERSION,
|
||||
AGENT_RATING_PROJECT_ISOLATION_VERSION,
|
||||
AGENT_RATINGS_PROJECT_PARTITION_VERSION,
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -2090,6 +2094,7 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => {
|
||||
MEMORY_RECALL_RECORDS_VERSION,
|
||||
MISSION_FEATURE_SPEC_ALIGNMENT_VERSION,
|
||||
AGENT_RATING_PROJECT_ISOLATION_VERSION,
|
||||
AGENT_RATINGS_PROJECT_PARTITION_VERSION,
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -2184,6 +2189,7 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => {
|
||||
MEMORY_RECALL_RECORDS_VERSION,
|
||||
MISSION_FEATURE_SPEC_ALIGNMENT_VERSION,
|
||||
AGENT_RATING_PROJECT_ISOLATION_VERSION,
|
||||
AGENT_RATINGS_PROJECT_PARTITION_VERSION,
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -2278,6 +2284,7 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => {
|
||||
MEMORY_RECALL_RECORDS_VERSION,
|
||||
MISSION_FEATURE_SPEC_ALIGNMENT_VERSION,
|
||||
AGENT_RATING_PROJECT_ISOLATION_VERSION,
|
||||
AGENT_RATINGS_PROJECT_PARTITION_VERSION,
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -2336,6 +2343,63 @@ pgDescribe("schema-applier: VAL-SCHEMA-006 AUTOINCREMENT → identity with seque
|
||||
});
|
||||
});
|
||||
|
||||
pgDescribe("schema-applier: agent ratings project partition", () => {
|
||||
let ctx: TestContext | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
await teardownDb(ctx);
|
||||
ctx = null;
|
||||
});
|
||||
|
||||
it("creates the composite rating key and reapplying is a no-op", async () => {
|
||||
/*
|
||||
FNXC:AgentRatingsProjectIsolation 2026-08-12-01:00:
|
||||
Fresh baselines and repaired upgrades must agree that duplicate rating IDs are legal only across project partitions. Reapplying after bookkeeping must not mutate the healthy schema.
|
||||
*/
|
||||
ctx = await setupFreshDb();
|
||||
await applySchemaBaseline(ctx.db);
|
||||
const columns = (await ctx.db.execute(sql`
|
||||
SELECT column_name FROM information_schema.columns
|
||||
WHERE table_schema = 'project' AND table_name = 'agent_ratings' AND column_name = 'project_id'
|
||||
`)) as unknown as Array<{ column_name: string }>;
|
||||
expect(columns).toEqual([{ column_name: "project_id" }]);
|
||||
const keys = (await ctx.db.execute(sql`
|
||||
SELECT a.attname AS column_name
|
||||
FROM pg_constraint c
|
||||
JOIN unnest(c.conkey) WITH ORDINALITY AS k(attnum, ordinal) ON true
|
||||
JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = k.attnum
|
||||
WHERE c.conrelid = 'project.agent_ratings'::regclass AND c.contype = 'p'
|
||||
ORDER BY k.ordinal
|
||||
`)) as unknown as Array<{ column_name: string }>;
|
||||
expect(keys).toEqual([{ column_name: "project_id" }, { column_name: "id" }]);
|
||||
await expect(applySchemaBaseline(ctx.db)).resolves.toMatchObject({ applied: false });
|
||||
});
|
||||
|
||||
it("repairs a drifted key that contains project_id but omits rating id", async () => {
|
||||
/*
|
||||
FNXC:AgentRatingsProjectPartition 2026-08-12-01:30:
|
||||
Historical drift can leave (project_id, agent_id) as the primary key. The
|
||||
upgrade must replace it because only (project_id, id) protects rating identity.
|
||||
*/
|
||||
ctx = await setupFreshDb();
|
||||
await applySchemaBaseline(ctx.db);
|
||||
await ctx.db.execute(sql`ALTER TABLE project.agent_ratings DROP CONSTRAINT agent_ratings_pkey`);
|
||||
await ctx.db.execute(sql`ALTER TABLE project.agent_ratings ADD CONSTRAINT agent_ratings_pkey PRIMARY KEY (project_id, agent_id)`);
|
||||
await ctx.db.execute(sql`DELETE FROM public.fusion_schema_migrations WHERE version = ${AGENT_RATINGS_PROJECT_PARTITION_VERSION}`);
|
||||
|
||||
await expect(applySchemaBaseline(ctx.db)).resolves.toMatchObject({ applied: true });
|
||||
const keys = (await ctx.db.execute(sql`
|
||||
SELECT a.attname AS column_name
|
||||
FROM pg_constraint c
|
||||
JOIN unnest(c.conkey) WITH ORDINALITY AS k(attnum, ordinal) ON true
|
||||
JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = k.attnum
|
||||
WHERE c.conrelid = 'project.agent_ratings'::regclass AND c.contype = 'p'
|
||||
ORDER BY k.ordinal
|
||||
`)) as unknown as Array<{ column_name: string }>;
|
||||
expect(keys).toEqual([{ column_name: "project_id" }, { column_name: "id" }]);
|
||||
});
|
||||
});
|
||||
|
||||
pgDescribe("schema-applier: VAL-SCHEMA-005 CHECK constraints preserved and enforced", () => {
|
||||
let ctx: TestContext | null = null;
|
||||
|
||||
|
||||
@@ -1040,8 +1040,12 @@ export class AgentStore extends EventEmitter {
|
||||
* FNXC:SqliteFinalRemoval 2026-06-26-09:15:
|
||||
* Backend-mode: delegate to async Drizzle addRating helper. The score CHECK
|
||||
* constraint is enforced by PostgreSQL (VAL-SCHEMA-005).
|
||||
*
|
||||
* FNXC:AgentRatingsProjectIsolation 2026-08-12-01:00:
|
||||
* Ratings use the soft workflow project accessor: unbound compatibility stores
|
||||
* must retain trigger-stamped writes rather than throw like heartbeat operations.
|
||||
*/
|
||||
const saved = await addRatingAsync(this.asyncLayer!.db, rating, this.workflowProjectId);
|
||||
const saved = await addRatingAsync(this.asyncLayer!.db, rating, this.workflowProjectId);
|
||||
this.emit("rating:added", saved);
|
||||
return saved;
|
||||
}
|
||||
@@ -1050,8 +1054,12 @@ export class AgentStore extends EventEmitter {
|
||||
/*
|
||||
* FNXC:SqliteFinalRemoval 2026-06-26-09:15:
|
||||
* Backend-mode: delegate to async Drizzle getRatings helper.
|
||||
*
|
||||
* FNXC:AgentRatingsProjectIsolation 2026-08-12-01:00:
|
||||
* workflowProjectId is intentionally soft so an unbound store keeps its historic
|
||||
* unscoped compatibility read instead of invoking backendProjectId's hard guard.
|
||||
*/
|
||||
return getRatingsAsync(this.asyncLayer!.db, agentId, options, this.workflowProjectId);
|
||||
return getRatingsAsync(this.asyncLayer!.db, agentId, options, this.workflowProjectId);
|
||||
}
|
||||
|
||||
async getRatingSummary(agentId: string): Promise<AgentRatingSummary> {
|
||||
@@ -1118,8 +1126,12 @@ export class AgentStore extends EventEmitter {
|
||||
/*
|
||||
* FNXC:SqliteFinalRemoval 2026-06-26-09:15:
|
||||
* Backend-mode: delegate to async Drizzle deleteRating helper.
|
||||
*
|
||||
* FNXC:AgentRatingsProjectIsolation 2026-08-12-01:00:
|
||||
* Use the soft workflow project binding to scope bound deletes without breaking
|
||||
* the documented unbound compatibility path.
|
||||
*/
|
||||
await deleteRatingAsync(this.asyncLayer!.db, ratingId, this.workflowProjectId);
|
||||
await deleteRatingAsync(this.asyncLayer!.db, ratingId, this.workflowProjectId);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,10 @@
|
||||
* - `agent_api_keys` — API key records (data jsonb, revokedAt)
|
||||
* - `agent_config_revisions` — config revision history (data jsonb)
|
||||
* - `agent_blocked_states` — blocked-task dedup snapshots (data jsonb)
|
||||
* - `agent_ratings` — agent ratings (score CHECK 1..5)
|
||||
* - `agent_ratings` — project-owned agent ratings (score CHECK 1..5)
|
||||
*
|
||||
* FNXC:AgentRatingsProjectIsolation 2026-08-12-01:00:
|
||||
* Rating helpers receive an optional trailing project id. Blank or undefined bindings preserve compatibility behavior: writes are trigger-stamped while reads and deletes remain unscoped.
|
||||
*
|
||||
* SQLite → PostgreSQL notes (VAL-SCHEMA-004):
|
||||
* - The `data` and `metadata` columns on `agents` (and the `data` columns on
|
||||
@@ -861,6 +864,9 @@ function mapRatingRow(row: AgentRatingRow): AgentRating {
|
||||
/**
|
||||
* Get ratings for an agent (newest first), optionally filtered by category
|
||||
* and capped at `limit`.
|
||||
*
|
||||
* FNXC:AgentRatingsProjectIsolation 2026-08-12-01:00:
|
||||
* A bound project id scopes the read; an absent or blank id deliberately remains an unscoped compatibility read.
|
||||
*/
|
||||
export async function getRatings(
|
||||
handle: QueryHandle,
|
||||
@@ -888,6 +894,9 @@ export async function getRatings(
|
||||
|
||||
/**
|
||||
* Delete a rating by id.
|
||||
*
|
||||
* FNXC:AgentRatingsProjectIsolation 2026-08-12-01:00:
|
||||
* Bound deletion is constrained to its rating partition, while an unbound compatibility store retains the prior unscoped behavior.
|
||||
*/
|
||||
export async function deleteRating(
|
||||
handle: QueryHandle,
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
FNXC:AgentRatingsProjectIsolation 2026-08-12-01:00:
|
||||
Migration 0006 dynamically gave every project table an ownership column, RLS policy, trigger, and composite key. This targeted reconciliation is intentionally idempotent: it is a no-op on that healthy shape while repairing historical databases whose agent_ratings metadata or ORM contract drifted.
|
||||
*/
|
||||
DO $$
|
||||
DECLARE
|
||||
legacy_owner text := '__legacy_unscoped__';
|
||||
migration_project_count integer := 0;
|
||||
primary_key_name text;
|
||||
primary_key_is_project_partition boolean;
|
||||
BEGIN
|
||||
IF to_regclass('project.agent_ratings') IS NULL THEN
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
IF to_regclass('public.fusion_sqlite_migrations') IS NOT NULL THEN
|
||||
SELECT count(DISTINCT project_id), min(project_id)
|
||||
INTO migration_project_count, legacy_owner
|
||||
FROM public.fusion_sqlite_migrations
|
||||
WHERE project_id IS NOT NULL AND project_id <> '';
|
||||
IF migration_project_count <> 1 THEN
|
||||
legacy_owner := '__legacy_unscoped__';
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
ALTER TABLE project.agent_ratings ADD COLUMN IF NOT EXISTS project_id text;
|
||||
UPDATE project.agent_ratings
|
||||
SET project_id = legacy_owner
|
||||
WHERE project_id IS NULL OR project_id = '';
|
||||
ALTER TABLE project.agent_ratings
|
||||
ALTER COLUMN project_id SET DEFAULT COALESCE(NULLIF(current_setting('fusion.project_id', true), ''), '__legacy_unscoped__'),
|
||||
ALTER COLUMN project_id SET NOT NULL;
|
||||
|
||||
ALTER TABLE project.agent_ratings ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE project.agent_ratings FORCE ROW LEVEL SECURITY;
|
||||
DROP POLICY IF EXISTS fusion_project_isolation ON project.agent_ratings;
|
||||
CREATE POLICY fusion_project_isolation ON project.agent_ratings
|
||||
USING (current_setting('fusion.project_bypass', true) = 'on' OR project_id = current_setting('fusion.project_id', true))
|
||||
WITH CHECK (current_setting('fusion.project_bypass', true) = 'on' OR project_id = current_setting('fusion.project_id', true));
|
||||
|
||||
IF to_regprocedure('project.fusion_assign_project_id()') IS NOT NULL THEN
|
||||
DROP TRIGGER IF EXISTS fusion_assign_project_id ON project.agent_ratings;
|
||||
CREATE TRIGGER fusion_assign_project_id
|
||||
BEFORE INSERT OR UPDATE OF project_id ON project.agent_ratings
|
||||
FOR EACH ROW EXECUTE FUNCTION project.fusion_assign_project_id();
|
||||
END IF;
|
||||
|
||||
/*
|
||||
FNXC:AgentRatingsProjectPartition 2026-08-12-01:30:
|
||||
A primary key merely containing project_id does not establish rating identity:
|
||||
(project_id, agent_id) still allows duplicate rating ids within a project.
|
||||
Rebuild every non-exact key so the durable contract is ordered (project_id, id).
|
||||
*/
|
||||
SELECT c.conname,
|
||||
ARRAY(
|
||||
SELECT a.attname::text
|
||||
FROM unnest(c.conkey) WITH ORDINALITY AS key_column(attnum, ordinal)
|
||||
JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = key_column.attnum
|
||||
ORDER BY key_column.ordinal
|
||||
) = ARRAY['project_id', 'id']
|
||||
INTO primary_key_name, primary_key_is_project_partition
|
||||
FROM pg_constraint c
|
||||
WHERE c.conrelid = 'project.agent_ratings'::regclass AND c.contype = 'p';
|
||||
|
||||
IF primary_key_name IS NOT NULL AND NOT primary_key_is_project_partition THEN
|
||||
EXECUTE format('ALTER TABLE project.agent_ratings DROP CONSTRAINT %I', primary_key_name);
|
||||
ALTER TABLE project.agent_ratings ADD CONSTRAINT agent_ratings_pkey PRIMARY KEY (project_id, id);
|
||||
ELSIF primary_key_name IS NULL THEN
|
||||
ALTER TABLE project.agent_ratings ADD CONSTRAINT agent_ratings_pkey PRIMARY KEY (project_id, id);
|
||||
END IF;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "idxAgentRatingsProjectAgentId"
|
||||
ON project.agent_ratings(project_id, agent_id);
|
||||
END $$;
|
||||
@@ -62,7 +62,7 @@ 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 = "0054";
|
||||
export const SCHEMA_BASELINE_VERSION = "0055";
|
||||
/** 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";
|
||||
@@ -213,6 +213,8 @@ export const MEMORY_RECALL_RECORDS_VERSION = "0052";
|
||||
export const MISSION_FEATURE_SPEC_ALIGNMENT_VERSION = "0053";
|
||||
/** FNXC:MultiProjectIsolation 2026-08-11-10:25: keep rating identity project-local after the universal ownership migration. */
|
||||
export const AGENT_RATING_PROJECT_ISOLATION_VERSION = "0054";
|
||||
/** FNXC:AgentRatingsProjectIsolation 2026-08-12-01:00: targeted idempotent reconciliation protects historical rating ownership drift. */
|
||||
export const AGENT_RATINGS_PROJECT_PARTITION_VERSION = "0055";
|
||||
|
||||
/** SECURITY DEFINER helper that only inserts LEGACY_ADOPTION_DRAINED_MARKER. */
|
||||
export const LEGACY_ADOPTION_DRAINED_MARKER_FUNCTION = "fusion_mark_legacy_adoption_drained";
|
||||
@@ -441,6 +443,7 @@ const SPEC_LOCK_SOURCE_REVISION_BIGINT_MIGRATION_PATH = join(MIGRATIONS_DIR, "00
|
||||
const MEMORY_RECALL_RECORDS_MIGRATION_PATH = join(MIGRATIONS_DIR, "0052_fn_8922_memory_recall_records.sql");
|
||||
const MISSION_FEATURE_SPEC_ALIGNMENT_MIGRATION_PATH = join(MIGRATIONS_DIR, "0053_mission_feature_spec_alignment.sql");
|
||||
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");
|
||||
|
||||
/**
|
||||
* Ensure the migration bookkeeping table exists. Lives in the public schema so
|
||||
@@ -565,6 +568,7 @@ export async function applySchemaBaseline(
|
||||
const memoryRecallRecordsAlreadyApplied = applied.includes(MEMORY_RECALL_RECORDS_VERSION);
|
||||
const missionFeatureSpecAlignmentAlreadyApplied = applied.includes(MISSION_FEATURE_SPEC_ALIGNMENT_VERSION);
|
||||
const agentRatingProjectIsolationAlreadyApplied = applied.includes(AGENT_RATING_PROJECT_ISOLATION_VERSION);
|
||||
const agentRatingsProjectPartitionAlreadyApplied = applied.includes(AGENT_RATINGS_PROJECT_PARTITION_VERSION);
|
||||
assertBinaryNotOlderThanDatabase(applied);
|
||||
let schemaChanged = false;
|
||||
|
||||
@@ -1241,6 +1245,12 @@ export async function applySchemaBaseline(
|
||||
await tx.execute(sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${AGENT_RATING_PROJECT_ISOLATION_VERSION}) ON CONFLICT (version) DO NOTHING`);
|
||||
schemaChanged = true;
|
||||
}
|
||||
if (!agentRatingsProjectPartitionAlreadyApplied) {
|
||||
const migrationSql = await readFile(AGENT_RATINGS_PROJECT_PARTITION_MIGRATION_PATH, "utf8");
|
||||
await tx.execute(sql.raw(migrationSql));
|
||||
await tx.execute(sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${AGENT_RATINGS_PROJECT_PARTITION_VERSION}) ON CONFLICT (version) DO NOTHING`);
|
||||
schemaChanged = true;
|
||||
}
|
||||
return { applied: schemaChanged, pluginHooksRun: pluginHooks.length };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2041,6 +2041,10 @@ export const messages = projectSchema.table("messages", {
|
||||
index("idxMessagesCreatedAt").on(t.createdAt),
|
||||
]);
|
||||
|
||||
/*
|
||||
FNXC:AgentRatingsProjectIsolation 2026-08-12-01:00:
|
||||
Agent ratings belong to the same project-local identity partition as durable agents. Migration 0006 dynamically reconciled deployed tables; the explicit 0055 reconciliation keeps this Drizzle contract and its physical key/index guarantees aligned on every upgrade path.
|
||||
*/
|
||||
export const agentRatings = projectSchema.table("agent_ratings", {
|
||||
projectId: text("project_id").notNull().default(sql`current_setting('fusion.project_id', true)`),
|
||||
id: text("id").notNull(),
|
||||
@@ -2057,6 +2061,7 @@ export const agentRatings = projectSchema.table("agent_ratings", {
|
||||
primaryKey({ columns: [t.projectId, t.id] }),
|
||||
check("agent_ratings_score_check", sql`${t.score} BETWEEN 1 AND 5`),
|
||||
index("idxAgentRatingsAgentId").on(t.projectId, t.agentId),
|
||||
index("idxAgentRatingsProjectAgentId").on(t.projectId, t.agentId),
|
||||
index("idxAgentRatingsCreatedAt").on(t.createdAt),
|
||||
]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user