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) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-8901-deterministic-duplicate-409.md
Normal file
7
.changeset/fn-8901-deterministic-duplicate-409.md
Normal file
@@ -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.
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user