The channel push drops the reply and reaction relations, and a dropped relation is invisible rather than degraded #124

Open
opened 2026-08-26 09:05:54 +00:00 by jlxq0 · 1 comment
Owner

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 a ReadEvent with in_reply_to already 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, plus attachment and filename for uploads, replayed on the replay path, and suspicious when the heuristic trips. Live path src/channel.rs:1424-1437, replay path src/channel.rs:1045-1057. No relation of any kind.

1. Replies

m.in_reply_to is 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. ReadEvent already carries in_reply_to (src/mcp.rs:1074), computed at src/mcp.rs:8136 from content["m.relates_to"]["m.in_reply_to"]["event_id"], and read_recent_messages documents and returns it (src/mcp.rs:2499). The replay path holds a populated ReadEvent at src/channel.rs:1033 and discards it. The live path holds an OriginalSyncRoomMessageEvent, whose content.relates_to carries 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_to to 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.replace reaches 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_handler registers only OriginalSyncRoomMessageEvent (src/channel.rs:1275), and an m.reaction is not one, so no handler sees it. On the replay path carried_of requires 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.sh polls for a reaction on a message it sent and whose id it knows.

Nothing today covers this. get_event_receipts is read receipts and answers a different question. send_reaction exists (src/mcp.rs:4667) and is write-only. Reactions are reachable in principle by walking the raw event JSON from read_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.annotation relationships are not aggregated by the server and m.annotation is not included in the m.relations property (event_annotations.md). So there is no count to read, and the only route is GET /_matrix/client/v1/rooms/{roomId}/relations/{eventId}/m.annotation, which returns the annotation events themselves — carrying the per-annotation sender and origin_server_ts that 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_thread calls room.relations(root_event_id, RelationsOptions { include_relations: IncludeRelations::RelationsOfType(RelationType::Thread), .. }) at src/mcp.rs:2652-2660. The same call with RelationType::Annotation is 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 is invalid_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.sh strips 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 through attr_escape like 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_to lives in content, 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 in app.matrix_mcp.channel account 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 in AGENTS.md as 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_escape in build_params (src/channel.rs:606-623), which handles &, quotes and angle brackets, so it cannot close the <channel> tag or forge suspicious="false". That is enough for a room id and not enough for a message body: attr_escape does not run escape_injection_markers or the role-token escaping the primary body gets from content_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 suspicious verdict 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 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 a `ReadEvent` with `in_reply_to` already 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`, plus `attachment` and `filename` for uploads, `replayed` on the replay path, and `suspicious` when the heuristic trips. Live path `src/channel.rs:1424-1437`, replay path `src/channel.rs:1045-1057`. No relation of any kind. ## 1. Replies `m.in_reply_to` is 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. `ReadEvent` already carries `in_reply_to` (`src/mcp.rs:1074`), computed at `src/mcp.rs:8136` from `content["m.relates_to"]["m.in_reply_to"]["event_id"]`, and `read_recent_messages` documents and returns it (`src/mcp.rs:2499`). The replay path holds a populated `ReadEvent` at `src/channel.rs:1033` and discards it. The live path holds an `OriginalSyncRoomMessageEvent`, whose `content.relates_to` carries 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_to` to 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.replace` reaches 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_handler` registers only `OriginalSyncRoomMessageEvent` (`src/channel.rs:1275`), and an `m.reaction` is not one, so no handler sees it. On the replay path `carried_of` requires 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.sh` polls for a reaction on a message it sent and whose id it knows. Nothing today covers this. `get_event_receipts` is read receipts and answers a different question. `send_reaction` exists (`src/mcp.rs:4667`) and is write-only. Reactions are reachable in principle by walking the raw `event` JSON from `read_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.annotation` relationships are *not* aggregated by the server and `m.annotation` is not included in the `m.relations` property ([event_annotations.md](https://github.com/matrix-org/matrix-spec/blob/main/content/client-server-api/modules/event_annotations.md)). So there is no count to read, and the only route is `GET /_matrix/client/v1/rooms/{roomId}/relations/{eventId}/m.annotation`, which returns the annotation events themselves — carrying the per-annotation `sender` and `origin_server_ts` that 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_thread` calls `room.relations(root_event_id, RelationsOptions { include_relations: IncludeRelations::RelationsOfType(RelationType::Thread), .. })` at `src/mcp.rs:2652-2660`. The same call with `RelationType::Annotation` is 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 is `invalid_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.sh` strips 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 through `attr_escape` like 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_to` lives in `content`, 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 in `app.matrix_mcp.channel` account 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 in `AGENTS.md` as 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_escape`d 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_escape` in `build_params` (`src/channel.rs:606-623`), which handles `&`, quotes and angle brackets, so it cannot close the `<channel>` tag or forge `suspicious="false"`. That is enough for a room id and not enough for a message body: `attr_escape` does not run `escape_injection_markers` or the role-token escaping the primary body gets from `content_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 `suspicious` verdict 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.
jlxq0 changed title from The channel push drops the reply relation, so a session cannot see what a reply answers to The channel push drops every relation: replies, edits and reactions arrive as nothing 2026-08-26 09:07:10 +00:00
jlxq0 changed title from The channel push drops every relation: replies, edits and reactions arrive as nothing to The channel push drops the reply and reaction relations, and a dropped relation is invisible rather than degraded 2026-08-26 09:11:39 +00:00
Author
Owner

This does not close on merge. It closes when a reply reaches Julian with a reference attached.

#126 is merged and v0.10.6 is tagged at 92173da, image sha256:58a51605. The pod is still on sha256:8c25c57e, which is v0.10.5.

The acceptance, in order:

  1. The running pod's digest is sha256:58a516053d28606af35e4c5e6227e4014309f86050b85642f26531e26200ff3e, read per pod on the container named app, compared against what the tag build pushed rather than re-resolved from the tag.
  2. A restarted session receives a pushed reply carrying in_reply_to and 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.
  3. The target is @mantis_ai_bot, which has 57 demonstrated pushes in the server log. Not Honoka: its four channel lines are two no live session pairs 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#109 auto-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.

**This does not close on merge. It closes when a reply reaches Julian with a reference attached.** `#126` is merged and `v0.10.6` is tagged at `92173da`, image `sha256:58a51605`. The pod is still on `sha256:8c25c57e`, which is `v0.10.5`. **The acceptance, in order:** 1. The running pod's digest is `sha256:58a516053d28606af35e4c5e6227e4014309f86050b85642f26531e26200ff3e`, read per pod on the container named `app`, compared against what the tag build pushed rather than re-resolved from the tag. 2. A **restarted** session receives a pushed reply carrying `in_reply_to` and 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. 3. The target is `@mantis_ai_bot`, which has 57 demonstrated pushes in the server log. Not Honoka: its four channel lines are two `no live session` pairs 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#109` auto-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.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
jlxq0/matrix-mcp#124
No description provided.