Skip to content

Implement to_device routing to appservices (complete sender.rs TODO) #1

@mangas

Description

@mangas

Tracking issue for finishing the appservice transaction sender so it delivers to_device events to appservices.

Symptom

mautrix bridges (signal, whatsapp, telegram — v0.2604.0, bridgev2 framework) configured with mandatory end-to-bridge encryption fail to decrypt any messages they receive. The bridges request the megolm session keys via m.room_key_request to-device events, the homeserver accepts the request (HTTP 200), but the response from the user's device never reaches the bridge. After a 22-second timeout the bridge posts the standard ⚠️ Your message was not bridged: the bridge hasn't received the decryption keys notice into the portal room.

This affects every mautrix bridge run with encryption.require: true, on every modern Matrix client.

Root cause

In src/service/sending/sender.rs (current main, line 829-830):

.send_request(appservice, ruma::api::appservice::event::push_events::v1::Request {
    txn_id: txn_id.into(),
    events: pdu_jsons,
    ephemeral: edu_jsons,
    to_device: Vec::new(), // TODO
})

Tuwunel hardcodes the to_device slice of every appservice transaction to an empty vec. This means the homeserver never delivers to_device events (including megolm session-key shares) to AS-namespaced devices, even when the appservice has device_management: true in its registration (MSC4190) and the bridge has uploaded device keys for its bot user.

git blame puts the TODO in commit da92b97 (2026-03-23). It was left explicitly as a TODO during a recent refactor of the AS sender; there is no architectural blocker discussed anywhere in the tracker.

Upstream context

Symmetry

device_management is currently honored at five AS-callable endpoints (api/client/device.rs, api/client/register.rs, api/client/keys/upload_signing_keys.rs). The sender does not reference it — it just always sends to_device: []. The natural gate for the routing path is the same flag.

The existing ephemeral path at sender.rs:802 is the right shape to mirror:

if appservice.receive_ephemeral { /* gather edu_jsons */ }

The matching to_device block would be:

if appservice.registration.device_management {
    /* gather to_device events targeted at users in this AS's namespace,
       since this AS's to_device cursor */
}

Implementation sketch

  1. Cursor per AS — analogous to max_edu_count at sender.rs:399, add a max_to_device_count (or similar) advanced as AS transactions are acknowledged. Stored in the same place as the existing AS cursors.
  2. Gather — at the appservice transaction build site, for AS-es with registration.device_management set, scan the to-device queue for events whose recipient matches the AS's user namespace, since the cursor. Tuwunel already persists to-device events when AS users call PUT /_matrix/client/v3/sendToDevice/... (the inbound side works), so the storage exists; this is read-side only.
  3. Populate the to_device: field on the appservice push_events::v1::Request.
  4. Advance the cursor only after the transaction is acked by the AS, to avoid lost events on retries; same pattern as PDU/EDU.

Concurrency hazards to revisit before merging:

Spec references

Plan

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions