paperclipai/paperclip · error · QueuedCommentMutationForbiddenError

Only the queued message author can discard it

Error message

Only the queued message author can discard it

What it means

QueuedCommentMutationForbiddenError thrown by createDiscardQueuedComment when decideQueuedCommentActorOwnsEntry determines the acting user/agent did not author the queued comment. Only the author may discard their own queued message; this maps onto the route's HTTP 403 forbidden response with the message unchanged.

Solutions

  1. Have the actual author (same actorType/actorId/agentId) perform the discard, or log in as that user.
  2. If operators must be able to remove others' queued messages, extend decideQueuedCommentActorOwnsEntry/route policy to allow board-operator overrides rather than working around the 403.
  3. Check ownership client-side (compare actor to entry.comment.authorUserId/authorAgentId) and hide/disable the discard control when not the author.
  4. Verify the right credentials are being sent — a shared API key or wrong user token can make the request look like a non-author.

Example fix

// before
for (const entry of queue.entries) await api.discardQueuedComment({ issueId, commentId: entry.comment.id }); // 403 on others' entries
// after
for (const entry of queue.entries) {
  if (entry.canDiscard) await api.discardQueuedComment({ issueId, commentId: entry.comment.id });
}
Defensive patterns

Strategy: validation

Validate before calling

function canDiscard(actor, entry) {
  if (actor.actorType === "agent") return entry.comment.authorAgentId === actor.agentId;
  return entry.comment.authorUserId === actor.userId;
}

Type guard

function isOwnQueuedEntry(entry) {
  return typeof entry === "object" && entry !== null && "canDiscard" in entry && entry.canDiscard === true;
}

Try / catch

try {
  await discardQueuedComment(input);
} catch (e) {
  if (e.name === "QueuedCommentMutationForbiddenError" || /author can discard/.test(e.message)) {
    notify("Only the author can discard this queued message");
  } else throw e;
}

Prevention

When it happens

Trigger: Calling discardQueuedComment (authenticated as actor X) on a queued comment whose authorAgentId/authorUserId belong to actor Y. Common with board operators trying to delete another agent's queued message, or an agent API key acting on a comment queued by a different agent.

Common situations: An operator wants to clear a colleague agent's queued message and hits 403. An agent automation iterates all queued comments on an issue and discards them, but only owns some. A user session and an agent key both point at the same issue but different authorship.

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/a9f3d3ac355b5ed7. Report an issue: GitHub.

Appendix: source

Thrown at server/src/modules/wake-queue/application/queued-comment-use-cases.ts:267

      { issue: input.issue, actor: input.actor, queueId: input.queueId },
      async (locked, tx) => {
        if (input.revision !== undefined) {
          requireMutationTarget(locked.queue, input.queueId, input.revision);
        }

        const entry = locked.queue.entries.find((candidate) => candidate.comment.id === input.commentId);
        if (!entry) {
          throw new QueuedCommentMutationError("queued_comment_not_pending", "The queued message is no longer pending");
        }
        const owns = decideQueuedCommentActorOwnsEntry({
          actorType: input.actor.actorType,
          actorId: input.actor.actorId,
          actorAgentId: input.actor.agentId,
          authorAgentId: entry.comment.authorAgentId,
          authorUserId: entry.comment.authorUserId,
        });
        if (!owns) {
          throw new QueuedCommentMutationForbiddenError("Only the queued message author can discard it");
        }

        const deleted = await tx.deleteComment({ issueId: input.issue.id, commentId: input.commentId });
        if (!deleted) {
          throw new QueuedCommentMutationError("queued_comment_not_pending", "The queued message is no longer pending");
        }
        await tx.deleteCommentReferenceSource(input.commentId);
        await tx.syncCommentExternalObjectsSafely(input.commentId);

        const remainingIds = locked.queue.entries.map((candidate) => candidate.comment.id).filter((id) => id !== input.commentId);
        const queueBecomesEmpty = remainingIds.length === 0;

        let cancelledRun: { id: string } | null = null;
        let nextWake = locked.wake;
        let nextQueueRun = locked.queueRun;

        if (queueBecomesEmpty) {
          await tx.cancelWake({

View on GitHub (pinned to 3f1d897a7c)