Skip to content

Edits cost ~2 bytes/char (Vec<u8> CBOR array): messages over ~467 chars can be sent but never edited #443

Description

@sanity

Problem

Reported on Matrix (2026-07-21) by @ofansifkapital-xmpp and confirmed by @Ivvvor: message edits appear to be silently dropped. Both reporters attributed it to encrypted (private) rooms; the measurements below suggest the real variable is edit length, which is privacy-independent apart from 16 bytes.

An edit action costs ~2.1 bytes per ASCII character, while a plain message costs ~1.01. With the default max_message_size = 1000, that means:

effective ceiling
send a message ~985 chars
edit a message ~467 chars (public) / ~459 chars (private)

Consequence: any message longer than ~467 characters can be sent but can never be edited. Before #431 there was no edit-size gate at all, so attempting it produced exactly the reported symptom — click Save, form closes, text reverts, nothing on the wire, nothing in the console. After #431 it fails loudly (Message too long — 1006/1000 bytes, Save disabled), but it still cannot be done.

Root cause

ActionContentV1::payload is a bare Vec<u8> (common/src/room_state/content.rs:82-89):

pub struct ActionContentV1 {
    pub action_type: u32,
    pub target: MessageId,
    pub payload: Vec<u8>,   // <-- CBOR-encoded EditPayload
}

serde has no distinct byte-string type in the derive path, so ciborium serialises Vec<u8> as a CBOR array of integers. Every byte >= 0x18 costs 2 bytes, and all printable ASCII is >= 0x20. TextContentV1 { text: String } becomes a CBOR text string, so plain messages don't pay this — hence the asymmetry.

The edit text is also double-encoded: EditPayload { new_text } is CBOR'd into payload, then payload is re-encoded as an array of ints.

Verification

Independent reproduction of the encoding (standalone ciborium program mirroring the structs):

 100 chars -> ActionContentV1 =  258 bytes   TextContentV1 =  108
 400 chars -> ActionContentV1 =  860 bytes   TextContentV1 =  409
 470 chars -> ActionContentV1 = 1000 bytes   TextContentV1 =  479
 900 chars -> ActionContentV1 = 1860 bytes   TextContentV1 =  909

Cross-checked against the live published UI's own byte counter (build 2026-07-21T19:35:51Z, commit f3f6907): a 900-char edit measured 1866 bytes in a public room and 1882 in a private room — the 16-byte delta is exactly the AES-GCM tag. A 470-char edit measured 1006/1000 with Save disabled. These match the standalone numbers within a few bytes.

Suggested fix

#[serde(with = "serde_bytes")] pub payload: Vec<u8> would roughly halve the cost and bring the edit ceiling in line with the send ceiling.

This is a wire-format change — it alters ActionContentV1's CBOR encoding, so it changes the room-contract WASM and needs the room-contract + delegate migration ritual, plus a compatibility story for edit/reaction actions already stored in existing rooms under the old encoding. Flagging rather than implementing; wants a decision on the migration approach.

A non-breaking stopgap would be to raise max_message_size, but that doesn't fix the class (per #431's own reasoning) and leaves the send/edit asymmetry in place.

What this does NOT explain

Both reporters framed this as private-room-specific. The doubling happens in ActionContentV1::encode() before encryption, so public rooms are equally affected. I could not reproduce a privacy-specific edit failure on the current build: short edits in private rooms worked in every scenario tried (owner edit, edit across sessions, re-edit, edit after a new message, invitee-on-a-different-node edit, and live cross-member propagation).

Two gaps remain open:

  1. Secret rotation is untested. Every test room sat at secret_version: 0. A room that has had a member banned (which triggers rotation) is untested, as is a member who joined after a rotation. Note that handle_edit_message silently returns when no current-version secret is available (ui/src/components/conversation.rs:1544-1547) — that path is private-room-specific and would look identical to this bug. Related: UI: refresh secrets state before encrypting outbound private messages #271, Late joiners cannot decrypt messages after a secret rotation while owner is offline #309.
  2. Whether the reporters' edits were actually long. Worth asking them for the length of the messages they tried to edit, and whether those rooms have had members removed.

Related

[AI-assisted - Claude]

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions