Problem
In-room DMs (shipped in #244 / #243) carry only ECIES ciphertext on the wire — only the recipient can decrypt the body. As a side effect, the sender's UI shows their own sent DMs as the placeholder "sent — ciphertext only":
Direct messages with zorolin
sent — ciphertext only — 2026-05-16 15:48:05
You typed the message, you sent it, but you can't see what you typed afterwards. That's terrible UX, and it's the same in riverctl dm list (outbound shows as ciphertext-only too).
Why we didn't just store plaintext locally
The current design keeps zero extra plaintext anywhere: the ciphertext in ChatRoomStateV1.direct_messages is canonical for both sender and recipient. Adding in-memory plaintext to the UI would:
- Mislead users into thinking sent DMs persist across reloads / devices — they don't, the map evaporates.
- Add a special-cased lifecycle (populate on send, evict on purge, lose on reload) that's hard to reason about.
- Be the wrong shape if/when the same user opens River on a second device: device A's outbound DMs would still be ciphertext-only on device B.
Proposed solution
Persist outbound DM plaintext in the chat delegate, the same way the delegate already stores room secrets / signing keys / migrated rooms. That gives us:
- Persistence across reloads. Open River next week, your sent DMs are still there with the original plaintext you typed.
- Multi-device convergence. Both your devices already share the chat-delegate state — sent-DM plaintext should ride the same channel.
- Correct purge semantics. When a recipient signs a purge envelope tombstoning your DM, the contract drops the ciphertext on merge; we should drop the matching plaintext from the delegate on the same event.
- Encrypted at rest. Delegate storage already encrypts data tied to room secrets, so we don't widen the threat model.
Storage shape
Per chat-delegate convention, add a new key namespace:
// in `common/src/chat_delegate.rs`
pub const OUTBOUND_DMS_STORAGE_KEY: &[u8] = b\"outbound_dms\";
// stored value (serialized CBOR):
pub struct OutboundDmStore {
// Keyed by (room_owner_vk, recipient_member_id, purge_token).
// Vec<(...)> not HashMap so it serialises through any wire format
// including JSON — see existing `purge_versions` precedent in
// `DirectMessagesSummary` for why.
pub entries: Vec<OutboundDmEntry>,
}
pub struct OutboundDmEntry {
pub room_owner_vk: VerifyingKey,
pub recipient: MemberId,
pub purge_token: PurgeToken,
pub timestamp: u64,
pub plaintext: String,
}
Lifecycle
| Event |
Action |
| UI/CLI sends DM |
After compose_direct_message succeeds and the state delta is applied locally, append an OutboundDmEntry to the delegate store. |
| UI renders own outbound bubble |
Look up (room, peer, token) in OUTBOUND_DMS. Hit → render as BodyKind::Plaintext. Miss → render as BodyKind::Placeholder ("sent — ciphertext only") — this is the fall-through for DMs sent before the delegate started tracking them. |
| Recipient purges; tombstone arrives |
post_apply_cleanup already drops the ciphertext from contract state. Mirror that in the delegate: after the state-merge handler runs, prune any OutboundDmEntry whose (recipient, purge_token) is now in the recipient's purge envelope. |
| Per-pair cap reached on send |
Apply the same eviction policy the contract uses — drop the oldest outbound entry for that (sender, recipient) pair. Keeps the store bounded. |
| Delegate WASM key changes |
The existing legacy-delegates migration path (legacy_delegates.toml) already moves rooms_data across keys. Add outbound_dms to that migration so a delegate WASM rebuild doesn't orphan sent plaintext. |
Out of scope
- Backfilling pre-delegate outbound DMs. DMs sent under the current production UI before this lands stay as "sent — ciphertext only" forever. Documented limitation.
- Edit/delete of sent DMs. That's a different design conversation (the contract doesn't currently support DM edits — only purge-by-recipient).
- End-to-end encrypted device-to-device sync of the outbound store. The chat-delegate already encrypts its state tied to the room secret, so co-room devices converge; cross-room delegate sync is the same scope as room-secret sync.
Implementation sketch
common/src/chat_delegate.rs: add OUTBOUND_DMS_STORAGE_KEY, OutboundDmStore, OutboundDmEntry. Pure types, no contract changes.
ui/src/components/app/chat_delegate.rs: add save_outbound_dm() / load_outbound_dms() / prune_outbound_dms_for_purges(), all going through the existing ChatDelegateKey machinery.
ui/src/components/direct_messages/dm_thread_modal.rs: on send path, append to local cache + queue a delegate write. On render path, consult the cache for outbound bubbles.
cli/src/commands/dm.rs: same change — dm list should consult the equivalent outbound-store via the delegate when the user is running with delegate-backed signing.
delegates/chat-delegate/src/: handle the new storage key in the existing handler chain. Add migration entry if WASM hash changes.
Estimate
~1-2 days. No protocol change (room contract WASM stays as-is), so no migration tag is needed for the contract.
Related
[AI-assisted - Claude]
Problem
In-room DMs (shipped in #244 / #243) carry only ECIES ciphertext on the wire — only the recipient can decrypt the body. As a side effect, the sender's UI shows their own sent DMs as the placeholder "sent — ciphertext only":
You typed the message, you sent it, but you can't see what you typed afterwards. That's terrible UX, and it's the same in
riverctl dm list(outbound shows as ciphertext-only too).Why we didn't just store plaintext locally
The current design keeps zero extra plaintext anywhere: the ciphertext in
ChatRoomStateV1.direct_messagesis canonical for both sender and recipient. Adding in-memory plaintext to the UI would:Proposed solution
Persist outbound DM plaintext in the chat delegate, the same way the delegate already stores room secrets / signing keys / migrated rooms. That gives us:
Storage shape
Per chat-delegate convention, add a new key namespace:
Lifecycle
compose_direct_messagesucceeds and the state delta is applied locally, append anOutboundDmEntryto the delegate store.(room, peer, token)inOUTBOUND_DMS. Hit → render asBodyKind::Plaintext. Miss → render asBodyKind::Placeholder("sent — ciphertext only") — this is the fall-through for DMs sent before the delegate started tracking them.post_apply_cleanupalready drops the ciphertext from contract state. Mirror that in the delegate: after the state-merge handler runs, prune anyOutboundDmEntrywhose(recipient, purge_token)is now in the recipient's purge envelope.(sender, recipient)pair. Keeps the store bounded.legacy_delegates.toml) already movesrooms_dataacross keys. Addoutbound_dmsto that migration so a delegate WASM rebuild doesn't orphan sent plaintext.Out of scope
Implementation sketch
common/src/chat_delegate.rs: addOUTBOUND_DMS_STORAGE_KEY,OutboundDmStore,OutboundDmEntry. Pure types, no contract changes.ui/src/components/app/chat_delegate.rs: addsave_outbound_dm()/load_outbound_dms()/prune_outbound_dms_for_purges(), all going through the existingChatDelegateKeymachinery.ui/src/components/direct_messages/dm_thread_modal.rs: on send path, append to local cache + queue a delegate write. On render path, consult the cache for outbound bubbles.cli/src/commands/dm.rs: same change —dm listshould consult the equivalent outbound-store via the delegate when the user is running with delegate-backed signing.delegates/chat-delegate/src/: handle the new storage key in the existing handler chain. Add migration entry if WASM hash changes.Estimate
~1-2 days. No protocol change (room contract WASM stays as-is), so no migration tag is needed for the contract.
Related
[AI-assisted - Claude]