Skip to content

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 by id, replace in place
  • On MESSAGE_DELETE: remove by id
  • 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:

GET /api/channels/{id}/read-state
→ { last_read_id, mention_count }

When the user views a channel and reads all visible messages, acknowledge:

PUT /api/channels/{id}/read-state
{ "last_read_id": "<id of most recent message>" }

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_OPEN event): refresh the list
  • When the user opens a DM: GET /api/dm/{channelID}/messages?limit=50 if 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