{"record":{"id":"2e4e8e16e0043413","repo":"paperclipai/paperclip","slug":"paperclip-runner-chat-attachment-source-denied","errorCode":"paperclip_runner_chat_attachment_source_denied","errorMessage":"paperclip_runner_chat_attachment_source_denied","messagePattern":"paperclip_runner_chat_attachment_source_denied","errorType":"error_code","errorClass":null,"httpStatus":null,"severity":"error","filePath":"server/src/services/native-runtime/chat-attachment-reuse.ts","lineNumber":936,"sourceCode":"  sourceCommentId: string,\n  attachmentId: string,\n  allowEmpty = false,\n): Promise<ChatAttachmentReuseSource> {\n  const [sourceComment] = await tx\n    .select({ id: issueComments.id })\n    .from(issueComments)\n    .where(\n      and(\n        eq(issueComments.id, sourceCommentId),\n        eq(issueComments.companyId, binding.companyId),\n        eq(issueComments.issueId, binding.issueId),\n        isNull(issueComments.deletedAt),\n      ),\n    )\n    .for(\"update\")\n    .limit(1);\n  if (!sourceComment) {\n    throw new Error(\"paperclip_runner_chat_attachment_source_denied\");\n  }\n  const [row] = await tx\n    .select({\n      attachmentId: issueAttachments.id,\n      parentCommentId: issueComments.id,\n      filename: assets.originalFilename,\n      contentType: assets.contentType,\n      byteSize: assets.byteSize,\n      sha256: assets.sha256,\n      objectKey: assets.objectKey,\n      createdAt: issueAttachments.createdAt,\n    })\n    .from(issueAttachments)\n    .innerJoin(\n      assets,\n      and(\n        eq(assets.id, issueAttachments.assetId),\n        eq(assets.companyId, binding.companyId),","sourceCodeStart":918,"sourceCodeEnd":954,"githubUrl":"https://github.com/paperclipai/paperclip/blob/3f1d897a7c018d76563a21c6e39c3c9b03933622/server/src/services/native-runtime/chat-attachment-reuse.ts#L918-L954","documentation":"Thrown by loadSource in chat-attachment-reuse.ts:936 when the source comment row cannot be found under FOR UPDATE with the binding's companyId, issueId, and a null deletedAt. The runner asked to reuse a chat attachment from a comment that either does not exist, belongs to a different company/issue, or was soft-deleted. It is a deliberate authorization-shaped rejection: the service does not distinguish 'not found' from 'not allowed', so runners cannot probe for comment IDs.","triggerScenarios":"authorizeChatAttachmentReuse (via loadSource) is called with a sourceCommentId that: does not exist in issue_comments; exists under a different companyId or issueId than the ChatReuseBinding; or has a non-null deletedAt (soft-deleted before the call).","commonSituations":"The agent passes a stale or hallucinated comment id to the chat attachment reuse tool; the source comment was deleted by a user between the wake and the tool call; the agent references a comment from another issue or company; an old run binding is replayed after the comment was cleaned up.","solutions":["Verify the sourceCommentId comes from the current run's wake comment ids (contextSnapshot paperclipWake.commentIds) and matches the binding's issue; correct the id in the tool call.","Check the comment still exists and is not soft-deleted (issue_comments.deleted_at is null, same company_id and issue_id as the binding); if deleted, ask the user to re-send or re-attach the file.","Re-run or re-bind the issue so the binding references a live comment; stale bindings after comment cleanup must be refreshed.","Use the chat attachment list tool (listAuthorizedChatAttachments) to discover valid (sourceCommentId, attachmentId) pairs instead of guessing ids."],"exampleFix":"// before\nawait reuseChatAttachment({ binding, sourceCommentId: \"cmt-from-old-run\", attachmentId });\n// -> paperclip_runner_chat_attachment_source_denied (comment deleted / wrong issue)\n\n// after: resolve a live source comment from the binding's wake context\nconst page = await listAuthorizedChatAttachments({ db, binding, limit: 20 });\nawait reuseChatAttachment({ binding, sourceCommentId: page.items[0].sourceCommentId, attachmentId: page.items[0].attachmentId });","handlingStrategy":"validation","validationCode":"// resolve valid sources from the server instead of guessing ids\nconst page = await listAuthorizedChatAttachments({ db, binding, limit: 50 });\nconst valid = page.items.some(i => i.sourceCommentId === sourceCommentId && i.attachmentId === attachmentId);\nif (!valid) throw new Error(\"skip reuse: source not in authorized list\");","typeGuard":"function isSourceDenied(err: unknown): err is Error {\n  return err instanceof Error && err.message === \"paperclip_runner_chat_attachment_source_denied\";\n}","tryCatchPattern":"try {\n  await reuseChatAttachment({ binding, sourceCommentId, attachmentId });\n} catch (err) {\n  if (err instanceof Error && err.message === \"paperclip_runner_chat_attachment_source_denied\") {\n    // fall back to enumerating authorized sources rather than retrying the same id\n    return listAuthorizedChatAttachments({ db, binding, limit: 20 });\n  }\n  throw err;\n}","preventionTips":["Always take sourceCommentId from the run's wake comment ids, never from model-invented ids.","Treat soft-deleted comments as permanently unusable sources; never cache comment ids across user deletions.","Enumerate candidates with listAuthorizedChatAttachments before the first reuse call.","Scope any local id bookkeeping to (companyId, issueId) to prevent cross-issue id leakage."],"tags":["authorization","not-found","soft-delete","chat-attachment","native-runtime"],"backgroundTag":"record-not-found","analyzedSha":"3f1d897a7c018d76563a21c6e39c3c9b03933622","analyzedAt":"2026-09-18T08:03:59.046Z","contentChangedAt":"2026-09-18T08:03:59.046Z","schemaVersion":2},"datasetVersion":"2026-09-22T11:17:16.035Z"}