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
Membership management is a surprisingly interesting problem in decentralized systems. In a centralized
10
-
chat app, an admin can remove inactive users from a group. But in a decentralized system without
11
-
servers, who decides when someone is no longer active? And how do you enforce that decision across
12
-
every peer?
9
+
Membership management is a deceptively hard problem in decentralized systems. In a centralized chat
10
+
app, an admin can remove inactive users from a group. But in a decentralized system without servers,
11
+
who decides when someone is no longer active? How do you enforce that decision across every peer
12
+
without coordination? And how do you do it without clocks, which are notoriously unreliable in
13
+
distributed systems?
13
14
14
-
River answers this with a mechanism we call **message-based member lifecycle**: your
15
-
presence in a room's member list is tied to whether you have recent messages. No messages, no
16
-
membership entry. Send a message, and you're back automatically.
15
+
River's answer is **message-based member lifecycle**: your presence in a room's member list is tied
16
+
to whether you have recent messages. No messages, no membership entry. Send a message, and you're
17
+
back automatically. It's a mechanism that falls naturally out of the constraints of decentralized
18
+
systems — and it turns out to be a better model than what centralized apps do.
17
19
18
20
#### The Problem
19
21
20
22
Previously, River's room member list only grew. The only way to remove someone was to ban them, which
21
23
is a hostile action that isn't appropriate for someone who simply stopped chatting. Over time, rooms
22
-
would accumulate dozens of inactive members, making the member list meaningless and wasting bandwidth
24
+
accumulate dozens of inactive members, making the member list meaningless and wasting bandwidth
23
25
synchronizing their membership data across the network.
24
26
25
27
This is a common problem in decentralized systems. Without a central authority to curate membership,
26
28
most protocols either ignore the problem (letting lists grow unboundedly) or require manual
27
-
intervention from room administrators.
29
+
intervention from room administrators. Neither scales.
28
30
29
-
#### The Solution
31
+
#### Why Not Use Timestamps?
30
32
31
-
River takes a different approach. The room contract — the WebAssembly code that every peer runs to
32
-
validate state changes — now enforces a simple rule: **members exist in the list only while they have
33
-
at least one message in the room's recent message window** (100 messages by default, configurable by
34
-
the room owner).
33
+
The obvious approach is to prune members who haven't been active for some period — say, 30 days.
34
+
This is what centralized systems do. But it has a fundamental problem in a decentralized context:
35
+
**peers don't share a reliable clock.**
35
36
36
-
When your last message ages out of the recent messages buffer, you're automatically pruned from the
37
-
member list. When you send a new message, your original invitation is bundled with the message, and
38
-
you reappear. From the user's perspective, nothing changes — they can always participate in rooms
39
-
they've been invited to. But the member list now reflects who's actually active.
37
+
Timestamps in distributed systems are unreliable. Peers can have skewed clocks, and there's no
38
+
authority to adjudicate disagreements. If Alice's clock is a month ahead, she'd see Bob as inactive
39
+
when he isn't. If Bob's clock is behind, his "recent" messages would look old to everyone else. You
40
+
end up needing a consensus mechanism just to agree on what time it is — which is a heavyweight
41
+
solution for a simple membership problem.
40
42
41
-
This is conceptually similar to how a conference room works in real life: if you leave and come back
43
+
A heartbeat-based approach (periodic "I'm here" messages) avoids the clock problem but creates
44
+
bandwidth overhead proportional to the number of members, even when nobody is actually chatting. It
45
+
also requires peers to be online to send heartbeats, which conflicts with the reality of mobile and
46
+
intermittently-connected devices.
47
+
48
+
#### The Solution: Messages as Proof of Presence
49
+
50
+
River takes a different approach. The room contract — the WebAssembly code that every peer executes
51
+
to validate state changes — enforces a simple rule: **members exist in the list only while they have
52
+
at least one message in the room's recent message window.**
53
+
54
+
The message window defaults to 100 messages and is configurable by the room owner. When your last
55
+
message scrolls out of that window, you're automatically pruned from the member list. When you send a
56
+
new message, your original invitation is bundled with the message delta, and you reappear. From the
57
+
user's perspective, nothing changes — they can always participate in rooms they've been invited to.
58
+
But the member list reflects who's actually active.
59
+
60
+
This works because messages are the one thing peers already agree on — they're part of the shared
61
+
state. No additional protocol, no clocks, no heartbeats. The membership list becomes a **derived
62
+
property** of the message history rather than an independent data structure to maintain.
63
+
64
+
It's conceptually similar to how a conference room works in real life: if you leave and come back
42
65
later, you don't need a new invitation — but no one would list you as "present" while you're away.
43
66
44
67
<imgsrc="/img/member-lifecycle.svg"alt="Member lifecycle diagram showing how members are pruned when inactive and automatically re-added when they send a message"style="width: 100%; max-width: 800px; margin: 20px0;">
45
68
69
+
#### Why 100 Messages?
70
+
71
+
The default window size of 100 messages is a trade-off between three concerns:
72
+
73
+
-**Too small** (e.g. 10): Members get pruned quickly in active rooms, leading to frequent
74
+
prune/rejoin cycles and unnecessary bandwidth from re-transmitting invite chains.
75
+
-**Too large** (e.g. 10,000): The member list would grow toward the old unbounded behavior,
76
+
defeating the purpose. The message buffer itself would also consume significant bandwidth during
77
+
synchronization.
78
+
-**100 messages** strikes a practical balance: in a moderately active room, this represents roughly
79
+
a day or two of conversation. Members who haven't spoken in that time are likely genuinely
80
+
inactive.
81
+
82
+
Room owners can tune this via the room configuration. A high-traffic announcement channel might use a
83
+
smaller window; a slow-moving coordination group might use a larger one.
84
+
85
+
#### Deterministic Convergence
86
+
87
+
A critical requirement: all peers must converge to the **same** member list, regardless of the order
88
+
they receive messages. If Alice receives Bob's message before Carol's, and Dave receives them in the
89
+
opposite order, they must still end up with identical member lists.
90
+
91
+
River achieves this through the contract's `post_apply_cleanup` function, which runs after every
92
+
state change. It scans the current message window, collects the set of message authors, walks their
93
+
invite chains, and retains only the members who are needed. The result is then sorted
94
+
deterministically by member ID. Because the function operates on the same inputs (the converged
95
+
message list) regardless of how those messages arrived, every peer reaches the same output.
96
+
97
+
This is a property of the CRDT (Conflict-free Replicated Data Type) design: the merge operation is
98
+
commutative and idempotent, so message ordering doesn't affect the final state.
99
+
46
100
#### Preserving Invite Chains
47
101
48
102
One subtlety: River's permission model uses invite chains. The room owner invites Bob and Dave, Bob
@@ -51,48 +105,138 @@ her invite chain back to the room owner would be broken.
51
105
52
106
The pruning algorithm handles this by keeping members who are in the invite chain of anyone with
53
107
recent messages. Bob stays in the list as long as Carol (or anyone Bob invited) is active, even if
54
-
Bob himself hasn't sent a message recently. Dave, having no active members in his invite chain, gets
55
-
pruned.
108
+
Bob himself hasn't sent a message recently. Dave, having no active invitees in his chain, gets
109
+
pruned normally.
110
+
111
+
This creates an interesting emergent property: "connectors" — members who invited many active users —
112
+
persist in the list even without posting, because they're structurally necessary. This roughly
113
+
mirrors real social dynamics, where a person who brought a group together remains relevant even if
114
+
they go quiet.
56
115
57
116
<imgsrc="/img/invite-chain-pruning.svg"alt="Invite chain preservation diagram showing how inactive members in an active member's invite chain are kept"style="width: 100%; max-width: 800px; margin: 20px0;">
58
117
59
118
#### Bans Survive Pruning
60
119
61
-
Getting the interaction between pruning and bans right required careful thought. If Alice bans
62
-
Charlie and then Alice goes inactive, what happens to the ban?
120
+
Getting the interaction between pruning and bans right required careful thought and we got it wrong on
121
+
the first try.
63
122
64
-
The old logic removed bans when the banning member left the member list — which would mean that
65
-
inactive members' bans would silently disappear, allowing banned users to rejoin. The new logic
66
-
distinguishes between members who were *pruned* (just inactive) and members who were *banned*
67
-
(explicitly removed). Bans issued by pruned members persist. Only bans from members who were
68
-
themselves banned are treated as orphaned and removed.
123
+
If Alice bans Charlie and then Alice goes inactive, what happens to the ban? The original logic
124
+
removed bans when the banning member left the member list — which meant that inactive members' bans
125
+
would silently disappear, allowing banned users to rejoin. We caught this during testing when a
126
+
banned user reappeared after the banner went inactive.
69
127
70
-
#### Enforced by the Contract
128
+
The fix required distinguishing between two reasons a member might leave the list:
71
129
72
-
All of this runs inside the room contract's WebAssembly, which means every peer enforces the same
73
-
rules. Any delta that arrives — whether from a current or outdated peer — is validated and then cleaned up
74
-
by the contract's `post_apply_cleanup` function, which runs after every state change and
75
-
deterministically prunes members who shouldn't be there.
130
+
-**Pruned** (inactive): The member's bans persist. They're still a legitimate member of the
131
+
community; they just haven't spoken recently.
132
+
-**Banned** (explicitly removed): The member's bans become orphaned and are cleaned up. A banned
133
+
member's authority is revoked, including any bans they issued.
134
+
135
+
This distinction matters because it preserves the intent of moderation actions. A ban is a deliberate
136
+
community decision that should survive the banner going AFK.
137
+
138
+
#### What Happens During Network Partitions?
139
+
140
+
In a decentralized system, peers can temporarily lose connectivity and accumulate divergent state. What
141
+
happens when they reconnect?
142
+
143
+
Each peer independently applies the same pruning rules to its local state. When peers sync, they
144
+
merge their message lists (keeping all unique messages up to the buffer limit) and then re-run
145
+
`post_apply_cleanup`. Because pruning is derived from the merged message list — not from each peer's
146
+
individual pruning decisions — the result converges correctly.
76
147
77
-
This is the power of Freenet's contract model: the rules of the application are embedded in code that
78
-
every participant executes, not policies that a server enforces on your behalf.
148
+
A concrete scenario: Alice is offline for a day. Her peer has an older message list. When she
149
+
reconnects, her peer receives the latest messages from the network, merges them with her local state,
150
+
and prunes accordingly. She doesn't need to "catch up" on pruning decisions — she just needs the
151
+
current messages, and the pruning falls out deterministically.
79
152
80
-
#### What It Looks Like
153
+
The same applies to conflicting membership changes: if two peers independently prune different
154
+
members, the merged state will prune based on the combined message history, which is the correct
155
+
behavior.
81
156
82
-
For users, the change is minimal. The member list sidebar now shows "Active Members" — the people
83
-
actually participating. If you've been invited to a room but haven't chatted recently, you won't
84
-
appear in the list, but you can send a message at any time and you'll reappear instantly. No
85
-
re-invitation needed, no admin action required.
157
+
#### Cost and Performance
86
158
87
-
#### Current Limitations
159
+
Pruning adds computational work to every state merge, but the cost is modest:
88
160
89
-
Private rooms are temporarily disabled while we work through the implications of pruning for
90
-
encrypted room key distribution, where the room owner needs to be online to distribute secrets to
91
-
re-joining members.
161
+
-**Scanning messages for authors**: Linear in the message buffer size (default 100).
162
+
-**Walking invite chains**: Linear in the number of members times chain depth.
163
+
-**Sorting the member list**: O(n log n) in the member count.
164
+
165
+
With the defaults (100 messages, max 200 members), this takes microseconds on typical hardware —
166
+
negligible compared to CBOR serialization and network latency.
167
+
168
+
The more significant impact is on **bandwidth**. Each member adds roughly 200–300 bytes of
169
+
serialized data (public key, invitation signature, member info). At 200 members, that's ~50KB per
170
+
full state sync. Pruning inactive members directly reduces this. In a room where only 20 of 200
171
+
invited members are active, pruning reduces the synchronized member data by 90%.
172
+
173
+
When a pruned member rejoins, their message delta includes their original `AuthorizedMember` entry
174
+
and their full invite chain — a one-time cost of a few hundred bytes. This is significantly cheaper
175
+
than maintaining their membership data in every sync while they're inactive.
176
+
177
+
#### User Experience
178
+
179
+
From the user's perspective, the most visible change is that the member list sidebar now shows
180
+
"Active Members" — the people currently participating. If you've been invited to a room but haven't
181
+
chatted recently, you won't appear in the list.
182
+
183
+
When a pruned user opens the room and sends a message, they reappear instantly. No re-invitation
184
+
needed, no admin action required. The UI caches their original invitation and nickname locally, so
185
+
the rejoin is seamless — they don't lose their display name or need to reconfigure anything.
186
+
187
+
There is an intentional design choice here: **we don't notify users when they're pruned.** Being
188
+
pruned isn't a punishment or an event — it's the natural state of not participating. Just as you
189
+
wouldn't expect a notification for leaving a conference room by walking away, you shouldn't be
190
+
alarmed by not appearing in the active list of a room you haven't used recently.
191
+
192
+
#### Limitations and Open Questions
193
+
194
+
**Private rooms and secret distribution.** In private rooms, messages are encrypted with a shared
195
+
secret that the room owner distributes to members. When a member is pruned, their encrypted secret
196
+
copy is cleaned up. When they rejoin, the owner needs to re-distribute the secret — which requires
197
+
the owner to be online. We're working on approaches to handle this, including pre-cached secrets and
198
+
peer-assisted distribution.
199
+
200
+
**Lurkers.** Some users want to read without posting. Under the current design, they'll be pruned
201
+
from the member list, though they can still read messages (the room state is still synchronized to
202
+
their peer). This is arguably correct — a read-only user isn't an "active member" — but it may feel
203
+
surprising. Future iterations could introduce a lightweight presence mechanism for users who want to
204
+
remain visible without posting, but we'd need to solve the heartbeat bandwidth problem mentioned
205
+
earlier.
206
+
207
+
**High-churn rooms.** In a room with very high message volume and many participants, the prune/rejoin
208
+
cycle could create noticeable churn in the member list. The message window size mitigates this, but
209
+
rooms with hundreds of active members may need larger windows, which increases sync bandwidth. The
210
+
`max_members` configuration (default 200) provides a hard cap, after which the contract prunes
211
+
members with the longest invite chains first.
212
+
213
+
**Invite chain depth.** Deep invite chains (A invited B who invited C who invited D...) create a
214
+
structural dependency: pruning A cascades to B, C, and D unless they have independent paths to the
215
+
owner. In practice, most rooms have shallow chains (1-2 levels deep), but this could be an issue in
216
+
large communities with deep delegation hierarchies.
217
+
218
+
#### Enforced by the Contract
219
+
220
+
All of this runs inside the room contract's WebAssembly, which means every peer enforces the same
221
+
rules. Deltas arriving from any peer — current or outdated — are validated and then cleaned up by the
222
+
contract's `post_apply_cleanup` function. No peer can opt out of pruning or maintain a stale member
223
+
list.
224
+
225
+
This is the key architectural insight: by embedding lifecycle rules in the contract rather than
226
+
treating them as optional policy, the system is self-maintaining. Every state transition is an
227
+
opportunity to clean up, and the cleanup is deterministic.
92
228
93
229
#### A Pattern for Decentralized Systems
94
230
95
-
The broader lesson here is that decentralized applications need self-managing data structures.
96
-
Without a server to run maintenance tasks, the data itself has to define its own lifecycle rules. By
97
-
embedding pruning logic in the contract, River rooms stay clean without any human intervention — a
98
-
small example of the kind of autonomous behavior that makes decentralized applications practical.
231
+
The broader lesson is that decentralized applications need **self-managing data structures**. Without
232
+
a server to run maintenance tasks, the data itself has to define its own lifecycle rules. The
233
+
alternative — hoping that human administrators will manually curate state across a peer-to-peer
234
+
network — doesn't scale.
235
+
236
+
Message-based lifecycle is one instance of a general principle: **derive structure from the data you
237
+
already have, rather than maintaining separate metadata that can drift out of sync.** The member list
238
+
isn't a separate thing to manage; it's a view over the message history. This eliminates an entire
239
+
class of consistency problems.
240
+
241
+
We expect this pattern — contracts that enforce their own housekeeping as part of every state
242
+
transition — to be common across Freenet applications. River is just the first example.
0 commit comments