From a9b30013bb00b8a02f5dc4d591cef47d793a0b2d Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 26 Jul 2026 15:38:05 -0700 Subject: [PATCH] fix(core): approval dedupe lookup matches on PostgreSQL instead of minting duplicates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `findLatestByDedupeKey` read `targetContext` through the string-only `fromJson`. In backend (PostgreSQL) mode that column is jsonb and Drizzle returns it ALREADY PARSED, so the dedupe scan never matched: every gate retry minted a duplicate approval request, and an approved grant could never be redeemed. The live database shows the signature plainly — 17 approved requests, 0 completed. Normalize both shapes in one place (`normalizeTargetContext`), applied at `rowToRequest` and both dedupe scan sites, so a row resolves whether it arrives as a JSON string (SQLite) or a parsed object (Postgres). The regression test asserts shape-independence rather than the single reported case: the same stored key must resolve in BOTH shapes, and must not match a different key or an absent context in either. Mutation-checked — reverting the scan sites fails exactly the parsed-object case. Cherry-picked ahead of #2457, which carries the wider approval/permission hardening pass, because this one is an active production defect on its own. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/approval-dedupe-pg-lookup.md | 7 ++ ...roval-request-dedupe-context-shape.test.ts | 84 +++++++++++++++++++ packages/core/src/approval-request-store.ts | 23 ++++- 3 files changed, 111 insertions(+), 3 deletions(-) create mode 100644 .changeset/approval-dedupe-pg-lookup.md create mode 100644 packages/core/src/__tests__/approval-request-dedupe-context-shape.test.ts 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); }