feat(matrix): isolate matrix thread history and reply placement#71738
feat(matrix): isolate matrix thread history and reply placement#71738ga-it wants to merge 15 commits into
Conversation
Greptile SummaryThis PR introduces per-thread context isolation for the Matrix extension — routing room history, watermarks, and API reads to thread-scoped sub-structures — and fixes the outbound send leak where
Confidence Score: 3/5Merging is safe for most scenarios but the thread sub-queue re-eviction race can silently drop messages for high-traffic threads. One P1 finding (stale watermark re-insertion after FIFO eviction of a thread sub-queue) can cause silent message loss in high-thread-volume rooms, pulling the score below 4. The P2s are low-risk cleanup items. extensions/matrix/src/matrix/monitor/room-history.ts — specifically the consumeHistory method and its interaction with thread sub-queue FIFO eviction.
|
| function clearRoomWatermarks(roomId: string): void { | ||
| const roomSuffix = `:${roomId}`; | ||
| for (const key of agentWatermarks.keys()) { | ||
| if (key.endsWith(roomSuffix)) { | ||
| if (parseWatermarkKey(key)?.roomId === roomId) { | ||
| agentWatermarks.delete(key); | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
clearRoomWatermarks silently skips legacy colon-format keys
clearRoomWatermarks delegates room-ID matching to parseWatermarkKey, which only parses JSON-encoded keys and returns null for the legacy "agentId:roomId" format. Any legacy watermark entries still alive in the map (written by old code before this PR) are therefore invisible to clearRoomWatermarks, so they will not be evicted when their room is FIFO-evicted from roomQueues. They accumulate until the global MAX_WATERMARK_ENTRIES cap kicks in.
While this won't cause incorrect history (the legacy key is only ever read in computePendingHistory, never re-written, and a freshly-evicted room has a new baseIndex), it does mean the legacy watermark for an evicted room persists and counts against the 5 000-entry cap. In a high-throughput deployment where rooms rotate frequently, this could exhaust watermark capacity faster than expected.
Prompt To Fix With AI
This is a comment left during a code review.
Path: extensions/matrix/src/matrix/monitor/room-history.ts
Line: 133-139
Comment:
**`clearRoomWatermarks` silently skips legacy colon-format keys**
`clearRoomWatermarks` delegates room-ID matching to `parseWatermarkKey`, which only parses JSON-encoded keys and returns `null` for the legacy `"agentId:roomId"` format. Any legacy watermark entries still alive in the map (written by old code before this PR) are therefore invisible to `clearRoomWatermarks`, so they will not be evicted when their room is FIFO-evicted from `roomQueues`. They accumulate until the global `MAX_WATERMARK_ENTRIES` cap kicks in.
While this won't cause incorrect history (the legacy key is only ever *read* in `computePendingHistory`, never re-written, and a freshly-evicted room has a new `baseIndex`), it does mean the legacy watermark for an evicted room persists and counts against the 5 000-entry cap. In a high-throughput deployment where rooms rotate frequently, this could exhaust watermark capacity faster than expected.
How can I resolve this? If you propose a fix, please make it concise.| const rawThreadEvents: MatrixRawEvent[] = []; | ||
| if (!opts.before && !opts.after) { | ||
| appendUniqueEvent( | ||
| rawThreadEvents, | ||
| (await client.getEvent(resolvedRoom, opts.threadId)) as MatrixRawEvent | null, | ||
| ); | ||
| } | ||
| for (const event of res.chunk) { | ||
| appendUniqueEvent(rawThreadEvents, event); | ||
| } | ||
| hydratedChunk = await client.hydrateEvents(resolvedRoom, rawThreadEvents); | ||
| } else { |
There was a problem hiding this comment.
Root event consumes one slot from the caller-requested
limit
When threadId is supplied and there is no pagination cursor (!opts.before && !opts.after), the root event is prepended to rawThreadEvents before the hydratedChunk.slice(0, limit) cap is applied. As a result, callers receive limit - 1 thread-reply events rather than limit. For example, requesting limit: 5 on a thread with 10 replies returns [root, reply1, reply2, reply3, reply4] — the root occupies the first slot.
If the intent is for limit to bound the number of reply events (not counting the root), the /relations fetch should use limit and the root prepend should happen after slicing, or the request should fetch limit - 1 events when the root will be prepended.
Prompt To Fix With AI
This is a comment left during a code review.
Path: extensions/matrix/src/matrix/actions/messages.ts
Line: 130-141
Comment:
**Root event consumes one slot from the caller-requested `limit`**
When `threadId` is supplied and there is no pagination cursor (`!opts.before && !opts.after`), the root event is prepended to `rawThreadEvents` before the `hydratedChunk.slice(0, limit)` cap is applied. As a result, callers receive `limit - 1` thread-reply events rather than `limit`. For example, requesting `limit: 5` on a thread with 10 replies returns `[root, reply1, reply2, reply3, reply4]` — the root occupies the first slot.
If the intent is for `limit` to bound the number of *reply* events (not counting the root), the `/relations` fetch should use `limit` and the root prepend should happen after slicing, or the request should fetch `limit - 1` events when the root will be prepended.
How can I resolve this? If you propose a fix, please make it concise.There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 76e66ca7f3
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| hydratedChunk = mainEvents; | ||
| } | ||
|
|
||
| const processedChunk = hydratedChunk.slice(0, limit); |
There was a problem hiding this comment.
Keep main-timeline pagination aligned with returned events
When thread filtering is active, this path can over-fetch extra pages, but it then truncates to limit and still returns nextBatch from the last fetched page. In thread-heavy rooms, callers that paginate with nextBatch will skip non-thread events that were fetched but dropped by this slice (for example, when page 2 contributes more events than needed to hit limit), so history reads become lossy.
Useful? React with 👍 / 👎.
| if (queue.generation !== snapshot.queueGeneration) { | ||
| // The room was evicted and recreated before this trigger completed. Reject the stale | ||
| // snapshot so it cannot advance or erase state for the new queue generation. | ||
| return; |
There was a problem hiding this comment.
Invalidate stale thread snapshots after thread-queue eviction
Thread sub-queues can be evicted independently, but consumeHistory only validates the room generation before writing a watermark. If an in-flight trigger for an evicted thread finishes after that thread queue is recreated (same threadRootId), the stale snapshotIdx is accepted and can advance the watermark past newly queued messages, hiding fresh thread history until enough events accumulate.
Useful? React with 👍 / 👎.
|
Codex review: needs changes before merge. Reviewed June 5, 2026, 3:44 AM ET / 07:44 UTC. Summary PR surface: Source +445, Tests +751, Docs +1. Total +1197 across 13 files. Reproducibility: yes. Source inspection of current main shows Matrix room history is room-only and buildThreadRelation always emits fallback reply metadata, while the PR discussion supplies live Matrix/Synapse proof for the changed behavior. Review metrics: 1 noteworthy metric.
Merge readiness Overall follows the weaker of proof and patch quality, so missing proof can cap an otherwise strong patch. Rank-up moves:
Mantis proof suggestion Risk before merge
Maintainer options:
Next step before merge
Security Review findings
Review detailsBest possible solution: Keep this PR open for Matrix maintainer review, remove the release-owned changelog line, and explicitly choose whether this branch's thread-only fallback/session isolation behavior or the related maintainer-owned Matrix branch should be the landing path. Do we have a high-confidence way to reproduce the issue? Yes. Source inspection of current main shows Matrix room history is room-only and buildThreadRelation always emits fallback reply metadata, while the PR discussion supplies live Matrix/Synapse proof for the changed behavior. Is this the best way to solve the issue? Unclear as a final product choice. The PR is a strong Matrix-owned implementation with tests and live proof, but the fallback visibility and thread-scoped history changes are compatibility decisions and a related maintainer-owned Matrix PR is also open. Full review comments:
Overall correctness: patch is correct AGENTS.md: found and applied where relevant. Codex review notes: model gpt-5.5, reasoning high; reviewed against 1a3ce7c2a8da. Label changesLabel justifications:
Evidence reviewedPR surface: Source +445, Tests +751, Docs +1. Total +1197 across 13 files. View PR surface stats
Acceptance criteria:
What I checked:
Likely related people:
What the crustacean ranks mean
Shiny media proof means a screenshot, video, or linked artifact directly shows the changed behavior. Runtime, network, CSP, and security claims still need visible diagnostics. How this review workflow works
|
8c468ec to
98416dc
Compare
0c11f1a to
1fa6dc4
Compare
|
Superseded: this duplicate proof/re-review request was accidentally posted from the wrong connected GitHub account. Please ignore this copy. The same proof has been reposted from the PR/fork account |
|
@clawsweeper re-review please. I refreshed the current-head real behavior proof in the PR body for Current implementation path exercised in live Matrix/Synapse setup: Summary: untyped recursive relation reads returned Thread A direct reply plus an indirect edit child relation, kept Thread B isolated, the current-head parent-chain filter kept root/reply/edit scoped to Thread A while excluding unrelated main-room content, fallback reply metadata was absent, and the temporary proof room was left. {
"captured_at_utc": "2026-05-14T08:05:36Z",
"checks": {
"client_filter_excludes_unrelated_main": true,
"client_filter_includes_thread_a_root_reply_edit": true,
"edit_a_is_indirect_child_relation": true,
"recursive_query_used": true,
"reply_a_fallback_absent": true,
"reply_b_fallback_absent": true,
"thread_a_direct_reply_returned": true,
"thread_a_excludes_thread_b_reply": true,
"thread_a_indirect_edit_returned": true,
"thread_b_direct_reply_returned": true,
"thread_b_excludes_thread_a_reply": true,
"typed_m_thread_segment_absent": true,
"untyped_endpoint_path_used": true
},
"cleanup": "left temporary proof room",
"current_head_client_side_filter_result": {
"thread_a_filtered_event_hashes": [
"667870547242",
"2eac1aa671a0",
"498a5a89fcf4"
],
"thread_b_filtered_event_hashes": [
"938b4b1e0d0f",
"3b22215ca715"
]
},
"current_request_path_template": "GET /_matrix/client/v1/rooms/{roomId}/relations/{threadRootId}?dir=f&limit=20&recurse=true",
"event_hashes": {
"main": "f6ffd45208ab",
"thread_a_indirect_edit": "498a5a89fcf4",
"thread_a_reply": "2eac1aa671a0",
"thread_a_root": "667870547242",
"thread_b_reply": "3b22215ca715",
"thread_b_root": "938b4b1e0d0f"
},
"homeserver_hash": "74c7bd2b1a2b",
"overall_real_behavior_proof_passed": true,
"pr_head": "60b0c9244f01d1e99c74e47c285e84a81a138970",
"reply_relation_shapes": {
"thread_a_has_is_falling_back": false,
"thread_a_has_m_in_reply_to": false,
"thread_a_indirect_edit_relation_keys": [
"event_id",
"rel_type"
],
"thread_a_relation_keys": [
"event_id",
"rel_type"
],
"thread_b_has_is_falling_back": false,
"thread_b_has_m_in_reply_to": false,
"thread_b_relation_keys": [
"event_id",
"rel_type"
]
},
"room_hash": "b4f71058d518",
"runtime_account_hash": "e51963bdfce8",
"untyped_recursive_relations": {
"thread_a_next_batch_present": false,
"thread_a_prev_batch_present": false,
"thread_a_relation_event_hashes": [
"2eac1aa671a0",
"498a5a89fcf4"
],
"thread_b_next_batch_present": false,
"thread_b_prev_batch_present": false,
"thread_b_relation_event_hashes": [
"3b22215ca715"
]
}
}Re-review progress:
|
a830104 to
4714035
Compare
|
🦞🧹 I asked ClawSweeper to review this item again. Re-review progress:
|
|
🦞🧹 I asked ClawSweeper to review this item again. Re-review progress:
|
|
🦞🧹 I asked ClawSweeper to review this item again. Re-review progress:
|
|
🦞🧹 I asked ClawSweeper to review this item again. Re-review progress:
|
|
Closed as superseded by the maintainer-owned Matrix fix that landed in #90415 at #90415 includes the Matrix thread history isolation and reply placement behavior from this PR, with focused tests and real Matrix QA proof: AWS Crabbox Matrix QA Thanks @ga-it for pushing this behavior forward. |
PR: feat(matrix): isolate thread history and fix threaded reply placement
Target:
openclaw/openclawmainBranch name suggestion:
feat/matrix-thread-context-isolationTitle
Summary
Problem: Matrix threads were known to routing and session logic, but room-history tracking and message reads were room-flat. All messages in a room — regardless of which thread they belonged to — competed for the same context window. Separately, threaded bot replies included unconditional
is_falling_back+m.in_reply_tofallback metadata in their event relations, causing Matrix clients (e.g. Element) to surface them in the main room timeline even when the intended target was a thread.Why it matters: Without per-thread context isolation, a busy Matrix room or DM pollutes every thread's LLM context with unrelated messages. With the send-path leak, thread replies appear as duplicated messages in the room timeline, confusing users and growing room scroll.
What changed:
room-history.ts— room queues now carry per-thread sub-queues keyed by thread root event ID; watermarks are scoped by(agentId, roomId, threadRootId).handler.ts—_threadRootIdis threaded through all three room-history call sites (recordPending,prepareTrigger,consumeHistory).messages.ts— Matrix read actions acceptthreadId; thread reads use the untyped v1/relations/{threadId}endpoint withrecurse: true, hydrate returned events, and filter by native thread root client-side; main-room reads filter out direct and indirect thread events and over-fetch to compensate.tool-actions.ts—threadIdis parsed and forwarded insendMessageandreadMessagesaction handlers.send/formatting.ts—buildThreadRelationno longer unconditionally includesis_falling_backandm.in_reply_to; fallback metadata is only added when an explicitreplyToIdis supplied.send.ts(editMessageMatrix) — when editing a threaded message, thread context now goes inm.new_content["m.relates_to"](per spec) rather than the outer REPLACE relation level (which caused main-room surface viam.in_reply_to).What did NOT change: Session routing, DM allowlists, reaction handling, non-threaded group rooms, Telegram, or any other channel. The
channels.matrix.dm.threadRepliesconfig key already existed in the schema; no config schema changes are needed.Verification in this port:
4files,74teststsconfig.extensions.jsonWhy This Is The Right OpenClaw Direction
This PR does not introduce a new threading model for OpenClaw. It brings the Matrix
extension into line with the isolation model that the Telegram extension already uses
for forum topics and DM topics.
Current Telegram behavior already treats thread identity as conversation identity:
message_thread_idrather than stripping it, even in DMs,because silently dropping thread scope would misroute replies
chatId + messageThreadId, so topic traffic doesnot collapse into one flat group session
-100200300:topic:77In other words, Telegram in OpenClaw already behaves as "topic/thread = isolated
conversation scope". This Matrix change applies the same principle:
fallback is explicitly requested
That consistency matters because it reduces channel-specific surprises:
still bleeds into every thread
Matrix vs Telegram Contrast
Telegram today
message_thread_id.chat:topic:thread.that would misroute replies.
Matrix before this PR
storage and reads.
Matrix after this PR
scope.
metadata.
The net effect is that Matrix becomes behaviorally much closer to Telegram’s proven
topic-isolation model.
Change Type
Scope
Linked Issue / PR
channels.matrix.threadRepliesconfig,threads.ts,thread-context.tsRoot Cause
Flat room history (inbound context leak):
room-history.tsmaintained a single queue per room.handler.tsresolved_threadRootIdcorrectly but never forwarded it intorecordPending,prepareTrigger, orconsumeHistory, so all room traffic — regardless of thread — was merged into one context window.Reply placement leak (outbound):
buildThreadRelationinsend/formatting.tsunconditionally returned:{ "rel_type": "m.thread", "event_id": "$root", "is_falling_back": true, "m.in_reply_to": { "event_id": "$root" } }The
is_falling_back/m.in_reply_tocombination is the spec signal for clients that do not support threads to surface the message in the main timeline. Supporting clients (Element Web) also respect it, resulting in visible duplicate messages.Edit leak (editMessageMatrix):
When draft-stream previews were finalized via
editMessageMatrix, the REPLACE event carriedm.in_reply_to: { event_id: threadRoot }at the outer relation level. BecausethreadRootis often the user's original DM (a main-room event), Synapse and some clients surfaced edited thread messages as replies to main-room events._threadRootIdwas resolved but not propagated; fallback metadata was unconditional; REPLACE events carried reply context at the wrong nesting level.is_falling_back; no tests asserted thread-scoped history isolation.is_falling_backsemantics are easy to misread as "required for thread sends."Prior Art In OpenClaw
Telegram already contains the core architectural pattern this PR is moving Matrix
toward:
extensions/telegram/src/send.tsmessage_thread_idand explicitly warns against stripping DM topicthread IDs because doing so misroutes replies
extensions/telegram/src/bot-core.tsbuildTelegramGroupPeerId(chatId, messageThreadId)extensions/telegram/src/thread-bindings.test.ts-100200300:topic:77This is useful upstream context because it shows OpenClaw already endorses
thread/topic-qualified conversation identity on another major channel.
Files Changed
Verification
Commands used during the upstream port:
Focused test result:
Coverage added by this PR includes:
m.new_contentKey Diffs
send/formatting.ts—buildThreadRelationBefore:
After:
send.ts—editMessageMatrixthread context placementBefore:
After:
room-history.ts— thread sub-queues (abbreviated)handler.ts—_threadRootIdthreadingThree call sites updated:
messages.ts— thread-aware readsTest Plan
Tests that must change
send.test.ts— existing test asserts pre-fix behavior:New tests to add
send.test.ts:replyToId→ nois_falling_back, nom.in_reply_toreplyToId→ includesis_falling_back+m.in_reply_toeditMessageMatrixwiththreadId→m.new_contentcontains thread relation; outer REPLACE relation has nom.in_reply_toroom-history.test.ts:recordPendingwiththreadRootIdroutes to thread sub-queueprepareTriggerscoped to thread returns only that thread's entriesconsumeHistoryadvances watermark per thread independentlyMAX_THREAD_QUEUES_PER_ROOMmessages.test.ts:readMatrixMessageswiththreadId→ uses relations endpoint, includes root eventreadMatrixMessageswithoutthreadId→ main-room endpoint, filters out thread eventsDiagram
User-visible / Behavior Changes
editMessageMatrixfor threaded messages correctly places thread context inm.new_contentper the Matrix spec; Synapse keeps edited messages in the thread timeline.buildThreadRelationthat relied on unconditionalis_falling_backmust now pass an explicitreplyToIdto preserve that behavior. No production caller currently does this.channels.matrix.dm.threadRepliesalready existed; setting it to"always"now reliably keeps all DM replies thread-scoped. No new config keys.thread/topic traffic is treated as its own conversation scope instead of flattening
into the parent room.
Security Impact
/relations/{threadId}withrecurse: true). Same auth, same homeserver, narrower scope.Repro + Verification
Environment
channels.matrix.dm.threadReplies = "always"Steps
channels.matrix.dm.threadReplies = "always".context.compiledentry in the bot's trajectory JSONL for the T1 reply to C.Expected
messages_to_llm: 2(A + T1 bot reply) — not 4 or more.context.compiledshowsmessages_to_llm: 0thenmessages_to_llm: 2(only B + T2 reply), with no T1 content.Actual (pre-fix)
is_falling_back.m.in_reply_toto the main room even after the initial send was corrected.Evidence
messages_to_llm: 2for a thread reply with 1 prior exchange (session file named*-topic-$<threadRootId>.trajectory.jsonl).grep -c 'replaceRelation\["m.in_reply_to"\]' dist/send-*.js→ 0 after fix (confirmed in deployed container).newContent["m.relates_to"] = buildThreadRelation(threadId)(per-spec placement).Human Verification
context.compiled.messagesin trajectory JSONL contains only thread-scoped messages.room-history.test.ts,threads.test.ts,send.test.ts,messages.test.ts).Real behavior proof
60b0c9244f01d1e99c74e47c285e84a81a138970after rebasing onto currentorigin/main; the Matrix implementation diff exercised by the live proof is unchanged by the rebase.60b0c9244f01d1e99c74e47c285e84a81a138970; created a temporary private Matrix room, sent one main-room message, two native thread roots, one directm.threadreply under each root, and one indirectm.replaceedit relation under Thread A's reply. QueriedGET /_matrix/client/v1/rooms/{roomId}/relations/{threadRootId}?dir=f&limit=20&recurse=truewith no/m.threador/m.room.messagepath segment, then applied the same bounded parent-chain thread filter used byreadMatrixMessages({ threadId }).2026-05-14T08:05:36Z; runtime account hashe51963bdfce8; homeserver hash74c7bd2b1a2b; room hashb4f71058d518; request pathGET /_matrix/client/v1/rooms/{roomId}/relations/{threadRootId}?dir=f&limit=20&recurse=true; stale typed/m.threadand/m.thread/m.room.messagepath segments were not used; Thread A relation hashes['2eac1aa671a0', '498a5a89fcf4']; Thread B relation hashes['3b22215ca715']; Thread A filtered hashes['667870547242', '2eac1aa671a0', '498a5a89fcf4']; Thread B filtered hashes['938b4b1e0d0f', '3b22215ca715']; cleanupleft temporary proof room. Full redacted output:{ "captured_at_utc": "2026-05-14T08:05:36Z", "checks": { "client_filter_excludes_unrelated_main": true, "client_filter_includes_thread_a_root_reply_edit": true, "edit_a_is_indirect_child_relation": true, "recursive_query_used": true, "reply_a_fallback_absent": true, "reply_b_fallback_absent": true, "thread_a_direct_reply_returned": true, "thread_a_excludes_thread_b_reply": true, "thread_a_indirect_edit_returned": true, "thread_b_direct_reply_returned": true, "thread_b_excludes_thread_a_reply": true, "typed_m_thread_segment_absent": true, "untyped_endpoint_path_used": true }, "cleanup": "left temporary proof room", "current_head_client_side_filter_result": { "thread_a_filtered_event_hashes": [ "667870547242", "2eac1aa671a0", "498a5a89fcf4" ], "thread_b_filtered_event_hashes": [ "938b4b1e0d0f", "3b22215ca715" ] }, "current_request_path_template": "GET /_matrix/client/v1/rooms/{roomId}/relations/{threadRootId}?dir=f&limit=20&recurse=true", "event_hashes": { "main": "f6ffd45208ab", "thread_a_indirect_edit": "498a5a89fcf4", "thread_a_reply": "2eac1aa671a0", "thread_a_root": "667870547242", "thread_b_reply": "3b22215ca715", "thread_b_root": "938b4b1e0d0f" }, "homeserver_hash": "74c7bd2b1a2b", "overall_real_behavior_proof_passed": true, "pr_head": "60b0c9244f01d1e99c74e47c285e84a81a138970", "reply_relation_shapes": { "thread_a_has_is_falling_back": false, "thread_a_has_m_in_reply_to": false, "thread_a_indirect_edit_relation_keys": [ "event_id", "rel_type" ], "thread_a_relation_keys": [ "event_id", "rel_type" ], "thread_b_has_is_falling_back": false, "thread_b_has_m_in_reply_to": false, "thread_b_relation_keys": [ "event_id", "rel_type" ] }, "room_hash": "b4f71058d518", "runtime_account_hash": "e51963bdfce8", "untyped_recursive_relations": { "thread_a_next_batch_present": false, "thread_a_prev_batch_present": false, "thread_a_relation_event_hashes": [ "2eac1aa671a0", "498a5a89fcf4" ], "thread_b_next_batch_present": false, "thread_b_prev_batch_present": false, "thread_b_relation_event_hashes": [ "3b22215ca715" ] } }event_idandrel_type;is_falling_backandm.in_reply_towere absent. The temporary proof room was left after the run. Overall proof passed:True.Compatibility / Migration
dm.threadRepliesalready in schema; defaults unchangedThe only breaking change is for callers of
buildThreadRelationthat depended on the unconditionalis_falling_back. No such callers exist outside the extension.Risks and Mitigations
Risk: Synapse loses thread timeline membership for edited messages if it relied on the outer
m.in_reply_toin REPLACE events.m.new_content["m.relates_to"], not the outer REPLACE relation. Test confirmed edited messages remain in thread timeline post-fix.Risk: Thread sub-queue memory growth in rooms with many long-lived threads.
MAX_THREAD_QUEUES_PER_ROOM = 50cap with FIFO eviction; per-queue entry cap inherited from room queue.Risk: Over-fetch multiplier for main-room reads (3×) could increase Matrix API call volume.
Risk:
messages.test.tsthread read path tests require mocking the/relationsendpoint.