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
- 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.
- 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.
- Populate the
to_device: field on the appservice push_events::v1::Request.
- 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
Tracking issue for finishing the appservice transaction sender so it delivers
to_deviceevents 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_requestto-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 keysnotice 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(currentmain, line 829-830):Tuwunel hardcodes the
to_deviceslice of every appservice transaction to an empty vec. This means the homeserver never deliversto_deviceevents (including megolm session-key shares) to AS-namespaced devices, even when the appservice hasdevice_management: truein its registration (MSC4190) and the bridge has uploaded device keys for its bot user.git blameputs the TODO in commitda92b97(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
m.typingandm.receiptrouting to AS via theephemeralslot of the transaction. Sets the pattern for the same shape of fix onto_device. Previously reverted once (deadlock); the deadlock context is worth reading before redoing the same pattern.device_management = true); it does not yet ship the half where the homeserver routes inboundto_deviceevents back to that device through AS transactions. That second half is what this issue tracks.Symmetry
device_managementis 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 sendsto_device: []. The natural gate for the routing path is the same flag.The existing
ephemeralpath atsender.rs:802is the right shape to mirror:The matching
to_deviceblock would be:Implementation sketch
max_edu_countatsender.rs:399, add amax_to_device_count(or similar) advanced as AS transactions are acknowledged. Stored in the same place as the existing AS cursors.registration.device_managementset, 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 callPUT /_matrix/client/v3/sendToDevice/...(the inbound side works), so the storage exists; this is read-side only.to_device:field on the appservicepush_events::v1::Request.Concurrency hazards to revisit before merging:
Spec references
to_device: Vec<Raw<AnyToDeviceEvent>>onappservice::event::push_events::v1::Request, so the wire format is done.Plan
main, mirror PR Route ephemeral events to appservices with receive_ephemeral matrix-construct/tuwunel#406's pattern, gate onregistration.device_management.encryption.require: true.