fix(core): approval dedupe lookup matches on PostgreSQL instead of minting duplicates

`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) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-26 15:38:05 -07:00
parent 30f81ac0cd
commit a9b30013bb
3 changed files with 111 additions and 3 deletions

View File

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

View File

@@ -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<string, unknown>;
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();
});
});

View File

@@ -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<string, unknown> | undefined {
if (value === null || value === undefined) return undefined;
if (typeof value === "string") return fromJson<Record<string, unknown>>(value);
if (typeof value === "object") return value as Record<string, unknown>;
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<Record<string, unknown>>(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<Record<string, unknown>>(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<Record<string, unknown>>(row.targetContext);
const context = ApprovalRequestStore.normalizeTargetContext(row.targetContext);
if (context?.approvalDedupeKey === input.dedupeKey) {
return this.rowToRequest(row);
}