lobehub/lobehub · error · TRPCError

FORBIDDEN

FORBIDDEN

Error message

Message does not belong to this topic

What it means

A FORBIDDEN security guard in the notify assistant-mode branch. When a remote runtime asks to update an existing message (via messageId or the runningOperation placeholder), the server loads that message and verifies its topicId matches the request's topicId. MessageModel.update scopes only by userId, so without this check a compromised or buggy remote runtime could overwrite messages in other conversations belonging to the same user. This is a cross-conversation isolation enforcement.

Solutions

  1. Do not reuse messageIds across topics — each notify update target must come from the same topic's prior notify/create response.
  2. If using the placeholder path, ensure runningOperation.assistantMessageId was seeded for THIS topic (the execAgent that started the run set it).
  3. On the client/runtime side, track the messageId returned by the first notify call and pass it back only within the same topicId.
  4. If the error persists, verify no concurrent runs overwrote topic.metadata.runningOperation with a foreign assistantMessageId.

Example fix

// before — reusing a messageId from another topic
notify({ topicId: 'B', role: 'assistant', messageId: 'msg-from-topic-A', content });

// after — use the messageId returned by this topic's first notify
const { messageId } = await notify({ topicId: 'B', role: 'assistant', content });
await notify({ topicId: 'B', role: 'assistant', messageId, content: 'updated' });
Defensive patterns

Strategy: validation

Validate before calling

// Before updating a message via notify, verify it belongs to the topic
if (messageId) {
  const msg = await client.message.getById.query({ id: messageId });
  if (!msg || msg.topicId !== topicId) {
    throw new Error('Message/topic mismatch — do not send update');
  }
}
await client.agentNotify.notify.mutate({ topicId, role: 'assistant', messageId, content });

Type guard

const messageBelongsToTopic = (
  msg: { topicId?: string | null } | null | undefined,
  expectedTopicId: string,
): msg is { topicId: string } =>
  !!msg && msg.topicId === expectedTopicId;

Try / catch

try {
  await client.agentNotify.notify.mutate({ topicId, role: 'assistant', messageId, content });
} catch (e) {
  if (isTRPCError(e, 'FORBIDDEN') && /does not belong/.test(e.message)) {
    // Discard the stale messageId, create a new message instead of updating
    await client.agentNotify.notify.mutate({ topicId, role: 'assistant', content });
  }
}

Prevention

When it happens

Trigger: A notify call with role='assistant' and a messageId (or a topic with a runningOperation.assistantMessageId placeholder) where the resolved message's topicId differs from the request topicId. Happens if a remote agent replays a stale messageId from a previous run, sends the wrong messageId, or if the placeholder metadata points at a message from a different topic.

Common situations: Remote hetero agent (openclaw/hermes) caches a messageId from a prior run and reuses it after the topic changed; client passes a messageId harvested from a different conversation; a race where runningOperation metadata was not cleared and points at an orphaned message; test fixtures that share message ids across topics.

Related errors


AI-assisted analysis of lobehub/lobehub@10f24d7ade (2026-08-12). Data as JSON: /api/errors/7484e99c94dc1d10. Report an issue: GitHub.

Appendix: source

Thrown at apps/server/src/routers/lambda/agentNotify.ts:303

    // 2a. Assistant mode: write message directly without triggering LLM
    if (role === 'assistant') {
      try {
        // Resolve the target message ID:
        // 1. Caller-supplied messageId (subsequent notify calls with --message-id)
        // 2. Placeholder assistantMessageId seeded by execAgent (first notify call for remote hetero)
        // Using the placeholder avoids creating a second empty bubble in the UI.
        const placeholderMessageId = (topic.metadata as any)?.runningOperation
          ?.assistantMessageId as string | undefined;
        const resolvedMessageId = messageId ?? placeholderMessageId;

        // Update existing message if we have a resolved target
        if (resolvedMessageId) {
          // Security: verify the message belongs to this topic before writing.
          // MessageModel.update scopes only by userId; without this check, a remote
          // runtime could overwrite messages from other conversations.
          const existingMsg = await ctx.messageModel.findById(resolvedMessageId);
          if (!existingMsg || existingMsg.topicId !== topicId) {
            throw new TRPCError({
              code: 'FORBIDDEN',
              message: 'Message does not belong to this topic',
            });
          }

          // Terminal signal (done or error) with empty content + existing
          // placeholder → just finalize the run, no message update. Pass the
          // resolved id so the finalizer can reload the agent's final reply
          // (written in-place via earlier `lh notify` calls) into
          // `lastAssistantContent` — bot completion callbacks and the task
          // lifecycle follow-ups (handoff / auto-review / brief) depend on it.
          if (isTerminal && !content) {
            void publishRemoteHeteroEvent(resolvedMessageId);
            return { messageId: resolvedMessageId, operationId: undefined, topicId };
          }
          await ctx.messageModel.update(resolvedMessageId, { content });
          void publishRemoteHeteroEvent(resolvedMessageId);
          if (shouldContinue) {

View on GitHub (pinned to 10f24d7ade)