diff --git a/.changeset/approval-dedupe-pg-lookup.md b/.changeset/approval-dedupe-pg-lookup.md new file mode 100644 index 0000000000..4ff7b46393 --- /dev/null +++ b/.changeset/approval-dedupe-pg-lookup.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Approval reuse now works on PostgreSQL instead of minting a duplicate request every retry. +category: fix +dev: `ApprovalRequestStore.findLatestByDedupeKey` fed Drizzle's already-parsed jsonb `targetContext` through the string-only `fromJson`, so the dedupe scan never matched in backend mode. Adds `normalizeTargetContext` to handle both the SQLite JSON-string and PG parsed-object shapes at `rowToRequest` plus both dedupe scan sites. diff --git a/packages/core/src/__tests__/approval-request-dedupe-context-shape.test.ts b/packages/core/src/__tests__/approval-request-dedupe-context-shape.test.ts new file mode 100644 index 0000000000..14e357c319 --- /dev/null +++ b/packages/core/src/__tests__/approval-request-dedupe-context-shape.test.ts @@ -0,0 +1,84 @@ +/* +FNXC:ApprovalRedemption 2026-07-26-17:45: +Regression cover for the approval-reuse outage: `findLatestByDedupeKey` read +`targetContext` through a string-only JSON parse, so in PostgreSQL backend mode — +where Drizzle hands back an ALREADY-PARSED jsonb object — the dedupe scan never +matched. Every gate retry minted a duplicate approval request and an approved +grant could never be redeemed. The live database showed the signature clearly: +17 approved requests, 0 completed. + +The invariant under test is shape-independence, not one reproduction: the SAME +stored dedupe key must resolve whether the row arrives as a JSON STRING (SQLite) +or as a PARSED OBJECT (Postgres jsonb). Both directions are asserted here, plus +the non-matching and absent-context cases, so a future change that silently +handles only one shape fails. + +The store's constructor takes an injectable `Database`, so this drives the real +public `findLatestByDedupeKey` through a fake prepare/all seam — no database, no +network, no timers. +*/ +import { describe, expect, it } from "vitest"; +import { ApprovalRequestStore } from "../approval-request-store.js"; + +type Row = Record; + +function makeRow(targetContext: unknown): Row { + return { + id: "apr-1", + status: "approved", + requesterActorId: "agent-7", + requesterActorType: "agent", + requesterActorName: "Executor", + targetActionCategory: "command_execution", + targetActionOperation: "shell command", + targetActionSummary: "run a command", + targetResourceType: "shell", + targetResourceId: "", + targetContext, + taskId: "FN-1", + runId: "run-1", + requestedAt: "2026-07-26T00:00:00.000Z", + decidedAt: null, + completedAt: null, + createdAt: "2026-07-26T00:00:00.000Z", + updatedAt: "2026-07-26T00:00:00.000Z", + }; +} + +/** Minimal sync-Database stand-in: every prepare().all() returns the given rows. */ +function fakeDb(rows: Row[]) { + return { + prepare: () => ({ all: () => rows }), + } as never; +} + +function findKey(rows: Row[], dedupeKey: string) { + const store = new ApprovalRequestStore(fakeDb(rows)); + return store.findLatestByDedupeKey({ + requesterActorId: "agent-7", + taskId: "FN-1", + dedupeKey, + }); +} + +describe("findLatestByDedupeKey targetContext shape handling", () => { + it("matches when the row stores context as a JSON string (SQLite shape)", async () => { + const rows = [makeRow(JSON.stringify({ approvalDedupeKey: "key-abc" }))]; + await expect(findKey(rows, "key-abc")).resolves.toMatchObject({ id: "apr-1" }); + }); + + it("matches when the row stores context as a parsed object (Postgres jsonb shape)", async () => { + // The regression: this row previously fell through the string-only parse and never matched. + const rows = [makeRow({ approvalDedupeKey: "key-abc" })]; + await expect(findKey(rows, "key-abc")).resolves.toMatchObject({ id: "apr-1" }); + }); + + it("does not match a different dedupe key in either shape", async () => { + await expect(findKey([makeRow(JSON.stringify({ approvalDedupeKey: "other" }))], "key-abc")).resolves.toBeNull(); + await expect(findKey([makeRow({ approvalDedupeKey: "other" })], "key-abc")).resolves.toBeNull(); + }); + + it("does not match when the row has no context at all", async () => { + await expect(findKey([makeRow(null)], "key-abc")).resolves.toBeNull(); + }); +}); diff --git a/packages/core/src/approval-request-store.ts b/packages/core/src/approval-request-store.ts index 77c36699a5..ac6d813511 100644 --- a/packages/core/src/approval-request-store.ts +++ b/packages/core/src/approval-request-store.ts @@ -84,6 +84,22 @@ export class ApprovalRequestStore { return this.db; } + /* + FNXC:ApprovalRedemption 2026-07-26-16:40: + In backend (PostgreSQL) mode `targetContext` is a jsonb column that Drizzle + returns ALREADY PARSED, while the sync SQLite path stores a JSON string. + Feeding the parsed object through the string-only `fromJson` made + `findLatestByDedupeKey` never match in PG mode, so every gate retry minted a + duplicate approval request and approved-grant reuse silently never worked in + production. Normalize both shapes here. + */ + private static normalizeTargetContext(value: unknown): Record | undefined { + if (value === null || value === undefined) return undefined; + if (typeof value === "string") return fromJson>(value); + if (typeof value === "object") return value as Record; + return undefined; + } + private rowToRequest(row: ApprovalRequestRow): ApprovalRequest { return { id: row.id, @@ -101,7 +117,7 @@ export class ApprovalRequestStore { summary: row.targetActionSummary, resourceType: row.targetResourceType, resourceId: row.targetResourceId, - context: fromJson>(row.targetContext), + context: ApprovalRequestStore.normalizeTargetContext(row.targetContext), }, taskId: row.taskId ?? undefined, runId: row.runId ?? undefined, @@ -302,7 +318,8 @@ export class ApprovalRequestStore { .where(and(...conditions)) .orderBy(desc(table.createdAt), desc(table.id)); for (const row of rows as ApprovalRequestRow[]) { - const context = fromJson>(row.targetContext); + // FNXC:ApprovalRedemption 2026-07-26-16:40: jsonb rows arrive parsed; see normalizeTargetContext. + const context = ApprovalRequestStore.normalizeTargetContext(row.targetContext); if (context?.approvalDedupeKey === input.dedupeKey) { return this.rowToRequest(row); } @@ -325,7 +342,7 @@ export class ApprovalRequestStore { `).all(...params) as ApprovalRequestRow[]; for (const row of rows) { - const context = fromJson>(row.targetContext); + const context = ApprovalRequestStore.normalizeTargetContext(row.targetContext); if (context?.approvalDedupeKey === input.dedupeKey) { return this.rowToRequest(row); }