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

  1. Only pass sourceCommentId/attachmentId pairs returned by list_chat_attachments in the same run and conversation binding.
  2. Re-issue the external-chat authorization (restart the wait/approval flow) if it expired or was revoked.
  3. Verify the run binding (runId/company/conversation) still matches the chat the attachment belongs to.
  4. 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

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


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)