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
- Do not reuse messageIds across topics — each notify update target must come from the same topic's prior notify/create response.
- If using the placeholder path, ensure runningOperation.assistantMessageId was seeded for THIS topic (the execAgent that started the run set it).
- On the client/runtime side, track the messageId returned by the first notify call and pass it back only within the same topicId.
- 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
- Never reuse a messageId across different topics — track it per-topic on the caller side.
- Use the messageId returned by the first notify call within the same topic only.
- If the remote runtime caches messageIds, scope the cache by topicId.
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)