You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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:
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.
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
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.
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.
Subscription budget. See below. This is the one that could sink the design.
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.
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.
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.
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.
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:DirectMessagesSummary.message_signaturescarries raw 64-byte signatures, 19.8 KB at the 300-DM cap, in every room summary, recomputed and byte-compared every anti-entropy cycle. Every peer's summary advertises every DM to every member, participant or not.Two costs that are not tracked anywhere yet:
MAX_DM_CIPHERTEXT_BYTES = 32_768is a hard-coded protocol constant with no recorded rationale, against a defaultmax_message_sizeof 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:
Configuration, so message size is owner-tunable room config. The 32 KiB constant stops mattering.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:
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
room_owner_vkis 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.MemberIdis 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_paramsis 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.remove_client_from_all_subscriptionsdrops the count to zero and the upstream lease lapses within minutes. The inbox then sits at(0, 0, stale)invictim_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.MAX_SUBSCRIPTIONS_PER_CLIENT = 50per 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.ContractKey, not perCodeHash(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.tomlentry, 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 noserde_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 theed25519::Signatureencoding trap already documented in.claude/rules/contract-summary-determinism.md. Verified by decodingcommon/tests/direct_messages_wire_format.hex.MAX_DM_CIPHERTEXT_BYTESshould 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.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
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.Related: #525, #596, #461, #432, #435, #422, #519, #524, #230, #234, #238.
[AI-assisted - Claude]