State Model¶
Partly out of date
Rendered from ProjectInfo/client/state-model.md on docs/encryption-modes-spec. Its protocol content is still useful, but parts of it describe an older client or an earlier API. Where it disagrees with the code, the code wins — see the spec errata.
What to cache locally, when to fetch vs. rely on events, and how to keep it consistent.
Overview¶
The client maintains a local cache of everything the user can see. The gateway stream keeps it live; REST fills gaps on startup and reconnect.
┌─────────────────────────────────────────────┐
│ Local State │
│ │
│ auth user, access_token │
│ servers server list + selected │
│ channels per-server channel list │
│ messages per-channel, newest 50–200 │
│ members per-server member list │
│ roles per-server role list │
│ presence userId → { status, text } │
│ friends friend list + pending │
│ dms DM channel list + messages │
│ channelKeys epochId → decrypted epoch key │ (never persisted)
│ e2eeSessions channelId → RatchetSession │ (stored securely)
└─────────────────────────────────────────────┘
Startup Sequence¶
Run this sequence after a successful login before rendering the main UI:
1. GET /api/users/@me → auth store (full profile)
2. GET /api/servers → server list
3. For each server:
GET /api/servers/{id}/channels → channel list
GET /api/servers/{id}/roles → role list (for permission resolution)
4. GET /api/dm → DM channel list
5. GET /api/users/@me/friends → friend list
6. Connect WebSocket → subscribe to all readable channels + servers
Load member lists lazily (when a user opens a server's member panel or mentions panel) — don't pre-fetch all members for all servers.
Message Cache¶
Keep a per-channel ring buffer of the most recent messages (recommended cap: 200).
- On channel open: if cache is cold,
GET /api/channels/{id}/messages?limit=50 - On scroll to top: fetch older messages with
?before=<oldest_message_id_in_cache>; prepend to cache - On
MESSAGE_CREATE: append to cache; if over cap, drop the oldest entry - On
MESSAGE_UPDATE: find byid, replace in place - On
MESSAGE_DELETE: remove byid - On reconnect /
hello: clear cache for all channels; re-fetch on next open
For rolling_key channels: store decrypted plaintext in the cache — never raw ciphertext. If decryption fails (key not yet available), store a placeholder string and re-attempt when the key arrives.
Presence¶
Maintain a map of userId → { status, statusText }. Seed it from:
- GET /api/servers/{id}/members — each member has a status field (if returned)
- Friends list — fetch friend profiles
- PRESENCE_UPDATE events — update the map in real time
Display presence on member list entries, friend list entries, and DM channel headers.
Read State¶
The server tracks last_read_id per channel per user. On load:
When the user views a channel and reads all visible messages, acknowledge:
Use mention_count to show badge numbers on channels in the sidebar.
Channels: Visibility and Sort¶
The channel list for a server is sorted by position (ascending). Channels with parent_id set are children of a category; group them under their category's entry.
Only show channels where the resolved permission includes PermViewChannel (bit 0). See moderation.md for the resolution algorithm.
Channel types:
| Type | Shows in | UI |
|------|----------|----|
| text | Text section | Chat input + message list |
| voice | Voice section | Join button; participant list |
| announcement | Text section | Like text but send-limited |
| stage | Voice section | Speaker/audience roles |
| category | Header | Groups other channels |
DM Channels¶
DM channels are separate from server channels. They have no server_id.
- List:
GET /api/dm— ordered by most recent activity - When a new DM arrives (
DM_OPENevent): refresh the list - When the user opens a DM:
GET /api/dm/{channelID}/messages?limit=50if cache cold
Channel Keys (rolling_key channels)¶
Store decrypted epoch keys in memory only — never write them to disk:
// In-memory store
epochKeys: Map<epochId: string, {
epochId: string;
epochNum: number;
key: Uint8Array; // 32 bytes
}>
Clear all epoch keys on logout. On receipt of a MESSAGE_CREATE with encryption_mode === "rolling_key":
1. Look up the key by epoch_id
2. If not found: GET /api/channels/{id}/epoch → open sealed key → cache it
3. Decrypt and display
E2EE Sessions (DMs)¶
Each DM channel has one RatchetSession per local device. Sessions are stateful and must survive app restarts.
Store them in:
- Desktop: OS credential store keyed by channel ID
- Mobile: iOS Keychain / Android Keystore
- Fallback: encrypted file using a device-derived key (not localStorage)
Session state includes the root key, chain keys, ratchet keypairs, and the skipped-message-key map. Serialise the full session state after every encrypt/decrypt and persist it atomically.
Consistency Rules¶
| Rule | Why |
|---|---|
| Never derive state from events alone | Events can be missed; always re-fetch on reconnect |
| Permission checks are UI hints only | Server always re-validates; never block an action on client-only perm check |
| Don't mutate on optimistic update failure | Roll back the local change on 4xx |
| Message cap enforcement | Dropping old messages from a capped buffer prevents unbounded memory growth |
| Epoch key lifetime | Keep epoch keys indefinitely in memory for the session (users need to decrypt old messages); clear on logout |