A corrected message reaches a channel session as a second message, so a session acts on text its sender has withdrawn #125

Closed
opened 2026-08-26 09:10:49 +00:00 by jlxq0 · 0 comments
Owner

Live defect, not a missing feature. Split out of #124, which covers the general shape.

What a session sees today

Julian sends a message, notices a mistake and corrects it in his client. His client sends an m.room.message whose content.body is * <corrected text>, whose content["m.new_content"]["body"] is the corrected text, and whose content["m.relates_to"] is {"rel_type": "m.replace", "event_id": "<the original>"}.

An m.replace is an m.room.message, so it reaches push_message like any other. carried_of_live matches on msgtype and m.text is carried, and the body delivered is ev.content.body() — the * -prefixed fallback. The meta is built at src/channel.rs:1424-1437 and carries room, sender, event, and nothing about the relation.

What the session observes is two near-identical instructions moments apart, the second prefixed with an asterisk, with nothing saying the second supersedes the first, and it has usually already acted on the first. The replay path is the same: both events are in the timeline, carried_of carries both, so on the next attach a session reads the original and the correction as two separate messages in order.

The presentation is the worst available one: it reads as the sender repeating themselves rather than replacing what they said. And the failure is silent in the direction that produces action — nothing is missing, so nothing looks wrong, and the session has no prompt to go and check.

Why this is worse than the reply gap

A dropped reply relation makes a message hard to interpret. A dropped m.replace makes a session act on text its sender has already withdrawn, and the withdrawal is what it cannot see. Correcting a message is ordinary use of a Matrix client, so this does not require anything unusual to trigger.

Fix

Carry the relation. At minimum the push should mark the event as a replacement and name the event it replaces, so a session can recognise the pair. Beyond that there is a design decision this issue should settle:

  • Deliver the edit as a distinct event carrying replaces="<event_id>", leaving the session to reconcile, or
  • deliver m.new_content.body rather than the * fallback, since the fallback exists for clients that cannot render edits and a channel session is not one.

The second is better prose and loses the asterisk convention a reader might otherwise use to notice. Whichever, the relation must be in the meta: a session that cannot see the relation cannot reconcile anything.

The replay choice, and what a reader observes under each

Decide this rather than inherit it. The sentence to decide against is what a session sees after reattaching.

Replay sends A session that never saw the original A session that saw the original before detaching
Both, unmarked (today) Two near-identical instructions, acts on both Same, plus the original it already acted on
Only the latest, unmarked Everything it needs, nothing stale The correction with no trace of what it corrects
Only the latest, marked with the replaced event id Everything it needs, nothing stale Matches the id and knows precisely which of its instructions was superseded

The third costs one attribute and pays neither of the other two costs. It also generalises past replay: on the live path an edit arriving as an unmarked new message is the same defect whether or not anyone reattached, so one marking serves both surfaces.

Verification

A test asserting the meta of a pushed m.replace, with a control that must go red: a plain m.text with no relation must not carry the attribute. If the delivered body changes to m.new_content, a second control that an edit whose m.new_content is missing or malformed falls back rather than delivering an empty body.

#124 — the general case: the channel push is a lossy projection of the timeline and every relation it drops is invisible rather than degraded.

Live defect, not a missing feature. Split out of #124, which covers the general shape. ## What a session sees today Julian sends a message, notices a mistake and corrects it in his client. His client sends an `m.room.message` whose `content.body` is `* <corrected text>`, whose `content["m.new_content"]["body"]` is the corrected text, and whose `content["m.relates_to"]` is `{"rel_type": "m.replace", "event_id": "<the original>"}`. An `m.replace` is an `m.room.message`, so it reaches `push_message` like any other. `carried_of_live` matches on msgtype and `m.text` is carried, and the body delivered is `ev.content.body()` — the `* `-prefixed fallback. The meta is built at `src/channel.rs:1424-1437` and carries `room`, `sender`, `event`, and nothing about the relation. What the session observes is two near-identical instructions moments apart, the second prefixed with an asterisk, with nothing saying the second supersedes the first, and it has usually already acted on the first. The replay path is the same: both events are in the timeline, `carried_of` carries both, so on the next attach a session reads the original and the correction as two separate messages in order. The presentation is the worst available one: it reads as the sender repeating themselves rather than replacing what they said. And the failure is silent in the direction that produces action — nothing is missing, so nothing looks wrong, and the session has no prompt to go and check. ## Why this is worse than the reply gap A dropped reply relation makes a message hard to interpret. A dropped `m.replace` makes a session act on text its sender has already withdrawn, and the withdrawal is what it cannot see. Correcting a message is ordinary use of a Matrix client, so this does not require anything unusual to trigger. ## Fix Carry the relation. At minimum the push should mark the event as a replacement and name the event it replaces, so a session can recognise the pair. Beyond that there is a design decision this issue should settle: - Deliver the edit as a distinct event carrying `replaces="<event_id>"`, leaving the session to reconcile, or - deliver `m.new_content.body` rather than the `* ` fallback, since the fallback exists for clients that cannot render edits and a channel session is not one. The second is better prose and loses the asterisk convention a reader might otherwise use to notice. Whichever, the relation must be in the meta: a session that cannot see the relation cannot reconcile anything. ### The replay choice, and what a reader observes under each Decide this rather than inherit it. The sentence to decide against is what a session sees after reattaching. | Replay sends | A session that never saw the original | A session that saw the original before detaching | |---|---|---| | Both, unmarked (today) | Two near-identical instructions, acts on both | Same, plus the original it already acted on | | Only the latest, unmarked | Everything it needs, nothing stale | The correction with no trace of what it corrects | | Only the latest, marked with the replaced event id | Everything it needs, nothing stale | Matches the id and knows precisely which of its instructions was superseded | The third costs one attribute and pays neither of the other two costs. It also generalises past replay: on the live path an edit arriving as an unmarked new message is the same defect whether or not anyone reattached, so one marking serves both surfaces. ## Verification A test asserting the meta of a pushed `m.replace`, with a control that must go red: a plain `m.text` with no relation must not carry the attribute. If the delivered body changes to `m.new_content`, a second control that an edit whose `m.new_content` is missing or malformed falls back rather than delivering an empty body. ## Related #124 — the general case: the channel push is a lossy projection of the timeline and every relation it drops is invisible rather than degraded.
jlxq0 closed this issue 2026-08-26 15:55:09 +00:00
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#125
No description provided.