matrix-org / matrix-org/matrix-rust-sdk
latestEditJson returns stale edit for chained m.replace edits sent via appservice impersonation
Nobody has claimed this yet.
- Dominant language
- Rust
- Stars
- 2.3k
- Forks
- 500
- Avg merge
- 1d 16h
- Merged PRs (30d)
- 106
Description
## Summary
When multiple `m.replace` edits target the same event and are sent via appservice impersonation (`?user_id=@user`), the SDK on that user's device returns the **first** edit in `latestEditJson` instead of the **latest** one. The SDK also applies the wrong edit to `messageContent.body`.
This is a regression introduced by PR #5994 (merged Jan 6 2026). Versions before that PR (e.g. `matrix-rust-components-swift` v25.12.19) work correctly.
## Reproduction
1. User A is logged into an iOS app using `matrix-rust-components-swift` v26.04.01
2. An appservice sends `m.room.message` (msgtype `m.notice`) to a room as User A (`?user_id=@userA`)
3. The appservice sends an `m.replace` edit targeting that event, also as User A — content: `"phase": "ongoing"`
4. The appservice sends a **second** `m.replace` edit targeting the **same** root event, also as User A — content: `"phase": "ended"`
5. On User A's device, read `EventTimelineItem.lazyProvider.debugInfo().latestEditJson`
**Expected:** Returns the second edit (`"phase": "ended"`)
**Actual:** Returns the first edit (`"phase": "ongoing"`)
`messageContent.body` is also stuck on the first edit's body text.
## Key observation
The first edit stored in `latestEditJson` has **no `age` field** — it's in local echo format. The SDK treats the appservice-impersonated event as a local echo because the sender matches the logged-in user, even though the client never sent it.
```json
// First edit — stuck as "latest" (no age = local echo)
{"content":{"body":"ongoing","m.new_content":{...}}}
// Second edit — correct but ignored (has age = remote echo)
{"age":99456250,"content":{"body":"ended","m.new_content":{...}}}
```
## Root cause
PR #5994 added deduplication to prevent local and remote echoes from coexisting in `related_events`. The deduplication matches by `own_id`. When two **different** edits arrive (edit 1 = ongoing, edit 2 = ended), they have different `own_id`s so deduplication doesn't apply. Both are stored. The SDK then selects the first one (the phantom local echo) instead of the latest.
The fundamental issue: the SDK creates local echo entries for events it never sent. When an appservice sends an event as `@userA`, and `@userA`'s device receives it via sync, the SDK incorrectly classifies it as a local echo.
## Who is affected
- **Affected:** `matrix-rust-components-swift` v26.04.01 (commit `388ced09`) and likely all versions after PR #5994
- **Not affected:** `matrix-rust-components-swift` v25.12.19 (before PR #5994)
- **Not affected:** `sdk-android` v25.9.15 (same Rust SDK but before PR #5994)
- **Not affected:** `matrix-js-sdk` (Web) — different SDK
## Our use case
We build a VoIP calling app. The backend sends call timeline events as the call initiator via appservice impersonation. The lifecycle is: started -> ongoing (updated as participants join/leave) -> ended. All edits target the same root event. On the initiator's iOS device, the timeline tile stays stuck on "ongoing" and never shows "ended".
## Workaround
Downgrading to `matrix-rust-components-swift` v25.12.19 (before PR #5994).
## Related
- #5994 — Deduplicate aggregation local and remote echo (introduced the regression)
- #6096 — Handle edits when the target event is not directly preceding
- #5802 — EventTimelineItem::latest_edit_json is always empty for events edited locally
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start by reviewing PR #5994 and the related issues #6096 and #5802, then reproduce the chained appservice-impersonated m.replace sequence described here. Trace how related events are classified and selected, and verify that latestEditJson and messageContent.body use the second edit with phase "ended" rather than the first.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- rust
- Domain
- backend
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 48/100