{"record":{"id":"cdf29000ab530468","repo":"paperclipai/paperclip","slug":"approved-tool-target-changed","errorCode":"approved_tool_target_changed","errorMessage":"Approved tool action target changed after review","messagePattern":"Approved tool action target changed after review","errorType":"http","errorClass":"ToolGatewayHttpError","httpStatus":409,"severity":"error","filePath":"server/src/services/tool-gateway.ts","lineNumber":5862,"sourceCode":"\n  async function matchingAgentActionRequest(input: {\n    session: ToolGatewaySession;\n    toolName: string;\n    argumentsHash: string;\n  }) {\n    if (!input.session.issueId || !input.session.agentId) return null;\n    const [match] = await db\n      .select({ actionRequest: toolActionRequests, invocation: toolInvocations })\n      .from(toolActionRequests)\n      .innerJoin(toolInvocations, eq(toolInvocations.id, toolActionRequests.invocationId))\n      .where(and(\n        eq(toolActionRequests.companyId, input.session.companyId),\n        eq(toolActionRequests.issueId, input.session.issueId),\n        eq(toolActionRequests.canonicalArgumentsHash, input.argumentsHash),\n        eq(toolInvocations.agentId, input.session.agentId),\n        eq(toolInvocations.toolName, input.toolName),\n        inArray(toolActionRequests.status, [\"pending\", \"approved\", \"executing\", \"rejected\", \"executed\"]),\n      ))\n      .orderBy(desc(toolActionRequests.createdAt))\n      .limit(1);\n    if (!match) return null;\n    // The gateway builds an ask-first request in two steps inside one call: it\n    // inserts the row with a null signature and a null expiry, then signs the\n    // row and sets the expiry. A concurrent matching call can observe the row in\n    // this window. A null signature does not prove the create stopped, because a\n    // parallel create can still be signing the same row right now. Only treat an\n    // unsigned row as abandoned after the grace time from createdAt has passed;\n    // before that, return the match so the retry replays approval_required and\n    // does not create a duplicate request or expire a live row. After the grace\n    // time an unsigned row stays pending forever and the review queue hides it,\n    // so it can never be approved. Do not replay it as a live approval. Expire\n    // the row and let the retry create a fresh, signable request. A null expiry\n    // alone (without this guard) also makes the getTime() check below unsafe.\n    const pendingRequest = match.actionRequest;\n    const pendingUnsigned =\n      pendingRequest.status === \"pending\"","sourceCodeStart":5844,"sourceCodeEnd":5880,"githubUrl":"https://github.com/paperclipai/paperclip/blob/01ad8584922b5d85292b1723cae71fa0d9b07a19/server/src/services/tool-gateway.ts#L5844-L5880","documentation":"Post-approval integrity check: the signed payload decoded but the target it names (tool/invocation identity) differs from what the stored invocation now references, meaning the review target changed after the board reviewed it. Execution is refused with 409 so a modified action never runs under a stale approval.","triggerScenarios":"Thrown at server/src/services/tool-gateway.ts:5675 when the library encounters an invalid state.","commonSituations":"See trigger scenarios.","solutions":["The tool/target changed after review. Re-submit for approval with the final target; never alter an approved action's target."],"exampleFix":null,"handlingStrategy":"validation","validationCode":null,"typeGuard":null,"tryCatchPattern":null,"preventionTips":[],"tags":[],"backgroundTag":null,"analyzedSha":"01ad8584922b5d85292b1723cae71fa0d9b07a19","analyzedAt":"2026-08-18T22:49:45.177Z","contentChangedAt":"2026-08-18T22:49:45.177Z","schemaVersion":2},"datasetVersion":"2026-09-14T05:17:10.506Z"}