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:
7
.changeset/approval-dedupe-pg-lookup.md
Normal file
7
.changeset/approval-dedupe-pg-lookup.md
Normal 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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user