Skip to content

feat(dm): make 1:1 private rooms the DM surface, with zero-friction creation #610

Description

@sanity

Problem

DMs are carried inside ChatRoomStateV1 (common/src/room_state/direct_messages.rs). That was the right call for #230 Phase 1, but the shape has accumulated real costs, and several open issues are all symptoms of the same root cause rather than independent bugs:

Two costs that are not tracked anywhere yet:

  • Metadata leak by design. Every co-member sees who DMed whom. That was an accepted tradeoff of the in-room approach and is documented as such, but it is a real privacy cost.
  • Size limits are unprincipled. MAX_DM_CIPHERTEXT_BYTES = 32_768 is a hard-coded protocol constant with no recorded rationale, against a default max_message_size of 1000 bytes for ordinary room messages. There is no per-room or per-relationship knob because there is no per-relationship object to hang one on.

Underlying all of it: DMs have no home of their own, so they live in a shared object and every bound has to be global.

Approach: 1:1 private rooms become the DM surface

Rather than inventing a new contract type, use the one River already has. A DM conversation is a private two-member room.

What that gets us, without new protocol:

  • Principled limits. A 1:1 room has its own Configuration, so message size is owner-tunable room config. The 32 KiB constant stops mattering.
  • The bloat class disappears. DM data leaves community room state entirely: no member pinning, no DM signatures in the shared summary, no DM contribution to shared-room merge cost.
  • Metadata privacy. Conversations are separate contracts, so co-members no longer see who is talking to whom.
  • Features for free. Edits, replies, reactions, read state, private-room encryption and secret rotation. DMs have none of these today and each would have had to be rebuilt.
  • Blocking becomes possible. A conversation is a subscription you can drop, which is what feat(dm): no way to leave/block a DM — the UI ✕ is a local archive that un-hides on the next message #461 needs.

In-room DMs are not deleted. They shrink to one job: carrying the invite. That path already exists (riverctl dm invite, invite_via_dm_picker_modal.rs, DirectMessageBody::Invite). An invite is small and bounded, so the per-message cap can drop to invite-sized and the per-pair cap to a handful.

The hard requirement: zero perceived friction

Creating a room currently feels like a process, and a DM must not. If "Message someone" walks a user through room creation, this redesign has failed regardless of how clean the protocol is. The bar is: click Message, type, send.

That means:

  • Room name is derived from the two nicknames. Never prompted.
  • Configuration comes from a DM preset. The edit-room modal is not in the path.
  • The invitation is generated and delivered automatically over the in-room DM invite carrier.
  • Acceptance is automatic when the invite comes from a co-member of a room you are both in. The trust basis already exists; they can already DM you today.
  • These render in the existing DM rail (dm_rail_section.rs) as conversations, never in the room list as rooms.

Blocking must be designed in from the start rather than deferred (#461), because auto-accept without a block is a spam vector.

Open design decisions

  1. Creation race. If both parties click Message simultaneously, two rooms are created. Options: deterministic "lower-sorted participant key creates", or first-mover-wins with the invite carrying the room key plus a tiebreak when two exist. Leaning toward the latter.
  2. Ownership asymmetry. Whoever creates the room is owner and can configure and ban. For a 1:1 conversation that is odd and should be a deliberate choice, not inherited by default.
  3. Subscription budget. See below. This is the one that could sink the design.
  4. History does not migrate. room_owner_vk is inside the DM signed bytes (direct_messages.rs:338-360), so existing DM signatures are only valid within their original room. Lifting history into a new contract would require re-signing with senders' keys, which is impossible. This is a hard cutover. Given the accepted position when Official room stuck at 200/200 members: the DM exemption pins members forever #519 was closed (that some DM loss is tolerable for now), that is probably acceptable, but it should be a stated decision.
  5. Per-room identity. MemberId is per-room, so the same person in two rooms is two unrelated identities. A DM room started from room A is a different relationship object than one started from room B, unless we deliberately unify.

Why not per-recipient inbox contracts

This was the obvious alternative and the original #234 design. Investigation says no, as the network stands today. Recording it so it is not re-proposed without the constraints being addressed.

Derived-key discovery works fine: ContractKey::from_params is a pure offline computation, so a client can compute an inbox key from (recipient_vk, room_owner_vk) with no prior pointer. The problem is retention.

  • A zero-subscriber contract is the top eviction victim. When the recipient disconnects, remove_client_from_all_subscriptions drops the count to zero and the upstream lease lapses within minutes. The inbox then sits at (0, 0, stale) in victim_order() (ring/hosting/cache.rs:808-819), which is the first eviction slot on every holder simultaneously, since they all rank it identically. A sender's PUT does reset recency (implemented, put/op_ctx_task.rs:2503-2517), but recency is compared after subscriber count, so it only breaks ties among already-abandoned contracts. There is no TTL and no durability tier: freenet-core's hosting invariants state plainly that idle zero-demand contracts are meant to evaporate.
  • Subscription ceilings. MAX_SUBSCRIPTIONS_PER_CLIENT = 50 per WebSocket connection, and core's own comment puts renewal capacity at roughly 40 concurrent. River already takes about two subscriptions per room (UI and chat-delegate separately), so a user in 20 rooms is near the ceiling before any DM subscriptions exist.
  • Module cache is keyed per ContractKey, not per CodeHash (runtime/pool.rs:75), so N inboxes sharing one WASM consume N module slots. Gateways are already at effectively full occupancy (freenet-core#4877).

Note that constraints 2 and 3 apply to this proposal too. Every DM conversation is another room, and River takes ~2 subscriptions per room. This is the main risk to the whole approach and needs measurement before committing: how many concurrent DM conversations can a client actually hold? If the answer is small, we need River-side subscription budgeting (lazy subscribe on thread open, unsubscribe on close) regardless of which design wins.

For reference, freenet/mail already targets cross-context DMs with strict metadata privacy. #238 reverted #234 partly on that redundancy. Nothing here changes that: this proposal covers "message someone I share a room with", not general mail.

Correcting the record on #234

The revert (#238) is often summarized as per-pair inbox contracts having failed. They did not. #234 was never deployed: no WASM artifact, no legacy_delegates.toml entry, no on-network test, and Phases 2 and 3 were never written. The stated revert reasons were redundancy with freenet/mail and a judgment that member-to-member DMs belong in room state since they need membership-chain auth anyway. There is no field evidence against the inbox design; the reasons above are the actual technical case, and they come from how core's hosting model evolved after #234.

Interim work, independent of this redesign

These stand on their own and should not wait:

  • DirectMessage::ciphertext: Vec<u8> has no serde_bytes, so CBOR encodes it as an array rather than a byte string, costing roughly 1.9x on all DM ciphertext in state and in every delta. Same class as the ed25519::Signature encoding trap already documented in .claude/rules/contract-summary-determinism.md. Verified by decoding common/tests/direct_messages_wire_format.hex.
  • MAX_DM_CIPHERTEXT_BYTES should come down now. Under this proposal in-room DMs become invite carriers, and invites are small. The current value has no recorded justification and is far larger than anything the role requires.
  • perf: DM summary carries raw 64-byte Ed25519 signatures — 19.8 KB at the DM cap (deferred half of #571) #596 (signature digest in the DM summary) applies to whatever DM state remains and is worth doing either way.

Both the cap change and the encoding change are room-contract WASM changes, so they need the migration ritual (.claude/rules/delegate-migration.md) and a republish.

Plan

  1. Interim: lower the DM ciphertext cap, add serde_bytes, land perf: DM summary carries raw 64-byte Ed25519 signatures — 19.8 KB at the DM cap (deferred half of #571) #596. One WASM migration covering all three.
  2. Measure the subscription ceiling against realistic DM conversation counts. This gates everything else.
  3. DM preset + auto-derived room creation, no prompts.
  4. Auto-invite over the existing invite carrier, auto-accept from co-members, plus blocking.
  5. DM rail renders 1:1 rooms as conversations.
  6. Shrink in-room DMs to invite-only once conversations have moved.

Related: #525, #596, #461, #432, #435, #422, #519, #524, #230, #234, #238.

[AI-assisted - Claude]

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

Status
Todo

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions