The channel push drops the reply and reaction relations, and a dropped relation is invisible rather than degraded #124
Labels
No labels
blocked
bug
documentation
duplicate
enhancement
good first issue
help wanted
invalid
question
waiting-on-julian
wontfix
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
jlxq0/matrix-mcp#124
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
The channel push is a lossy projection of the timeline, and every relation it drops is invisible rather than degraded. A dropped attachment at least announced itself as a message about a file; a dropped relation leaves nothing behind. The sharpest instance is
src/channel.rs:1033, where the replay path holds aReadEventwithin_reply_toalready populated and throws it away — not a relation we failed to fetch, one we already had in hand.The reply gap was found only because Julian asked whether his replies arrive as replies. Nobody would have found the reaction gap at all, because a reaction that never arrives leaves no trace of having been sent.
All line references are at
21d74338.What the push carries today
room,sender,event, plusattachmentandfilenamefor uploads,replayedon the replay path, andsuspiciouswhen the heuristic trips. Live pathsrc/channel.rs:1424-1437, replay pathsrc/channel.rs:1045-1057. No relation of any kind.1. Replies
m.in_reply_tois dropped. A session receiving a reply sees a message with no indication that it is one and cannot tell what it answers.The information is not missing from the system.
ReadEventalready carriesin_reply_to(src/mcp.rs:1074), computed atsrc/mcp.rs:8136fromcontent["m.relates_to"]["m.in_reply_to"]["event_id"], andread_recent_messagesdocuments and returns it (src/mcp.rs:2499). The replay path holds a populatedReadEventatsrc/channel.rs:1033and discards it. The live path holds anOriginalSyncRoomMessageEvent, whosecontent.relates_tocarries the same relation.Measured consequence, reported by Mantis 2026-08-26. Julian replied to a gate-chase message with "Who is this and what is this for?". The session could not see what it was attached to, guessed a newly created mailbox, and answered a question he had not asked; he had to screenshot his own client to show it the thread. Three of his last six messages carried a reply relation and the session was blind to all three.
Fix: add
in_reply_toto the channel meta, and a truncated quotation of the referenced message beside it. The id alone would let a session fetch the reference, but that is a round trip per message and it will not always be taken; a line of the quoted body, enough to recognise, makes a reply answerable on sight. The referenced event is usually already in the timeline the push path is walking.2. Edits — split out to #125
An
m.replacereaches a session as a second, near-identical message carrying the*fallback body, with nothing marking it a correction. That is a live defect on ordinary input rather than a missing feature, so it has its own issue: #125.3. Reactions
client.add_event_handlerregisters onlyOriginalSyncRoomMessageEvent(src/channel.rs:1275), and anm.reactionis not one, so no handler sees it. On the replay pathcarried_ofrequires a msgtype, so it is dropped there too. Reactions reach a channel session as nothing at all.Julian intends to answer permission gates with an emoji from his phone without typing. That makes a reaction input rather than decoration, and it needs the same care as a message body.
3a. Push a reaction as its own channel event, naming the reacting user, the emoji key and the event it annotates.
3b. A way to read the reactions on a given event id.
config/hooks/gate-matrix.shpolls for a reaction on a message it sent and whose id it knows.Nothing today covers this.
get_event_receiptsis read receipts and answers a different question.send_reactionexists (src/mcp.rs:4667) and is write-only. Reactions are reachable in principle by walking the raweventJSON fromread_recent_messages, since it sets no type filter (src/mcp.rs:2540), but there is no structured field, no per-event query, and it means paging a room timeline to answer a question about one event.Bundled aggregations are not an option, and that changed the design in the right direction. The spec states that
m.annotationrelationships are not aggregated by the server andm.annotationis not included in them.relationsproperty (event_annotations.md). So there is no count to read, and the only route isGET /_matrix/client/v1/rooms/{roomId}/relations/{eventId}/m.annotation, which returns the annotation events themselves — carrying the per-annotationsenderandorigin_server_tsthat a count could never have given. "Only Julian, and only after the ask" is checkable because of the route we are forced onto.The plumbing exists.
read_threadcallsroom.relations(root_event_id, RelationsOptions { include_relations: IncludeRelations::RelationsOfType(RelationType::Thread), .. })atsrc/mcp.rs:2652-2660. The same call withRelationType::Annotationis the endpoint above. The SDK path, the joined-room guard, the chunk decoding and the sandboxing are all in place; what is missing is the tool.Contract the tool commits to, in its own documentation
Empty and unreadable must be distinguishable, and this is a shipping condition. A consumer that cannot tell "no reactions yet" from "could not read" either never resolves a gate or resolves one it should not. Three outcomes, following
read_thread's existing shape: a fetch failure is an MCP error (ErrorData::internal_error,src/mcp.rs:2656), not-joined isinvalid_params, and a successful read with no annotations is an ok result with an empty list. Written into the tool description so a later change cannot narrow it to two.Fresh server call per invocation, no caching, no local aggregation. A poll reflects server state at the moment of the poll. The consumer's settling window — on first seeing a reaction, wait one interval, re-read, act on what is there — depends on this and on nothing else, and it is the reason the consumer needs no defensive re-check.
The emoji key is returned exactly as the sender sent it. No normalisation, no folding of variation selectors, no stripping of skin-tone modifiers: the repository pulls in no Unicode normalisation crate and the read path performs none, so the key travels as the bytes in
content["m.relates_to"]["key"]. A consumer that quotes the emoji back to the sender quotes the one they tapped. Matching is the consumer's job and normalising is not the tool's:gate-matrix.shstrips variation selectors and skin-tone modifiers before comparing against its small allow/deny set, which it can only do while it still has the original to quote. Folding here would make an equality test possible and a correct quotation impossible, so a later tidy-up that normalises the key is a regression rather than a cleanup. Note that the key is an arbitrary string per the spec, so in the channel push it lands in attribute position and goes throughattr_escapelike every other meta value; a key containing&, a quote or an angle bracket is therefore escaped in the push and unescaped in the tool result, and those are two different surfaces with two different answers.3c. Redaction. The spec's wording is that when a child event is redacted the relationship is broken and the server must disassociate it, and
m.relates_tolives incontent, which redaction strips. Both are arguments; neither is a measurement of this homeserver. What has to be recorded here is a response body from a read at T+1 after a redaction at T. Until then the tool's documentation says nothing about redacted annotations, because a contract asserted from the spec is a contract nobody measured.Constraints common to replies and reactions
The sender allowlist has to be enforced server-side. A reaction from anyone in the room reaches
/relations, and once an emoji means "approve this tool call", approval is available to whoever is standing in the room. Message delivery and the permission verdict branch both gate on the allowlist inapp.matrix_mcp.channelaccount data; no reaction path gates on anything, because no reaction path exists. The gate belongs in the read tool and in the push, not in the consumer, so that it cannot be omitted by the next consumer. This sentence belongs inAGENTS.mdas well as here.A value that passes through two paths with different escaping reads differently depending on which one you asked. The reaction key is the instance: verbatim through the tool result,
attr_escaped in the push. That is a rule about our own surfaces rather than about Matrix, and it applies to any value the tool layer and the channel layer both carry. When the two must differ, say which surface a documented value describes.Quoted or reflected text is untrusted prose, not an identifier. Every meta value already goes through
attr_escapeinbuild_params(src/channel.rs:606-623), which handles&, quotes and angle brackets, so it cannot close the<channel>tag or forgesuspicious="false". That is enough for a room id and not enough for a message body:attr_escapedoes not runescape_injection_markersor the role-token escaping the primary body gets fromcontent_sandbox::evaluate. A quoted body needs body-grade treatment.The related event may have a different sender than the event carrying the relation, including one not on the allowlist. A reply is therefore a way to put an unallowlisted body in front of a session. Whose
suspiciousverdict is reported, and whether an excerpt from a stranger is carried at all, this issue should settle rather than let fall out of the implementation.Say when a reference could not be resolved. If the referenced event is not in the timeline, undecryptable or redacted, emit the id with an explicit marker rather than omitting the attribute. A missing attribute reads as "not a reply", which is the failure being fixed. Absent and unresolvable must not look the same.
Verification
For each relation, the test asserts the meta of a pushed event, with a control that must go red: the same event without the relation must not carry the attribute, and an unresolvable reference must carry the id with its marker rather than nothing. An assertion that only checks for presence passes against a hardcoded value, so the negative is what pins it. For reactions the controls are the unallowlisted reactor and, once measured, the redacted annotation.
Blocks
mantis-32's permission-gate hook (config/hooks/gate-matrix.sh) is blocked on 3b.The channel push drops the reply relation, so a session cannot see what a reply answersto The channel push drops every relation: replies, edits and reactions arrive as nothingThe channel push drops every relation: replies, edits and reactions arrive as nothingto The channel push drops the reply and reaction relations, and a dropped relation is invisible rather than degradedThis does not close on merge. It closes when a reply reaches Julian with a reference attached.
#126is merged andv0.10.6is tagged at92173da, imagesha256:58a51605. The pod is still onsha256:8c25c57e, which isv0.10.5.The acceptance, in order:
sha256:58a516053d28606af35e4c5e6227e4014309f86050b85642f26531e26200ff3e, read per pod on the container namedapp, compared against what the tag build pushed rather than re-resolved from the tag.in_reply_toand an excerpt. Every session with a channel mount keeps what it negotiated at connect, so a reply to a session predating the rollout is expected to arrive bare, and that is a known state rather than a result.@mantis_ai_bot, which has 57 demonstrated pushes in the server log. Not Honoka: its four channel lines are twono live sessionpairs from 10:47Z and nothing since, so its allowlist is untested rather than working.Why this is written here rather than agreed between sessions. A merged PR, a green suite and a closed issue are three pieces of evidence about this repository and none about the person who reported it.
ksc_web#109auto-closed on merge while the member was still locked out of a paid site, because production was serving an image built before any of it.Julian raised this three times in one evening. The thing he is waiting for is a reply that quotes what it is replying to, and nothing short of observing that is this issue being fixed.