paperclipai/paperclip · error
paperclip_runner_chat_attachment_read_not_authorized
paperclip_runner_chat_attachment_read_not_authorized
Error message
paperclip_runner_chat_attachment_read_not_authorized
What it means
Before staging any bytes, the scope verifies in a transaction that the run's external-chat response-wait authorization still resolves to "authorized" (and that the specific source comment/attachment is reusable via authorizeChatAttachmentReuse). If authorization is anything else — expired wait, revoked consent, wrong conversation, or the attachment not in the authorized source set — this error is thrown and no bytes are read.
Solutions
- Only pass sourceCommentId/attachmentId pairs returned by list_chat_attachments in the same run and conversation binding.
- Re-issue the external-chat authorization (restart the wait/approval flow) if it expired or was revoked.
- Verify the run binding (runId/company/conversation) still matches the chat the attachment belongs to.
- Check authorizeChatAttachmentReuse results to confirm the source is still part of the authorized lineage.
Example fix
// before
await scope.read({ sourceCommentId: otherChatCommentId, attachmentId });
// after
const listed = await listChatAttachments({ sourceCommentId: authorizedCommentId });
await scope.read({ sourceCommentId: authorizedCommentId, attachmentId: listed.attachments[0].id }); Defensive patterns
Strategy: try-catch
Validate before calling
const authorized = await listChatAttachments({ sourceCommentId });
if (!authorized.some(a => a.id === attachmentId)) throw new Error("attachment not in authorized set"); Type guard
function isNotAuthorizedError(e: unknown): boolean {
return e instanceof Error && e.message === "paperclip_runner_chat_attachment_read_not_authorized";
} Try / catch
try {
return await scope.read(input);
} catch (e) {
if (isNotAuthorizedError(e)) {
return { status: "authorization_required" }; // re-run approval flow
}
throw e;
} Prevention
- Only read attachments listed by list_chat_attachments within the same run binding.
- Re-establish external-chat authorization before resuming long-idle runs.
- Never carry attachment ids across conversations or run bindings.
- Surface authorization state to the agent before attempting reads.
When it happens
Trigger: Reading an attachment whose source comment is outside the run's authorized context snapshot; the external-chat wait window closed or was revoked before the read; run binding points at a different conversation than the attachment; allowEmpty authorization fails for stale lineage.
Common situations: Agent attempting to read attachments from another conversation; user rescinding chat integration consent mid-run; run resumed after its authorization window expired; stale ids from an older turn used in a new run.
Understand the failure class
Background: Permission denied / not authorized / 403 Forbidden: access-control rejections when the caller lacks the required role, grant, or ownership — this error's family across 18 libraries.
Related errors
- access.reasonCode
- currentAccess.reasonCode
- not_authorized
- Only the queued message author can edit it
- agent_authorization_required
AI-assisted analysis of paperclipai/paperclip@3f1d897a7c (2026-09-18).
Data as JSON: /api/errors/5afaf0898aa78949.
Report an issue: GitHub.
Appendix: source
Thrown at server/src/services/native-runtime/chat-attachment-read.ts:140
}
async #authorizeOnce(input: {
sourceCommentId: string;
attachmentId: string;
}): Promise<ChatAttachmentReuseSource> {
return this.options.db.transaction(async (transaction) => {
const tx = transaction as unknown as Db;
// Source rows are also locked by the existing lineage reader. Bound
// their waits so an inverse source-writer lock order cannot deadlock.
await tx.execute(sql`set local lock_timeout = '50ms'`);
const authorization =
await resolveExternalChatResponseWaitAuthorizationInTransaction(
tx,
this.options.binding,
"nonblocking",
);
if (authorization !== "authorized")
throw new Error("paperclip_runner_chat_attachment_read_not_authorized");
const [run] = await tx
.select({ contextSnapshot: heartbeatRuns.contextSnapshot })
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, this.options.binding.runId));
const source = await authorizeChatAttachmentReuse({
db: tx,
binding: this.options.binding,
contextSnapshot: run!.contextSnapshot,
allowEmpty: true,
...input,
});
this.#assertOpen();
return source;
});
}
async #read(input: { sourceCommentId: string; attachmentId: string }) {
const source = await this.#authorized(input);View on GitHub (pinned to 3f1d897a7c)