docs/references/chat/message-tree.md
Canonical reference for the chat message-tree model: how a topic's messages are
structured, the invariants that hold, and the contract consumers (read paths, the flow
canvas) rely on. Schema: src/main/data/db/schemas/message.ts. Service:
src/main/data/services/MessageService.ts.
Scope: topic chat messages (
messagetable). Agent-session messages (agent_session_message) are a separate, flat model and are not covered here.
A topic's messages form a tree stored as an adjacency list — each row points at its
parent via parentId. Multi-model responses (one user turn, N assistant replies) are
sibling groups: rows that share a parentId and a non-zero siblingsGroupId.
| Column | Meaning |
|---|---|
parentId | Parent message id. NULL only for the virtual root (see below). |
topicId | Owning topic (FK, ON DELETE CASCADE). |
role | user / assistant / system content, or root (virtual root sentinel). |
siblingsGroupId | 0 = normal single branch; >0 = members of one multi-model group under the same parent. |
topic.activeNodeId | The currently-selected leaf — the "where we are" pointer that read paths walk up from. |
Every topic owns exactly one content-less virtual root: role = 'root',
parentId = NULL, data = { parts: [] }. Every real message hangs below it. The
first user turn and its resends are ordinary siblings under this shared parent, so
"resend the first message" is structurally identical to any other sibling creation — no
multiple physical roots.
root (role='root', parentId=NULL, no content, never rendered)
├─ user "v1" ┐
├─ user "v2" ├─ one siblingsGroup — "resend first message" = a normal sibling
└─ user "v3" ┘
└─ assistant → user → assistant → …
The dedicated role = 'root' makes the row self-identifying: role-filtered content
queries (WHERE role = 'system', etc.) exclude it for free — no parentId IS NOT NULL
caveat. role = 'root' and parentId IS NULL are equivalent; parentId IS NULL stays
the indexed root lookup key.
| Invariant | Enforced by |
|---|---|
| Exactly one (live) virtual root per topic | message_topic_root_uniq — a partial UNIQUE index on (topic_id) WHERE parent_id IS NULL AND deleted_at IS NULL. Rejects a second live root on insert. |
| Every content message has a non-null parent | DB CHECK message_root_parent_check ((role = 'root') = (parent_id IS NULL)) — a content row (role != 'root') with a null parent is rejected at the storage layer, not by convention. First-turn content messages get parentId = <virtual root>. |
role = 'root' ⇔ parentId IS NULL | Same DB CHECK message_root_parent_check. createRootMessageTx (runtime) / ChatMigrator (migration) are the sole writers of the root row, but the biconditional itself is enforced structurally. |
activeNodeId is never the virtual root | NULL for an empty topic, otherwise a content message; read paths drop the root from the active path. |
| The virtual root is deletable only via topic deletion | delete() hard-rejects it (see below); the topic FK ON DELETE CASCADE is the only path that removes it. |
The virtual root is created eagerly, in the same transaction that creates the topic — so every topic has its root from birth. Writers:
MessageService.createRootMessageTx(tx, topicId) — called by TopicService.create,
TopicService.duplicate, and TemporaryChatService persist.ChatMigrator builds the same row inline per topic and reparents former v1
physical roots onto it, so migrated topics match freshly created ones.Message-creation paths never create the root — they read it via
getRootMessageIdTx(tx, topicId) (throws if absent; a missing root is a loud bug, never
papered over).
| Target | Behavior |
|---|---|
| Virtual root | Rejected (INVALID_OPERATION), regardless of cascade. Deleting it would orphan first-turn children (unique-index violation) or leave a rootless topic. |
Content message, cascade = false | Splice the node out: reparent its children onto its parent (their grandparent), then delete it. A child carries its siblingsGroupId (relative to its old parent), so each distinct non-zero moved group is rebased to a fresh id above any group already at the destination — it can't merge into an unrelated group there. |
Content message, cascade = true | Delete the message and its whole subtree. |
| "Clear all messages" | clearTopicMessages(topicId) (DELETE /topics/:topicId/messages) — deletes every non-root row of the topic in one statement and clears activeNodeId; the content-less virtual root stays. The structural replacement for the old "delete the root to clear the topic" (now rejected). |
The self-FK (parentId → message.id) is ON DELETE CASCADE. Deleting a node
removes its whole subtree in one statement — no leaf-first ordering, and no SET NULL
to manufacture a colliding parentId = NULL row. This is why cascade = true,
clearTopicMessages, purgeByTopicIdsTx (topic delete), and the topic FK cascade are
all single unordered deletes that stay correct. cascade = false reparents children before
deleting the node, so the cascade fires on nothing. (A cascade = false delete of a
first-turn message reparents its children onto the virtual root — structurally valid;
they become first-turn nodes.)
SET NULLwas actively wrong undermessage_topic_root_uniq: it nulls a surviving in-set child'sparentIdmid-delete, transiently creating a secondparentId = NULLrow that violates the index (a reachable crash when deleting any multi-model topic).PRAGMA defer_foreign_keysdoes not help — it defers FK checking, not the action.
rootId is the authoritative first-turn signal. getBranchMessages and getTree
return rootId: string | null (the topic's virtual-root id) on every page, alongside
activeNodeId. A message is a first turn iff message.parentId === rootId — the only
reliable check. Do not infer "first turn" from "parent not in the loaded list" (the
branch is paginated, the root is never in the response) nor from the v1 askId field
(role-coupled, undefined for user messages). When rootId is unknown, treat nothing as a
first turn (fail-safe). See #16120.getPathRowsToNodeTx walks from a node up to the virtual root and excludes the
root — the displayed conversation starts at the first user message.getTree finds the virtual root (parentId IS NULL), drops it from the active
path, and treats its children as the logical roots. First-turn nodes keep their real
parent (the virtual root id) in the response; the virtual root is never returned as a
node. Hence TreeNode.parentId and SiblingsGroup.parentId are non-null string.feat/chat-page integration branch, not this PR branch): the edge builder will skip
edges whose parent isn't a rendered node — the virtual root, which first turns hang off
but which is never a node — so first turns still render as graph roots.role = 'root',
so it is excluded by construction.