From 12eeeaf046b52661d8003b6461db0f1d8eff2ca1 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 11 Aug 2026 18:21:24 -0700 Subject: [PATCH] FN-8901: restore duplicate conflict responses Require matching named proposal claims before a deterministic duplicate can be reused. - Restore 409 duplicate responses for ordinary or unrelated proposal intake. - Preserve duplicate reuse for matching trusted recommendation claims. - Cover active and terminal canonical task cases. - Add a patch changeset for the intake fix. Files changed: .changeset/fn-8901-deterministic-duplicate-409.md | 7 ++++ .../__tests__/routes-tasks-near-duplicate.test.ts | 47 ++++++++++++++++++++++ .../src/routes/register-task-workflow-routes.ts | 11 ++++- 3 files changed, 64 insertions(+), 1 deletion(-) Fusion-Task-Id: FN-8901 Fusion-Task-Lineage: f9a41ceb-46aa-4589-9fdc-7f1cc35a01e1 Co-authored-by: Fusion (runfusion.ai) --- .../fn-8901-deterministic-duplicate-409.md | 7 +++ .../routes-tasks-near-duplicate.test.ts | 47 +++++++++++++++++++ .../routes/register-task-workflow-routes.ts | 11 ++++- 3 files changed, 64 insertions(+), 1 deletion(-) create mode 100644 .changeset/fn-8901-deterministic-duplicate-409.md diff --git a/.changeset/fn-8901-deterministic-duplicate-409.md b/.changeset/fn-8901-deterministic-duplicate-409.md new file mode 100644 index 0000000000..191feb57f3 --- /dev/null +++ b/.changeset/fn-8901-deterministic-duplicate-409.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Restore duplicate conflict responses for ordinary task intake. +category: fix +dev: createTaskThroughGuardedIntake now requires a named proposalClaimId before trusted duplicate reuse. diff --git a/packages/dashboard/src/__tests__/routes-tasks-near-duplicate.test.ts b/packages/dashboard/src/__tests__/routes-tasks-near-duplicate.test.ts index 0f5d33ba8e..feb7af81c5 100644 --- a/packages/dashboard/src/__tests__/routes-tasks-near-duplicate.test.ts +++ b/packages/dashboard/src/__tests__/routes-tasks-near-duplicate.test.ts @@ -268,6 +268,53 @@ describe("routes /api/tasks near duplicate", () => { expect(match.reason).toBeUndefined(); }); + it.each([ + ["no proposal claim", undefined], + ["an unrelated proposal claim", "recommendation:FN-1:rec-1"], + ])("blocks an ordinary deterministic duplicate with %s", async (_label, proposalClaimId) => { + const title = routeIncoming.title; + const description = routeIncoming.description; + const fingerprint = core.computeContentFingerprint({ title, description }) as string; + const { app, tasks } = buildApp([ + mkTask({ + id: "FN-8002", + title, + description, + column: "todo", + proposalClaimId, + source: { sourceType: "api", sourceMetadata: { contentFingerprint: fingerprint } }, + }), + ]); + + const res = await performRequest(app, "POST", "/api/tasks", JSON.stringify({ title, description }), { "content-type": "application/json" }); + + expect(res.status).toBe(409); + const match = (res.body as { details: { matches: Array<{ id: string; deterministic?: boolean; reason?: string }> } }).details.matches[0]; + expect(match).toMatchObject({ id: "FN-8002", deterministic: true }); + expect(match.reason).toBeUndefined(); + expect(tasks).toHaveLength(1); + }); + + it.each(["done", "archived"] as const)("allows an ordinary deterministic create when the canonical is %s", async (column) => { + const title = routeIncoming.title; + const description = routeIncoming.description; + const fingerprint = core.computeContentFingerprint({ title, description }) as string; + const { app, tasks } = buildApp([ + mkTask({ + id: "FN-8003", + title, + description, + column, + source: { sourceType: "api", sourceMetadata: { contentFingerprint: fingerprint } }, + }), + ]); + + const res = await performRequest(app, "POST", "/api/tasks", JSON.stringify({ title, description }), { "content-type": "application/json" }); + + expect(res.status).toBe(201); + expect(tasks).toHaveLength(2); + }); + it("fails open when intent extraction throws", async () => { const extractorSpy = vi.spyOn(core, "extractIntentSignature").mockImplementation(() => { throw new Error("boom"); diff --git a/packages/dashboard/src/routes/register-task-workflow-routes.ts b/packages/dashboard/src/routes/register-task-workflow-routes.ts index abb79e2e93..97eaf12199 100644 --- a/packages/dashboard/src/routes/register-task-workflow-routes.ts +++ b/packages/dashboard/src/routes/register-task-workflow-routes.ts @@ -1910,7 +1910,11 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork if ( deterministicGuard.action === "duplicate" && deterministicGuard.existing - && deterministicGuard.existing.proposalClaimId === trusted?.proposalClaimId + && typeof trusted?.proposalClaimId === "string" + && trusted.proposalClaimId.length > 0 + && typeof deterministicGuard.existing.proposalClaimId === "string" + && deterministicGuard.existing.proposalClaimId.length > 0 + && deterministicGuard.existing.proposalClaimId === trusted.proposalClaimId ) { /* FNXC:TaskRecommendations 2026-08-08-05:27: @@ -1918,6 +1922,11 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork A second process can observe its newly-created child while checking the normal content guard; reuse that immutable same-recommendation winner and repair the parent link rather than surfacing the ordinary duplicate conflict reserved for distinct recommendations. + + FNXC:TaskRecommendations 2026-08-12-00:58: + Reuse is reserved for a named proposal claim on both the trusted request and canonical. + Comparing absent ids made every ordinary deterministic duplicate return 200 and skipped + the duplicate-blocker classification that preserves legitimate done or archived creates. */ const trustedCreateResult = await trusted?.onCreated?.(deterministicGuard.existing); res.status(200).json(trusted?.responseForCreated?.(deterministicGuard.existing, trustedCreateResult) ?? deterministicGuard.existing);