commit cc3e10e
xplshn
·
2026-07-12 19:40:45 +0000 UTC
parent cc3e10e
Initial commit
6 files changed,
+9096,
-0
+60,
-0
1@@ -0,0 +1,60 @@
2+<p align="center">
3+ <img src="docs/assets/logo.png" width=150>
4+</p>
5+
6+<p align="center">
7+ <b>Wire</b>, a sane chat protocol
8+</p>
9+
10+<p align="center">
11+ <a href="docs/wire_spec-0.8.txt">spec rev 0.8</a> •
12+ <a href="docs/IMPL.md">implementation notes</a> •
13+ ISC license
14+</p>
15+
16+---
17+
18+Wire is a text-based, line-oriented, UTF-8 chat protocol. It replaces IRC and Discord idioms with orthogonal primitives. It is not backwards compatible with IRC or any derivative.
19+
20+The design goals, in order:
21+
22+1. **Orthogonal** - primitives compose, they don't overlap
23+2. **Transparent** - no hidden state, all context is explicit
24+3. **Extensible** - features are optional namespaces
25+4. **Efficient** - one line per logical unit
26+5. **Human-legible** - minimal clients are first-class citizens, raw wire lines are readable without tooling
27+
28+---
29+
30+## The protocol (ELI5 explanation)
31+
32+Every line follows the same shape:
33+
34+```
35+VERB key=value key=value :optional payload
36+```
37+
38+A few real examples:
39+
40+```
41+MSG cid=01JXYZ456 mid=01JXYZ789 :hello world
42+REACT mid=01JXYZ789 eid=thumbsup
43+SUB rid=/wire.example.org/acme/general
44+ERR code=NOPERM :you are not allowed to do that
45+```
46+
47+Tags are unordered. Unknown tags are silently ignored. No special-casing per verb. IDs are 26-char Crockford base32 ULIDs, user IDs are derived from Ed25519 public keys and are stable across servers.
48+
49+Transport: TCP with mandatory TLS 1.3. Default port: `6789`
50+
51+## Spec
52+
53+The normative specification is [`docs/wire_spec-0.8.txt`](docs/wire_spec-0.8.txt). The implementation notes with Go code examples are in [`docs/IMPL.md`](docs/IMPL.md). When in doubt, check the spec.
54+
55+---
56+
57+### 3rd party Implementations:
58+
59+#### Clients:
60+
61+1. [cable](https://git.sr.ht/~chld/cable), by [sr.ht/~chld](https://git.sr.ht/~chld)
+814,
-0
1@@ -0,0 +1,814 @@
2+# WIRE Protocol - Implementation Notes (Go)
3+
4+All examples are non-normative. They cover the most error-prone
5+primitives; the normative specification is `wire_spec-0_8.txt`.
6+ISC license applies to all code in this file.
7+
8+---
9+
10+## 23.1 Key generation
11+
12+```go
13+import "crypto/ed25519"
14+
15+// GenerateIdentityKey returns a fresh Ed25519 keypair.
16+// Store privKey securely; pubKey is shared freely.
17+func GenerateIdentityKey() (pubKey ed25519.PublicKey, privKey ed25519.PrivateKey, err error) {
18+ return ed25519.GenerateKey(nil) // uses crypto/rand
19+}
20+```
21+
22+---
23+
24+## 23.2 UID derivation
25+
26+```go
27+import (
28+ "crypto/ed25519"
29+ "crypto/sha256"
30+ "encoding/base32"
31+)
32+
33+var enc = base32.NewEncoding("0123456789ABCDEFGHJKMNPQRSTVWXYZ").
34+ WithPadding(base32.NoPadding)
35+
36+// UIDFromPublicKey derives the canonical wire uid from an Ed25519 public key.
37+// The uid is globally stable: same keypair produces the same uid on every server.
38+// Algorithm: SHA-256(pubkey_bytes)[0:16] encoded as Crockford base32, 26 chars.
39+func UIDFromPublicKey(pub ed25519.PublicKey) string {
40+ sum := sha256.Sum256(pub)
41+ return enc.EncodeToString(sum[:16]) // 26 chars
42+}
43+
44+// KeyFingerprint returns the human-readable fingerprint for key verification UIs.
45+// Format: lowercase hex of SHA-256(pubkey), colon-grouped in four 16-char groups.
46+func KeyFingerprint(pub ed25519.PublicKey) string {
47+ sum := sha256.Sum256(pub)
48+ h := fmt.Sprintf("%x", sum)
49+ return h[0:16] + ":" + h[16:32] + ":" + h[32:48] + ":" + h[48:64]
50+}
51+```
52+
53+---
54+
55+## 23.3 Payload unescaping
56+
57+```go
58+import "strings"
59+
60+// UnescapePayload decodes the wire payload escape sequences.
61+// Rules (Sect. 2.4): \n LF, \t TAB, \\ backslash.
62+// Unknown sequences (\x for any other x) are preserved verbatim.
63+// Single-pass, left-to-right. Servers relay payload verbatim and never call this.
64+func UnescapePayload(s string) string {
65+ if !strings.ContainsRune(s, '\\') {
66+ return s // fast path: no escapes present
67+ }
68+ var b strings.Builder
69+ b.Grow(len(s))
70+ i := 0
71+ for i < len(s) {
72+ if s[i] != '\\' || i+1 >= len(s) {
73+ b.WriteByte(s[i])
74+ i++
75+ continue
76+ }
77+ switch s[i+1] {
78+ case 'n':
79+ b.WriteByte('\n')
80+ case 't':
81+ b.WriteByte('\t')
82+ case '\\':
83+ b.WriteByte('\\')
84+ default:
85+ // preserve unknown escape verbatim
86+ b.WriteByte('\\')
87+ b.WriteByte(s[i+1])
88+ }
89+ i += 2
90+ }
91+ return b.String()
92+}
93+```
94+
95+---
96+
97+## 23.4 Line parsing
98+
99+```go
100+import (
101+ "strings"
102+ "net/url"
103+)
104+
105+// Line is a parsed wire protocol line.
106+type Line struct {
107+ Verb string // e.g. "MSG", "KVAL", "OK"
108+ Tags map[string]string // decoded tag values
109+ Payload *string // nil if no ' :' delimiter; "" if empty payload
110+ Raw []byte // original raw bytes (for relay without re-encode)
111+}
112+
113+// ParseLine parses one wire line. The LF terminator must have been stripped.
114+// Returns an error if the line is malformed.
115+// Servers that relay lines opaquely use line.Raw and never call ParseLine.
116+func ParseLine(raw string) (Line, error) {
117+ if len(raw) == 0 {
118+ return Line{}, fmt.Errorf("empty line")
119+ }
120+
121+ var payload *string
122+ body := raw
123+
124+ // Split payload: find first ' :' sequence.
125+ if idx := strings.Index(raw, " :"); idx >= 0 {
126+ p := raw[idx+2:]
127+ payload = &p
128+ body = raw[:idx]
129+ }
130+
131+ parts := strings.Fields(body)
132+ if len(parts) == 0 {
133+ return Line{}, fmt.Errorf("missing verb")
134+ }
135+
136+ verb := parts[0]
137+ tags := make(map[string]string, len(parts)-1)
138+
139+ for _, part := range parts[1:] {
140+ eq := strings.IndexByte(part, '=')
141+ if eq < 0 {
142+ return Line{}, fmt.Errorf("malformed tag (no '='): %q", part)
143+ }
144+ key := part[:eq]
145+ val := part[eq+1:]
146+ decoded, err := url.QueryUnescape(strings.ReplaceAll(val, "+", "%2B"))
147+ if err != nil {
148+ return Line{}, fmt.Errorf("bad percent-encoding in tag %q: %w", key, err)
149+ }
150+ tags[key] = decoded
151+ }
152+
153+ return Line{
154+ Verb: verb,
155+ Tags: tags,
156+ Payload: payload,
157+ Raw: []byte(raw),
158+ }, nil
159+}
160+
161+// TagOK returns the tag value and whether it was present.
162+func (l *Line) TagOK(key string) (string, bool) {
163+ v, ok := l.Tags[key]
164+ return v, ok
165+}
166+```
167+
168+---
169+
170+## 23.5 PresenceStore
171+
172+```go
173+import "sync"
174+
175+// PresenceStore holds per-uid online status.
176+// Never flushed to disk; empty on server restart.
177+// The same memory structure is used for TypingStore (per-cid, per-uid).
178+type PresenceStore struct {
179+ mu sync.RWMutex
180+ store map[string]PresenceEntry // key: uid
181+}
182+
183+type PresenceEntry struct {
184+ Status string // "online" | "idle" | "dnd" | "invisible" | "offline"
185+ UpdatedAt int64 // unix-ms
186+}
187+
188+func (ps *PresenceStore) Set(uid, status string, ts int64) {
189+ ps.mu.Lock()
190+ defer ps.mu.Unlock()
191+ if ps.store == nil {
192+ ps.store = make(map[string]PresenceEntry)
193+ }
194+ ps.store[uid] = PresenceEntry{Status: status, UpdatedAt: ts}
195+}
196+
197+// Get returns the presence entry and whether it is known.
198+// Returns ("offline", false) for unknown uids.
199+func (ps *PresenceStore) Get(uid string) (PresenceEntry, bool) {
200+ ps.mu.RLock()
201+ defer ps.mu.RUnlock()
202+ e, ok := ps.store[uid]
203+ return e, ok
204+}
205+```
206+
207+---
208+
209+## 23.6 KVALStore
210+
211+KVALStore is the durable server-side key-value store backing all `KVAL`
212+scopes. Each `(scope, id, key)` triple maps to a value and a last-modified
213+timestamp.
214+
215+```go
216+import (
217+ "sync"
218+ "fmt"
219+)
220+
221+// KVALKey is the composite store key.
222+type KVALKey struct {
223+ Scope string // "user" | "comm" | "chan" | "mute"
224+ ID string // uid (user/mute), gid (comm), cid (chan)
225+ Key string // e.g. "bio", "banner", "notice"
226+}
227+
228+// KVALEntry holds the current value and the unix-ms of the last op=set.
229+type KVALEntry struct {
230+ Value string
231+ TS int64 // unix-ms of most recent op=set (Sect. 2.6 ts= invariant)
232+}
233+
234+// KVALStore is the authoritative store for all KVAL scopes.
235+// Implementations MUST flush this to durable storage; it is not ephemeral.
236+type KVALStore struct {
237+ mu sync.RWMutex
238+ store map[KVALKey]KVALEntry
239+}
240+
241+func NewKVALStore() *KVALStore {
242+ return &KVALStore{store: make(map[KVALKey]KVALEntry)}
243+}
244+
245+// Set writes a key. ts is the server commit unix-ms.
246+// The caller MUST persist before returning OK to the client (Sect. 2.6 commit-before-OK).
247+func (s *KVALStore) Set(scope, id, key, value string, ts int64) {
248+ s.mu.Lock()
249+ defer s.mu.Unlock()
250+ s.store[KVALKey{scope, id, key}] = KVALEntry{Value: value, TS: ts}
251+}
252+
253+// Del removes a key. Returns (true, entry) if the key existed,
254+// (false, zero) if it was already absent (idempotent: caller returns OK, no broadcast).
255+func (s *KVALStore) Del(scope, id, key string) (existed bool, entry KVALEntry) {
256+ s.mu.Lock()
257+ defer s.mu.Unlock()
258+ k := KVALKey{scope, id, key}
259+ entry, existed = s.store[k]
260+ if existed {
261+ delete(s.store, k)
262+ }
263+ return
264+}
265+
266+// Get retrieves a single key. Returns (entry, true) if found, (zero, false) if not.
267+func (s *KVALStore) Get(scope, id, key string) (KVALEntry, bool) {
268+ s.mu.RLock()
269+ defer s.mu.RUnlock()
270+ e, ok := s.store[KVALKey{scope, id, key}]
271+ return e, ok
272+}
273+
274+// GetAll returns all set keys for (scope, id), in unspecified order.
275+// An empty slice means no keys are set (OK count=0 total=0; no NOTFOUND).
276+func (s *KVALStore) GetAll(scope, id string) []struct {
277+ Key string
278+ Entry KVALEntry
279+} {
280+ s.mu.RLock()
281+ defer s.mu.RUnlock()
282+ var out []struct {
283+ Key string
284+ Entry KVALEntry
285+ }
286+ for k, v := range s.store {
287+ if k.Scope == scope && k.ID == id {
288+ out = append(out, struct {
289+ Key string
290+ Entry KVALEntry
291+ }{k.Key, v})
292+ }
293+ }
294+ return out
295+}
296+
297+// DeleteByID removes all keys for a given (scope, id).
298+// Called on COMM op=delete (scope=comm, id=gid) to cascade-delete community branding.
299+// Returns the count of keys removed.
300+func (s *KVALStore) DeleteByID(scope, id string) int {
301+ s.mu.Lock()
302+ defer s.mu.Unlock()
303+ count := 0
304+ for k := range s.store {
305+ if k.Scope == scope && k.ID == id {
306+ delete(s.store, k)
307+ count++
308+ }
309+ }
310+ return count
311+}
312+```
313+
314+### Validation helpers
315+
316+```go
317+// defined key sets per scope. servers MUST reject unknown keys with ERR BADARG.
318+var kvallAllowedKeys = map[string]map[string]bool{
319+ "user": {
320+ "bio": true,
321+ "banner": true,
322+ "color": true,
323+ "status_text": true,
324+ "bot": true, // implementation extension; not in Sect. 7.4
325+ },
326+ "comm": {
327+ "description": true,
328+ "icon": true,
329+ "banner": true,
330+ "color": true,
331+ "rules": true,
332+ },
333+ "chan": {
334+ "notice": true, // implementation extension; not in Sect. 7.5b scope registry
335+ },
336+ "mute": {
337+ "mute_channels": true,
338+ "mute_users": true,
339+ "mute_roles": true,
340+ "ignore_channels": true,
341+ "ignore_users": true,
342+ "ignore_roles": true,
343+ },
344+}
345+
346+// ValidateKVAL checks scope, id type, and key legality.
347+// Returns a non-nil error string for ERR BADARG payload.
348+func ValidateKVAL(scope, id, key string) error {
349+ keys, ok := kvallAllowedKeys[scope]
350+ if !ok {
351+ return fmt.Errorf("unknown kval scope %q", scope)
352+ }
353+ if key != "" && !keys[key] {
354+ return fmt.Errorf("unknown key %q for scope %q", key, scope)
355+ }
356+ // key=name is forbidden for scope=comm (Sect. 7.5b).
357+ if scope == "comm" && key == "name" {
358+ return fmt.Errorf("key=name is not valid for scope=comm; use COMM op=update name=")
359+ }
360+ return nil
361+}
362+```
363+
364+---
365+
366+## 23.7 Server dispatch sketch (KVAL handler)
367+
368+```go
369+// HandleKVAL is a non-normative dispatch sketch for the KVAL verb.
370+// conn is the authenticated connection; line is the parsed client line.
371+// store and kvstore are the server's durable stores.
372+func HandleKVAL(conn *Conn, line Line, kvstore *KVALStore) {
373+ op, _ := line.TagOK("op")
374+ scope, _ := line.TagOK("scope")
375+ id, _ := line.TagOK("id")
376+ key, _ := line.TagOK("key")
377+
378+ // Validate scope and key before permission checks.
379+ if err := ValidateKVAL(scope, id, key); err != nil {
380+ conn.SendERR("BADARG", err.Error())
381+ return
382+ }
383+
384+ // op=info is server-only; clients MUST NOT send it.
385+ if op == "info" {
386+ conn.SendERR("BADARG", "clients must not send KVAL op=info")
387+ return
388+ }
389+
390+ switch op {
391+ case "set":
392+ checkWritePermission(conn, scope, id) // scope-specific; sends NOPERM and returns on failure
393+ val := ""
394+ if line.Payload != nil {
395+ val = *line.Payload
396+ }
397+ ts := NowMS()
398+ kvstore.Set(scope, id, key, val, ts)
399+ conn.SendOK("KVAL", "op=set", "scope="+scope, "id="+id, "key="+key)
400+ BroadcastKVALInfo(conn.Server, scope, id, key, val, ts)
401+
402+ case "del":
403+ checkWritePermission(conn, scope, id)
404+ ts := NowMS()
405+ existed, _ := kvstore.Del(scope, id, key)
406+ conn.SendOK("KVAL", "op=del", "scope="+scope, "id="+id, "key="+key)
407+ if existed {
408+ BroadcastKVALDel(conn.Server, scope, id, key, ts)
409+ // no broadcast if key did not exist (idempotent, Sect. 7.5d)
410+ }
411+
412+ case "get":
413+ checkReadPermission(conn, scope, id) // sends NOPERM and returns on failure
414+ if key != "" {
415+ // single-key form
416+ entry, ok := kvstore.Get(scope, id, key)
417+ if !ok {
418+ conn.SendERR("NOTFOUND", "key not set")
419+ return
420+ }
421+ conn.SendKVALInfo(scope, id, key, entry.Value, entry.TS)
422+ conn.SendOK("KVAL", "op=get", "scope="+scope, "id="+id, "key="+key)
423+ } else {
424+ // all-keys form
425+ entries := kvstore.GetAll(scope, id)
426+ for _, e := range entries {
427+ conn.SendKVALInfo(scope, id, e.Key, e.Entry.Value, e.Entry.TS)
428+ }
429+ n := len(entries)
430+ conn.SendOK("KVAL", fmt.Sprintf("op=get scope=%s id=%s count=%d total=%d", scope, id, n, n))
431+ }
432+
433+ default:
434+ conn.SendERR("BADARG", fmt.Sprintf("unknown KVAL op %q", op))
435+ }
436+}
437+```
438+
439+### Broadcast scope routing
440+
441+```go
442+// BroadcastKVALInfo sends a KVAL op=info event to the correct delivery scope.
443+// Routing is scope-dependent (Sect. 7.5b):
444+// scope=user: all connections sharing at least one channel with id (uid)
445+// scope=comm: all members of the community id (gid)
446+// scope=chan: all current subscribers of the channel id (cid)
447+// scope=mute: all sessions of the subject uid (multi-device sync)
448+func BroadcastKVALInfo(srv *Server, scope, id, key, val string, ts int64) {
449+ line := fmt.Sprintf("KVAL op=info scope=%s id=%s key=%s ts=%d :%s\n",
450+ scope, id, key, ts, val)
451+ switch scope {
452+ case "user":
453+ srv.BroadcastToSharedChannelPeers(id, line)
454+ case "comm":
455+ srv.BroadcastToCommunityMembers(id, line)
456+ case "chan":
457+ srv.BroadcastToChannelSubscribers(id, line)
458+ case "mute":
459+ srv.BroadcastToUID(id, line)
460+ }
461+}
462+
463+func BroadcastKVALDel(srv *Server, scope, id, key string, ts int64) {
464+ line := fmt.Sprintf("KVAL op=del scope=%s id=%s key=%s ts=%d\n",
465+ scope, id, key, ts)
466+ switch scope {
467+ case "user":
468+ srv.BroadcastToSharedChannelPeers(id, line)
469+ case "comm":
470+ srv.BroadcastToCommunityMembers(id, line)
471+ case "chan":
472+ srv.BroadcastToChannelSubscribers(id, line)
473+ case "mute":
474+ srv.BroadcastToUID(id, line)
475+ }
476+}
477+```
478+
479+---
480+
481+## 23.8 ULID generation
482+
483+```go
484+import (
485+ "crypto/rand"
486+ "encoding/binary"
487+ "strings"
488+ "time"
489+)
490+
491+var ullidEnc = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
492+
493+// NewULID generates a server-assigned ULID (Sect. 3).
494+// Time component is the current unix millisecond.
495+// Random component is 10 bytes from crypto/rand.
496+// Servers MUST use this for mid, cid, gid, eid, etc.; never client-provided IDs.
497+func NewULID() string {
498+ now := uint64(time.Now().UnixMilli())
499+ var rnd [10]byte
500+ if _, err := rand.Read(rnd[:]); err != nil {
501+ panic("crypto/rand unavailable: " + err.Error())
502+ }
503+
504+ var b [26]byte
505+ // 10-char time component (48 bits, big-endian, base32)
506+ t := now
507+ for i := 9; i >= 0; i-- {
508+ b[i] = ullidEnc[t&0x1f]
509+ t >>= 5
510+ }
511+ // 16-char random component (80 bits, base32)
512+ val := binary.BigEndian.Uint64(rnd[:8])
513+ val2 := uint16(rnd[8])<<8 | uint16(rnd[9])
514+ for i := 25; i >= 18; i-- {
515+ b[i] = ullidEnc[val&0x1f]
516+ val >>= 5
517+ }
518+ extra := uint64(val2)
519+ for i := 17; i >= 10; i-- {
520+ b[i] = ullidEnc[extra&0x1f]
521+ extra >>= 5
522+ }
523+ return string(b[:])
524+}
525+
526+// IsValidID returns true if s is a valid 26-char Crockford base32 ID.
527+func IsValidID(s string) bool {
528+ if len(s) != 26 {
529+ return false
530+ }
531+ const valid = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
532+ for _, c := range s {
533+ if !strings.ContainsRune(valid, c) {
534+ return false
535+ }
536+ }
537+ return true
538+}
539+```
540+
541+---
542+
543+## Notes on the KVAL store and Sect. 2.6 invariants
544+
545+#### commit-before-OK
546+
547+`kvstore.Set` / `kvstore.Del` MUST complete (including any durable write
548+flush) before `conn.SendOK` is called. An in-memory implementation
549+satisfies this trivially; BoltDB and similar persistent stores must flush
550+before sending the OK line.
551+
552+#### op=del idempotency
553+
554+`DeleteByID` (cascade on COMM op=delete) and the single-key `Del` are both
555+idempotent. The `existed` return value gates the broadcast, so a retry after
556+a dropped connection will not send a spurious deletion event.
557+
558+#### scope=comm cascade
559+
560+Call `kvstore.DeleteByID("comm", gid)` in the `COMM op=delete` handler,
561+after the OK is sent to the acting uid and before or alongside closing
562+subscriptions. No broadcast is needed; `MEMBER op=delete` already signals
563+community destruction to all members.
564+
565+#### scope=chan cascade
566+
567+Call `kvstore.DeleteByID("chan", cid)` in the `COMM op=channel.delete`
568+handler, atomically with the channel deletion. Same pattern as scope=comm;
569+`CHAN op=delete` already signals channel destruction.
570+
571+#### scope=user GC on ERASE
572+
573+When a uid is erased, call `kvstore.DeleteByID("user", uid)` before sending
574+`OK verb=ERASE`. No broadcast is needed; `MEMBER op=erase` already signals
575+the erasure.
576+
577+---
578+
579+## 23.9 ERASE handler sketch
580+
581+```go
582+// HandleERASE manages the two-step account erasure flow.
583+// Step 1: client sends ERASE (no tags). Server issues CHALLENGE.
584+// Step 2: client sends ERASE confirm=<sig>. Server validates and executes.
585+func HandleERASE(conn *Conn, line Line) {
586+ confirm, hasConfirm := line.TagOK("confirm")
587+
588+ if !hasConfirm {
589+ // Step 1: issue challenge.
590+ // Store nonce in connection state tagged as "erase" context
591+ // so the AUTH handler does not accept it for AUTH.
592+ nonce := GenerateNonce() // 32 bytes CSPRNG, hex-encoded to 64 chars
593+ conn.PendingEraseNonce = nonce
594+ conn.Send(fmt.Sprintf("CHALLENGE nonce=%s\n", nonce))
595+ return
596+ }
597+
598+ // Step 2: validate signature.
599+ nonce := conn.PendingEraseNonce
600+ if nonce == "" {
601+ conn.SendERR("BADARG", "no pending erase challenge on this connection")
602+ return
603+ }
604+ conn.PendingEraseNonce = "" // consume nonce (single-use)
605+
606+ uid := conn.AuthUID
607+ pubkey := conn.Server.IdentityStore.GetPubKey(uid)
608+ if pubkey == nil {
609+ conn.SendERR("GONE", "account already erased")
610+ return
611+ }
612+
613+ // erase_msg = nonce_bytes || uid_bytes || "erase"
614+ nonceBytes, _ := hex.DecodeString(nonce)
615+ eraseMsg := append(nonceBytes, []byte(uid)...)
616+ eraseMsg = append(eraseMsg, []byte("erase")...)
617+
618+ sigBytes, _ := base64.RawURLEncoding.DecodeString(confirm)
619+ if !ed25519.Verify(pubkey, eraseMsg, sigBytes) {
620+ conn.SendERR("AUTHFAIL", "invalid erase signature")
621+ return
622+ }
623+
624+ // Guard: last server.admin cannot erase.
625+ if conn.Server.IsLastServerAdmin(uid) {
626+ conn.SendERR("FORBIDDEN", "cannot erase the last server.admin")
627+ return
628+ }
629+
630+ ts := NowMS()
631+
632+ // Commit erasure atomically:
633+ // 1. Delete identity content (pubkey, name, avatar). Retain uid tombstone.
634+ conn.Server.IdentityStore.MarkErased(uid, ts)
635+ // 2. Delete KVAL scope=user keys.
636+ conn.Server.KVALStore.DeleteByID("user", uid)
637+ // 3. Delete MEMBER records, subscriptions, tokens, READSTATE, PINGs,
638+ // invite codes, KEYROTATE alias chain.
639+ conn.Server.MemberStore.DeleteAllForUID(uid)
640+ conn.Server.SessionStore.RevokeAll(uid)
641+ conn.Server.ReadStateStore.DeleteAllForUID(uid)
642+ conn.Server.PingStore.DeleteAllForUID(uid)
643+ conn.Server.InviteStore.InvalidateAllByUID(uid)
644+ conn.Server.KeyRotateStore.DeleteAliasChain(uid)
645+
646+ // Send OK before closing connection.
647+ conn.Send(fmt.Sprintf("OK verb=ERASE ts=%d\n", ts))
648+
649+ // Broadcast MEMBER op=erase to each community.
650+ gids := conn.Server.MemberStore.CommunitiesForUID(uid) // collected before deletion
651+ for _, gid := range gids {
652+ line := fmt.Sprintf("MEMBER op=erase gid=%s uid=%s ts=%d\n", gid, uid, ts)
653+ conn.Server.BroadcastToCommunityMembers(gid, line)
654+ }
655+
656+ // Broadcast MEMBER op=erase (no gid=) to each DM partner.
657+ dmPartners := conn.Server.ChannelStore.DMPartnersForUID(uid)
658+ for _, partnerUID := range dmPartners {
659+ line := fmt.Sprintf("MEMBER op=erase uid=%s ts=%d\n", uid, ts)
660+ conn.Server.SendToUID(partnerUID, line)
661+ }
662+
663+ // Terminate all sessions for this uid, including this connection.
664+ conn.Server.TerminateAllSessions(uid)
665+}
666+```
667+
668+If `ERASE purge=1` was sent (and `cap=purge` is advertised), the server
669+iterates every channel and purges messages authored by the erasing uid
670+before sending `OK`. A `HIST op=purged` broadcast with a `uid=` tag fires
671+per channel to let clients evict only that uid's messages from their local
672+cache. The OK and connection termination follow after all purges commit.
673+This is the expensive path; the uid explicitly opts in.
674+
675+---
676+
677+## 23.10 HIST op=purge -- permission check and broadcast
678+
679+```go
680+// CheckPurgePermission returns an error string for ERR NOPERM,
681+// or nil if the requesting uid may execute the purge.
682+// Permission model: Sect. 29.1.
683+func CheckPurgePermission(conn *Conn, cid, filterUID string) error {
684+ isSelfContent := filterUID != "" && filterUID == conn.AuthUID
685+ isDM := conn.Server.ChannelStore.IsDM(cid)
686+
687+ if isSelfContent {
688+ // Self-content purge: any non-banned member may proceed.
689+ // delete_policy does NOT apply (Sect. 29.1).
690+ if isDM {
691+ // Either DM participant may always purge their own messages.
692+ return nil
693+ }
694+ // Community channel: check uid is non-banned member.
695+ if conn.Server.MemberStore.IsBanned(conn.AuthUID, conn.Server.ChannelStore.GIDForCID(cid)) {
696+ return fmt.Errorf("banned members may not purge their own content")
697+ }
698+ return nil
699+ }
700+
701+ // Other-content or unfiltered purge: requires msg.delete (community)
702+ // or delete_policy consent (DM).
703+ if isDM {
704+ // delete_policy enforcement is handled by the caller's state machine.
705+ return nil
706+ }
707+ // Community: require msg.delete (or board.manage on type=board).
708+ if !conn.HasChannelPerm(cid, "msg.delete") {
709+ return fmt.Errorf("msg.delete permission required")
710+ }
711+ return nil
712+}
713+```
714+
715+After the purge commits, broadcast `HIST op=purged` to all channel
716+subscribers (Sect. 29.1); echo `before=` if the request included it.
717+The `uid=` tag in the broadcast is an implementation extension for the
718+ERASE purge=1 path: it lets clients evict only that uid's messages rather
719+than flushing the entire channel cache. It is not part of the spec grammar
720+for this verb.
721+
722+```go
723+// EmitPurgedBroadcast sends HIST op=purged to all cid subscribers,
724+// echoing all filters that were active (Sect. 29.1).
725+func EmitPurgedBroadcast(srv *Server, cid, byUID, filterUID, before string, ts int64) {
726+ var b strings.Builder
727+ b.WriteString(fmt.Sprintf("HIST op=purged cid=%s", cid))
728+ if before != "" {
729+ b.WriteString(" before=" + before)
730+ }
731+ if filterUID != "" {
732+ b.WriteString(" uid=" + filterUID)
733+ }
734+ b.WriteString(fmt.Sprintf(" by=%s ts=%d\n", byUID, ts))
735+ srv.BroadcastToChannelSubscribers(cid, b.String())
736+}
737+```
738+
739+## 23.11 HIST / LIST default direction
740+
741+Bare `HIST cid=X limit=50` with no anchor returns the newest `limit=`
742+events in ascending mid order. `LIST` uses the same default.
743+
744+`before=` and `after=` are mutually exclusive; reject both-present requests
745+at the top of the handler:
746+
747+```go
748+func validateHISTAnchors(before, after string) error {
749+ if before != "" && after != "" {
750+ return errors.New("BADARG: before= and after= are mutually exclusive")
751+ }
752+ return nil
753+}
754+```
755+
756+The three anchor forms translate to distinct queries:
757+
758+```go
759+// Default (no anchor): return the newest limit rows in ascending order.
760+// Implemented as DESC LIMIT n, then reverse in-memory.
761+func queryHISTDefault(db *DB, cid string, limit int) ([]Event, bool, error) {
762+ rows, err := db.Query(
763+ `SELECT * FROM events WHERE cid = ? AND deleted = 0
764+ ORDER BY mid DESC LIMIT ?`, cid, limit+1)
765+ if err != nil {
766+ return nil, false, err
767+ }
768+ events := collectRows(rows)
769+ hasMore := len(events) > limit
770+ if hasMore {
771+ events = events[:limit]
772+ }
773+ // reverse to ascending mid order for the client
774+ slices.Reverse(events)
775+ return events, hasMore, nil
776+}
777+
778+// before=<mid>: return limit rows strictly before mid, ascending.
779+func queryHISTBefore(db *DB, cid, before string, limit int) ([]Event, bool, error) {
780+ rows, err := db.Query(
781+ `SELECT * FROM events WHERE cid = ? AND mid < ? AND deleted = 0
782+ ORDER BY mid DESC LIMIT ?`, cid, before, limit+1)
783+ if err != nil {
784+ return nil, false, err
785+ }
786+ events := collectRows(rows)
787+ hasMore := len(events) > limit
788+ if hasMore {
789+ events = events[:limit]
790+ }
791+ slices.Reverse(events)
792+ return events, hasMore, nil
793+}
794+
795+// after=<mid>: return limit rows strictly after mid, ascending.
796+func queryHISTAfter(db *DB, cid, after string, limit int) ([]Event, bool, error) {
797+ rows, err := db.Query(
798+ `SELECT * FROM events WHERE cid = ? AND mid > ? AND deleted = 0
799+ ORDER BY mid ASC LIMIT ?`, cid, after, limit+1)
800+ if err != nil {
801+ return nil, false, err
802+ }
803+ events := collectRows(rows)
804+ hasMore := len(events) > limit
805+ if hasMore {
806+ events = events[:limit]
807+ }
808+ return events, hasMore, nil
809+}
810+```
811+
812+For default and `before=` responses, `has_more=1` means older history
813+exists; the client scrolls back using `before=<oldest-returned-mid>`.
814+For `after=` responses, `has_more=1` means newer messages follow the
815+returned window.
+0,
-0
+0,
-0
+0,
-0
+8222,
-0
1@@ -0,0 +1,8222 @@
2+ ██╗ ██╗██╗██████╗ ███████╗
3+ ██║ ██║██║██╔══██╗██╔════╝
4+ ██║ █╗ ██║██║██████╔╝█████╗
5+ ██║███╗██║██║██╔══██╗██╔══╝
6+ ╚███╔███╔╝██║██║ ██║███████╗
7+ ╚══╝╚══╝ ╚═╝╚═╝ ╚═╝╚══════╝
8+ wire protocol specification
9+ rev %-version-%
10+ author: xplshn / wire-wg
11+ license: ISC
12+
13+ revision history: see CHANGES.md
14+ implementation notes (Go): see IMPL.md
15+
16+%- version = "0.8" -%
17+
18+========================================================================
19+WIRE - a sane chat protocol
20+========================================================================
21+
22+ WIRE is a text-based, line-oriented, chat protocol encoded in UTF-8.
23+ It replaces idioms found in IRC and Discord with a set of orthogonal
24+ primitives. It is not backwards compatible with IRC or any
25+ derivative.
26+
27+ The WIRE protocol is governed by five core design principles:
28+
29+ 1. Orthogonality -- Protocol primitives are independent and
30+ composable, with minimal semantic overlap.
31+ 2. Transparency -- All protocol state is explicit; no implicit or
32+ hidden context is assumed.
33+ 3. Extensibility -- Features are introduced via optional namespaces
34+ and capabilities.
35+ 4. Efficiency -- Each logical operation is represented by exactly
36+ one line.
37+ 5. Human Legibility -- The protocol is directly readable and
38+ writable by humans. Minimal ("L0") clients, bots, and simple
39+ text tools are first-class participants.
40+
41+ Non-goals:
42+
43+ - Compatibility with IRC or IRC-derived protocols
44+ - Mandatory encryption at the protocol level (transport security is
45+ provided by TLS; optional end-to-end encryption is defined in the
46+ e2e capability, see %-history_purge_e2ee-%)
47+ - Binary framing
48+ - Uniform feature support across all clients
49+
50+ The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT",
51+ "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this
52+ document are to be interpreted as described in RFC 2119.
53+
54+%- transport = "Sect. 1" -%
55+========================================================================
56+%-transport -% -- TRANSPORT
57+========================================================================
58+
59+ WIRE operates over TCP. TLS is REQUIRED for all non-loopback
60+ connections and MUST be version 1.3 or higher. Plain TCP MAY be used
61+ only on loopback interfaces. The default port is 6789.
62+
63+ %- transport_encodingandframing = "Sect. 1.1" -%
64+ 1.1 Encoding and Framing
65+
66+ All data MUST be encoded in UTF-8. The protocol is line-delimited;
67+ each line is a single message terminated by a single Line Feed (LF,
68+ %x0A) octet.
69+
70+ A Carriage Return (CR, %x0D) octet immediately preceding LF MUST be
71+ ignored by receivers. A CR octet appearing in any other position is
72+ invalid and constitutes a protocol error.
73+
74+ %- transport_errorhandling = "Sect. 1.2" -%
75+ 1.2 Error Handling
76+
77+ On invalid CR placement, the server MUST send ERR code=BADARG and
78+ close the connection.
79+
80+ %- transport_linelengthlimits = "Sect. 1.3" -%
81+ 1.3 Line Length Limits
82+
83+ The maximum line length is 4096 octets, measured on the wire after
84+ UTF-8 encoding and before escape processing. This includes verb,
85+ tags, optional " :", payload, and LF.
86+
87+ %- transport_enforcement = "Sect. 1.4" -%
88+ 1.4 Enforcement
89+
90+ Overlong lines MUST be rejected with ERR code=TOOLONG. If sent by a
91+ client, the server MUST close the connection, as framing can no
92+ longer be trusted.
93+
94+%- lineformat = "Sect. 2" -%
95+========================================================================
96+%-lineformat-% -- LINE FORMAT
97+========================================================================
98+
99+ %- lineformat_overview = "Sect. 2.1" -%
100+ 2.1 Overview
101+
102+ WIRE messages are UTF-8 encoded sequences terminated by a single
103+ line feed (LF). Each message consists of a mandatory verb, followed
104+ by zero or more metadata tags, and an optional payload.
105+
106+ %- lineformat_grammar = "Sect. 2.2" -%
107+ 2.2 Grammar
108+
109+ The syntax is defined using ABNF as specified in RFC 5234:
110+
111+ line = verb *(SP tag) [SP ":" payload] LF
112+ verb = 1*16UPPER
113+ tag = tag-key "=" tag-val
114+ tag-key = ALPHA *31( ALPHA / DIGIT / "_" )
115+ tag-val = 1*(TCHAR / pct-encoded)
116+ pct-encoded = "%" 2HEXDIG
117+ payload = *PCHAR
118+
119+ UPPER = %41-5A
120+
121+ TCHAR = %x01-08 / %x0B-0C / %x0E-1F
122+ TCHAR =/ %x21-3C / %x3E-7E / %x80-FF
123+ ; any octet except
124+ ; NUL %x00 TAB %x09 LF %x0A
125+ ; CR %x0D SPACE %x20 '%' %x25 '=' %x3D
126+
127+ PCHAR = %x01-09 / %x0B-0C / %x0E-FF
128+ ; any octet except NUL %x00 and LF %x0A
129+
130+ The verb is a case-sensitive uppercase ASCII string between one and
131+ sixteen octets in length. Tags are key-value pairs separated from
132+ the verb and from each other by a single space (SP).
133+
134+ %- lineformat_payloadsemantics = "Sect. 2.3" -%
135+ 2.3 Message Delimitation and Payload Semantics
136+
137+ The payload, if present, is introduced by the first occurrence of
138+ the two-octet sequence SP ":" (%x20 %x3A). Because the SP octet
139+ cannot appear within the verb or within unencoded tag values, this
140+ sequence unambiguously separates the head from the payload.
141+
142+ The colon is a structural delimiter and MUST NOT be included in the
143+ interpreted payload. However, a payload can have begin with ":",
144+ i.e. the line "... ::text" has the payload ":text".
145+
146+ A message without the SP ":" delimiter has no payload. A message in
147+ which the delimiter is immediately followed by LF has a present but
148+ zero-length payload. Implementations MUST distinguish between these
149+ cases.
150+
151+ %- lineformat_tagsemantics = "Sect. 2.4" -%
152+ 2.4 Tag Semantics
153+
154+ Tags are unordered metadata elements. Receivers MUST ignore
155+ unrecognized tag keys.
156+
157+ If a tag key appears multiple times within a single line, only the
158+ first occurrence is significant. Subsequent occurrences MUST be
159+ ignored and MUST NOT be treated as an error.
160+
161+ For response lines (e.g., OK), servers MUST include all tags
162+ required by the response grammar. Tags not recognized by the server
163+ MUST be silently discarded and MUST NOT be echoed. This requirement
164+ does not apply to server-originated broadcast or asynchronous lines.
165+
166+ %- lineformat_tagvalueencoding = "Sect. 2.5" -%
167+ 2.5 Tag Value Encoding
168+
169+ Tag values are restricted to the TCHAR production and the
170+ percent-encoding mechanism defined in this section. The octets NUL,
171+ TAB, LF, CR, SP, and = (%x00, %x09, %x0A, %x0D, %x20, and %x3D) MUST
172+ NOT appear in unencoded form.
173+
174+ Any disallowed octet or any octet not representable by TCHAR MUST be
175+ encoded using percent-encoding as defined in RFC 3986, sec. 2.1.
176+ Each encoded octet is represented as a percent character followed by
177+ exactly two uppercase hexadecimal digits.
178+
179+ The percent character (%x25) MUST be encoded as %25 whenever
180+ encoding is applied. A literal percent character is permitted only
181+ when it begins a valid percent-encoded sequence.
182+
183+ Receivers MUST decode all valid percent-encoded sequences before
184+ interpreting tag values. Any malformed encoding, including invalid
185+ hexadecimal digits or truncated sequences, MUST result in an ERR
186+ message with code BADARG.
187+
188+ Characters in the unreserved set [A-Za-z0-9.~_-] SHOULD NOT be
189+ encoded, but MAY be encoded. Receivers MUST accept both forms.
190+
191+ Servers MUST relay tag values verbatim and MUST NOT perform
192+ decoding, re-encoding, or normalization when forwarding messages.
193+ Decoding MAY be performed internally when required for processing.
194+
195+ %- lineformat_payloadencoding = "Sect. 2.6" -%
196+ 2.6 Payload Encoding and Escape Sequences
197+
198+ The payload is an arbitrary sequence of octets subject to the
199+ constraints defined in this section. The NUL and LF octets (%x00
200+ and %x0A) MUST NOT appear in the payload.
201+
202+ The presence of a literal LF octet within the payload region
203+ constitutes a framing violation. In such cases, the server MUST
204+ respond with ERR code=BADARG and MUST close the connection.
205+
206+ After escape processing, the decoded payload length MUST NOT exceed
207+ 4096 octets. If a MSG payload exceeds this limit, the server MUST
208+ reject it with ERR code=TOOLONG. Escape processing uses a
209+ single-pass, non-recursive mechanism in which the backslash (%x5C)
210+ is the escape introducer. The sequences \n, \t, and \\ represent
211+ line feed, horizontal tab, and a literal backslash, respectively.
212+
213+ Processing MUST be performed left-to-right in a single pass. The
214+ sequence \\n decodes to a backslash followed by "n" and MUST NOT be
215+ interpreted as a line feed.
216+
217+ Any escape sequence not defined in this section MUST be preserved
218+ verbatim. Clients MUST NOT treat unknown escape sequences as
219+ errors.
220+
221+ Escape processing is a client-side concern. Servers MUST relay
222+ payloads verbatim and MUST NOT interpret, normalize, or otherwise
223+ modify escape sequences during transit.
224+
225+ If a payload contains no backslash octets, escape processing is a
226+ no-op; in such instances, implementations SHOULD avoid unnecessary
227+ memory allocation or redundant scanning to ensure processing
228+ efficiency.
229+
230+ %- lineformat_examples = "Sect. 2.7" -%
231+ 2.7 Examples
232+
233+ MSG cid=01JXYZ456 mid=01JXYZ789 :hello world
234+ SUB rid=/wire.example.org/acme/general
235+ ERR code=NOPERM :you are not allowed to do that
236+ REACT mid=01JXYZ789 eid=thumbsup
237+ PING
238+ MSG cid=01JXYZ456 :line one\nline two
239+
240+
241+ %- lineformat_parsingmodel = "Sect. 2.8" -%
242+ 2.8 Parsing Model
243+
244+ This section defines the normative parsing algorithm for a WIRE
245+ message. Implementations MAY utilize any internal strategy,
246+ provided the externally observable behavior is identical to the
247+ procedure described herein.
248+
249+ Given an input line:
250+
251+ 1. Framing and Sanitization
252+
253+ A line MUST terminate with LF (%x0A). If the octet
254+ immediately preceding LF is CR (%x0D), it MUST be discarded.
255+ Any other occurrence of CR within the line MUST be treated as
256+ a protocol error.
257+
258+ 2. Length Validation
259+
260+ After removal of an optional trailing CR and excluding the
261+ terminating LF, the line length MUST NOT exceed 4096 octets.
262+ Violations MUST result in ERR code=TOOLONG, and the server
263+ MUST close the connection.
264+
265+ 3. Payload Separation
266+
267+ The parser identifies the first occurrence of the sequence SP
268+ ":". If present, all octets following the colon up to the
269+ terminating LF constitute the payload, and the preceding
270+ portion constitutes the head. If absent, the entire line is
271+ the head and no payload is present.
272+
273+ The delimiter is not included in either component.
274+
275+ 4. Head Processing
276+
277+ The head MUST be split on SP octets. The first token is the
278+ verb and MUST conform to the verb production. Remaining
279+ tokens are tag expressions. Failure to parse a valid verb or
280+ malformed tokens MUST result in ERR code=BADARG.
281+
282+ 5. Tag Processing
283+
284+ Each tag token MUST contain exactly one '=' octet separating
285+ the key and value. Duplicate keys are handled according to
286+ the rule defined in %-lineformat_tagsemantics-%.
287+
288+ Tag values MUST be validated and percent-decoded. Any
289+ malformed encoding MUST result in ERR code=BADARG.
290+
291+ 6. Payload Handling
292+
293+ If present, the payload MUST be treated as an uninterpreted
294+ octet sequence. Escape processing, as defined in
295+ %-lineformat_payloadencoding-%, is performed by clients only.
296+
297+ 7. Error Handling
298+
299+ Parsing is defined as a single-pass process. Upon any
300+ validation failure, the server MUST emit an ERR response with
301+ an appropriate code. The connection MUST be terminated if the
302+ error compromises framing or stream integrity.
303+
304+ %- lineformat_responserouting = "Sect. 2.9" -%
305+ 2.9 Response Routing
306+
307+ %- lineformat_responserouting_generalrule = "Sect. 2.9.1" -%
308+ 2.9.1 General Rule
309+
310+ Certain verbs are used for both single-item queries and multi-item
311+ list responses. These include, but are not limited to, CHAN,
312+ ROLE, MEMBER, COMM, VOICE, THREAD, SRVADM, TYPING, INVITE, PIN,
313+ KVAL, PROFILE, PUBKEY, UPLOAD, and ATTACH.
314+
315+ Clients MUST route response lines using pending-request state.
316+ Each response line is associated with the most recently issued
317+ request that has not yet received its terminal OK. This rule is
318+ universal; implementations MUST NOT introduce per-verb special
319+ cases.
320+
321+ %- lineformat_singleline_multiline_responses = "Sect. 2.9.2" -%
322+ 2.9.2 Single-Line and Multi-Line Responses
323+
324+ The verbs described above MAY produce either a single response
325+ line or a sequence of response lines. In all cases, the response
326+ is terminated by a final OK line.
327+
328+ Clients MUST continue consuming response lines until the terminal
329+ OK is received and MUST NOT assume that a single response line
330+ constitutes a complete response.
331+
332+ When present, the count= tag in the terminal OK indicates the
333+ number of response lines and MAY be used to validate completeness.
334+
335+ %- lineformat_subunsub = "Sect. 2.9.3" -%
336+ 2.9.3 SUB and UNSUB
337+
338+ The SUB and UNSUB verbs each produce exactly one terminal OK and
339+ otherwise follow the same pending-request routing rules as all
340+ other requests.
341+
342+ %- lineformat_pipelining = "Sect. 2.9.4" -%
343+ 2.9.4 Pipelining
344+
345+ Clients MUST NOT issue concurrent requests targeting the same
346+ resource. Two requests are considered to target the same resource
347+ if they use the same verb and identical identifying parameters
348+ (e.g., cid= or gid=). Such requests are indistinguishable at the
349+ protocol level and cannot be reliably routed.
350+
351+ Clients MAY pipeline requests that target distinct resources. In
352+ such cases, response lines for multi-line verbs typically include
353+ sufficient identifying tags (e.g., cid= or gid=) to permit routing
354+ based on tag inspection. The pending-request rule serves as a
355+ fallback when tag-based routing is insufficient.
356+
357+ Server-generated broadcast events, including MSG, EDIT, DEL,
358+ REACT, UNREACT, PIN, UNPIN, and history-related events, include
359+ identifying tags such as cid= and are therefore self-identifying.
360+ Clients MAY route such events independently of pending-request
361+ state.
362+
363+ %- lineformat_readstateexception = "Sect. 2.9.5" -%
364+ 2.9.5 READSTATE Exception
365+
366+ Clients MUST NOT pipeline the requests READSTATE op=query
367+ cid=<cid> and READSTATE op=query gid=<gid> when the specified
368+ group contains the referenced channel. Both forms produce
369+ response lines of the form READSTATE op=query cid=<cid> ..., which
370+ are indistinguishable based solely on their content.
371+
372+ Accordingly, clients MUST serialize these requests. This is the
373+ only cross-form pipelining restriction defined by the protocol;
374+ all other restrictions apply only to requests targeting identical
375+ resources.
376+
377+ %- lineformat_directionaldisambiguation = "Sect. 2.9.6" -%
378+ 2.9.6 Directional Disambiguation
379+
380+ Response routing applies only to server-generated response lines.
381+ It is distinct from bidirectional verb disambiguation (see
382+ %-lineformat_bidirectionalverbs-%), where the same verb may appear
383+ in both client-to-server and server-to-client directions.
384+
385+ In such cases, direction is determined by tag content, most
386+ commonly the op= value, and in some cases by the presence of
387+ server-specific tags such as uid= or ts=.
388+
389+ Response routing MUST NOT be used to disambiguate bidirectional
390+ verbs.
391+
392+ %- lineformat_protocolinvariants = "Sect. 2.10" -%
393+ 2.10 Protocol Invariants
394+
395+ The requirements in this section apply to all protocol operations.
396+
397+ %- lineformat_commitbeforeok = "Sect. 2.10.1" -%
398+ 2.10.1 Commit-Before-OK
399+
400+ For any mutating request that results in an OK response, the
401+ server MUST commit the corresponding state change to its
402+ authoritative store before transmitting the OK.
403+
404+ A client issuing a subsequent query after receiving OK MUST
405+ observe the committed state.
406+
407+ The UPLOAD operation is an exception. In this case, the OK
408+ response confirms only upload slot negotiation; the actual commit
409+ occurs asynchronously after the associated HTTP transfer
410+ completes.
411+
412+ %- lineformat_listshaperules = "Sect. 2.10.2" -%
413+ 2.10.2 Shape of List Responses
414+
415+ A terminal OK concluding a multi-line response MUST include both
416+ count= and total= tags. The count= value indicates the number of
417+ response lines in the current page, while total= indicates the
418+ total number of matching items across all pages. For bounded
419+ result sets, these values are equal.
420+
421+ Certain operations deviate from this model. History-oriented
422+ operations such as HIST and LIST use a has_more= indicator instead
423+ of total= due to pagination over unbounded, mutating streams. The
424+ READSTATE op=query operation returns a mapping from channel
425+ identifiers to state values; in this case, count= appears on
426+ individual response lines, and the terminal OK omits total=. These
427+ exceptions are specific and do not establish a general pattern.
428+
429+ %- lineformat_timestampsemantics = "Sect. 2.10.3" -%
430+ 2.10.3 Timestamp Semantics
431+
432+ In all op=info response lines, the ts= tag represents the Unix
433+ timestamp, in milliseconds, of the most recent mutation to the
434+ entity.
435+
436+ In all broadcast event lines, ts= represents the time at which the
437+ server committed the event.
438+
439+ This interpretation MUST be preserved unless explicitly overridden
440+ by the relevant section. For example, VOICE op=info uses the
441+ joined= tag to represent session join time, consistent with MEMBER
442+ op=info.
443+
444+ %- lineformat_bidirectionalverbs = "Sect. 2.11" -%
445+ 2.11 Bidirectional Verb Directionality
446+
447+ Some verbs may be transmitted in both client-to-server and
448+ server-to-client directions. The direction of a line is determined
449+ by the presence of the uid= tag.
450+
451+ Client-generated lines MUST NOT include uid=. Server-generated
452+ lines MUST include both uid= and ts=. A line containing uid= is
453+ therefore considered server-originated, while a line without uid= is
454+ considered client-originated.
455+
456+ Any violation of this rule MUST be treated as a protocol error.
457+
458+ This rule applies to verbs including THREAD, PIN, EMOJI, KVAL,
459+ VSTREAM, and VOICE, though it is not limited to these.
460+
461+ %- lineformat_paginatedops = "Sect. 2.12" -%
462+ 2.12 Paginated Operations
463+
464+ List operations follow a uniform pagination model based on offset=
465+ and limit= parameters.
466+
467+ A list request is expressed as VERB op=list with optional offset=
468+ and limit= parameters. If omitted, offset= defaults to zero and
469+ limit= defaults to a verb-specific value. Servers MUST enforce a
470+ maximum limit and clamp any larger value accordingly.
471+
472+ The response consists of zero or more VERB op=info lines, followed
473+ by a terminal OK line containing count= and total=.
474+
475+ The count= value indicates the number of items returned in the
476+ current page, while total= indicates the total number of matching
477+ items.
478+
479+ Default and maximum limits are defined per operation. For example,
480+ history and subscription-related operations typically default to 50
481+ items with a maximum of 200, while community listing operations use
482+ lower limits. Individual operation sections define the exact
483+ values.
484+
485+ History-oriented operations and other unbounded streams use the
486+ has_more= tag instead of total= as described in
487+ %-lineformat_listshaperules-%.
488+
489+%- identifiers = "Sect. 3" -%
490+========================================================================
491+%-identifiers-% -- IDENTIFIERS
492+========================================================================
493+
494+ %- identifiers_overview = "Sect. 3.1" -%
495+ 3.1 Overview
496+
497+ WIRE defines identifiers for all addressable entities. With the
498+ exception of User Identifiers (uid) and Resource Paths (rid), all
499+ identifiers are 26-character ULIDs (Universally Unique
500+ Lexicographically Sortable Identifiers).
501+
502+ A standard ULID within WIRE consists of a 48-bit timestamp coupled
503+ with an 80-bit randomness component, which is then encoded using
504+ Crockford Base32. This structure ensures that identifiers are
505+ lexicographically sortable by their creation time while providing
506+ strong collision resistance across distributed server instances.
507+
508+ The following identifier types are defined:
509+
510+ uid -- User: Derived from public key, see %-identifiers_uid-%
511+ cid -- Channel: Any addressable room
512+ sid -- Server: Instance identifier
513+ mid -- Message: Specific message instance
514+ eid -- Emoji: Custom emoji identifier
515+ aid -- Attachment: File/media attachment
516+ tid -- Thread: Conversation thread
517+ gid -- Group: Community or group identifier
518+ rolid -- Role: Permission/role identifier
519+
520+ Resource Paths (rid), which serve as human-readable, hierarchical
521+ strings for deep-linking, intentionally diverge from the ULID
522+ format. Their construction and constraints are detailed in
523+ %-identifiers_ulid-%.
524+
525+ %- identifiers_uid = "Sect. 3.2" -%
526+ 3.2 User Identifiers (uid)
527+
528+ %- identifiers_uid_overview = "Sect. 3.2.1" -%
529+ 3.2.1 Overview
530+
531+ The User Identifier (uid) serves as an identity-derived, globally
532+ stable reference for a user. Unlike resource identifiers, the uid
533+ is computed deterministically from the user’s cryptographic public
534+ key and is never assigned by the server.
535+
536+ Clients MUST NOT supply a uid explicitly during resource creation
537+ or authentication initialization. Instead, the server acts as the
538+ authoritative derivation engine, generating the uid from the
539+ public key presented during the authentication handshake. Servers
540+ MUST verify that any client-claimed uid perfectly matches the
541+ derived value; mismatches MUST result in immediate rejection.
542+
543+ %- identifiers_uid_derivation = "Sect. 3.2.2" -%
544+ 3.2.2 Derivation Algorithm (Ed25519):
545+
546+ The uid is computed as:
547+
548+ 1. Obtain the raw 32-byte Ed25519 public key.
549+ 2. Compute the SHA-256 digest of the key-type discriminator
550+ concatenated with the raw public key bytes:
551+ SHA-256("ed25519:" || pubkey_bytes).
552+ 3. Extract the first 16 bytes of the resulting hash.
553+ 4. Encode these 16 bytes using Crockford Base32 (uppercase,
554+ strictly without padding).
555+
556+ This deterministic process yields a fixed 26-character string. The
557+ public key MUST be exactly 32 bytes after decoding.
558+
559+ The following is a non-normative reference implementation in Go:
560+
561+ package main
562+
563+ import (
564+ "crypto/ed25519"
565+ "crypto/sha256"
566+ "encoding/base32"
567+ )
568+
569+ var alphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
570+ var uidEncoding = base32.NewEncoding(alphabet).
571+ WithPadding(base32.NoPadding)
572+ var prefix = []byte("ed25519:")
573+
574+ func UIDFromPublicKey(pub ed25519.PublicKey) string {
575+ data := append(prefix, pub...)
576+ sum := sha256.Sum256(data)
577+ return uidEncoding.EncodeToString(data[:16])
578+ }
579+
580+ %- identifiers_uid_properties = "Sect. 3.2.3" -%
581+ 3.2.3 Properties
582+
583+ - Consistency: Identical public keys MUST reliably yield
584+ identical uid strings across all server implementations.
585+
586+ - Collision Resistance: The derivation algorithm utilizes a
587+ 128-bit output space, providing cryptographically strong
588+ resistance against collisions.
589+
590+ - Format Constraints: The uid is formatted as a 26-character
591+ Crockford Base32 string. While it visually matches the
592+ character set of a standard ULID, it explicitly does not encode
593+ a timestamp and is therefore not time-ordered.
594+
595+ - Validation: Servers MUST independently derive the uid from the
596+ presented public key and MUST reject the authentication request
597+ if the supplied uid fails to match the server-derived value.
598+
599+ %- identifiers_uid_keytypediscriminators = "Sect. 3.2.4" -%
600+ 3.2.4 Key Type Discriminators
601+
602+ To prevent confusion between algorithms and ensure future-proof
603+ key handling, the uid derivation MUST include a key-type
604+ discriminator prefix:
605+
606+ uid = base32(SHA256(discriminator || pubkey_bytes)[:16])
607+
608+ In WIRE 0.8, the only defined key-type discriminator is
609+ "ed25519:", which indicates an Ed25519 public key.
610+
611+ Discriminators MUST be unique and non-overlapping. Any
612+ specification introducing a new cryptographic key type must define
613+ a distinct discriminator prior to implementation. Since the uid is
614+ computed directly from the discriminator and the key, changing the
615+ underlying key type inherently produces a new uid.
616+
617+ Clients that need to upgrade or modify their cryptography MUST
618+ utilize the KEYROTATE mechanism (see
619+ %-authentication_keyrotate_overview-%) to preserve identity
620+ continuity.
621+
622+ %- identifiers_uid_keyfingerprint = "Sect. 3.2.5" -%
623+ 3.2.5 Key Fingerprint
624+
625+ For the purposes of out-of-band human verification, clients SHOULD
626+ generate and display a visual key fingerprint. This fingerprint
627+ is derived from the SHA-256 hash of the public key, formatted as
628+ lowercase hexadecimal characters, and grouped into 8-byte (16 hex
629+ character) colon-separated segments.
630+
631+ aabbccdd11223344:eeff00112233aabb:ccddeeff00112233:aabb99887766
632+
633+ This fingerprint is strictly intended for local UI display and
634+ user-to-user verification; it is not transmitted over the WIRE
635+ protocol.
636+
637+ %- identifiers_ulid = "Sect. 3.3" -%
638+ 3.3 ULID-Based Identifiers
639+
640+ %- identifiers_ulid_generation = "Sect. 3.3.1" -%
641+ 3.3.1 Generation
642+
643+ All ULID identifiers are server-generated.
644+
645+ Message, attachment, emoji, and thread identifiers (mid, aid, eid,
646+ tid) MUST be minted at the exact moment the corresponding resource
647+ is created.
648+
649+ Structural identifiers (cid, gid, rolid) MUST be assigned by the
650+ server when the parent entity is instantiated.
651+
652+ The server is solely responsible for ensuring that all identifiers
653+ are unique within their respective scope.
654+
655+ %- identifiers_ulid_clientconstraints = "Sect. 3.3.2" -%
656+ 3.3.2 Client Constraints
657+
658+ Clients MUST NOT provide precomputed values for any
659+ server-assigned identifier when creating a resource. If a client
660+ does supply a value for a server-assigned field, the server MUST
661+ reject the request, returning an ERR response with code=BADARG.
662+
663+ %- identifiers_ulid_monotonicity = "Sect. 3.3.3" -%
664+ 3.3.3 Monotonicity
665+
666+ To guarantee strictly deterministic chronological ordering,
667+ servers must enforce monotonicity. When generating multiple ULIDs
668+ of the same type within a single millisecond, the randomness
669+ component of each subsequent ULID must be strictly greater than
670+ that of the previous one. This ensures correct per-channel message
671+ ordering even under high-concurrency conditions (see
672+ %-messages_ordering-%).
673+
674+ If the randomness component exhausts its 80-bit capacity within a
675+ single millisecond, the server must increment the timestamp by at
676+ least one millisecond before generating additional ULIDs.
677+
678+ %- identifiers_scopeanduniqueness = "Sect. 3.4" -%
679+ 3.4 Identifier Scope and Uniqueness
680+
681+ %- identifiers_uniqueness_mid = "Sect. 3.4.1" -%
682+ 3.4.1 Message Identifiers (mid)
683+
684+ Message identifiers are strictly scoped to their parent channel. A
685+ mid MUST be guaranteed unique within the boundaries of a specific
686+ channel.
687+
688+ Because ULID randomness is finite, collisions across entirely
689+ different channels are statistically possible. Therefore, clients
690+ MUST NOT treat mid values as globally unique primary keys. All
691+ client-side storage, caching, and state management keyed by a
692+ message MUST utilize a composite key consisting of both the
693+ channel and the message: (cid, mid).
694+
695+ %- identifiers_rid = "Sect. 3.5" -%
696+ 3.5 Resource Paths (rid)
697+
698+ A Resource Path (rid) is a stable, hierarchical, slash-separated
699+ string primarily utilized for logical navigation and deep-linking.
700+ These URL-friendly identifiers are designed to expose the logical
701+ topology of the system to end-users.
702+
703+ To be considered valid, a rid MUST begin with a leading forward
704+ slash (/). Every segment within the path MUST be constrained to
705+ lowercase alphabetic characters, numeric digits, hyphens, and
706+ underscores. Paths MUST NOT contain empty segments (e.g., //) and
707+ MUST NOT terminate with a trailing slash.
708+
709+ To prevent abuse and maintain performance, paths are strictly
710+ limited to a maximum depth of eight segments, with each individual
711+ segment capped at 64 characters in length.
712+
713+ Example: /server-slug/community-slug/channel-slug
714+
715+ The following ABNF defines the normative structure for a valid
716+ rid:
717+
718+ rid = 1*8( "/" segment )
719+ segment = 1*64( SEGCHAR )
720+ SEGCHAR = %x61-7A / %x30-39 / "-" / "_" ; [a-z0-9_-]
721+
722+ In addition to the standard slash-separated form, the WIRE
723+ protocol recognizes two convenient shorthand forms when a rid is
724+ utilized as a subscription (SUB) target (see
725+ %-namespaces_resources-%):
726+
727+ @uid
728+ Resolves to a Direct Message (DM) stream with the specified
729+ user.
730+ #cid
731+ Resolves to a specific channel identified by its opaque ULID.
732+
733+ These shorthand expressions do not conform to the normative rid
734+ ABNF productions. Consequently, the server MUST intercept and
735+ resolve these shortcuts into their fully qualified, canonical rid
736+ counterparts prior to core processing. The @uid form is
737+ intrinsically local to the current server instance. The #cid form
738+ is similarly utilized within message markup (%-wire_markup_2-%)
739+ and does not function as a standard rid in that specific context.
740+
741+ %- identifiers_slugs = "Sect. 3.6" -%
742+ 3.6 Slugs
743+
744+ A slug is a highly stable, human-readable string that functions as a
745+ single segment within a Resource Path (rid). Slugs act as the
746+ immutable logical aliases for underlying resources. This
747+ immutability ensures that deep-links and external bookmarks remain
748+ perpetually functional, even if a community or channel administrator
749+ later modifies the user-facing display name of the resource.
750+
751+ The allowable character set for a slug is a strict subset of the
752+ segment rule, but constrained to a tighter maximum length to
753+ prioritize URL brevity. The normative ABNF structure for a slug is:
754+
755+ slug = 1*32( SLUGCHAR )
756+ SLUGCHAR = %x61-7A / DIGIT / "-" / "_" ; [a-z0-9_-]
757+
758+ Slug uniqueness is enforced within specific logical boundaries to
759+ preclude routing collisions:
760+
761+ - Community slugs: MUST be unique globally across a single server
762+ instance.
763+ - Channel slugs: MUST be unique within their parent community.
764+
765+ Slugs are strictly immutable the moment a resource is committed to
766+ the server’s authoritative datastore. Alterations to a resource’s
767+ human-friendly display name MUST NOT trigger a change to the
768+ underlying slug. If an administrator wishes to force a transition
769+ to a new slug, the underlying entity MUST be entirely deleted and
770+ recreated. This unyielding constraint guarantees that rid
771+ references are safe for long-term external indexing.
772+
773+ If a client requests resource creation but omits the slug parameter,
774+ the server MUST autonomously derive the slug via the following
775+ ordered algorithm:
776+
777+ 1. Normalize the provided display name using Unicode NFC, then
778+ convert it to lowercase.
779+ 2. Iterate through the string, replacing any character not
780+ present in the SEGCHAR set with a hyphen (-).
781+ 3. Collapse any consecutive hyphens into a single hyphen.
782+ 4. Trim trailing and leading hyphens from the string.
783+ 5. Truncate the resulting string to a maximum of 32 characters.
784+ 6. If the result is completely empty, or if it collides with a
785+ pre-existing slug within the same scope, the server MUST
786+ append a decimal numeric suffix (e.g., -2, -3) without leading
787+ zeros, iterating until a globally unique slug is successfully
788+ produced.
789+
790+ Clients MUST NOT attempt to predict the final slug. The
791+ authoritative server-generated slug, returned to the client in the
792+ terminal OK response, is the sole valid value for constructing
793+ subsequent rid references.
794+
795+%- authentication = "Sect. 4" -%
796+========================================================================
797+%-authentication-% -- AUTHENTICATION
798+========================================================================
799+
800+ In the WIRE protocol, the cryptographic keypair intrinsically
801+ represents the identity. The uid is a deterministic artifact derived
802+ directly from a user’s public key (see %-identifiers-%). The system
803+ strictly eschews the traditional concept of "accounts" or discrete
804+ registration phases independent of the authentication flow; a user’s
805+ initial successful cryptographic handshake with a server implicitly
806+ establishes their presence and provisions their identity.
807+
808+ WIRE supports two primary authentication vectors:
809+
810+ - PUBKEY
811+
812+ The foundational and primary authentication method. It relies on
813+ an Ed25519 keypair, where the uid is derived deterministically
814+ from the public key. Any entity possessing the corresponding
815+ private key can cryptographically prove their authorization to
816+ authenticate as that uid.
817+
818+ - TOKEN
819+
820+ A secondary, highly restricted authentication method. It utilizes
821+ an opaque bearer token issued by a server on behalf of a specific
822+ uid. This method is expressly designed for headless bots, API
823+ access, and transient session tokens where exposing the private
824+ key to local memory is undesirable or impossible. A TOKEN is
825+ permanently bound to a specific uid and scoped strictly to the
826+ issuing server instance.
827+
828+ %- authentication_connectionprelude = "Sect. 4.1" -%
829+ 4.1 Connection Prelude
830+
831+ The initialization of a WIRE connection requires a strict preamble
832+ to exchange protocol versions and establish baseline server state
833+ before authentication can proceed.
834+
835+ CLIENT SERVER
836+ ────── ──────
837+ HELLO version=1 [level=N]
838+ :clientname/version ────►
839+ ◄──── HELLO sid=<ulid> ts=<unix-ms>
840+ :servername/version
841+ [CAPS] ────►
842+ ◄──── [CAPS list=csv]
843+
844+ The server responds to the initial HELLO with its sid (a server ULID
845+ that remains strictly stable across service restarts) and ts (the
846+ server’s current internal Unix timestamp in milliseconds). Clients
847+ SHOULD record this ts value, as it serves as a mandatory component
848+ of the subsequent cryptographic challenge payload (see
849+ %-authentication_pubkey_auth-%).
850+
851+ The payload of the HELLO message MUST conform to the following ABNF
852+ structure:
853+
854+ payload = name "/" version
855+ name = 1*63( ALPHA / DIGIT / "." / "-" / "_" )
856+ version = 1*31 ( ALPHA / DIGIT / "." / "-" )
857+
858+ Example: ":wire.example.org/0.8"
859+
860+ The server’s name provided in the HELLO payload dictates the
861+ canonical hostname for rid construction. Clients MUST extract the
862+ substring preceding the first forward slash (/) and utilize it for
863+ all subsequent rid derivations. In the preceding example,
864+ wire.example.org acts as the canonical hostname.
865+
866+ Servers MUST reject malformed HELLO payloads by issuing an ERR
867+ code=BADARG response, followed immediately by closing the transport
868+ connection.
869+
870+ %- authentication_pubkey_auth = "Sect. 4.2" -%
871+ 4.2 PUBKEY Authentication (Ed25519)
872+
873+ The primary authentication vector utilizes a cryptographic
874+ challenge-response mechanism. This ensures verification of private
875+ key possession without the transmission of sensitive material over
876+ the transport layer.
877+
878+ %- authentication_pubkey_overview = "Sect. 4.2.1" -%
879+ 4.2.1 Protocol Exchange Overview
880+
881+ CLIENT SERVER
882+ ────── ──────
883+ AUTH method=PUBKEY
884+ uid=<uid>
885+ :b64url-pubkey ────►
886+ ◄──── CHALLENGE nonce=<hex32>
887+ AUTH method=PUBKEY
888+ sig=<b64url-sig> ────►
889+ ◄──── OK uid=<uid> :welcome
890+ (or ERR code=AUTHFAIL :reason)
891+
892+ %- authentication_pubkey_challengeresponse = "Sect. 4.2.2" -%
893+ 4.2.2 The Challenge-Response Sequence
894+
895+ The authentication sequence begins when the client asserts its
896+ identity via an AUTH command, providing its claimed uid and raw
897+ Ed25519 public key (encoded as an unpadded Base64URL string). The
898+ server immediately verifies that the uid is a valid deterministic
899+ derivative of the provided public key.
900+
901+ Upon successful validation, the server generates a 32-byte
902+ cryptographically secure pseudorandom nonce. It transmits this
903+ nonce to the client as a 64-character lowercase hexadecimal string
904+ within a CHALLENGE message. To mitigate replay attacks and
905+ resource exhaustion, the server MUST restrict this nonce to a
906+ single use and enforce a strict 60-second Time-To-Live (TTL).
907+
908+ To complete the cryptographic proof, the client returns a second
909+ AUTH command containing a signature in the sig tag. This
910+ follow-up command MUST NOT contain a message body.
911+
912+ %- authentication_pubkey_signatureconstruction = "Sect. 4.2.3" -%
913+ 4.2.3 Signature Construction
914+
915+ The client generates the Ed25519 signature over a composite binary
916+ payload, designated as signed_msg. This payload is the strict
917+ concatenation of three elements: the 32-byte binary representation
918+ of the server’s hex-encoded nonce, the 26-byte raw UTF-8 string of
919+ the server’s sid, and an 8-byte big-endian unsigned integer
920+ representing the server’s ts in milliseconds.
921+
922+ signed_msg = nonce_bytes || sid_bytes || ts_bytes
923+
924+ By including the sid and ts (both acquired during the Connection
925+ Prelude), the protocol binds the signature to a specific server
926+ instance and a specific point in time, providing robust protection
927+ against connection hijacking and cross-server replay.
928+
929+ (See %-implementation_notes_2-% for the normative reference
930+ implementation in Go.)
931+
932+ %- authentication_pubkey_verification = "Sect. 4.2.4" -%
933+ 4.2.4 Server Verification Procedure
934+
935+ Upon receiving the cryptographic proof, the server MUST execute
936+ the following verification sequence. Any failure during this
937+ process MUST trigger an immediate ERR code=AUTHFAIL and terminate
938+ the transport connection.
939+
940+ 1. Decode the hex nonce and verify it exists in the active
941+ state table, remains unconsumed, and has not exceeded its
942+ 60-second TTL.
943+ 2. Reconstruct the signed_msg locally, utilizing the active
944+ session’s sid and the original ts issued during the HELLO
945+ exchange.
946+ 3. Verify the client’s provided sig against the locally
947+ reconstructed signed_msg using the initially asserted public
948+ key.
949+ 4. Re-derive the uid from the public key to guarantee it
950+ perfectly matches the uid claimed in the initial assertion.
951+ 5. Mark the nonce as consumed and transition the session state
952+ to "Authenticated."
953+
954+ %- authentication_operationalconsiderations = "Sect. 4.2.5" -%
955+ 4.2.5 Operational Considerations
956+
957+ Because the ts embedded within the signed message is the exact
958+ value yielded during the HELLO phase, the server relies entirely
959+ on its own internal clock for signature reconstruction. The
960+ client’s local system clock is mathematically irrelevant to the
961+ signature’s validity, rendering client-server clock skew a
962+ non-issue. The sole temporal constraint is the server-enforced
963+ 60-second TTL on the active nonce.
964+
965+ If the server’s authoritative datastore lacks a pre-existing
966+ record for the validated uid, the server seamlessly instantiates a
967+ new identity record, persisting the presented public key.
968+ Subsequent authentications utilizing the same key will succeed
969+ transparently.
970+
971+ If the server possesses an existing record for the uid, but the
972+ stored public key diverges from the key currently being presented,
973+ the server MUST reject the attempt with ERR code=AUTHFAIL and MUST
974+ NOT overwrite the stored key. Identity recovery and key
975+ replacement dictate the strict use of the KEYROTATE protocol (see
976+ %-authentication_keyrotation-%).
977+
978+ %- authentication_tokenauthentication = "Sect. 4.3" -%
979+ 4.3 Token Authentication
980+
981+ Token authentication provides a highly streamlined,
982+ single-round-trip alternative expressly designed for non-interactive
983+ clients, such as automated bots or headless services.
984+
985+ %- authentication_token_overview = "Sect. 4.3.1" -%
986+ 4.3.1 Protocol Exchange Overview
987+
988+ CLIENT SERVER
989+ ────── ──────
990+ AUTH method=TOKEN uid=<uid>
991+ :token ────►
992+ ◄──── OK uid=<uid> :welcome
993+ (or ERR code=AUTHFAIL :reason)
994+
995+ %- authentication_token_issuance = "Sect. 4.3.2" -%
996+ 4.3.2 Token Issuance and Scope
997+
998+ A token functions as an opaque bearer credential, provisioned by
999+ the server through out-of-band channels (such as a secured HTTP
1000+ API or embedded within a KEYROTATE response payload). These
1001+ tokens are inextricably bound to a specific (uid, server) tuple.
1002+ Consequently, a token issued by Server A holds no authority and
1003+ remains strictly invalid if presented to Server B.
1004+
1005+ %- authentication_token_validation = "Sect. 4.3.3" -%
1006+ 4.3.3 Transport and Validation Constraints
1007+
1008+ While the internal formatting of the token is an implementation
1009+ detail completely opaque to the WIRE protocol, clients MUST
1010+ restrict token transmission exclusively to transport layers
1011+ secured by TLS. During the exchange, the uid tag provided in the
1012+ AUTH command MUST perfectly align with the internal uid bound to
1013+ the token. Servers MUST reject any mismatched assertions with an
1014+ immediate ERR code=AUTHFAIL.
1015+
1016+ %- authentication_token_operational = "Sect. 4.3.4" -%
1017+ 4.3.4 Operational Considerations
1018+
1019+ By eschewing the cryptographic challenge-response sequence, token
1020+ authentication achieves high efficiency via a single network
1021+ round-trip. However, this inherently forfeits the strict
1022+ cryptographic proof-of-possession that underpins the primary
1023+ PUBKEY vector. Therefore, token authentication is NOT RECOMMENDED
1024+ as the primary mechanism for user-facing clients.
1025+
1026+ If a bearer token reaches its administrative expiry threshold
1027+ while maintaining an active session, the server MUST unilaterally
1028+ sever the connection and issue ERR code=NOTAUTH :token expired.
1029+ The server SHALL NOT provide forewarning, renegotiation, or a
1030+ grace period. The client MUST acquire a fresh token or revert to
1031+ PUBKEY authentication before attempting reconnection.
1032+
1033+ %- authentication_preauthentication_restrictions = "Sect. 4.4" -%
1034+ 4.4 Pre-Authentication Restrictions
1035+
1036+ Until the server transmits an OK response confirming a successful
1037+ authentication exchange, it MUST maintain a heavily restricted
1038+ security posture. During this transient phase, the server is
1039+ permitted to process only a minimal subset of the protocol’s command
1040+ vocabulary.
1041+
1042+ %- authentication_restricted_verbs = "Sect. 4.4.1" -%
1043+ 4.4.1 Permitted Command Subset
1044+
1045+ The server SHALL ONLY acknowledge the following verbs in a
1046+ pre-authenticated context:
1047+
1048+ - HELLO: For protocol versioning and session initialization.
1049+ - AUTH: To execute the primary or secondary authentication flows.
1050+ - CAPS: To negotiate optional protocol capabilities.
1051+ - PING: To verify transport layer liveness.
1052+ - SRVADM (op=policy): To retrieve the access policies
1053+ governing the instance.
1054+
1055+ Any verb not explicitly listed above MUST be abruptly rejected
1056+ with ERR code=NOTAUTH, and the server SHOULD NOT process the
1057+ request body.
1058+
1059+ %- authentication_srvadm_exception = "Sect. 4.4.2" -%
1060+ 4.4.2 SRVADM Scope and Logic
1061+
1062+ The SRVADM op=policy command is uniquely permitted prior to
1063+ authentication because it provides the client with the necessary
1064+ parameters to satisfy the server’s specific access requirements.
1065+ However, this permission is strictly limited. All other
1066+ administrative operations, including but not limited to
1067+ op=role.assign, op=role.revoke, op=perm.grant, op=perm.revoke,
1068+ op=list, and op=info, MUST be rejected if attempted before a
1069+ session is fully authenticated.
1070+
1071+ NOTE:
1072+
1073+ Requests for prohibited administrative actions in this phase
1074+ MUST return ERR code=NOTAUTH rather than ERR code=NOPERM. The
1075+ NOPERM code is reserved exclusively for authenticated users who
1076+ possess a valid identity but lack the requisite administrative
1077+ authorization to perform the requested operation.
1078+
1079+ %- authentication_identity = "Sect. 4.5" -%
1080+ 4.5 Post-Auth: Display Name and Profile (IDENTIFY)
1081+
1082+ Once a session has transitioned to the “Authenticated” state, a
1083+ client MAY declare or update its human-readable profile parameters
1084+ using the IDENTIFY verb. This mechanism allows the underlying
1085+ cryptographic uid to be associated with a display name and an
1086+ optional avatar.
1087+
1088+ %- authentication_identify_syntax = "Sect. 4.5.1" -%
1089+ 4.5.1 Command Syntax and Constraints
1090+
1091+ The IDENTIFY command utilizes the following structure:
1092+
1093+ IDENTIFY name=<urlencoded-displayname> [avatar=<aid|https-url>]
1094+
1095+ Upon receipt, the server updates its internal directory mapping
1096+ the uid to the provided display name.
1097+
1098+ The name tag value MUST be between 1 and 64 decoded characters.
1099+ It MUST consist exclusively of printable UTF-8 characters; control
1100+ characters are strictly prohibited.
1101+
1102+ The avatar tag value MAY be an aid referencing a previously
1103+ UPLOADED internal asset, or a percent-encoded (as per
1104+ %-lineformat_tagvalueencoding-%) HTTPS URL.
1105+
1106+ Implementations designed to prohibit external image loading MUST
1107+ reject HTTP/HTTPS URLs with ERR code=BADARG.
1108+
1109+ %- authentication_identify_defaulting = "Sect. 4.5.2" -%
1110+ 4.5.2 Provisioning and Defaulting
1111+
1112+ The use of IDENTIFY is OPTIONAL. If a client omits this
1113+ configuration, the server SHALL assign a default display name,
1114+ typically a truncated hexadecimal representation of the uid. Upon
1115+ a successful update, the server confirms the profile change by
1116+ replying to the originating client:
1117+
1118+ OK verb=IDENTIFY uid=<uid> ts=<unix-ms>
1119+
1120+ The explicit inclusion of uid= in the OK response is crucial.
1121+ Because IDENTIFY is the operation that associates a uid with a
1122+ display name for the first time, the client state machine may not
1123+ yet have fully committed the derived uid to local storage. Echoing
1124+ the uid provides necessary confirmation.
1125+
1126+ %- authentication_identify_propagation = "Sect. 4.5.3" -%
1127+ 4.5.3 Profile Updates and Propagation
1128+
1129+ Established users MAY issue an IDENTIFY command at any time to
1130+ refresh their nomenclature or avatar. In such instances, the
1131+ server MUST broadcast the updated profile to all active users who
1132+ share a channel with the acting uid via a PROFILE event:
1133+
1134+ PROFILE uid=<uid> name=<urlencoded-name>
1135+ [avatar=<aid|https-url>] ts=<unix-ms>
1136+
1137+ NOTE:
1138+ IDENTIFY is the only mutating verb designed to echo the acting
1139+ uid; standard mutating verbs typically echo only the identifiers
1140+ of the resources they modified.
1141+
1142+ %- authentication_keyrotation = "Sect. 4.6" -%
1143+ 4.6 Key Rotation (KEYROTATE)
1144+
1145+ The WIRE protocol provides a first-class mechanism for cryptographic
1146+ key rotation, enabling users to replace an active keypair in
1147+ response to compromise, routine hygiene, or operational policy. The
1148+ KEYROTATE procedure is designed to ensure continuity of identity
1149+ while requiring explicit cryptographic authorization from both the
1150+ legacy and successor keypairs.
1151+
1152+ A rotation sequence may only be initiated from a session that has
1153+ already completed full authentication using the PUBKEY method with
1154+ the currently active (legacy) credentials. This requirement ensures
1155+ that only a legitimately authenticated principal may attempt to
1156+ transition the identity.
1157+
1158+ %- authentication_keyrotate_overview = "Sect. 4.6.1" -%
1159+ 4.6.1 Protocol Exchange Overview
1160+
1161+ The KEYROTATE exchange proceeds as a challenge-response sequence
1162+ in which the client first proposes a new public key, and then
1163+ proves control over both the existing and proposed identities:
1164+
1165+ CLIENT SERVER
1166+ ────── ──────
1167+
1168+ KEYROTATE newkey=<b64url-newpubkey> ────►
1169+ ◄──── CHALLENGE nonce=<hex32>
1170+
1171+ KEYROTATE sig_old=<b64url-sig-from-old-key>
1172+ sig_new=<b64url-sig-from-new-key> ────►
1173+ ◄──── OK uid=<new-uid>
1174+ (or ERR code=AUTHFAIL :reason)
1175+
1176+ The server-issued nonce binds the operation to a fresh,
1177+ non-replayable context, while the dual-signature requirement
1178+ ensures that both key generations explicitly authorize the
1179+ transition.
1180+
1181+ %- authentication_keyrotate_payload = "Sect. 4.6.2" -%
1182+ 4.6.2 Rotation Signature Payload
1183+
1184+ To enforce strict cryptographic domain separation and prevent
1185+ signature reuse across protocol contexts (for example, reusing an
1186+ AUTH signature for a KEYROTATE operation), the rotation payload is
1187+ defined as a distinct structured message:
1188+
1189+ rotate_msg = nonce_bytes || sid_bytes ||
1190+ old_uid_bytes || new_uid_bytes
1191+
1192+ Each component is defined as follows:
1193+
1194+ - nonce_bytes
1195+ The 32-byte binary form of the server-provided nonce, which
1196+ is originally transmitted as a hex-encoded value.
1197+
1198+ - sid_bytes
1199+ The 26-byte UTF-8 encoding of the server’s sid. Inclusion
1200+ of the sid binds the rotation to a specific server instance
1201+ and prevents cross-server replay attacks.
1202+
1203+ - old_uid_bytes
1204+ The 26-byte UTF-8 encoding of the currently authenticated
1205+ uid.
1206+
1207+ - new_uid_bytes
1208+ The 26-byte UTF-8 encoding of the uid derived from the
1209+ proposed new public key.
1210+
1211+ The client MUST produce two signatures over the exact same
1212+ rotate_msg: sig_old, generated using the legacy private key, and
1213+ sig_new, generated using the new private key. By binding both
1214+ identifiers and the server-specific context into a single payload,
1215+ the protocol guarantees that the rotation is jointly authorized
1216+ and non-transferable.
1217+
1218+ %- authentication_keyrotate_preconditions = "Sect. 4.6.3" -%
1219+ 4.6.3 Normative Preconditions
1220+
1221+ The server MUST verify that the legacy key corresponds to the
1222+ canonical, currently active credential for the authenticated uid.
1223+ Any KEYROTATE attempt referencing a uid that has already been
1224+ rotated (and therefore exists only as an alias) MUST be rejected
1225+ with ERR code=AUTHFAIL.
1226+
1227+ Additionally, the uid derived from the proposed new key MUST be
1228+ globally unique within the server’s datastore. It MUST NOT
1229+ collide with any active uid or any historical alias. Violations
1230+ of this constraint MUST result in rejection with ERR
1231+ code=AUTHFAIL.
1232+
1233+ %- authentication_keyrotate_succees = "Sect. 4.6.4" -%
1234+ 4.6.4 Normative Success Behavior
1235+
1236+ Upon successful validation of both signatures, the server MUST
1237+ execute the rotation as a single, strictly atomic transaction. The
1238+ following effects are required:
1239+
1240+ First, all other active sessions authenticated under the legacy
1241+ uid MUST be immediately disconnected. This forces all clients to
1242+ re-establish authentication under the updated identity state.
1243+
1244+ Second, the authoritative datastore MUST be updated such that the
1245+ newly provided public key becomes the primary credential for the
1246+ identity stream.
1247+
1248+ Third, the legacy uid MUST be permanently retired and recorded as
1249+ an immutable alias that resolves directly to the new uid. Once in
1250+ this state, the old uid MUST NOT be accepted as a source identity
1251+ for any future KEYROTATE operations.
1252+
1253+ Fourth, all relational state—including, but not limited to,
1254+ community memberships, role assignments, and message history—MUST
1255+ remain logically continuous. The new uid becomes the canonical
1256+ identifier without requiring physical data migration.
1257+
1258+ Fifth, the server MUST broadcast a migration event to all peers
1259+ that share communities with the user:
1260+
1261+ PROFILE uid=<new-uid> name=<n> prev_uid=<old-uid> ts=<unix-ms>
1262+
1263+ The presence of the prev_uid field instructs clients to merge any
1264+ cached state associated with the legacy identity into the new one,
1265+ preserving continuity at the application layer.
1266+
1267+ Finally, the initiating session MUST receive:
1268+
1269+ OK uid=<new-uid>
1270+
1271+ From that point forward, the connection is considered fully
1272+ authenticated under the new uid, and all subsequent protocol
1273+ operations are evaluated within that updated identity context.
1274+
1275+ If any step within this sequence fails, the server MUST perform a
1276+ complete rollback to the pre-rotation state and return ERR
1277+ code=AUTHFAIL. Partial commits are explicitly forbidden.
1278+
1279+ %- authentication_keyrotate_graceperiod = "Sect. 4.6.5" -%
1280+ 4.6.5 Grace Period Implementation
1281+
1282+ To accommodate users operating multiple or intermittently
1283+ connected devices, servers SHOULD implement a bounded grace period
1284+ following a successful rotation. A duration of 30 days is
1285+ RECOMMENDED.
1286+
1287+ During this interval, standard AUTH requests signed with the
1288+ legacy key remain valid. However, successful authentication using
1289+ such credentials MUST return:
1290+
1291+ OK uid=<new-uid> key_deprecated=1
1292+
1293+ The key_deprecated=1 marker serves as an explicit signal to the
1294+ client that the credential is no longer authoritative and SHOULD
1295+ be replaced. Client implementations are expected to prompt the
1296+ user to generate a new keypair or synchronize credentials from a
1297+ primary device.
1298+
1299+ Internally, the server MUST resolve all legacy-key authentications
1300+ directly to the new uid. All actions performed under such
1301+ sessions are canonically attributed to the new identity.
1302+
1303+ To prevent replay or duplication of rotation attempts during the
1304+ grace window, servers MUST maintain a dedicated rotation ledger
1305+ keyed by (old_uid, new_uid). Any KEYROTATE request that attempts
1306+ to reproduce an already recorded transition MUST be rejected with
1307+ ERR code=AUTHFAIL, bypassing standard nonce validation entirely.
1308+
1309+ %- authentication_replayprotection = "Sect. 4.7" -%
1310+ 4.7 Replay Protection
1311+
1312+ The integrity of both the PUBKEY and KEYROTATE authentication flows
1313+ depends critically on the strict single-use property of the
1314+ CHALLENGE nonce. Each nonce represents a unique, time-bound
1315+ authorization context and MUST NOT be reused under any
1316+ circumstances.
1317+
1318+ To enforce this guarantee, servers MUST maintain an active set of
1319+ previously used nonces. Each entry in this set is subject to a
1320+ strict Time-To-Live (TTL) of 60 seconds. Any authentication payload
1321+ that attempts to reuse a nonce still residing within this validity
1322+ window MUST be rejected immediately with ERR code=AUTHFAIL.
1323+
1324+ Upon expiration of the 60-second TTL, a nonce may be safely removed
1325+ from the active set. At that point, replay attempts are
1326+ independently mitigated by the temporal validation inherent in the
1327+ protocol, as the nonce will no longer satisfy freshness
1328+ requirements. As a result, the total memory footprint of the
1329+ used-nonce set is deterministically bounded and can be expressed as:
1330+
1331+ maximum entries ≈ maximum authentications per second × 60
1332+
1333+ Implementations MAY impose a strict upper bound on the size of the
1334+ nonce set as a defensive measure. In scenarios involving extreme
1335+ authentication throughput or adversarial flooding, where this
1336+ capacity is exhausted, the server is permitted to temporarily reject
1337+ or drop incoming authentication requests. This behavior is
1338+ considered compliant, provided it is necessary to preserve overall
1339+ system stability.
1340+
1341+%- namespaces_resources = "Sect. 5" -%
1342+========================================================================
1343+%-namespaces_resources-% -- NAMESPACES AND RESOURCES
1344+========================================================================
1345+
1346+ %- namespaces_resources_overview = "Sect. 5.1" -%
1347+ 5.1 Overview
1348+
1349+ The WIRE protocol organizes all protocol-visible state around three
1350+ primary resource types: the server, the community, and the channel.
1351+ A server represents a single WIRE instance and constitutes the root
1352+ of the namespace. Within a server, a community provides a named
1353+ grouping that encapsulates roles, branding, and an associated
1354+ collection of channels. A channel, in turn, represents a message
1355+ stream that may either belong to a community or, in the case of
1356+ direct messaging, exist directly under the server.
1357+
1358+ These resources are addressed through resource identifiers (rid),
1359+ which together form a hierarchical namespace spanning all protocol
1360+ primitives.
1361+
1362+ %- namespaces_resources_rid = "Sect. 5.2" -%
1363+ 5.2 Resource Identifiers
1364+
1365+ A resource identifier (rid) is a structured path that uniquely
1366+ identifies a resource within the namespace of a server. The
1367+ protocol defines several canonical forms:
1368+
1369+ @uid
1370+ Identifies a direct message (DM) channel with the specified
1371+ user.
1372+
1373+ #cid
1374+ Refers to a channel by its opaque ULID. This form is valid
1375+ only in markup contexts (see %-wire_markup_2-%) and MUST NOT
1376+ be used as a rid in protocol operations.
1377+
1378+ /hostname
1379+ Identifies the server root. The hostname is obtained from the
1380+ HELLO payload as defined in
1381+ %-authentication_connectionprelude-%.
1382+
1383+ /hostname/slug
1384+ Identifies a community. The slug is derived as specified in
1385+ %-identifiers-%.
1386+
1387+ /hostname/slug/chanslug
1388+ Identifies a channel within a community. The channel slug is
1389+ assigned at creation time (COMM op=channel.create) and is
1390+ returned in CHAN op=info via the slug= field.
1391+
1392+ Resource identifiers are constructed exclusively from slugs. Display
1393+ names are never embedded in the path. For example, a channel named
1394+ "General Chat" with slug "general-chat" is addressed as:
1395+
1396+ /wire.example.org/acme/general-chat
1397+
1398+ The display name is transmitted independently (for example,
1399+ name=General%20Chat in CHAN op=info). Slugs function as stable,
1400+ machine-oriented identifiers, whereas display names are
1401+ human-readable and may change without affecting addressing.
1402+
1403+ Rids are transmitted as tag values and are therefore subject to the
1404+ encoding rules defined in %-lineformat_tagvalueencoding-%. Because
1405+ slugs are restricted to the character set [a-z0-9_-], they do not
1406+ require percent-encoding. A complete rid is emitted verbatim as a
1407+ tag value without additional transformation.
1408+
1409+ Examples:
1410+
1411+ /wire.example.org/acme/general
1412+ /wire.example.org/acme/announcements
1413+ @7ZNKQ5XMVP2R4ABCDE01JXAAA
1414+
1415+ The @uid form is a local shorthand that omits the hostname and is
1416+ resolved relative to the current server context.
1417+
1418+ %- namespaces_resources_hostnamehandling = "Sect. 5.3" -%
1419+ 5.3 Hostname Handling
1420+
1421+ The hostname component of a rid is derived from the HELLO payload,
1422+ as specified in %-authentication_connectionprelude-%. Servers MUST
1423+ accept fully qualified rids that begin with their configured
1424+ hostname.
1425+
1426+ For convenience, servers MAY also accept hostname-omitted forms,
1427+ such as "/acme/general", resolving them implicitly against their own
1428+ hostname. This behavior is OPTIONAL. Clients SHOULD prefer fully
1429+ qualified rids to ensure portability and to avoid ambiguity,
1430+ particularly in federated deployments (see
1431+ %-server_to_server_federation-%).
1432+
1433+ %- namespaces_resources_subscribing = "Sect. 5.4" -%
1434+ 5.4 Subscribing to Resources
1435+
1436+ Clients may subscribe to channels using either a human-readable rid
1437+ or an opaque channel identifier (cid). These forms are strictly
1438+ equivalent:
1439+
1440+ SUB rid=<rid>
1441+ SUB cid=<cid>
1442+
1443+ In practice, the rid form is typically used during discovery, while
1444+ the cid form is preferred once the mapping has been resolved and
1445+ persisted locally.
1446+
1447+ Servers MUST accept both forms unconditionally. A SUB request that
1448+ omits both rid= and cid= MUST be rejected with ERR code=BADARG.
1449+
1450+ Regardless of the request form, the server response is normalized. A
1451+ successful subscription MUST include both rid= and cid=:
1452+
1453+ OK verb=SUB rid=<rid> cid=<cid>
1454+
1455+ When the client supplies cid=, the server derives the corresponding
1456+ rid from its internal channel record. For community channels, this
1457+ is the fully qualified path. For DM channels, the value is the
1458+ stored per-(uid, cid) mapping (see
1459+ %-namespaces_resources_dmchannels-%). This symmetry eliminates any
1460+ dependency on the original request form.
1461+
1462+ After subscription, all subsequent events for the channel are
1463+ identified exclusively by cid=<cid>. The rid is not repeated in
1464+ event lines. Clients are responsible for maintaining the rid-to-cid
1465+ mapping.
1466+
1467+ %- namespaces_resources_communitymembershipreqs = "Sect. 5.5" -%
1468+ 5.5 Community Membership Requirements
1469+
1470+ Subscription to a community channel is contingent upon prior
1471+ membership in that community. A client MUST successfully join the
1472+ community (COMM op=join) before issuing SUB for any channel within
1473+ it. Attempts to subscribe without membership MUST be rejected with
1474+ ERR code=NOPERM.
1475+
1476+ Direct message channels are explicitly exempt from this requirement.
1477+
1478+ %- namespaces_resources_dmchannels = "Sect. 5.6" -%
1479+ 5.6 Direct Message Channels
1480+
1481+ A direct message (DM) channel is a private, bidirectional message
1482+ stream between exactly two users. Such channels do not belong to
1483+ any community and MUST NOT appear in COMM op=list or COMM
1484+ op=channel.list.
1485+
1486+ DM channels are created implicitly upon first subscription:
1487+
1488+ SUB rid=@<target-uid>
1489+
1490+ If no DM channel exists between the authenticated user and the
1491+ target, the server MUST create one atomically and return its cid:
1492+
1493+ OK verb=SUB rid=@<target-uid> cid=<cid>
1494+
1495+ The cid of a DM channel is both stable and symmetric. Both
1496+ participants MUST observe the same cid regardless of which party
1497+ initiates the subscription. Concretely:
1498+
1499+ SUB rid=@<target-uid> issued by uid A
1500+ SUB rid=@<uid-A> issued by target-uid
1501+
1502+ MUST resolve to the same cid.
1503+
1504+ Once created, DM channels are permanent and MUST NOT be deleted by
1505+ the server. Users MAY SUB or UNSUB such channels at any time;
1506+ however, unsubscribing does not destroy the channel or its history.
1507+
1508+ Each DM subscription is associated with a rid stored per (uid, cid)
1509+ pair. Accordingly, the same cid appears as @<uid-B> for uid-A and
1510+ as @<uid-A> for uid-B.
1511+
1512+ The target uid MUST correspond to a known user on the server, that
1513+ is, one that has previously authenticated. Otherwise, the request
1514+ MUST be rejected with ERR code=NOTFOUND.
1515+
1516+ DM channels generate PING kind=user events for the recipient on
1517+ every message (see %-messages_ping-%). Role mentions (rolmentions=)
1518+ are not valid within DM channels and MUST be rejected with ERR
1519+ code=BADARG.
1520+
1521+ %- namespaces_resources_dmcreation = "Sect. 5.7" -%
1522+ 5.7 DM Creation Policies
1523+
1524+ Certain properties of a DM channel are defined exclusively at
1525+ creation time, that is, during SUB rid=@<uid> when no channel yet
1526+ exists. These properties are immutable thereafter. Supplying such
1527+ fields when targeting an existing DM or any non-DM channel MUST
1528+ result in ERR code=BADARG.
1529+
1530+ Servers MUST persist these fields as part of the DM channel record
1531+ and echo them in the SUB response.
1532+
1533+ The delete_policy field governs message deletion semantics and
1534+ accepts the following values:
1535+
1536+ unilateral
1537+ Either participant may independently purge the DM history.
1538+ This is the default when the field is omitted.
1539+
1540+ mutual
1541+ HIST op=purge requires explicit confirmation from both
1542+ participants.
1543+
1544+ Immutability of this field prevents retroactive weakening of
1545+ deletion constraints.
1546+
1547+ The e2e field enables end-to-end encryption:
1548+
1549+ e2e=0|1 (default: 0)
1550+
1551+ When enabled, the channel operates using Layer 1 encryption (X3DH
1552+ combined with the Double Ratchet), as defined in
1553+ %-history_purge_e2ee_2-%. The enc=skdm mechanism is not used for DM
1554+ channels.
1555+
1556+ In SUB responses, delete_policy MUST always be present. The e2e
1557+ field MUST be omitted when set to its default value (0), in
1558+ accordance with the tag-absence rule (see
1559+ %-lineformat_tagsemantics-%).
1560+
1561+ These fields MUST NOT be accepted for community channels.
1562+
1563+ %- namespaces_resources_subdurability = "Sect. 5.8" -%
1564+ 5.8 Subscription Durability
1565+
1566+ Subscriptions are durable and scoped to the user identity (uid),
1567+ rather than to any specific connection or session. A subscription
1568+ represents persistent intent to receive events from a channel across
1569+ all devices and sessions associated with that uid.
1570+
1571+ Servers MUST persist the subscription set per uid and restore it
1572+ upon re-authentication. Following AUTH, the server MUST reactivate
1573+ all stored subscriptions on the newly established connection as if
1574+ the client had reissued SUB for each channel. This restoration
1575+ occurs after the PING burst (see %-messages_ping-%).
1576+
1577+ The SUB command adds a channel to the durable set and activates
1578+ delivery on the current connection. Conversely, UNSUB removes the
1579+ channel from the durable set. UNSUB requests that omit cid= MUST be
1580+ rejected with ERR code=BADARG.
1581+
1582+ %- namespaces_resources_sublisting = "Sect. 5.9" -%
1583+ 5.9 Subscription Listing
1584+
1585+ Clients may enumerate subscriptions using:
1586+
1587+ SUB op=list [offset=<n>] [limit=<n>]
1588+
1589+ The default offset is 0, the default limit is 50, and the maximum
1590+ permitted limit is 200. Servers MUST clamp limit to 200.
1591+
1592+ The server responds with one SUB op=info line per subscription,
1593+ followed by:
1594+
1595+ OK verb=SUB op=list count=<n> total=<n>
1596+
1597+ Each SUB op=info line includes both cid=<cid> and rid=<rid>. The
1598+ rid is the fully qualified value associated with the uid, while cid
1599+ is the opaque ULID.
1600+
1601+ The total field represents the complete size of the subscription
1602+ set. Because this set is owned by the uid and mutates only through
1603+ explicit client action, it remains stable throughout the query. This
1604+ behavior differs from unbounded streams such as HIST or LIST, which
1605+ instead rely on has_more= (see %-lineformat_listshaperules-%).
1606+
1607+ %- namespaces_resources_subgc = "Sect. 5.10" -%
1608+ 5.10 Subscription Garbage Collection
1609+
1610+ Servers MUST automatically remove subscriptions under specific
1611+ conditions to preserve consistency:
1612+
1613+ - When a channel is deleted (CHAN op=delete), the server removes
1614+ the cid from all subscription sets prior to closing active
1615+ connections.
1616+
1617+ - When a user leaves, is removed from, or is banned from a
1618+ community (COMM op=leave, MEMBER op=kick, MEMBER op=ban), all
1619+ subscriptions to channels within that community are removed.
1620+
1621+ - When a community is deleted (COMM op=delete), all subscriptions
1622+ to its channels are removed for all users.
1623+
1624+ A user who is banned and subsequently unbanned does not regain any
1625+ prior subscriptions and MUST explicitly resubscribe after rejoining.
1626+
1627+ %- namespaces_resources_multiconnsemantics = "Sect. 5.11" -%
1628+ 5.11 Multi-Connection Semantics
1629+
1630+ When a user maintains multiple concurrent sessions, all sessions
1631+ share a single, unified subscription set. A SUB or UNSUB issued on
1632+ any session applies immediately and globally to all sessions
1633+ associated with that uid.
1634+
1635+ Servers MUST deliver events to all active connections of a
1636+ subscribed user, not solely to the connection that initiated the
1637+ subscription.
1638+
1639+ This behavior is consistent with other uid-scoped state, including
1640+ read markers and notification dismissal, which are likewise shared
1641+ across sessions.
1642+
1643+%- messages = "Sect. 6" -%
1644+========================================================================
1645+%-messages-% -- MESSAGES
1646+========================================================================
1647+
1648+ %- messages_sending = "Sect. 6.1" -%
1649+ 6.1 Sending
1650+
1651+ The MSG verb is the primary mechanism for publishing content to a
1652+ channel. A client MUST supply cid=<cid> on all client-originated
1653+ MSG lines, as the server requires the channel identifier at
1654+ ingestion time to route the message prior to assigning a mid. This
1655+ differs from REACT and UNREACT (see %-reactions-%), which omit cid=
1656+ in their client forms because the server can resolve the channel
1657+ from its mid→cid index.
1658+
1659+ The complete syntax is:
1660+
1661+ MSG cid=<cid> [tid=<tid>] [ref=<mid>]
1662+ [mentions=<csv-uid>]
1663+ [rolmentions=<csv-rolid>]
1664+ [attachments=<csv-aid:urlencoded-mime>]
1665+ [title=<urlencoded-title>]
1666+ [tags=<csv>]
1667+ [enc=xchacha20|skdm] [target=<uid>]
1668+ :body
1669+
1670+
1671+ The tid= field identifies a thread. Its semantics vary by channel
1672+ type. On text channels, tid= associates the message with an
1673+ existing thread; absence of tid= produces a top-level message. On
1674+ forum channels, absence of tid= combined with presence of title=
1675+ creates a new thread, with the server assigning tid= in the
1676+ broadcast. If tid= is present on a forum channel, the message is a
1677+ reply and title= MUST NOT be present. On board channels, tid= is
1678+ not used; any MSG carrying tid= MUST be rejected with ERR
1679+ code=BADARG. On voice channels, tid= is ignored.
1680+
1681+ The ref= field carries the mid of a quoted or replied-to message. It
1682+ has no server-side semantics and is used solely for client-side
1683+ rendering. It is valid on all channel types.
1684+
1685+ The mentions= field is a comma-separated list of uid values that are
1686+ explicitly referenced. Servers MUST validate that each uid is a
1687+ member of the relevant community.
1688+
1689+ The rolmentions= field contains role identifiers. This includes
1690+ built-in roles such as 'everyone' and 'here' (see
1691+ %-community_management_2-%). Servers MUST validate that each rolid
1692+ exists or is a valid built-in, and MUST enforce mention permissions.
1693+
1694+ The attachments= field declares a comma-separated list of aid%3Amime
1695+ pairs corresponding to attachments referenced in the body.
1696+ Examples:
1697+
1698+ aid MIME type aid:mime (percent-encoded)
1699+ ─────── ─────────────── ───────────────────────────
1700+ 01JXAAA image/png 01JXAAA%3Aimage%2Fpng
1701+ 01JXBBB application/pdf 01JXBBB%3Aapplication%2Fpdf
1702+
1703+ The client MUST include one entry per aid:<aid> reference. The mime
1704+ value MUST match that provided at UPLOAD time. Servers MUST NOT
1705+ parse the body to derive this field. Instead, they validate that
1706+ each aid corresponds to a previously uploaded and confirmed asset.
1707+ Invalid entries are stripped and reported via WARN
1708+ code=ATTACH_STRIPPED. The server relays surviving entries verbatim
1709+ without normalization or re-derivation. The tag MUST be omitted
1710+ entirely when no attachments are present. A tag with an empty value
1711+ violates %-lineformat_tagsemantics-%.
1712+
1713+ The title= field is percent-encoded and limited to 256 decoded
1714+ characters. It is required for the first message in a forum thread
1715+ and for all board posts. It MUST NOT be present on thread replies.
1716+ Servers MUST ignore title= on text channels.
1717+
1718+ The tags= field provides up to 20 labels, each up to 64 characters.
1719+ It is meaningful only for board channels and MUST be ignored
1720+ elsewhere.
1721+
1722+ The body is encoded per %-lineformat_payloadencoding-%. Newlines
1723+ use the \n escape. The body MAY be empty on board posts.
1724+
1725+ After validation, the server responds:
1726+
1727+ OK verb=MSG cid=<cid> mid=<mid> [tid=<tid>] ts=<unix-ms>
1728+
1729+ The mid is a server-assigned ULID. On forum channels where a new
1730+ thread is created, tid= is included so the client learns the thread
1731+ identifier immediately. The OK is sent before the broadcast.
1732+
1733+ The mentions=, rolmentions=, and attachments= fields constitute
1734+ structural metadata and are explicitly distinct from any markup
1735+ contained within the message body (see %-wire_markup-%). Clients
1736+ MUST maintain consistency between these representations, however
1737+ servers do not enforce or validate this relationship.
1738+
1739+ These fields are not markup. The body MAY independently contain
1740+ @uid:name, @rolid:name, and  constructs for
1741+ client-side rendering (see %-wire_markup-%). The structural tags
1742+ and the body markup are orthogonal and serve distinct purposes.
1743+
1744+ The mentions= and rolmentions= fields drive server-side PING
1745+ delivery (see %-messages_ping-%). The attachments= field enables
1746+ receivers to render attachments without requiring a subsequent
1747+ ATTACH resolution round-trip. In contrast, markup within the body
1748+ exists solely for client-side presentation, including highlighting,
1749+ styling, and inline media rendering.
1750+
1751+ Clients MUST keep structural tags and body markup consistent.
1752+ Servers do NOT parse the body to derive or validate this
1753+ consistency. A message that includes attachments= but no
1754+ corresponding aid: markup in the body is valid. Conversely, body
1755+ markup referencing aid:<aid> without a corresponding attachments=
1756+ entry remains valid, but receiving clients MUST fall back to ATTACH
1757+ resolution to obtain the referenced asset metadata.
1758+
1759+ %- messages_receiving = "Sect. 6.2" -%
1760+ 6.2 Receiving
1761+
1762+ After issuing the OK response, the server broadcasts the message to
1763+ all subscribers of the channel, including the sender:
1764+
1765+ MSG cid=<cid> mid=<mid> uid=<uid> ts=<unix-ms>
1766+ [tid=<tid>] [ref=<mid>]
1767+ [mentions=<csv-uid>]
1768+ [rolmentions=<csv-rolid>]
1769+ [attachments=<csv-aid:urlencoded-mime>]
1770+ [title=<urlencoded-title>]
1771+ [tags=<csv>]
1772+ [enc=xchacha20|skdm] [target=<uid>]
1773+ :body
1774+
1775+ The ts field is expressed as a Unix epoch timestamp in milliseconds.
1776+ On forum channels, if a thread is created implicitly, the assigned
1777+ tid= is included in both the OK and the broadcast.
1778+
1779+ Servers relay mentions= and rolmentions= after validation. Invalid
1780+ uid entries are dropped and reported via WARN
1781+ code=MENTION_UID_INVALID (see %-adversarial_behavior_2-%). Role
1782+ mention validation follows a three-tier rule: insufficient
1783+ permission for 'everyone' or 'here' causes a hard reject; lack of
1784+ permission for other roles results in the entire tag being dropped
1785+ with a warning; unknown roles are removed individually.
1786+
1787+ %- messages_ordering = "Sect. 6.3" -%
1788+ 6.3 Per-CID Ordering Guarantee
1789+
1790+ Servers MUST deliver MSG events for a given cid in a consistent
1791+ total order. The canonical ordering is defined by mid lexicographic
1792+ order. Because mids are ULIDs assigned by the server under a
1793+ monotonic rule (see %-identifiers-%), this ordering corresponds to
1794+ server receipt order.
1795+
1796+ No ordering guarantees are made across channels. Ordering across
1797+ servers in federated environments is explicitly unspecified (see
1798+ %-server_to_server_federation-%).
1799+
1800+ %- messages_editing = "Sect. 6.4" -%
1801+ 6.4 Editing
1802+
1803+ The EDIT verb modifies an existing message:
1804+
1805+ EDIT mid=<mid> [tags=<csv>] [:new body]
1806+
1807+ The server responds:
1808+
1809+ OK verb=EDIT mid=<mid> cid=<cid> rev=<n> ts=<unix-ms>
1810+
1811+ and broadcasts:
1812+
1813+ EDIT mid=<mid> cid=<cid> uid=<uid> ts=<unix-ms> rev=<n>
1814+ [enc=xchacha20] [tags=<csv>] :new body
1815+
1816+ The rev counter starts at 1 for the first edit and increments for
1817+ each subsequent edit. The original message is implicitly revision
1818+ 0. The OK is sent before the broadcast, and the rev= value in the
1819+ OK MUST match the rev= in the broadcast. This allows the client to
1820+ learn the new revision immediately without waiting for the echo. The
1821+ cid= field is included in the OK so the originating client can route
1822+ the confirmation without maintaining pending-request state when
1823+ pipelining EDIT operations across channels.
1824+
1825+ Authorization is enforced as follows. The original author MAY
1826+ always edit their own messages. Any member possessing the
1827+ msg.delete permission MAY edit messages authored by others. Servers
1828+ MUST reject unauthorized EDIT attempts with ERR code=NOPERM. Servers
1829+ MUST store the current body; retention of edit history is optional.
1830+
1831+ Editing semantics vary by channel type. On type=board channels, the
1832+ tags= field carries the updated tag set for the post. Body and
1833+ tags= are independently optional in this context. An EDIT that
1834+ includes only tags= updates the tag set, an EDIT that includes only
1835+ a body payload updates the body, and an EDIT that includes both
1836+ updates both atomically. An EDIT that includes neither body nor
1837+ tags= on a board channel MUST be rejected with ERR code=BADARG.
1838+
1839+ On all non-board channel types, servers MUST silently ignore tags=
1840+ in EDIT payloads. Servers MUST NOT return ERR code=BADARG solely
1841+ because tags= was present. This behavior is symmetric with the MSG
1842+ tags= ignore rule (see %-messages_sending-%). On these channel
1843+ types, the body payload is required.
1844+
1845+ The title= field is immutable after message creation. Servers MUST
1846+ ignore any title= field supplied in an EDIT payload, regardless of
1847+ channel type.
1848+
1849+ The tags= field is included in server-broadcast EDIT lines only when
1850+ the tag set has changed on a board channel. It is absent for
1851+ body-only edits and for all non-board channel types. Clients MUST
1852+ update cached tags when tags= is present and MUST NOT clear the
1853+ cached tag set when tags= is absent.
1854+
1855+ EDIT verb directionality is normative. Parsers MUST use the
1856+ presence of uid= to distinguish direction. A client-sent EDIT line
1857+ carries mid=, optional tags= (board channels only), and an optional
1858+ body payload. A server-broadcast EDIT line carries cid=, uid=, ts=,
1859+ and rev= in addition to mid=. Servers MUST NOT emit EDIT lines
1860+ without uid=. Clients MUST NOT send EDIT lines containing uid= or
1861+ cid=. This rule is consistent with DEL (see %-messages_deletion-%),
1862+ KVAL (see %-users_presence_5-%), and ATTACH (see %-attachments_3-%).
1863+
1864+ Inclusion of cid= in the broadcast form ensures that every EDIT line
1865+ is self-identifying by channel, consistent with REACT, UNREACT, PIN,
1866+ and UNPIN (see %-lineformat_pipelining-%).
1867+
1868+ Structural tag immutability is strictly enforced. Structural tags
1869+ are the per-message metadata defined at creation time: ref=, tid=,
1870+ mentions=, rolmentions=, and attachments=. These fields are fixed
1871+ when the server returns OK verb=MSG and MUST NOT be modified by any
1872+ subsequent operation. EDIT replaces only the message body and, on
1873+ board channels, the tags= field. Servers MUST ignore any attempt to
1874+ include structural tags in an EDIT payload, and clients MUST NOT
1875+ include them in EDIT requests.
1876+
1877+ %- messages_deletion = "Sect. 6.5" -%
1878+ 6.5 Deletion
1879+
1880+ The DEL verb removes a message identified by its mid:
1881+
1882+ DEL mid=<mid>
1883+
1884+ Upon receiving a deletion request, the server responds to the
1885+ originating client with an acknowledgment containing the channel
1886+ identifier and timestamp:
1887+
1888+ OK verb=DEL mid=<mid> cid=<cid> ts=<unix-ms>
1889+
1890+ The server then broadcasts the deletion event to all subscribers:
1891+
1892+ DEL mid=<mid> cid=<cid> uid=<uid> ts=<unix-ms>
1893+
1894+ Directionality is defined normatively. Parsers MUST use the
1895+ presence of uid= to distinguish the direction of a DEL line. A
1896+ client-sent DEL includes only mid=, whereas the server broadcast
1897+ includes cid=, uid=, and ts= alongside mid=. Both cid= and uid= are
1898+ broadcast-only fields; clients MUST NOT include them when sending
1899+ DEL. This rule is consistent with the EDIT verb
1900+ (%-messages_editing-%). Including cid= in the broadcast ensures
1901+ each DEL line is self-identifying by channel, maintaining
1902+ consistency with EDIT, REACT, UNREACT, PIN, and UNPIN
1903+ (%-lineformat_pipelining-%).
1904+
1905+ The cid= tag is echoed in the OK response to allow the originating
1906+ client to route the confirmation without maintaining pending-request
1907+ state, even when pipelining multiple DELs across channels. The
1908+ server derives cid= from its internal mid→cid index at validation
1909+ time; no additional lookup is required. As with other verbs, the OK
1910+ is sent before the broadcast.
1911+
1912+ Servers MUST NOT return deleted messages in HIST responses, and they
1913+ SHOULD tombstone the mid to prevent future ID reuse.
1914+
1915+ %- messages_history = "Sect. 6.6" -%
1916+ 6.6 History
1917+
1918+ Clients retrieve message history using the HIST verb:
1919+
1920+ HIST cid=<cid> [before=<mid>] [after=<mid>] [limit=<n>]
1921+ [tid=<tid>]
1922+
1923+ In the absence of an explicit limit, the server returns up to 50
1924+ events, ordered by ascending mid. Any client-specified limit is
1925+ clamped to a maximum of 200. The before= and after= parameters are
1926+ mutually exclusive; a request that includes both MUST be rejected
1927+ with ERR code=BADARG.
1928+
1929+ When neither before= nor after= is supplied, the server returns the
1930+ most recent limit= events in ascending mid order. This behavior is
1931+ intended for initial channel loading. Clients MAY use HIST in the
1932+ following scenarios:
1933+
1934+ - Initial load: HIST cid=<cid> limit=50
1935+ - Scroll-up pagination: HIST cid=<cid> before=<oldest-loaded-mid>
1936+ limit=50
1937+ - Catch-up after reconnect: HIST cid=<cid> after=<cursor>
1938+ limit=50
1939+
1940+ The optional tid= parameter constrains results to a specific thread.
1941+ Its semantics depend on channel type. On text channels, tid=
1942+ identifies the thread and includes the root message. On forum
1943+ channels, it returns all replies belonging to the thread. On board
1944+ channels, where threading is unsupported, the presence of tid= MUST
1945+ result in ERR code=BADARG. The tid= filter MAY be combined with
1946+ before= or after= to enable pagination within a thread.
1947+
1948+ MSG lines returned in HIST MAY contain tags that were not present in
1949+ the original live broadcast. Notably, the root message of a
1950+ text-channel thread retroactively acquires tid=
1951+ (%-boards_forums_threads_3-%). Clients MUST therefore accept and
1952+ correctly process MSG lines carrying previously unseen tags.
1953+
1954+ The server replies with a sequence of event lines sorted by mid,
1955+ followed by a terminal OK. The event stream MAY include:
1956+
1957+ MSG -- messages not deleted (current body only)
1958+ EDIT -- most recent revision for each edited message
1959+ DEL -- tombstone for each deleted message
1960+ REACT / UNREACT -- all reaction events in the window
1961+ PIN / UNPIN -- pin-related events
1962+ THREAD -- thread rename, lock, or unlock events
1963+
1964+ THREAD events MUST match the live broadcast format exactly
1965+ (%-boards_forums_threads_2a-%). In this context, THREAD op=rename
1966+ includes uid=, ts=, and the updated title, while THREAD op=lock and
1967+ op=unlock include uid= and ts= only. The op=info variant is
1968+ strictly a query-reply form and MUST NOT appear in HIST. Receipt of
1969+ such a line outside a pending query MAY be treated by the client as
1970+ a protocol error, and the connection MAY be closed.
1971+
1972+ Event inclusion follows strict rules. When a message has been
1973+ deleted, only the corresponding DEL event is emitted and any MSG is
1974+ suppressed. EDIT events carry the current message body rather than
1975+ the original. Accordingly, HIST does not constitute a faithful
1976+ historical replay of edits. Instead, EDIT lines reflect the body as
1977+ it exists at query time, even when the edit occurred after the
1978+ requested window boundary. This design is intentional. The
1979+ protocol does not require servers to retain full revision history;
1980+ such functionality is delegated to the HISTREV extension
1981+ (%-future_optional_extensions-%). Clients MUST NOT interpret HIST
1982+ as reconstructing message state at an arbitrary past time. Rather,
1983+ HIST reconstructs the current editorial state within a historical
1984+ event window.
1985+
1986+ Each EDIT line in HIST MUST include rev=<n>, representing the
1987+ current revision number, and MUST also include cid=, consistent with
1988+ the live broadcast form. Reaction events (REACT and UNREACT) are
1989+ included so that clients can reconstruct complete reaction state
1990+ through ordered replay. These lines also carry cid=, as do PIN and
1991+ UNPIN events. A REACT followed by UNREACT from the same (uid, eid)
1992+ pair acting on the same mid cancels out logically, and clients are
1993+ expected to track only the net effect. All HIST event lines include
1994+ cid=, making them self-identifying and allowing clients to route
1995+ events correctly even when multiple HIST requests are pipelined.
1996+
1997+ The response terminates with:
1998+
1999+ OK verb=HIST cid=<cid> count=<n> has_more=0|1
2000+
2001+ The count field denotes the total number of event lines returned,
2002+ including MSG, EDIT, DEL, REACT, UNREACT, PIN, UNPIN, and THREAD.
2003+ enc=skdm lines (%-history_purge_e2ee_2b-%) are excluded from HIST
2004+ responses entirely and do not contribute to count=. The has_more
2005+ indicator signals whether additional events exist beyond the
2006+ returned window in the requested direction. Clients SHOULD rely on
2007+ this indicator for pagination rather than inferring completeness
2008+ from count, as event density may vary and some windows may be
2009+ reaction-dense. A value of has_more=0 indicates that the boundary
2010+ of available history has been reached. A value of has_more=1
2011+ indicates that additional events exist beyond the returned window.
2012+ For default (no anchor) and before= responses, has_more=1 indicates
2013+ that older history exists, while has_more=0 indicates that the
2014+ window includes the start of channel history.
2015+
2016+ %- messages_bodyformat = "Sect. 6.7" -%
2017+ 6.7 Body Format
2018+
2019+ The message body is encoded in accordance with
2020+ %-lineformat_payloadencoding-%. Its content follows the WIRE markup
2021+ rules defined in %-wire_markup-%. Servers are required to store and
2022+ relay message bodies verbatim, without transformation or
2023+ normalization.
2024+
2025+ Clients are responsible for decoding escape sequences prior to
2026+ applying any markup rendering. Rendering behavior itself is not
2027+ strictly prescribed; clients select an appropriate rendering depth
2028+ based on their conformance level and capabilities.
2029+
2030+ %- messages_ping = "Sect. 6.8" -%
2031+ 6.8 Mention Delivery (PING)
2032+
2033+ Following validation of mentions= and rolmentions= tags
2034+ (%-messages_sending-%), the server generates PING events for each
2035+ resolved target. These events are delivered out of band, meaning
2036+ they are sent directly to the target user’s active connection or
2037+ connections, independent of channel subscription state. Delivery of
2038+ PING events does not require channel membership validation at
2039+ delivery time; it is strictly driven by mention targeting.
2040+
2041+ A PING event delivered to a user has the following form:
2042+
2043+ PING mid=<mid> cid=<cid> [gid=<gid>] uid=<from-uid>
2044+ ts=<unix-ms> kind=<kind> [rolid=<rolid>] [count=<n>]
2045+
2046+ The gid= field is present for community channel PINGs and MUST be
2047+ omitted for direct-message channels, which do not belong to a
2048+ community (%-namespaces_resources-%). Clients MUST correctly handle
2049+ the absence of gid= and route events using cid= alone in such cases.
2050+
2051+ The kind field distinguishes between direct user mentions and
2052+ role-based mentions. When kind=user, the receiving uid was
2053+ explicitly listed in mentions=. When kind=role, one of the
2054+ recipient’s roles was listed in rolmentions=, and rolid= identifies
2055+ the triggering role. This includes the reserved roles 'everyone'
2056+ and 'here', which are not separate kinds but are represented as
2057+ kind=role with the corresponding reserved rolid. The server
2058+ resolves these roles to all eligible members and emits one PING per
2059+ member, consistent with user-defined roles.
2060+
2061+ The optional count= field is present only when multiple PING events
2062+ from the same sender to the same recipient, occurring within a
2063+ coalescing window, have been merged into a single delivery
2064+ (%-adversarial_behavior_4-%). The value represents the number of
2065+ individual PINGs coalesced. When absent, the implicit value is 1.
2066+ Clients SHOULD display count when greater than 1.
2067+
2068+ A single message may result in multiple PING events for the same
2069+ recipient when the user is mentioned both directly and via one or
2070+ more roles. Clients MUST deduplicate such events based on mid, so
2071+ that multiple PINGs referring to the same message produce a single
2072+ notification. In presentation, kind=user takes precedence over
2073+ kind=role. Clients MAY expose both forms in detailed views, but
2074+ MUST treat them as a single notification for badge counts and unread
2075+ indicators.
2076+
2077+ Receipt of a PING does not imply channel subscription. Clients that
2078+ require context MAY retrieve it explicitly, for example by issuing:
2079+
2080+ HIST cid=<cid> after=<mid> limit=1
2081+
2082+ Alternatively, the client may subscribe to the channel and then
2083+ fetch history through standard mechanisms.
2084+
2085+ In direct-message contexts, a PING of kind=user is always generated
2086+ for the recipient. No explicit mentions= tag is required, as the
2087+ server infers the target from the DM resource.
2088+
2089+ Servers MUST persist PING events that cannot be delivered
2090+ immediately to offline users. Upon reconnect, all pending PINGs are
2091+ delivered immediately after AUTH OK and before processing any
2092+ client-issued verbs. This ensures that clients can update unread
2093+ indicators prior to receiving channel event streams. Delivery order
2094+ is strictly oldest-first. Servers MAY expire undelivered PINGs
2095+ after a server-defined retention period, with a minimum of 30 days
2096+ RECOMMENDED.
2097+
2098+ A connected client MAY query pending PING events explicitly without
2099+ relying on reconnect delivery. The request form is:
2100+
2101+ PING op=list [gid=<gid>] [cid=<cid>] [after=<ts-unix-ms>]
2102+ [offset=<n>] [limit=<n>]
2103+
2104+ All filters are optional and may be combined. When gid= is present,
2105+ results are restricted to channels within that community, and
2106+ direct-message PINGs are excluded. When gid= is absent, PINGs from
2107+ all origins are returned. Default offset is 0. Default limit is
2108+ 50, with a maximum of 200; servers MUST enforce this upper bound.
2109+
2110+ The server responds with one PING event line per matching entry, in
2111+ oldest-first order, followed by:
2112+
2113+ OK verb=PING op=list count=<n> total=<n>
2114+
2115+ The count field reflects the number of entries in the current page,
2116+ while total reflects the total number of matching pending PINGs.
2117+ The op=list operation does not consume or mark PINGs as delivered.
2118+ Events returned by op=list are identical to those delivered on
2119+ reconnect, and clients MUST deduplicate them by mid.
2120+
2121+ Clients dismiss a PING by issuing:
2122+
2123+ PING op=dismiss mid=<mid>
2124+
2125+ The server removes the corresponding (uid, mid) entry from the
2126+ pending PING store. In accordance with the commit-before-OK
2127+ invariant (%-lineformat_commitbeforeok-%), this removal MUST occur
2128+ before the server emits the OK response. A subsequent PING op=list
2129+ issued immediately after the OK MUST NOT include the dismissed mid.
2130+
2131+ The originating session receives:
2132+
2133+ OK verb=PING op=dismiss mid=<mid>
2134+
2135+ All other active sessions for the same uid receive a broadcast:
2136+
2137+ PING op=dismiss mid=<mid> cid=<cid> uid=<uid> ts=<unix-ms>
2138+
2139+ The cid= field identifies the channel associated with the dismissed
2140+ PING and is included to allow correct routing without additional
2141+ lookup. Receiving sessions MUST remove the corresponding entry from
2142+ any displayed notification set. This mechanism provides
2143+ synchronization of notification state across multiple devices.
2144+
2145+ Dismissing a PING that is not present in the pending set, whether
2146+ due to prior delivery, consumption, or expiration, MUST succeed
2147+ silently and return OK. The operation does not mark the underlying
2148+ message as seen; advancing read state remains the responsibility of
2149+ READSTATE op=mark (%-messages_readstate-%).
2150+
2151+ The PING store is normatively keyed by (uid, mid). The cid
2152+ component is not part of this key. While %-identifiers-% requires
2153+ that client state keyed by mid also include cid for message content,
2154+ this rule does not apply to PING notification state. PING state is
2155+ scoped at the server level, where a given mid uniquely identifies a
2156+ message. Accordingly, clients MUST key their PING notification store
2157+ on mid alone within the context of the uid.
2158+
2159+ Clients that do not persist local state across sessions will receive
2160+ the same pending PINGs on each reconnect until those events are
2161+ explicitly dismissed. Such clients MUST issue PING op=dismiss for
2162+ each processed PING to prevent repeated delivery. This represents
2163+ the only case in the protocol where a stateless client is required
2164+ to write state back to the server in order to maintain correct
2165+ behavior. The operation is lightweight and safe to perform
2166+ unconditionally after display.
2167+
2168+ Mention storm protection is defined in %-adversarial_behavior-%.
2169+
2170+ %- messages_readstate = "Sect. 6.9" -%
2171+ 6.9 Read State (READSTATE)
2172+
2173+ Read state is maintained per (uid, cid, mid) tuple, with values of
2174+ unseen (0) or seen (1), and a default of unseen. The compound key
2175+ (cid, mid) is required because mid is only unique within a channel
2176+ (%-identifiers-%). All READSTATE operations therefore use (cid,
2177+ mid) as the storage key. This differs from PING state
2178+ (%-messages_ping-%), which is keyed solely by (uid, mid) due to its
2179+ server-scoped semantics.
2180+
2181+ Servers maintain authoritative seen-state, while clients report
2182+ transitions. READSTATE is the single verb governing all read-state
2183+ operations.
2184+
2185+ To mark an individual message as seen, the client issues:
2186+
2187+ READSTATE op=mark mid=<mid> cid=<cid>
2188+
2189+ The cid parameter is mandatory, as mid is not globally unique. The
2190+ server MUST use the (cid, mid) pair as the compound key when
2191+ recording seen state, consistent with the %-identifiers-% invariant.
2192+ If the specified mid is unknown to the server, with neither a live
2193+ record nor a DEL tombstone, the server MUST return ERR
2194+ code=NOTFOUND. If the message has been deleted but a DEL tombstone
2195+ exists, the operation MUST succeed and return OK. This allows
2196+ clients to clear unread indicators for messages removed prior to
2197+ viewing. The commit-before-OK invariant
2198+ (%-lineformat_commitbeforeok-%) applies.
2199+
2200+ The originating session receives:
2201+
2202+ OK verb=READSTATE op=mark mid=<mid> cid=<cid>
2203+
2204+ All other sessions of the same uid receive:
2205+
2206+ READSTATE op=mark mid=<mid> cid=<cid> uid=<uid> ts=<unix-ms>
2207+
2208+ The cid field ensures correct routing of the update within the
2209+ client’s read-state store.
2210+
2211+ Bulk marking is supported via:
2212+
2213+ READSTATE op=mark cid=<cid> upto=<mid>
2214+
2215+ This marks all messages in the specified channel with mid less than
2216+ or equal to the supplied value as seen. The server replies with OK
2217+ to the originating session and broadcasts the update to other
2218+ sessions of the same uid.
2219+
2220+ Clients may query read state at different granularities. For a
2221+ single channel:
2222+
2223+ READSTATE op=query cid=<cid>
2224+
2225+ The server responds with a summary including count of unseen
2226+ messages, and optionally oldest and cursor markers, followed by OK.
2227+ The count includes all message types in the channel. In forum-style
2228+ channels, this includes both thread openers and replies; therefore,
2229+ count may remain non-zero even if only visible thread roots have
2230+ been marked seen.
2231+
2232+ The oldest field identifies the earliest unseen message and MUST be
2233+ present when count is non-zero. The cursor field represents the
2234+ highest mid marked seen and is omitted if no messages have been
2235+ marked seen. Both fields follow the standard tag absence semantics
2236+ (%-lineformat_tagsemantics-%): missing tags indicate absence of
2237+ value, not empty strings, and the literal 'none' MUST NOT be used.
2238+
2239+ The cursor is durable across reconnects and server restarts, as it
2240+ is derived from persisted seen-state. Clients typically use cursor
2241+ as the after= parameter when issuing HIST following reconnect. The
2242+ absence of cursor has defined semantics. If cursor is absent and
2243+ count is zero, the channel is fully up to date. If cursor is absent
2244+ and count is non-zero, the channel is entirely unread.
2245+
2246+ READSTATE op=query gid=<gid> returns one line per channel in which
2247+ the user has read state, defined as either a non-zero unseen count
2248+ or a cursor. Channels with no read-state entries are not included.
2249+ As a result, clients MUST NOT infer channel existence from READSTATE
2250+ responses and MUST query channel listings separately via COMM
2251+ (%-identifiers-%).
2252+
2253+ Single-message queries are also supported:
2254+
2255+ READSTATE op=query mid=<mid> cid=<cid>
2256+
2257+ The server responds with seen=0 or seen=1, followed by OK. The cid
2258+ parameter is required for the same reason as in mark operations.
2259+ The server MUST return ERR code=NOTFOUND if the specified (cid, mid)
2260+ pair does not exist. Deleted messages with tombstones remain valid
2261+ targets for this query.
2262+
2263+ Reception of a PING does not alter read state. Messages are marked
2264+ seen only through explicit READSTATE operations initiated by the
2265+ client. Clients SHOULD mark messages as seen when they are
2266+ presented to the user.
2267+
2268+ READSTATE events are not included in HIST responses. Read state is
2269+ user-private data and MUST NOT be broadcast to channel subscribers.
2270+
2271+ Edits do not affect seen state. A message marked as seen remains so
2272+ after subsequent edits. Clients MAY choose to re-surface edited
2273+ messages for user attention, but such behavior is a presentation
2274+ concern rather than a protocol requirement.
2275+
2276+%- users_presence = "Sect. 7" -%
2277+========================================================================
2278+%-users_presence-% -- USERS AND PRESENCE
2279+========================================================================
2280+
2281+ 7.1 user info
2282+
2283+ WHO uid=<uid>
2284+ server replies:
2285+ PROFILE uid=<uid> name=<urlencoded-name> [avatar=<aid|https-url>]
2286+ status=<status> ts=<unix-ms>
2287+ OK verb=WHO uid=<uid>
2288+ or
2289+ ERR code=NOTFOUND :unknown uid
2290+
2291+ status values: online, idle, dnd, invisible, offline
2292+
2293+ full profile fetch (normative):
2294+ WHO returns only the auth-layer identity fields (name=, avatar=, status=).
2295+ For complete profile including social-layer fields, see %-section_7_4-% (full profile fetch).
2296+
2297+ invisible masking in PROFILE replies:
2298+ if the queried uid's real status is invisible, the server MUST
2299+ return status=offline in the PROFILE reply, EXCEPT when the
2300+ requesting uid is the same as the queried uid (a user may always
2301+ see his own real status). this is the same rule as for PRESENCE
2302+ broadcasts (%-section_7_2-%): invisible is never exposed to other users.
2303+
2304+ PROFILE is the unified verb for both the WHO reply and the live
2305+ broadcast pushed on IDENTIFY (%-section_4_5-%) and KEYROTATE (%-section_4_6-%). clients use PROFILE for all identity update handling regardless
2306+ of whether they requested it or received it unsolicited.
2307+
2308+ PROFILE routing (normative):
2309+ PROFILE is always server-sent; clients MUST NOT send PROFILE.
2310+ parsers route PROFILE lines as follows:
2311+ - if a WHO request is pending (terminal OK not yet received):
2312+ the PROFILE line is the WHO reply; route it to that request.
2313+ - if a PROFILE line carries prev_uid=: it is a KEYROTATE
2314+ broadcast. no WHO is pending. clients MUST update their uid
2315+ cache, merging old and new uid entries.
2316+ - otherwise: it is an IDENTIFY broadcast (a user updated their
2317+ display name or avatar). clients MUST update their uid cache.
2318+ a PROFILE line with prev_uid= and a pending WHO for the same uid
2319+ is a degenerate case; the KEYROTATE interpretation takes precedence
2320+ and the WHO response will follow as a separate line or not at all
2321+ (the uid may have changed). clients SHOULD re-issue WHO with the
2322+ new uid in this case.
2323+
2324+ 7.2 presence
2325+
2326+ PRESENCE status=online|idle|dnd|invisible
2327+
2328+ server broadcasts to clients subscribed to any channel shared with
2329+ this user:
2330+ PRESENCE uid=<uid> status=<status> ts=<unix-ms>
2331+
2332+ DM channel delivery scope (normative):
2333+ DM cid subscriptions qualify as "shared channels" for PRESENCE
2334+ broadcast delivery. a uid subscribed to a DM cid with another uid
2335+ receives PRESENCE broadcasts for that uid, regardless of whether
2336+ any community channel is shared between them. the DM channel
2337+ establishes the relationship; the subscription activates delivery.
2338+
2339+ PRESENCE verb directionality (normative):
2340+ parsers MUST use uid= presence to distinguish direction for the event
2341+ form: a client-sent PRESENCE event line carries only status=; a
2342+ server-broadcast PRESENCE event line carries uid= and ts= in addition
2343+ to status=. servers MUST NOT send a bare PRESENCE with no uid=; clients
2344+ MUST NOT send PRESENCE with uid=. this is the same normative rule used
2345+ by EDIT (%-section_6_4-%), DEL (%-section_6_5-%), PIN (%-section_11_4-%), and TYPING
2346+ (%-section_7_3-%). the query form (op=list) and its reply form (op=info) are
2347+ exempt from this rule and are defined below.
2348+
2349+ servers MUST NOT send a reply (OK or ERR) to a valid PRESENCE event
2350+ line (status=); PRESENCE events are fire-and-forget. clients MUST NOT
2351+ wait for confirmation before considering the status change sent. the
2352+ op=list query form is not an event and does receive an OK reply
2353+ (see below).
2354+
2355+ presence state is ephemeral and connection-scoped:
2356+ - servers MUST NOT persist presence state to durable storage.
2357+ - on disconnect, server broadcasts PRESENCE uid=<uid> status=offline
2358+ to affected subscribers and discards the entry.
2359+ - the server-side presence store is a memory-only map from uid to
2360+ current status, written on PRESENCE verb, deleted on disconnect.
2361+ - servers MUST NOT attempt to restore presence state across restarts.
2362+ a restarting server has no presence state; clients reconnect and
2363+ re-declare their status.
2364+ - after AUTH OK on reconnect, a client's presence entry is empty:
2365+ the server has no record of the client's prior status. clients
2366+ that wish to maintain a non-offline presence status after
2367+ reconnection MUST re-send PRESENCE status=<status> after AUTH OK.
2368+ a client that does not re-send PRESENCE after reconnect will
2369+ appear offline to all other users until it does so. this is not
2370+ an error; it is the correct behaviour of an ephemeral,
2371+ connection-scoped store.
2372+
2373+ invisible status normative rules:
2374+ - when a client sends PRESENCE status=invisible, the server MUST
2375+ record the real status as invisible internally for this session.
2376+ - the server MUST immediately broadcast PRESENCE uid=<uid>
2377+ status=offline to all other subscribers (those sharing a channel
2378+ with this uid). the uid appears offline to all other users.
2379+ - the server MUST NOT transmit status=invisible to any connection
2380+ other than the owning uid's own sessions (e.g. multi-device sync).
2381+ - on disconnect of an invisible session, no additional broadcast is
2382+ sent (the user was already seen as offline).
2383+ - the 'here' built-in role (%-section_8_2-%) MUST treat invisible
2384+ members as offline at PING fan-out resolution time. an invisible
2385+ uid MUST NOT receive a PING via kind=role with rolid=here.
2386+
2387+ querying current presence (normative):
2388+
2389+ PRESENCE op=list gid=<gid>
2390+
2391+ returns the current non-offline status of all members of <gid>
2392+ who have an active presence entry in the server's presence store.
2393+ members with no entry or effective status=offline are omitted.
2394+
2395+ server replies with one line per qualifying member, then OK:
2396+ PRESENCE op=info uid=<uid> status=<status> ts=<unix-ms>
2397+ ...
2398+ OK verb=PRESENCE op=list gid=<gid> count=<n> total=<n>
2399+
2400+ status values in op=info lines: online, idle, dnd. invisible
2401+ masking applies: members with status=invisible are omitted (they
2402+ appear offline to all other uids; same rule as broadcast masking
2403+ above). ts= is the unix-ms of the most recent PRESENCE event
2404+ received from that uid on the current connection. count= and
2405+ total= are always equal (no pagination; the set is bounded by
2406+ non-offline active connections to the community).
2407+
2408+ the result is best-effort: members may transition while the
2409+ reply is in flight. clients MUST treat it as advisory, consistent
2410+ with TYPING op=list (%-section_7_3-%).
2411+
2412+ servers MUST reject PRESENCE op=list with no gid= with
2413+ ERR code=BADARG. servers MUST reject PRESENCE op=list for a
2414+ gid= the requesting uid is not a member of with ERR code=NOPERM.
2415+
2416+ this query closes the bulk-read gap for PRESENCE state: WHO
2417+ (%-section_7_1-%) is per-uid; op=list provides the reconnect snapshot
2418+ needed to initialize presence state for community members without
2419+ O(N) individual WHO requests. the best-effort caveat is
2420+ acknowledged (see %-section_24-% rationale).
2421+
2422+ 7.3 typing indicator
2423+
2424+ TYPING cid=<cid> state=start|stop
2425+
2426+ server broadcasts:
2427+ TYPING cid=<cid> uid=<uid> state=start|stop ts=<unix-ms>
2428+
2429+ TYPING verb directionality (normative):
2430+ parsers MUST use uid= presence to distinguish direction for the event
2431+ form: a client-sent TYPING event line carries cid= and state= only; a
2432+ server-broadcast TYPING event line carries uid= and ts= in addition.
2433+ servers MUST NOT send a bare TYPING with no uid=; clients MUST NOT send
2434+ TYPING with uid=. this is the same normative rule used by EDIT
2435+ (%-section_6_4-%), DEL (%-section_6_5-%), PIN (%-section_11_4-%), and PRESENCE (%-section_7_2-%).
2436+ the query form (op=list) and its reply form (op=info) are exempt from
2437+ this rule and are defined below.
2438+
2439+ clients SHOULD debounce; send stop after 3 seconds of inactivity.
2440+ clients SHOULD send stop before disconnecting.
2441+ servers MUST NOT persist typing events.
2442+ servers MUST NOT include typing events in HIST responses.
2443+ servers MUST NOT send a reply (OK or ERR) to a valid TYPING event line
2444+ (state=start|stop); TYPING events are fire-and-forget. clients MUST NOT
2445+ wait for confirmation before considering the state change sent. the
2446+ op=list query form is not an event and does receive an OK reply
2447+ (see below).
2448+
2449+ typing state store:
2450+ servers MUST maintain a memory-only per-cid typing state map:
2451+ { cid -> { uid -> last_active_ts } }
2452+ entries are added on state=start. entries are removed on state=stop
2453+ or when the client disconnects. entries expire implicitly after the
2454+ server-defined inactivity threshold (RECOMMENDED: 5 seconds). this
2455+ store is the same pattern as PresenceStore (IMPL.md Sect. 23.5): never
2456+ flushed to disk, empty on server restart.
2457+
2458+ querying current typing state:
2459+
2460+ TYPING op=list cid=<cid>
2461+
2462+ returns the currently-typing uids in cid as a best-effort snapshot.
2463+ the result may be stale by the time the OK arrives (typing state
2464+ expires within seconds). clients MUST treat this as advisory only.
2465+
2466+ server replies with one line per currently-typing uid, then OK:
2467+ TYPING op=info cid=<cid> uid=<uid> ts=<unix-ms>
2468+ ...
2469+ OK verb=TYPING op=list cid=<cid> count=<n> total=<n>
2470+
2471+ ts= is the unix-ms of the most recent state=start received from
2472+ that uid on this cid. count= and total= are always equal (no
2473+ pagination; set is bounded by active channel subscribers per %-section_2_6-%).
2474+
2475+ this query closes the "no hidden state" design invariant for
2476+ TYPING: typing state had a write path (state=start) but no read
2477+ path at all. PRESENCE had a per-uid read path (WHO) but no bulk
2478+ query; PRESENCE op=list (%-section_7_2-%) closes that analogous gap.
2479+ the best-effort caveat is acknowledged (see %-section_24-% rationale).
2480+
2481+ 7.4 user profile decoration
2482+
2483+ cap: kval. user profile decoration is served by the KVAL verb
2484+ (%-section_7_5-%) under scope=user. see %-section_7_5-% for all wire mechanics,
2485+ op= values, broadcast rules, and error semantics.
2486+
2487+ IDENTIFY (%-section_4_5-%) is the auth-layer verb that sets name= and
2488+ avatar=. KVAL scope=user is the separate, orthogonal social-layer
2489+ decoration: bio, banner image, accent color, and custom status text.
2490+ the two layers share no fields and are queried independently.
2491+
2492+ defined keys for scope=user:
2493+
2494+ bio -- wire markup (\n-encoded per %-section_2_4-%). the user's
2495+ freeform description. max 512 chars decoded. links
2496+ via [label](url) inline. clients render at L2+.
2497+ banner -- aid or https url (plain text). profile header image.
2498+ same format rules as community brand banner (%-section_7_5-%).
2499+ color -- 6-digit hex, no '#' prefix (e.g. '3b82f6'). accent
2500+ color used by clients for profile decoration.
2501+ status_text -- plain text, 1-128 chars decoded. custom status message
2502+ displayed alongside presence status= (%-section_7_2-%).
2503+ distinct from presence status=; the status= field
2504+ carries the machine-readable state (online, idle, dnd,
2505+ invisible, offline); status_text= is freeform display.
2506+
2507+ permission rule for scope=user: a uid MAY only set keys on its own
2508+ record. servers MUST reject KVAL op=set scope=user id=<other-uid>
2509+ from a uid that is not <other-uid> with ERR code=NOPERM, unless the
2510+ requesting uid holds server.admin. any authenticated uid may read
2511+ any uid's scope=user record (same as WHO; no read restriction).
2512+
2513+ broadcast delivery for scope=user: server broadcasts the KVAL op=info
2514+ event to all connections sharing at least one channel (community or
2515+ DM) with the subject uid. this mirrors the PRESENCE delivery scope
2516+ (%-section_7_2-%): the shared-channel relationship activates delivery.
2517+
2518+ full profile fetch (normative):
2519+ WHO returns only the auth-layer fields (name=, avatar=, status=).
2520+ KVAL op=get scope=user id=<uid> returns the social-layer fields.
2521+ a client rendering a complete user profile card MUST issue both:
2522+ WHO uid=<uid>
2523+ KVAL op=get scope=user id=<uid>
2524+ these may be pipelined; they produce responses on different verbs
2525+ (PROFILE and KVAL respectively) and are self-routing. servers
2526+ without cap=kval return ERR code=UNKNOWN on KVAL; clients MUST
2527+ treat this as an absent social-layer profile and render with
2528+ auth-layer fields only.
2529+
2530+ 7.5 KVAL -- scoped key-value store
2531+
2532+ KVAL is a generic, scope-typed key-value store primitive. it provides
2533+ one uniform verb for all named-field stores in the protocol, replacing
2534+ per-entity ad-hoc key/value designs. the scope= tag identifies which
2535+ store is addressed; the id= tag identifies the specific entity within
2536+ that scope. op= selects the operation. the key= and payload carry the
2537+ field name and value.
2538+
2539+ KVAL is governed by cap=kval. servers not advertising kval MUST
2540+ return ERR code=UNKNOWN on all KVAL lines. clients MUST check cap=kval
2541+ before sending any KVAL line.
2542+
2543+ 7.5a grammar
2544+
2545+ client sends (mutating):
2546+ KVAL op=set scope=<scope> id=<id> key=<key> :value
2547+ KVAL op=del scope=<scope> id=<id> key=<key>
2548+
2549+ client sends (query):
2550+ KVAL op=get scope=<scope> id=<id> [key=<key>]
2551+
2552+ server sends (response / event):
2553+ KVAL op=info scope=<scope> id=<id> key=<key> ts=<unix-ms> :value
2554+ KVAL op=del scope=<scope> id=<id> key=<key> ts=<unix-ms>
2555+
2556+ terminal OKs:
2557+ OK verb=KVAL op=set scope=<scope> id=<id> key=<key>
2558+ OK verb=KVAL op=del scope=<scope> id=<id> key=<key>
2559+ OK verb=KVAL op=get scope=<scope> id=<id> [key=<key>]
2560+ [count=<n> total=<n>] (key=-absent form only)
2561+
2562+ scope -- one of the defined scope names (%-section_7_5b-%). unknown scopes
2563+ MUST be rejected with ERR code=BADARG :unknown kval scope.
2564+ id -- the entity id within the scope. MUST be a valid id for
2565+ that scope type (uid for scope=user; gid for scope=comm).
2566+ servers MUST reject a mismatched id type with ERR
2567+ code=BADARG :id type mismatch for scope.
2568+ key -- one of the defined keys for this scope (%-section_7_5b-%). unknown
2569+ keys MUST be rejected with ERR code=BADARG :unknown key
2570+ for scope. key names use [a-z_], 1-32 chars.
2571+ value -- payload, encoded per %-section_2_4-%. format constraints
2572+ are per-key and listed in %-section_7_5b-%.
2573+
2574+ KVAL verb directionality (normative):
2575+ parsers MUST use op= value to distinguish direction:
2576+ client-sent: op=set, op=get, op=del
2577+ server-sent: op=info, op=del (broadcast of a deletion)
2578+ op=del appears in both directions with different tag sets:
2579+ client-sent op=del: scope=, id=, key= only; no ts=.
2580+ server-sent op=del: scope=, id=, key=, ts= (the commit time).
2581+ servers MUST NOT send op=set or op=get. clients MUST NOT send
2582+ op=info. servers MUST reject client-sent KVAL op=info with
2583+ ERR code=BADARG. the ts= tag is the reliable discriminant for
2584+ op=del direction: its presence means server-sent, its absence
2585+ means client-sent.
2586+
2587+ 7.5b scope registry
2588+
2589+ the following scopes are defined. each entry specifies: the scope
2590+ name, the id type, the valid key set with value constraints, the
2591+ write permission rule, and the broadcast delivery scope.
2592+
2593+ scope=user
2594+ id type: uid
2595+ keys: see %-section_7_4-% (bio, banner, color, status_text)
2596+ write: uid may only set/del its own record (id == own uid).
2597+ server.admin may set/del any uid's record.
2598+ read: any authenticated uid. no restriction.
2599+ broadcast: all connections sharing at least one channel
2600+ (community or DM) with the subject uid.
2601+ key limit: 4 defined keys maximum.
2602+
2603+ scope=comm
2604+ id type: gid
2605+ keys: see %-section_8_6-% (description, icon, banner, color, rules)
2606+ write: requires channel.manage or community.admin on the gid.
2607+ servers MUST reject KVAL op=set scope=comm key=name; the
2608+ display name is owned by COMM op=update (%-section_8_0-%).
2609+ read: any member of the community. non-members of private
2610+ communities receive ERR code=NOPERM.
2611+ broadcast: all members of the community (gid).
2612+ key limit: 5 defined keys maximum.
2613+
2614+ scope=mute
2615+ id type: uid
2616+ keys: mute_channels (comma-separated cids where notification
2617+ sounds/alerts are suppressed but info
2618+ remains visible in UI)
2619+ mute_users (comma-separated uids whose messages
2620+ never trigger notification sounds/alerts
2621+ but remain visible in UI)
2622+ mute_roles (comma-separated rolids: if a message
2623+ sender holds any listed role, suppress
2624+ notification sound/alert but remain visible)
2625+ ignore_channels (comma-separated cids to hide from UI
2626+ completely; PINGs are generated but not
2627+ rendered)
2628+ ignore_users (comma-separated uids to hide from UI
2629+ completely; messages are received but not
2630+ rendered)
2631+ ignore_roles (comma-separated rolids: hide messages
2632+ from senders holding any listed role)
2633+ write: uid may only set/del its own record (id == own uid).
2634+ server.admin may set/del any uid's record.
2635+ read: self only. non-self reads MUST return ERR code=NOPERM.
2636+ broadcast: all sessions of the subject uid (multi-device sync).
2637+ KVAL op=info for scope=mute MUST NOT broadcast to
2638+ connections of other uids.
2639+ key limit: 6 defined keys maximum.
2640+
2641+ adding a new scope in a future revision requires only a new entry
2642+ in this registry and a new id type binding. no verb changes are
2643+ needed. unknown scopes are rejected at the server; forward-compat
2644+ clients that check cap=kval before sending are unaffected.
2645+
2646+ 7.5c op=set
2647+
2648+ KVAL op=set scope=<scope> id=<id> key=<key> :value
2649+
2650+ sets or overwrites the value for key on the identified entity.
2651+ commit-before-OK invariant (%-section_2_6-%) applies: the server MUST commit
2652+ the new value before sending OK.
2653+
2654+ server replies to the originating client:
2655+ OK verb=KVAL op=set scope=<scope> id=<id> key=<key>
2656+
2657+ server broadcasts to the delivery scope for this scope (%-section_7_5b-%):
2658+ KVAL op=info scope=<scope> id=<id> key=<key> ts=<unix-ms> :value
2659+
2660+ ts= is the unix-ms at which the server committed the set.
2661+
2662+ 7.5d op=del
2663+
2664+ KVAL op=del scope=<scope> id=<id> key=<key>
2665+
2666+ deletes a key from the identified entity's record. after deletion,
2667+ KVAL op=get for that key returns ERR code=NOTFOUND.
2668+
2669+ server replies to the originating client:
2670+ OK verb=KVAL op=del scope=<scope> id=<id> key=<key>
2671+
2672+ server broadcasts to the delivery scope for this scope (%-section_7_5b-%):
2673+ KVAL op=del scope=<scope> id=<id> key=<key> ts=<unix-ms>
2674+
2675+ deleting a key that does not exist MUST return OK silently (no
2676+ broadcast). this is idempotent: a client that retries a del after
2677+ a dropped connection will not cause a spurious broadcast.
2678+
2679+ 7.5e op=get
2680+
2681+ KVAL op=get scope=<scope> id=<id> [key=<key>]
2682+
2683+ queries the current value(s) for the identified entity.
2684+
2685+ single-key form (key= present):
2686+ server replies with one KVAL op=info line, then OK:
2687+ KVAL op=info scope=<scope> id=<id> key=<key> ts=<unix-ms> :value
2688+ OK verb=KVAL op=get scope=<scope> id=<id> key=<key>
2689+ or, if the key has not been set:
2690+ ERR code=NOTFOUND :key not set
2691+
2692+ all-keys form (key= absent):
2693+ server replies with one KVAL op=info line per set key, then OK:
2694+ KVAL op=info scope=<scope> id=<id> key=<key> ts=<unix-ms> :value
2695+ ...
2696+ OK verb=KVAL op=get scope=<scope> id=<id> count=<n> total=<n>
2697+ count and total are always equal; the key set is bounded by the
2698+ scope's key limit (%-section_7_5b-%). if no keys have been set, the server
2699+ returns zero KVAL op=info lines then OK count=0 total=0.
2700+ ERR code=NOTFOUND is NOT returned for the all-keys form; it is
2701+ reserved for the single-key form only.
2702+
2703+ KVAL op=info is listed in %-section_2_5-% as a routable response verb.
2704+ parsers route KVAL op=info lines via pending-request state.
2705+ the (scope, id) pair in each line makes all-keys responses
2706+ self-routing when multiple KVAL op=get requests are pipelined
2707+ for different (scope, id) pairs.
2708+
2709+ 7.5f pipelining
2710+
2711+ KVAL op=get requests for different (scope, id) pairs MAY be
2712+ pipelined. each KVAL op=info response line carries both scope=
2713+ and id=, making it self-routing without pending-request state.
2714+ the same-resource pipeline prohibition (%-section_2_5-%) applies at the
2715+ (scope, id) granularity: clients MUST NOT pipeline two KVAL
2716+ op=get requests for the same (scope, id) pair.
2717+
2718+ 7.5g ts= semantics
2719+
2720+ KVAL follows the %-section_2_6-% ts= invariant unconditionally. ts= in KVAL
2721+ op=info response lines is the unix-ms of the most recent op=set
2722+ that produced the current value (last-modified time). ts= in KVAL
2723+ op=del broadcast lines is the unix-ms of the commit that deleted
2724+ the key (event commit time). no deviation from %-section_2_6-% exists for
2725+ KVAL; this subsection is a convenience pointer only.
2726+
2727+ 7.5h scope=mute client enforcement (normative)
2728+
2729+ scope=mute stores user notification preferences. servers do NOT
2730+ suppress PING generation or message delivery; all PINGs and messages
2731+ are sent normally. clients MUST fetch and cache all mute/ignore keys
2732+ on startup (KVAL op=get scope=mute id=<self-uid>). clients MUST NOT
2733+ request scope=mute for other uids; servers MUST reject with ERR
2734+ code=NOPERM.
2735+
2736+ when a client processes a PING or message, it MUST check mute and
2737+ ignore conditions:
2738+ - if the message's cid is in mute:channels, suppress notification
2739+ sound and desktop alert
2740+ - if the message's sender uid is in mute:users, suppress notification
2741+ sound and desktop alert
2742+ - if the sender's roles include any rolid in mute:roles, suppress
2743+ notification sound and desktop alert
2744+ - if the message's cid is in ignore:channels, mark as ignored
2745+ - if the message's sender uid is in ignore:users, mark as ignored
2746+ - if the sender's roles include any rolid in ignore:roles, mark as
2747+ ignored
2748+
2749+ mute preference behavior is normative: suppress all notification sounds
2750+ and desktop alerts. message rendering is client-side presentation.
2751+
2752+ ignore preference behavior is client-side presentation only. servers
2753+ do not enforce ignore state. clients MAY render ignored messages
2754+ collapsed (e.g. "<N> ignored message<s>"), expandable on click, or
2755+ apply any other presentation choice. ignored messages remain fully
2756+ received and available; the client chooses how to display them.
2757+
2758+ mute/ignore preference changes (KVAL op=info and op=del broadcasts)
2759+ take effect immediately on all devices when received. a client
2760+ receiving scope=mute broadcast updates MUST refresh its mute/ignore
2761+ cache and apply the new preferences to subsequent PINGs.
2762+
2763+%- community_management = "Sect. 8" -%
2764+========================================================================
2765+%-community_management-% -- COMMUNITY MANAGEMENT
2766+========================================================================
2767+
2768+ community management uses the COMM verb with an op= tag for all
2769+ operations: lifecycle (creation, deletion, joining, leaving, discovery)
2770+ and administration (roles, members, channels, branding). this keeps
2771+ the surface orthogonal: one verb, one tag selects the operation.
2772+
2773+ 8.0 community lifecycle
2774+
2775+ a community does not exist until COMM op=create is issued on the
2776+ wire. there is no out-of-band creation path. servers MUST NOT
2777+ require an HTTP API, dashboard, or other side-channel to bring a
2778+ community into existence. the protocol is self-contained.
2779+
2780+ this mirrors the IRC /JOIN-to-create idiom but makes the semantics
2781+ explicit and separates creation from subscription (SUB), which is
2782+ a channel-level concept.
2783+
2784+ COMM op=create name=<urlencoded-name> [slug=<slug>] [open=0|1]
2785+
2786+ name -- display name; percent-encoded; 1-64 chars decoded.
2787+ slug -- community slug; see %-section_3-% for grammar and
2788+ derivation rules. [a-z0-9_-], 1-32 chars.
2789+ if omitted, server derives slug from name (%-section_3-%).
2790+ server MUST reject a duplicate slug with ERR code=CONFLICT.
2791+ open -- 1: any authenticated user may COMM op=join without a
2792+ code. 0 (default): join requires a valid invite code.
2793+
2794+ server replies:
2795+ OK verb=COMM op=create gid=<gid> slug=<slug>
2796+ default_cid=<cid> default_slug=<slug>
2797+
2798+ gid -- the new community's ULID.
2799+ slug -- the canonical community slug (server-derived if
2800+ not supplied).
2801+ default_cid -- cid of the auto-created general channel.
2802+ default_slug -- slug of the auto-created general channel. clients
2803+ can immediately construct the channel's full rid:
2804+ /<hostname>/<community-slug>/<default_slug>
2805+
2806+ the creating user is automatically a member and is assigned the
2807+ built-in 'owner' role (%-section_8_0a-%). no further COMM op=join
2808+ is needed. the server MUST atomically create a default general
2809+ channel of type=text and MUST assign it to this community.
2810+
2811+ server broadcasts to all sessions of the creating uid:
2812+ MEMBER op=join gid=<gid> uid=<uid> ts=<unix-ms> role=owner
2813+
2814+ COMM op=delete gid=<gid> :reason
2815+
2816+ permanently deletes the community, all its channels, messages,
2817+ roles, member records, and KVAL scope=comm keys for the gid.
2818+ reason payload is required.
2819+
2820+ requires owner role. servers MUST reject with ERR code=NOPERM
2821+ if the requesting uid does not hold the owner role.
2822+
2823+ server replies to the acting uid FIRST:
2824+ OK verb=COMM op=delete gid=<gid>
2825+
2826+ after sending OK, the server broadcasts to all current members
2827+ including the acting uid:
2828+ MEMBER op=delete gid=<gid> by=<acting-uid> ts=<unix-ms> :reason
2829+
2830+ the acting uid receives both the OK and the MEMBER op=delete
2831+ broadcast; broadcasts to the acting uid are not suppressed. this
2832+ follows the universal OK-before-broadcast rule (see MSG, EDIT, DEL).
2833+
2834+ after broadcasting, the server closes all active subscriptions
2835+ on channels belonging to gid and removes all member records.
2836+ the gid is retired; servers MUST NOT reuse it.
2837+
2838+ in-flight queries during COMM op=delete (normative):
2839+ if a HIST query is in progress on a channel belonging to the
2840+ gid at the time op=delete is processed, the server MUST
2841+ complete the HIST response (emit all buffered event lines and
2842+ the terminal OK) before closing the subscription. the client
2843+ will subsequently receive MEMBER op=delete and have its
2844+ subscription dropped. servers MUST NOT terminate a HIST stream
2845+ mid-response with an ERR due to concurrent community deletion.
2846+
2847+ COMM op=join gid=<gid> [code=<invitecode>]
2848+
2849+ joins the authenticated user to the community.
2850+
2851+ if open=1 was set at creation time, code MAY be omitted.
2852+ if open=0, code is required; servers MUST reject without a valid
2853+ code with ERR code=NOPERM :invite required.
2854+
2855+ code is validated for expiry, use-count, and membership uniqueness.
2856+ COMM op=join with a code is the sole canonical form of invite
2857+ redemption.
2858+
2859+ server replies:
2860+ OK verb=COMM op=join gid=<gid>
2861+
2862+ server broadcasts to all current members:
2863+ MEMBER op=join gid=<gid> uid=<uid> ts=<unix-ms>
2864+
2865+ the joining user is NOT automatically subscribed to any channel.
2866+ he must issue SUB for each channel he wishes to receive messages
2867+ from. membership and subscription are orthogonal: membership
2868+ governs permissions; subscription governs event delivery.
2869+
2870+ note: after joining, a client that wishes to find available
2871+ channels MUST issue COMM op=channel.list gid=<gid> (%-section_8_4-%).
2872+ the server does not push channel information automatically on join.
2873+
2874+ COMM op=leave gid=<gid>
2875+
2876+ the authenticated user voluntarily leaves the community.
2877+ an owner MUST NOT leave if he is the sole remaining owner; server
2878+ rejects with ERR code=FORBIDDEN :transfer or delete community first.
2879+
2880+ server replies:
2881+ OK verb=COMM op=leave gid=<gid>
2882+
2883+ server broadcasts to all remaining members:
2884+ MEMBER op=leave gid=<gid> uid=<uid> ts=<unix-ms>
2885+
2886+ all active subscriptions the leaving user holds on channels
2887+ belonging to gid are silently dropped by the server.
2888+
2889+ COMM op=update gid=<gid> [name=<urlencoded-name>] [open=0|1]
2890+
2891+ updates the community display name, open/invite-only flag, or
2892+ both. at least one of name= or open= MUST be present; servers
2893+ MUST reject a COMM op=update with neither with ERR code=BADARG.
2894+ requires community.admin or owner role.
2895+
2896+ name -- new display name; percent-encoded; 1-64 chars decoded.
2897+ the community slug is NOT changed by a name update.
2898+ slugs are stable identifiers (%-section_3-%).
2899+ open -- 1: any authenticated user may join without a code.
2900+ 0: join requires a valid invite code.
2901+
2902+ server replies:
2903+ OK verb=COMM op=update gid=<gid>
2904+
2905+ server broadcasts to all current members:
2906+ COMM op=update gid=<gid> uid=<acting-uid>
2907+ [name=<urlencoded-name>] [open=0|1] ts=<unix-ms>
2908+
2909+ COMM op=update verb directionality (normative):
2910+ parsers MUST use uid= presence to distinguish direction: a client-
2911+ sent COMM op=update line carries gid= and the update tags (name=
2912+ and/or open=) but NO uid=; a server-broadcast COMM op=update line
2913+ carries uid= and ts= in addition. servers MUST NOT broadcast COMM
2914+ op=update without uid=; clients MUST NOT send COMM op=update with
2915+ uid=. note: COMM op=update is the only community entity operation
2916+ where the management verb (COMM) also serves as the broadcast verb.
2917+ this is an intentional design exception documented in %-section_24-%; the uid=
2918+ discriminant makes the direction unambiguous without a separate verb.
2919+
2920+ servers MUST include in the broadcast ONLY the tags that were
2921+ present in the send form (name= and/or open=); tags not submitted
2922+ in the send form MUST be omitted from the broadcast. clients MUST
2923+ update their local community cache on receiving this broadcast.
2924+ a tag absent from the broadcast means that field is unchanged;
2925+ clients MUST NOT clear or reset fields for absent tags.
2926+
2927+ note on branding vs. display name: COMM op=update name= is the
2928+ canonical community display name used in COMM op=info and membership
2929+ flows. key=name is not valid in KVAL scope=comm (see %-section_8_6-%).
2930+ there is exactly one source of truth for the community display name.
2931+
2932+ COMM op=list [offset=<n>] [limit=<n>] [q=<urlencoded-query>]
2933+
2934+ enumerates all communities hosted on this server that match:
2935+ - open communities (open=1), or
2936+ - communities of which the requesting uid is a member.
2937+ all matching communities are included in the result (paginated).
2938+
2939+ offset default: 0. limit default: 20. limit max: 100.
2940+ servers MUST clamp limit to 100.
2941+ q is an optional server-side substring filter on community name
2942+ and slug.
2943+
2944+ server replies with one COMM line per result:
2945+ COMM op=info gid=<gid> slug=<slug> name=<urlencoded-name>
2946+ open=0|1 members=<n> owner=<uid> ts=<unix-ms>
2947+ ...
2948+ OK verb=COMM op=list count=<n> total=<n>
2949+
2950+ count: number of lines in this page.
2951+ total: total matching communities across all pages (for pagination).
2952+
2953+ COMM op=info gid=<gid>
2954+
2955+ queries metadata for one community. any authenticated user may
2956+ query an open community; private communities return ERR
2957+ code=NOPERM unless the requester is a member.
2958+
2959+ server replies:
2960+ COMM op=info gid=<gid> slug=<slug> name=<urlencoded-name>
2961+ open=0|1 members=<n> owner=<uid> ts=<unix-ms>
2962+ OK verb=COMM op=info gid=<gid>
2963+
2964+ 8.0a built-in role: owner
2965+
2966+ in addition to 'everyone' and 'here' (%-section_8_2-%), each community
2967+ has a third immutable built-in role with the reserved literal rolid
2968+ 'owner'.
2969+
2970+ 'owner' -- held by the community creator and any uid to whom
2971+ ownership has been explicitly transferred. there MUST
2972+ be exactly one owner at all times; the server enforces
2973+ this invariant on all role.assign rolid=owner,
2974+ COMM op=leave, and COMM op=role.revoke operations.
2975+
2976+ the owner role implies ALL permissions unconditionally,
2977+ including community.admin. it cannot be dropped from a
2978+ permission resolution by any deny.
2979+
2980+ ownership transfer:
2981+ COMM op=role.assign gid=<gid> uid=<target-uid>
2982+ rolid=owner
2983+ transfers ownership to target-uid. the acting uid
2984+ MUST currently hold owner. the server atomically
2985+ assigns owner to target-uid and revokes it from the
2986+ acting uid; there is never a moment with two owners or
2987+ zero owners. servers MUST reject if the acting uid
2988+ does not hold owner with ERR code=NOPERM.
2989+
2990+ 'owner' appears in COMM op=role.list responses.
2991+ it cannot be renamed, deleted, or have its permissions
2992+ modified. COMM ops targeting it for those purposes
2993+ MUST be rejected with ERR code=BADARG.
2994+
2995+ 8.0b MEMBER broadcast
2996+
2997+ MEMBER is the community membership event verb. it is sent to all
2998+ current members of a community whenever membership changes.
2999+
3000+ MEMBER op=join gid=<gid> uid=<uid> ts=<unix-ms> [role=<rolid>]
3001+ -- a user joined (voluntarily or via invite).
3002+ role is present only for op=join at creation time (role=owner).
3003+
3004+ MEMBER op=leave gid=<gid> uid=<uid> ts=<unix-ms>
3005+ -- a user left voluntarily (COMM op=leave).
3006+
3007+ MEMBER op=kick gid=<gid> uid=<uid> by=<acting-uid> ts=<unix-ms> :reason
3008+ -- a user was kicked (COMM op=member.kick; %-section_8_3-%).
3009+ by= carries the moderator's uid. reason payload is the
3010+ stated reason, relayed verbatim from the COMM op=member.kick.
3011+
3012+ MEMBER op=ban gid=<gid> uid=<uid> by=<acting-uid>
3013+ dur=<seconds> ts=<unix-ms> :reason
3014+ -- a user was banned (COMM op=member.ban; %-section_8_3-%).
3015+ dur=0 means permanent. reason payload is the stated reason.
3016+
3017+ MEMBER op=timeout gid=<gid> uid=<uid> by=<acting-uid>
3018+ dur=<seconds> ts=<unix-ms> :reason
3019+ -- a user was timed out (COMM op=member.timeout; %-section_8_3-%).
3020+ reason payload is the stated reason.
3021+
3022+ MEMBER op=unban gid=<gid> uid=<uid> by=<acting-uid> ts=<unix-ms>
3023+ -- a ban was lifted (COMM op=member.unban; %-section_8_3-%).
3024+
3025+ MEMBER op=untimeout gid=<gid> uid=<uid> by=<acting-uid> ts=<unix-ms>
3026+ -- an active timeout was explicitly lifted before natural expiry
3027+ (COMM op=member.untimeout; %-section_8_3-%). no payload: an
3028+ explicit lift carries no required reason.
3029+
3030+ MEMBER op=delete gid=<gid> by=<acting-uid> ts=<unix-ms> :reason
3031+ -- the community itself was deleted (COMM op=delete).
3032+ by= carries the uid of the owner who issued the delete.
3033+
3034+ MEMBER event delivery scope (normative):
3035+ MEMBER events are delivered only to connections with at least one
3036+ active channel subscription in the gid. a uid that is a community
3037+ member but has zero active channel subscriptions in that community
3038+ will NOT receive live MEMBER events (op=join, op=leave, op=kick,
3039+ op=ban, op=timeout, op=unban, op=untimeout, op=delete) for that
3040+ community. this is a fundamental invariant of the protocol:
3041+ subscription scope equals event delivery scope.
3042+
3043+ dormant members (no active subscriptions) MUST recover membership
3044+ state by querying on reconnect. the normative recovery sequence:
3045+ 1. COMM op=info gid=<gid> -- verify community exists;
3046+ ERR code=NOTFOUND if deleted.
3047+ 2. COMM op=member.list gid=<gid> -- full current member set with roles,
3048+ banned_until=, timeout_until=
3049+ 3. COMM op=role.list gid=<gid> -- current role definitions
3050+
3051+ if COMM op=info returns ERR code=NOPERM, the community became
3052+ private and the member was removed; clients MUST purge local state
3053+ for the gid. if COMM op=info returns OK, the member's presence in
3054+ COMM op=member.list confirms current membership; absence indicates
3055+ the member was kicked (removed with no ongoing record).
3056+ clients MUST NOT assume cached membership state is current without
3057+ verifying via query after any period of zero subscriptions in a
3058+ community. the AUDIT extension (%-section_25-%) is the long-term path for
3059+ historical join/leave/ban queries; it is not defined in 0.8.
3060+
3061+ MEMBER events are NOT included in HIST responses (normative).
3062+ MEMBER events are gid-scoped; HIST is cid-scoped. including them
3063+ would conflate two distinct scopes and break the per-cid ordering
3064+ model. this exclusion is intentional and permanent for the HIST verb.
3065+
3066+ 8.1 community structure
3067+
3068+ communities contain: channels, forums, roles, members, branding.
3069+ all management operations use the COMM verb with an op= tag.
3070+
3071+ 8.2 roles and permissions
3072+
3073+ ROLE is the single verb for all role query replies and live-update
3074+ broadcasts. op= distinguishes the kind of event. this mirrors CHAN
3075+ and EMOJI: one verb, op= selects meaning, no separate event verb.
3076+
3077+ COMM op=role.create gid=<gid> name=<urlencoded-name>
3078+ [color=<hex6>] [emoji=<eid|cldr-name>] [hoist=0|1]
3079+ [perms=<permset>]
3080+
3081+ server replies:
3082+ OK verb=COMM op=role.create gid=<gid> rolid=<rolid>
3083+
3084+ rolid is the server-assigned ULID for the new role. the client
3085+ MUST use this rolid for all subsequent operations on the role.
3086+
3087+ server broadcasts to all community members:
3088+ ROLE op=create gid=<gid> rolid=<rolid> name=<urlencoded-name>
3089+ [color=<hex6>] [emoji=<eid|cldr-name>] [hoist=0|1]
3090+ [perms=<permset>] ts=<unix-ms>
3091+
3092+ clients receiving ROLE op=create MUST add the role to their
3093+ local role cache so subsequent assign/revoke events are meaningful
3094+ without a follow-up COMM op=role.list round-trip.
3095+
3096+ COMM op=role.rename gid=<gid> rolid=<rolid> name=<urlencoded-name>
3097+
3098+ renames a user-defined role. built-in roles (owner, everyone,
3099+ here) MUST be rejected with ERR code=BADARG.
3100+ requires role.manage permission.
3101+
3102+ server replies:
3103+ OK verb=COMM op=role.rename gid=<gid> rolid=<rolid>
3104+
3105+ server broadcasts to all community members:
3106+ ROLE op=rename gid=<gid> rolid=<rolid> name=<urlencoded-name>
3107+ ts=<unix-ms>
3108+
3109+ COMM op=role.perm gid=<gid> rolid=<rolid> [perms=<permset>]
3110+
3111+ replaces the permission set of a user-defined role. built-in
3112+ roles (owner, everyone, here) MUST be rejected with ERR code=BADARG.
3113+ requires role.manage permission.
3114+
3115+ perms= is the full new permission set for the role (same format
3116+ as the perms= tag on role.create). the server replaces the stored
3117+ set atomically; it is not additive. to clear all permissions, omit
3118+ the perms= tag entirely; a tag with an empty value violates the
3119+ 1*TCHAR rule of %-section_2_1-%. servers MUST treat an absent perms=
3120+ as meaning "clear all permissions" in this context.
3121+
3122+ server replies:
3123+ OK verb=COMM op=role.perm gid=<gid> rolid=<rolid>
3124+
3125+ server broadcasts to all community members:
3126+ ROLE op=perm gid=<gid> rolid=<rolid> [perms=<permset>] ts=<unix-ms>
3127+
3128+ perms= is omitted in the broadcast when the role's permission set
3129+ was cleared; its absence means the role now has no permissions.
3130+ clients MUST treat absent perms= in ROLE op=perm as clearing the
3131+ cached permission set for that role, not as "unchanged".
3132+
3133+ clients MUST update their local role cache on receiving ROLE op=perm.
3134+ the op=perm event is listed in the full op= enumeration in %-section_8_2a-% alongside all other ROLE event kinds.
3135+
3136+ COMM op=role.assign gid=<gid> uid=<uid> rolid=<rolid>
3137+
3138+ server replies:
3139+ OK verb=COMM op=role.assign gid=<gid> uid=<uid> rolid=<rolid>
3140+
3141+ server broadcasts to community members:
3142+ ROLE op=assign gid=<gid> rolid=<rolid> uid=<uid> ts=<unix-ms>
3143+
3144+ COMM op=role.revoke gid=<gid> uid=<uid> rolid=<rolid>
3145+
3146+ server replies:
3147+ OK verb=COMM op=role.revoke gid=<gid> uid=<uid> rolid=<rolid>
3148+
3149+ server broadcasts to community members:
3150+ ROLE op=revoke gid=<gid> rolid=<rolid> uid=<uid> ts=<unix-ms>
3151+
3152+ COMM op=role.delete gid=<gid> rolid=<rolid>
3153+
3154+ permanently removes a user-defined role. built-in roles (owner,
3155+ everyone, here) MUST be rejected with ERR code=BADARG.
3156+ all role assignments for this rolid are removed atomically.
3157+
3158+ server replies:
3159+ OK verb=COMM op=role.delete gid=<gid> rolid=<rolid>
3160+
3161+ server broadcasts to community members:
3162+ ROLE op=delete gid=<gid> rolid=<rolid> ts=<unix-ms>
3163+
3164+ clients MUST remove the role from their local role cache on
3165+ receiving ROLE op=delete.
3166+
3167+ built-in roles:
3168+
3169+ every community has three immutable built-in roles with reserved
3170+ literal rolids. they cannot be created, renamed, or deleted.
3171+ 'owner' is defined authoritatively in %-section_8_0a-%. the two
3172+ community-wide implicit-membership roles:
3173+
3174+ 'everyone' -- implicit membership: all current community members.
3175+ rolid literal is the string 'everyone'.
3176+ appears in rolmentions=, ROLE events, and
3177+ permission grants like any other role.
3178+ membership is dynamically resolved server-side;
3179+ no explicit assignment needed or permitted.
3180+
3181+ 'here' -- implicit membership: all members currently online
3182+ in the community (presence status != offline).
3183+ rolid literal is the string 'here'.
3184+ membership is resolved at PING fan-out time.
3185+ servers MUST check 'here' membership at PING
3186+ dispatch time, not at MSG validation time. a member
3187+ who transitions to offline or invisible between MSG
3188+ validation and async PING dispatch (see %-section_26_1-%,
3189+ PING_FANOUT_ASYNC) MUST NOT receive the PING for
3190+ that dispatch.
3191+ a member who goes offline between MSG arrival and
3192+ PING dispatch is skipped for 'here'.
3193+
3194+ built-in roles are listed in COMM op=role.list responses.
3195+ their appearance is configurable via COMM op=role.appearance
3196+ like any other role.
3197+ COMM op=role.assign and role.revoke are rejected for 'everyone'
3198+ and 'here' with ERR code=BADARG (membership is implicit and
3199+ dynamic). for 'owner' assignment and revocation rules see
3200+ %-section_8_0a-%.
3201+
3202+ canonical perms= for built-in roles (normative):
3203+
3204+ servers MUST include perms= in ROLE op=info and COMM op=role.list
3205+ responses for built-in roles as follows:
3206+
3207+ owner -- perms= tag MUST be omitted entirely. the owner
3208+ bypass is structural (all permissions implied
3209+ unconditionally) and cannot be expressed as a
3210+ permset. an empty perms= value would violate the
3211+ 1*TCHAR rule of %-section_2_1-%. clients receiving a
3212+ ROLE op=info for rolid=owner MUST treat the absence
3213+ of perms= as meaning "all permissions implied".
3214+
3215+ everyone -- perms=msg.send,attach.upload,react.use,
3216+ board.post,thread.create
3217+ these are the default permissions granted to all
3218+ community members. servers MAY extend this set via
3219+ configuration; the listed tokens are the normative
3220+ minimum baseline.
3221+
3222+ here -- perms=msg.send,attach.upload,react.use,
3223+ board.post,thread.create
3224+ identical to everyone. 'here' is a fan-out
3225+ membership filter, not a separate permission scope.
3226+ its permission set is always derived from and
3227+ identical to 'everyone'; it cannot be independently
3228+ configured. the distinction between them is delivery
3229+ scope only: @everyone mentions all community members;
3230+ @here mentions only currently online members. a role
3231+ with a deny on 'here' perms has no effect that is
3232+ not already achieved by a deny on 'everyone' perms.
3233+ clients MUST treat here's permission set as identical
3234+ to everyone's.
3235+
3236+ user-defined roles:
3237+
3238+ rolids are server-assigned ULIDs, assigned at role.create time.
3239+ names are URL-encoded, 1-64 chars decoded.
3240+
3241+ permset: comma-separated list of permission tokens.
3242+ defined tokens:
3243+ msg.send msg.delete msg.pin
3244+ member.ban member.kick member.timeout member.untimeout
3245+ channel.manage role.manage community.admin
3246+ attach.upload react.use thread.create thread.manage
3247+ board.post board.manage invite.create invite.manage
3248+ emoji.manage mention.role
3249+ mention.everyone mention.here
3250+
3251+ community.admin super-permission rule (normative):
3252+ community.admin is a super-permission that implies all other
3253+ permission tokens unconditionally. if a uid holds community.admin in
3254+ any role assigned to it, that uid has all other permissions on all
3255+ channels in the community regardless of their role assignments or
3256+ channel.perm denials. per-token implications listed below describe
3257+ the expected semantic relationship; all such implications reduce to
3258+ this master rule in the permission resolution algorithm.
3259+
3260+ msg.send:
3261+ required to send any MSG in the channel. the server MUST
3262+ reject MSG from a uid without msg.send with ERR code=NOPERM.
3263+ granted to 'everyone' by default on text, forum, and board
3264+ channels. communities that want a read-only channel (equivalent
3265+ to the former type=announce) MUST deny msg.send for the 'everyone'
3266+ role via COMM op=channel.perm on a type=text channel.
3267+
3268+ msg.delete:
3269+ required to delete or edit messages authored by others.
3270+ a user may always delete or edit his own messages regardless
3271+ of this permission.
3272+ note: on type=board channels, board.manage REPLACES this
3273+ permission for actions on other users' content. msg.delete is
3274+ NOT evaluated for others' posts on board channels; see board.manage.
3275+
3276+ msg.pin:
3277+ required to PIN or UNPIN any message in the channel.
3278+ note: on type=board channels, board.manage REPLACES this
3279+ permission for pin actions on other users' content; see board.manage.
3280+
3281+ attach.upload:
3282+ required to include aid= references in message bodies.
3283+ servers MUST strip aid= markup from MSG bodies sent by users
3284+ without this permission and report WARN code=ATTACH_STRIPPED.
3285+ granted to 'everyone' by default.
3286+
3287+ react.use:
3288+ required to send REACT or UNREACT on the channel.
3289+ granted to 'everyone' by default.
3290+
3291+ thread.create:
3292+ on text channels: required to start a new thread (send MSG
3293+ with tid= set to a new value --- a tid= not referencing an
3294+ existing thread). note: on forum channels, creating a thread
3295+ is controlled by msg.send; thread.create is not evaluated.
3296+ granted to 'everyone' by default on text channels.
3297+
3298+ thread.manage:
3299+ required to rename or lock a thread (%-section_11_2a-%).
3300+ a thread's original author may rename or lock his own thread
3301+ regardless of this permission.
3302+
3303+ board.post:
3304+ required to create a new top-level post (MSG with title=) on
3305+ a type=board channel. granted to 'everyone' by default.
3306+ msg.send is also required on board channels (it is the universal
3307+ MSG gate checked first in the permission resolution algorithm,
3308+ step 3); board.post is an additional gate on top of msg.send.
3309+ a uid that holds board.post but not msg.send is rejected at the
3310+ msg.send check before board.post is evaluated.
3311+
3312+ board.manage:
3313+ required to edit or delete posts authored by others on a
3314+ type=board channel, and to pin posts on board channels.
3315+
3316+ on type=board channels, board.manage REPLACES msg.delete and
3317+ msg.pin for actions on other users' content. msg.delete and
3318+ msg.pin are NOT evaluated for others' posts on board channels;
3319+ only board.manage is checked. a user without board.manage but
3320+ with msg.delete will be rejected on a board channel when
3321+ attempting to delete another user's post.
3322+
3323+ a user's own posts follow the universal self-edit rule: any
3324+ author may edit or delete his own posts on any channel type
3325+ regardless of msg.delete or board.manage.
3326+
3327+ channel.manage:
3328+ required to execute COMM op=channel.create, channel.delete,
3329+ channel.rename, channel.topic, channel.perm for channels within
3330+ this community.
3331+
3332+ role.manage:
3333+ required to execute COMM op=role.create, role.rename,
3334+ role.perm, role.assign, role.revoke, role.delete,
3335+ role.appearance for this community.
3336+
3337+ mention.role:
3338+ required to use rolmentions= on a MSG at all.
3339+ without it, the entire rolmentions= tag is dropped and the
3340+ sender receives WARN code=MENTION_ROLE_NOPERM :rolmentions
3341+ requires mention.role permission.
3342+
3343+ mention.everyone:
3344+ required to include the built-in rolid 'everyone' in rolmentions=.
3345+ attempting rolmentions=everyone without this permission causes
3346+ ERR code=NOPERM (hard reject, not a silent drop).
3347+
3348+ mention.here:
3349+ required to include the built-in rolid 'here' in rolmentions=.
3350+ attempting rolmentions=here without this permission causes
3351+ ERR code=NOPERM (hard reject, not a silent drop).
3352+
3353+ the asymmetry between mention.role (WARN + drop) and
3354+ mention.everyone / mention.here (ERR + reject) exists because
3355+ fan-out for built-in roles may be community-wide; the cost must
3356+ be explicitly rejected, not quietly discarded.
3357+
3358+ per-role mention control:
3359+ to restrict which specific user-defined roles a user may mention,
3360+ use the channel.perm mechanism to deny rolmentions on a per-role
3361+ basis. mention.role, mention.everyone, and mention.here are the
3362+ only normative mention permission tokens; there are no per-rolid
3363+ mention tokens.
3364+
3365+ permission resolution algorithm:
3366+
3367+ given a user U and a permission P on channel C in community G:
3368+
3369+ step 1: collect all roles assigned to U in G, including built-in
3370+ roles ('everyone' is always assigned to all members;
3371+ 'here' is assigned to online members at eval time).
3372+
3373+ step 2: if ANY of U's roles has community.admin in its allow set:
3374+ grant P unconditionally. stop.
3375+
3376+ step 3: evaluate community-level role permissions:
3377+ for each role R assigned to U:
3378+ if P is in R.deny: DENY. stop.
3379+ if any role R has P in R.allow: ALLOW. proceed to step 4.
3380+ else: DENY (no channel override can grant). stop.
3381+
3382+ step 4: if C has a channel-level perm override for any of U's roles:
3383+ for each role R with a channel override:
3384+ if P is in channel-deny for R: DENY. stop.
3385+ if P is in channel-allow for any R: ALLOW. stop.
3386+ // no channel override: community result from step 3 stands
3387+
3388+ summary: deny-wins at each layer. community.admin beats all.
3389+ channel-level overrides cannot grant a permission denied at
3390+ community level (except by community.admin).
3391+
3392+ 8.2a role appearance
3393+
3394+ roles carry visual metadata used by clients for rendering mentions,
3395+ member lists, and role badges. appearance is set with:
3396+
3397+ COMM op=role.appearance gid=<gid> rolid=<rolid>
3398+ [color=<hex6>] [emoji=<eid|cldr-name>] [hoist=0|1]
3399+
3400+ color -- 6-digit hex foreground color for this role's name display.
3401+ applied when rendering @rolid:name mentions in markup.
3402+ also used for member list coloring.
3403+ emoji -- a single emoji (custom eid or CLDR name) used as a role
3404+ badge/icon. clients display this beside the role name.
3405+ hoist -- 1 means show this role as a separate group in member lists.
3406+ 0 means members appear in the default group. default 0.
3407+
3408+ server replies:
3409+ OK verb=COMM op=role.appearance gid=<gid> rolid=<rolid>
3410+
3411+ server broadcasts to community members on appearance change:
3412+ ROLE op=appearance gid=<gid> rolid=<rolid>
3413+ [color=<hex6>] [emoji=<eid|cldr-name>]
3414+ [hoist=0|1] ts=<unix-ms>
3415+
3416+ ROLE is the single verb for all role events and query replies.
3417+ op= distinguishes the kind:
3418+ op=info -- reply to COMM op=role.list query (read-only)
3419+ op=create -- broadcast: role created
3420+ op=rename -- broadcast: role renamed
3421+ op=perm -- broadcast: role permission set changed
3422+ op=appearance -- broadcast: role appearance changed
3423+ op=assign -- broadcast: role assigned to a member
3424+ op=revoke -- broadcast: role revoked from a member
3425+ op=delete -- broadcast: role deleted
3426+
3427+ clients SHOULD cache role appearance per (gid, rolid).
3428+ on SUB to a community channel, server MAY proactively push the full
3429+ role list as ROLE op=info lines (same format as COMM op=role.list)
3430+ before the SUB OK, so clients can warm their role cache without a
3431+ separate round-trip. this mirrors the emoji push in %-section_10_4-%.
3432+ clients receiving these pre-SUB pushes MUST process them identically
3433+ to live ROLE op=info responses.
3434+
3435+ ROLE op=info pre-SUB-OK push routing (normative):
3436+ ROLE op=info lines arriving before a pending SUB OK are cache-warm
3437+ push lines. parsers MUST route them by gid= to the role cache for
3438+ that community. the pending SUB request provides the frame context;
3439+ %-section_2_5-% pending-request state covers these lines as part of the SUB
3440+ response. no additional discriminant tag is required.
3441+
3442+ cache-warm push principle (normative):
3443+ servers MAY proactively push, before a SUB OK, entity data required
3444+ for rendering incoming MSG lines in the subscribed channel. in 0.8
3445+ this means: ROLE op=info lines (required for mention display), EMOJI
3446+ op=info lines (required for emoji display), and CHAN op=perm lines
3447+ (required for permission state; see %-section_8_4-%). servers MUST NOT
3448+ proactively push entity data not required for message rendering ---
3449+ specifically, branding (KVAL scope=comm), member lists (MEMBER op=info), or
3450+ channel lists (CHAN op=info) --- as pre-SUB-OK unsolicited data.
3451+ clients query those on demand. this boundary is intentional:
3452+ unbounded pre-SUB pushes would block the client parser for an
3453+ unspecified duration on every subscribe.
3454+
3455+ fetching role list and appearance:
3456+ COMM op=role.list gid=<gid> [offset=<n>] [limit=<n>]
3457+
3458+ offset default: 0. limit default: 50. limit max: 200.
3459+ servers MUST clamp limit to 200.
3460+
3461+ server replies with one ROLE line per role, then OK:
3462+
3463+ ROLE op=info gid=<gid> rolid=<rolid> name=<urlencoded-name>
3464+ [color=<hex6>] [emoji=<eid|cldr-name>] [hoist=0|1]
3465+ [perms=<permset>] ts=<unix-ms>
3466+ ...
3467+ OK verb=COMM op=role.list gid=<gid> count=<n> total=<n>
3468+
3469+ count: number of lines in this page. total: total roles in gid.
3470+
3471+ ROLE op=info dual-direction use (normative):
3472+ ROLE op=info appears in three server-sent contexts:
3473+ (a) as the per-line reply to COMM op=role.list (%-section_8_2a-%).
3474+ (b) as the proactive cache-warm push before SUB OK (%-section_8_2a-%).
3475+ (c) as the single-line reply to COMM op=role.info (see below).
3476+ in all three contexts the line format is identical. parsers MUST
3477+ handle ROLE op=info in all contexts with one handler; routing is
3478+ via %-section_2_5-% pending-request state.
3479+ ROLE op=info is NEVER sent as an unsolicited live-event broadcast;
3480+ all unsolicited live events use the specific op= values listed above
3481+ (op=create, op=rename, op=perm, op=appearance, op=assign, op=revoke,
3482+ op=delete). this is the same design used by CHAN and EMOJI.
3483+
3484+ single-role lookup:
3485+
3486+ COMM op=role.info gid=<gid> rolid=<rolid>
3487+
3488+ queries metadata for a single role by rolid. any community member
3489+ may call this. non-members of private communities receive ERR
3490+ code=NOPERM.
3491+
3492+ server replies:
3493+ ROLE op=info gid=<gid> rolid=<rolid> name=<urlencoded-name>
3494+ [color=<hex6>] [emoji=<eid|cldr-name>] [hoist=0|1]
3495+ [perms=<permset>] ts=<unix-ms>
3496+ OK verb=COMM op=role.info gid=<gid> rolid=<rolid>
3497+ or
3498+ ERR code=NOTFOUND :role not found in this community
3499+
3500+ this closes the gap where a ROLE op=assign broadcast for an
3501+ unknown rolid forced a full COMM op=role.list round-trip. mirrors
3502+ COMM op=channel.info cid=<cid> exactly.
3503+
3504+ 8.3 member management
3505+
3506+ COMM op=member.ban gid=<gid> uid=<uid> [dur=<seconds>] :reason
3507+ COMM op=member.kick gid=<gid> uid=<uid> :reason
3508+ COMM op=member.timeout gid=<gid> uid=<uid> dur=<seconds> :reason
3509+ COMM op=member.unban gid=<gid> uid=<uid>
3510+ COMM op=member.untimeout gid=<gid> uid=<uid>
3511+
3512+ dur=0 on ban means permanent. dur= absent in COMM op=member.ban is
3513+ equivalent to dur=0: a ban with no explicit duration is permanent.
3514+ servers MUST emit dur=0 in the MEMBER op=ban broadcast when dur= was
3515+ absent in the send form; the broadcast always carries an explicit value.
3516+ servers MUST reject COMM op=member.timeout with dur=0 with
3517+ ERR code=BADARG :timeout duration must be positive. a zero-duration
3518+ timeout expires immediately and is semantically void; use
3519+ COMM op=member.ban dur=0 for a permanent restriction.
3520+ reason payload is required for ban and kick; SHOULD be present
3521+ for timeout. reason is stored in mod log. servers MUST persist
3522+ ban/timeout state with expiry.
3523+
3524+ owner protection (normative):
3525+ servers MUST reject COMM op=member.kick, op=member.ban, and
3526+ op=member.timeout targeting a uid that holds the owner role,
3527+ with ERR code=FORBIDDEN :owner role is protected. ownership must
3528+ be transferred (%-section_8_0a-%) before the owner can be restricted.
3529+
3530+ ban and timeout membership semantics (normative):
3531+ ban and timeout do NOT remove the uid's member record. a banned
3532+ uid remains listed in COMM op=member.list and is queryable via
3533+ COMM op=member.info; the reply includes banned_until= or
3534+ timeout_until= as appropriate (see MEMBER op=info below).
3535+ subscriptions are removed on ban and timeout (%-section_5-% GC rules);
3536+ the member record itself is retained.
3537+
3538+ a banned uid is rejected with ERR code=NOPERM on all write
3539+ operations: MSG, REACT, UNREACT, PIN, UNPIN, EDIT, DEL, and
3540+ channel management verbs. a timed-out uid is similarly rejected.
3541+ read operations (COMM op=info, COMM op=channel.list, WHO) are
3542+ permitted for banned members of open communities.
3543+ COMM op=leave MUST be accepted from a banned or timed-out uid;
3544+ a restricted member may remove themselves voluntarily.
3545+ COMM op=join MUST be rejected for a currently banned uid with
3546+ ERR code=NOPERM :you are banned.
3547+
3548+ because ban removes all channel subscriptions (%-section_5-% GC),
3549+ banned members receive no channel broadcasts for the duration of
3550+ the ban. the member record is retained for administrative query.
3551+
3552+ server replies for each operation:
3553+ OK verb=COMM op=member.ban gid=<gid> uid=<uid>
3554+ OK verb=COMM op=member.kick gid=<gid> uid=<uid>
3555+ OK verb=COMM op=member.timeout gid=<gid> uid=<uid>
3556+ OK verb=COMM op=member.unban gid=<gid> uid=<uid>
3557+ OK verb=COMM op=member.untimeout gid=<gid> uid=<uid>
3558+
3559+ corresponding broadcasts to community members (%-section_8_0b-%):
3560+ MEMBER op=ban gid=<gid> uid=<uid> by=<acting-uid> dur=<s> ts=<unix-ms> :reason
3561+ MEMBER op=kick gid=<gid> uid=<uid> by=<acting-uid> ts=<unix-ms> :reason
3562+ MEMBER op=timeout gid=<gid> uid=<uid> by=<acting-uid> dur=<s> ts=<unix-ms> :reason
3563+ MEMBER op=unban gid=<gid> uid=<uid> by=<acting-uid> ts=<unix-ms>
3564+ MEMBER op=untimeout gid=<gid> uid=<uid> by=<acting-uid> ts=<unix-ms>
3565+
3566+ the reason payload is relayed verbatim in the broadcast so that all
3567+ subscribed members (including moderator tooling and audit clients) can
3568+ log the stated reason without a separate query. MEMBER op=unban and
3569+ MEMBER op=untimeout carry no payload because an explicit lift has no
3570+ required reason.
3571+ ban reasons are stored server-side for mod-log purposes. in 0.8 there
3572+ is no protocol query for stored mod-log history; retrieval is reserved
3573+ for the AUDIT extension (%-section_25-%).
3574+
3575+ natural expiry of timed bans and timeouts (normative):
3576+ when a ban (dur>0) or timeout expires, the server lifts the
3577+ restriction silently. no broadcast is sent for server-side expiry;
3578+ there is no MEMBER op=unban or op=untimeout event for natural expiry.
3579+ clients MUST derive the expiry time from dur= and ts= in the original
3580+ MEMBER op=ban or MEMBER op=timeout broadcast:
3581+ expiry_unix_ms = ts + (dur * 1000)
3582+ a client that has not received the broadcast (e.g. was offline)
3583+ discovers expiry implicitly: COMM op=member.info for the uid
3584+ succeeds normally once the restriction has lifted. COMM op=member.unban
3585+ and COMM op=member.untimeout are for explicit admin lifts only;
3586+ they return OK silently if no restriction is currently active.
3587+ servers MUST NOT emit a MEMBER op=unban or MEMBER op=untimeout
3588+ broadcast when returning OK silently on a no-op lift; a broadcast
3589+ would misrepresent a no-op as a live moderation event. this is the
3590+ same normative pattern as PIN idempotency (%-section_11_4-%).
3591+
3592+ COMM op=member.list gid=<gid> [offset=<n>] [limit=<n>]
3593+
3594+ enumerates members of a community. any community member may call
3595+ this. non-members of private communities receive ERR code=NOPERM.
3596+ bulk enumeration concerns are addressed by rate limiting (%-section_18-%), not
3597+ by permission gating: COMM op=member.info (single-uid lookup) already
3598+ returns identical fields including banned_until= and timeout_until= to
3599+ any community member; the list form exposes no additional data.
3600+ offset default: 0. limit default: 50. limit max: 200.
3601+ servers MUST clamp limit to 200.
3602+
3603+ server replies with one MEMBER op=info line per member, then OK:
3604+ MEMBER op=info gid=<gid> uid=<uid> name=<urlencoded-name>
3605+ [roles=<csv-rolid>] joined=<unix-ms>
3606+ [banned_until=<unix-ms|permanent>] [timeout_until=<unix-ms>]
3607+ ts=<unix-ms>
3608+ ...
3609+ OK verb=COMM op=member.list gid=<gid> count=<n> total=<n>
3610+
3611+ roles= is a comma-separated list of user-defined rolids explicitly
3612+ assigned to this member, plus 'owner' if the member holds that role.
3613+ the tag is OMITTED entirely when the member has no assigned roles
3614+ (a tag with an empty value violates the 1*TCHAR rule of %-section_2_1-%).
3615+ built-in roles 'everyone' and 'here' are never included; their
3616+ membership is implicit and dynamic.
3617+ joined= is the unix-ms at which the member joined the community.
3618+ ts= is the unix-ms of the most recent mutation to this member record
3619+ (role assignment/revoke, ban, timeout, unban, or untimeout). equals
3620+ joined= if no mutation has occurred since joining. this follows the
3621+ %-section_2_6-% ts= invariant for op=info replies.
3622+ banned_until=permanent when a dur=0 (permanent) ban is active.
3623+ banned_until=<unix-ms> when a timed ban is active (expiry timestamp).
3624+ timeout_until=<unix-ms> when a timeout is active (expiry timestamp).
3625+ both tags are absent when no restriction is in effect.
3626+ COMM op=member.list includes banned and timed-out members; callers
3627+ identify restricted members by the presence of banned_until= or
3628+ timeout_until= in MEMBER op=info lines.
3629+
3630+ COMM op=member.info gid=<gid> uid=<uid>
3631+
3632+ queries a single member by uid. requires community membership;
3633+ non-members receive ERR code=NOPERM on private communities.
3634+ server replies:
3635+ MEMBER op=info gid=<gid> uid=<uid> name=<urlencoded-name>
3636+ [roles=<csv-rolid>] joined=<unix-ms>
3637+ [banned_until=<unix-ms|permanent>] [timeout_until=<unix-ms>]
3638+ ts=<unix-ms>
3639+ OK verb=COMM op=member.info gid=<gid> uid=<uid>
3640+ or
3641+ ERR code=NOTFOUND :uid is not a member of this community
3642+
3643+ NOTFOUND is returned for uids that are not members (i.e. never
3644+ joined, or were kicked). banned members are still members and
3645+ return MEMBER op=info with banned_until= present.
3646+
3647+ kick vs ban record semantics (normative):
3648+ kick and ban have deliberately different effects on the member record:
3649+
3650+ ban: the member record is retained. the uid remains queryable via
3651+ COMM op=member.info with banned_until= present. the ban is
3652+ visible protocol state. a banned uid may be unbanned by
3653+ COMM op=member.unban without creating a new member record.
3654+
3655+ kick: the member record is removed entirely. subsequent COMM
3656+ op=member.info for the kicked uid returns ERR code=NOTFOUND,
3657+ indistinguishable from a uid that has never joined. if the
3658+ kicked uid re-joins via COMM op=join, a fresh member record
3659+ is created with a new joined= timestamp. the fact that the
3660+ uid was previously a member and was kicked is NOT recoverable
3661+ from 0.8 protocol state.
3662+
3663+ this asymmetry is intentional: a ban is an ongoing restriction
3664+ (a uid status) and MUST be readable state. a kick is an instantaneous
3665+ removal with no ongoing restriction; retaining a tombstone would
3666+ require defining its lifecycle (query surface, expiry, cleanup) with
3667+ no corresponding protocol benefit in 0.8.
3668+
3669+ moderation history (who kicked whom, when, and why) is available
3670+ only via the AUDIT extension (%-section_25-%). in 0.8 there is no
3671+ protocol path to distinguish a kicked former member from a uid that
3672+ never joined. clients and operators that require this distinction
3673+ MUST use out-of-band logging or wait for the AUDIT extension.
3674+
3675+ MEMBER op=info disambiguation (normative):
3676+ MEMBER op=info lines appear in two server-sent contexts: as the
3677+ per-line body of COMM op=member.list responses, and as the single-
3678+ line reply to COMM op=member.info. within the same gid=, no tag
3679+ discriminant distinguishes them; parsers MUST route MEMBER op=info
3680+ lines using %-section_2_5-% pending-request state. pipelining COMM op=member.list
3681+ with COMM op=member.info for the same gid= is subject to the same-
3682+ resource pipeline prohibition (%-section_2_5-%); serialize them.
3683+ COMM op=member.list and COMM op=member.info for DIFFERENT gid=
3684+ values MAY be pipelined: each MEMBER op=info line carries gid= and
3685+ uid=, making it self-routing to the correct pending request by
3686+ (gid, uid) without pending-request state ambiguity.
3687+
3688+ 8.4 channels
3689+
3690+ channel management uses COMM ops for write operations and the CHAN
3691+ verb for both query responses and live-update broadcasts. the pattern
3692+ mirrors ROLE and EMOJI: CHAN op=info is a query reply;
3693+ CHAN op=create/delete/rename/topic/perm are broadcast events.
3694+
3695+ gid+cid co-validation (normative):
3696+ for all COMM ops that accept both gid= and cid=
3697+ (channel.delete, channel.rename, channel.topic, channel.perm):
3698+ servers MUST verify that the supplied cid belongs to the community
3699+ identified by gid. a mismatch MUST return ERR code=BADARG :cid
3700+ does not belong to this community. this prevents permission
3701+ resolution running against the wrong community context and makes
3702+ gid= a routing guarantee, not merely advisory context.
3703+
3704+ COMM op=channel.create gid=<gid> name=<urlencoded-name>
3705+ type=text|voice|forum|board [slug=<slug>] [e2e=0|1]
3706+
3707+ name -- display name; percent-encoded; 1-64 chars decoded.
3708+ slug -- channel slug; see %-section_3-% for grammar and derivation.
3709+ [a-z0-9_-], 1-32 chars, unique within the community.
3710+ if omitted, server derives slug from name (%-section_3-%).
3711+ server MUST reject a duplicate slug with ERR code=CONFLICT.
3712+
3713+ server replies:
3714+ OK verb=COMM op=channel.create gid=<gid> cid=<cid> slug=<slug>
3715+
3716+ server broadcasts to all community members:
3717+ CHAN op=create gid=<gid> cid=<cid> slug=<slug> name=<urlencoded-name>
3718+ type=text|voice|forum|board e2e=0|1 uid=<acting-uid> ts=<unix-ms>
3719+
3720+ channels are created with no topic. to set a topic, issue
3721+ COMM op=channel.topic after creation (see below).
3722+
3723+ COMM op=channel.delete gid=<gid> cid=<cid>
3724+
3725+ server replies:
3726+ OK verb=COMM op=channel.delete gid=<gid> cid=<cid>
3727+
3728+ server broadcasts to all community members:
3729+ CHAN op=delete gid=<gid> cid=<cid> uid=<acting-uid> ts=<unix-ms>
3730+
3731+ all clients subscribed to the deleted cid have their subscription
3732+ silently dropped by the server immediately after the broadcast.
3733+ clients receiving CHAN op=delete MUST remove the channel from their
3734+ local channel list and MAY display a "channel deleted" notice.
3735+
3736+ COMM op=channel.rename gid=<gid> cid=<cid> name=<urlencoded-name>
3737+
3738+ renames a channel's display name. the channel slug is NOT changed
3739+ by a rename; slugs are stable identifiers (%-section_3-%). to change
3740+ the slug, delete and recreate the channel.
3741+ requires channel.manage permission.
3742+
3743+ name -- new display name; percent-encoded; 1-64 chars decoded.
3744+
3745+ server replies:
3746+ OK verb=COMM op=channel.rename gid=<gid> cid=<cid>
3747+
3748+ server broadcasts to all community members:
3749+ CHAN op=rename gid=<gid> cid=<cid> name=<urlencoded-name>
3750+ uid=<acting-uid> ts=<unix-ms>
3751+
3752+ COMM op=channel.topic gid=<gid> cid=<cid> :topic (wire markup, \n-encoded)
3753+
3754+ updates the channel topic. the topic payload is wire markup per
3755+ %-section_9-%, \n-encoded per %-section_2_4-%.
3756+ requires channel.manage permission.
3757+
3758+ clearing the topic: sending COMM op=channel.topic with an empty
3759+ payload (the line ends with ' :' followed immediately by LF, per
3760+ %-section_2_1-%) clears the topic. the server MUST store no topic for
3761+ the channel and MUST broadcast CHAN op=topic with an empty payload.
3762+ clients MUST treat an empty payload in CHAN op=topic as a clear-
3763+ topic event and remove any cached topic for this cid. subsequent
3764+ CHAN op=info and COMM op=channel.info replies will carry no topic
3765+ payload (no ' :' delimiter on the line).
3766+
3767+ server replies:
3768+ OK verb=COMM op=channel.topic gid=<gid> cid=<cid>
3769+
3770+ server broadcasts to all community members:
3771+ CHAN op=topic gid=<gid> cid=<cid> uid=<uid> ts=<unix-ms>
3772+ :new topic (empty payload when topic is cleared)
3773+
3774+ COMM op=channel.perm gid=<gid> cid=<cid> rolid=<rolid>
3775+ [allow=<permset>] [deny=<permset>]
3776+
3777+ sets per-channel permission overrides for a role. allow= and deny=
3778+ are each independently optional; a tag absent from the send form
3779+ means no override in that direction (and clears any existing
3780+ override for that direction). sending with neither allow= nor deny=
3781+ present clears all channel-level overrides for the role entirely.
3782+ a tag with an empty value violates the 1*TCHAR rule of %-section_2_1-%
3783+ and MUST NOT be sent; use tag absence to express "no override".
3784+ the broadcast mirrors this: allow= or deny= is omitted when that
3785+ direction carries no override.
3786+
3787+ server replies:
3788+ OK verb=COMM op=channel.perm gid=<gid> cid=<cid> rolid=<rolid>
3789+
3790+ server broadcasts to all community members:
3791+ CHAN op=perm gid=<gid> cid=<cid> rolid=<rolid>
3792+ [allow=<permset>] [deny=<permset>] uid=<acting-uid> ts=<unix-ms>
3793+
3794+ CHAN op=perm verb directionality (normative):
3795+ parsers MUST use uid= presence to distinguish direction: a client-sent
3796+ COMM op=channel.perm line is a management command (COMM verb) and
3797+ produces no client-sent CHAN line. a server-broadcast CHAN op=perm line
3798+ carries uid= and ts=; a query-reply CHAN op=perm line (from COMM
3799+ op=channel.perm.list, see below) carries ts= but MUST NOT carry uid=.
3800+ clients MUST treat uid= absent in a CHAN op=perm line as a query reply,
3801+ not a live event. this is the same normative pattern used by ATTACH
3802+ (%-section_12_3-%).
3803+
3804+ ts= semantic (normative):
3805+ ts= in ALL CHAN op=perm lines --- both broadcast and query-reply --- is
3806+ defined as: the unix-ms at which the most recent COMM op=channel.perm
3807+ for this (cid, rolid) pair was committed by the server. for a broadcast
3808+ line, this equals the wall time of the event (since the broadcast IS
3809+ the commit). for a query-reply line, this is the stored last-modified
3810+ time for the override. the two values are always identical: the server
3811+ stores the commit time when it processes the channel.perm command and
3812+ returns the same value on query. implementors MUST use a single ts=
3813+ field with this unified semantic; no second timestamp field is needed.
3814+
3815+ COMM op=channel.perm.list gid=<gid> cid=<cid>
3816+
3817+ queries the current per-channel role permission overrides for a
3818+ channel. any community member may query. non-members of private
3819+ communities receive ERR code=NOPERM.
3820+
3821+ this query closes the hidden-state gap: channel-level overrides set
3822+ by COMM op=channel.perm are broadcast as CHAN op=perm events, but a
3823+ reconnecting client that missed those broadcasts has no way to
3824+ reconstruct the override state without this query surface. every other
3825+ persistent entity in the protocol has a query surface; channel perm
3826+ overrides are no exception.
3827+
3828+ server replies with one CHAN op=perm line per role that has any
3829+ override (allow= and/or deny= set), then OK:
3830+ CHAN op=perm gid=<gid> cid=<cid> rolid=<rolid>
3831+ [allow=<permset>] [deny=<permset>] ts=<unix-ms>
3832+ ...
3833+ OK verb=COMM op=channel.perm.list gid=<gid> cid=<cid> count=<n> total=<n>
3834+
3835+ ts= in each CHAN op=perm line is the unix-ms of the most recent
3836+ COMM op=channel.perm for that (cid, rolid) pair.
3837+ count: number of lines returned. no pagination: the number of roles
3838+ per channel is bounded by the community's role count.
3839+ if no overrides are set for any role on this channel: zero CHAN op=perm
3840+ lines, then OK count=0 total=0. ERR code=NOTFOUND is NOT returned for the
3841+ zero-override case.
3842+
3843+ servers MAY proactively push CHAN op=perm lines before the SUB OK
3844+ as part of the cache-warm burst (see %-section_8_2a-% cache-warm push principle).
3845+ these pre-SUB-OK CHAN op=perm lines carry ts= but no uid=, identical
3846+ to the COMM op=channel.perm.list reply format.
3847+
3848+ COMM op=channel.list gid=<gid> [offset=<n>] [limit=<n>]
3849+
3850+ enumerates all channels in a community. any community member may
3851+ query. non-members of private communities receive ERR code=NOPERM.
3852+
3853+ offset default: 0. limit default: 50. limit max: 200.
3854+ servers MUST clamp limit to 200.
3855+ (note: COMM op=list has a lower default of 20 because it enumerates
3856+ server-wide communities; a server may host many communities. channel
3857+ counts are bounded by a single community's design; 50 is the
3858+ appropriate default for intra-community enumeration.)
3859+
3860+ server replies with one CHAN op=info line per channel, then OK.
3861+ topic (wire markup) is the line payload; absent if not set:
3862+
3863+ CHAN op=info gid=<gid> cid=<cid> slug=<slug> name=<urlencoded-name>
3864+ type=text|voice|forum|board e2e=0|1 ts=<unix-ms>
3865+ [:topic]
3866+ ...
3867+ OK verb=COMM op=channel.list gid=<gid> count=<n> total=<n>
3868+
3869+ count: number of lines in this page. total: total channels in gid.
3870+ ts is the unix-ms of the last modification to the channel.
3871+ the payload carries the current topic as wire markup (\n-encoded
3872+ per %-section_2_4-%). if no topic has been set the payload is absent
3873+ (no ' :' delimiter on the line).
3874+
3875+ channel ordering (normative):
3876+ COMM op=channel.list returns channels in ascending cid (ULID)
3877+ order. because cids are server-assigned ULIDs whose embedded
3878+ timestamp reflects the channel creation time, ULID order is
3879+ canonical creation order. this gives clients a stable, predictable
3880+ channel list order without requiring a separate position field.
3881+ clients MUST NOT assume any other ordering and MUST use the
3882+ list order as the authoritative display order unless the user
3883+ has applied an explicit local sort. user-controlled channel
3884+ ordering (e.g. drag-and-drop reorder) is not defined in 0.8;
3885+ see %-section_25-% (future extensions) for CHAN op=order.
3886+
3887+ COMM op=channel.info cid=<cid>
3888+
3889+ queries metadata for a single channel by cid. any member of the
3890+ community that owns the channel may query. non-members of private
3891+ communities MUST receive ERR code=NOPERM, consistent with every
3892+ other community query verb.
3893+
3894+ server replies:
3895+ CHAN op=info gid=<gid> cid=<cid> slug=<slug> name=<urlencoded-name>
3896+ type=text|voice|forum|board e2e=0|1 ts=<unix-ms>
3897+ [:topic]
3898+ OK verb=COMM op=channel.info cid=<cid>
3899+
3900+ typical reconnect flow:
3901+
3902+ // 1. authenticate; server restores subscriptions and sends pending PINGs
3903+ // automatically --- no SUB re-issuance needed for previously subscribed
3904+ // channels. PING events arrive before further processing.
3905+
3906+ // 2. if client state was lost entirely (no local community/subscription cache):
3907+ // recover community membership and state via normative recovery sequence:
3908+ COMM op=list // all joined communities (paginated, default limit=20)
3909+ COMM op=member.list gid=<gid> // for each joined gid from op=list
3910+ COMM op=role.list gid=<gid> // for each joined gid from op=list
3911+
3912+ // 3. for each subscribed or rejoined community, check read state:
3913+ READSTATE op=query gid=<gid>
3914+ // note: a brand-new member who has never marked any message seen will
3915+ // receive zero READSTATE lines before the OK. this is correct and means
3916+ // the entire channel history is unread (cursor=absent, count=0 means
3917+ // up-to-date per %-section_6_9-%; cursor=absent, count>0 means fully unread).
3918+ // to distinguish "gid has channels" from "gid has no channels", the client
3919+ // MUST cross-reference with COMM op=channel.list gid=<gid>. the READSTATE
3920+ // response alone is insufficient for a first-time member.
3921+
3922+ // 4. fetch history from last read cursor on each subscribed channel:
3923+ HIST cid=<cid> after=<cursor> limit=50
3924+ // first-time load (no stored cursor): bare HIST returns newest messages
3925+ HIST cid=<cid> limit=50
3926+
3927+ // 5. to inspect current subscription set:
3928+ SUB op=list
3929+
3930+ // 6. a first-time client (no prior subscriptions) discovers channels and
3931+ // subscribes:
3932+ COMM op=channel.list gid=<gid>
3933+ SUB cid=<cid> // durable: server will restore this on next connect
3934+
3935+ // for dormant members that lost all state:
3936+ // on reconnect, run steps 2-3 above. if COMM op=list returns a community
3937+ // the client knew it was in but COMM op=member.list doesn't include the
3938+ // client's uid, the member was kicked. if COMM op=info returns ERR
3939+ // code=NOPERM, the community became private and the member was removed.
3940+ // clients MUST purge state for such communities.
3941+
3942+ note: a storage-free client can reconstruct all uid-scoped state from
3943+ server queries alone. no local storage is required for correct operation.
3944+
3945+ 8.5 invites
3946+
3947+ COMM op=invite.create gid=<gid> [uses=<n>] [ttl=<seconds>]
3948+ server replies:
3949+ OK verb=COMM op=invite.create gid=<gid> code=<code>
3950+ [ttl=<seconds>] [uses=<n>]
3951+
3952+ code= is an opaque server-generated string using characters [A-Za-z0-9],
3953+ 1-32 chars. clients MUST treat it as opaque; no structure is guaranteed.
3954+
3955+ uses= absent or omitted on invite.create means unlimited redemptions.
3956+ uses=<n> (positive integer) sets a finite redemption cap.
3957+ ttl= absent: no expiry (canonical form; servers MUST omit ttl= from
3958+ the OK and from INVITE op=info lines when no expiry was set).
3959+ ttl=0: MUST be rejected with ERR code=BADARG :ttl must be positive or
3960+ absent. a zero ttl would expire immediately and is semantically void.
3961+ this is parallel to the timeout dur=0 rejection (%-section_8_3-%).
3962+ ttl=<n> (positive integer): seconds until expiry.
3963+ the OK echoes ttl= and uses= when they were supplied, so callers
3964+ can confirm the values the server stored without a follow-up query.
3965+ both tags are absent in the OK when no expiry / unlimited redemptions.
3966+ expired or exhausted codes return ERR code=GONE on use.
3967+
3968+ resolving a code to community metadata (pre-join lookup):
3969+
3970+ COMM op=invite.info code=<code>
3971+
3972+ any authenticated user may call this regardless of membership.
3973+ server replies with the community metadata the code grants access to:
3974+
3975+ INVITE op=info gid=<gid> code=<code> name=<urlencoded-name>
3976+ open=0|1 members=<n> [ttl=<seconds>] [uses=<n>] [used=<n>]
3977+ OK verb=COMM op=invite.info code=<code>
3978+
3979+ gid= the community this code grants entry to.
3980+ name= the community display name; sufficient to show a preview UI.
3981+ open= whether the community is open (1) or invite-only (0).
3982+ members= current member count.
3983+ ttl= seconds remaining before expiry; absent if no expiry.
3984+ uses= max redemptions (positive integer); absent if unlimited.
3985+ used= number of times already redeemed.
3986+
3987+ if the code is expired, exhausted, or does not exist:
3988+ ERR code=GONE :invite code is invalid or expired
3989+
3990+ this is the correct path for any flow where the gid is not already
3991+ known --- e.g. a user clicks an invite link that carries only the code.
3992+ the client calls COMM op=invite.info to learn the gid and show a
3993+ community preview, then calls COMM op=join gid=<gid> code=<code>
3994+ to redeem.
3995+
3996+ to redeem a code:
3997+ COMM op=join gid=<gid> code=<code> (%-section_8_0-%)
3998+
3999+ 8.6 community branding
4000+
4001+ community branding is served by the KVAL verb (%-section_7_5-%) under
4002+ scope=comm. see %-section_7_5-% for all wire mechanics.
4003+
4004+ management commands (client -> server):
4005+ KVAL op=set scope=comm id=<gid> key=<key> :value
4006+ KVAL op=del scope=comm id=<gid> key=<key>
4007+ KVAL op=get scope=comm id=<gid> [key=<key>]
4008+
4009+ defined keys for scope=comm:
4010+
4011+ description -- wire markup (\n-encoded per %-section_2_4-%).
4012+ the community's public description, shown on
4013+ community info screens and invite previews.
4014+ links via [label](url) inline.
4015+ icon -- aid or https url (plain text).
4016+ banner -- aid or https url (plain text).
4017+ color -- 6-digit hex color, no '#' prefix (e.g. 'ff6600').
4018+ rules -- wire markup (\n-encoded per %-section_2_4-%).
4019+ the community's rules document.
4020+
4021+ key=name is not a valid scope=comm key. the community display name
4022+ is owned exclusively by COMM op=update name= (%-section_8_0-%); there
4023+ is exactly one source of truth. servers MUST reject
4024+ KVAL op=set scope=comm key=name with ERR code=BADARG.
4025+
4026+ write permission: requires channel.manage or community.admin on the
4027+ gid. servers MUST reject writes from uids without either permission
4028+ with ERR code=NOPERM (per %-section_7_5b-% scope registry).
4029+
4030+ read permission: any member of the community. non-members of
4031+ private communities receive ERR code=NOPERM.
4032+
4033+ broadcast: on a successful op=set or op=del the server broadcasts
4034+ a KVAL op=info (or KVAL op=del) line to all members of the gid,
4035+ per %-section_7_5c-% and %-section_7_5d-%. the acting uid is not carried in the KVAL
4036+ broadcast; audit of who changed a key is reserved for the AUDIT
4037+ extension (%-section_25-%). this is consistent with all other KVAL
4038+ broadcasts: the verb carries state, not actor identity.
4039+
4040+ query reply format (mirrors %-section_7_5e-%):
4041+ single-key:
4042+ KVAL op=info scope=comm id=<gid> key=<key> ts=<unix-ms> :value
4043+ OK verb=KVAL op=get scope=comm id=<gid> key=<key>
4044+ all-keys:
4045+ KVAL op=info scope=comm id=<gid> key=<key> ts=<unix-ms> :value
4046+ ...
4047+ OK verb=KVAL op=get scope=comm id=<gid> count=<n> total=<n>
4048+
4049+%- wire_markup = "Sect. 9" -%
4050+========================================================================
4051+%-wire_markup-% -- WIRE MARKUP
4052+========================================================================
4053+
4054+ wire markup is a strict defined subset of CommonMark with extensions.
4055+ all markup is OPTIONAL to render. servers relay body verbatim.
4056+ a conforming L0 client may display raw text without any rendering.
4057+
4058+ 9.1 decoding pipeline
4059+
4060+ clients MUST apply these steps in order:
4061+
4062+ 1. receive raw payload (as relayed verbatim by server)
4063+ 2. unescape: left-to-right single pass per %-section_2_4-%
4064+ resolve \n -> LF, \t -> TAB, \\ -> backslash
4065+ 3. apply wire markup rendering to the decoded string
4066+
4067+ markup is parsed on the decoded string, never on the wire form.
4068+ servers never execute step 2 or step 3.
4069+ this pipeline applies to all payloads in all verbs, not just MSG.
4070+
4071+ bot and non-rendering client pipeline:
4072+ step 2 (unescape) is REQUIRED for ALL clients, including bots,
4073+ CLI tools, and any client that does not render markup. a client
4074+ that skips step 2 will present raw escape sequences to its user
4075+ or application logic.
4076+ step 3 (markup rendering) is OPTIONAL. a client MAY stop after
4077+ step 2 and present the decoded plain text.
4078+ clients MUST NOT apply step 2 more than once to a payload.
4079+ the server relays the wire form verbatim; no intermediate
4080+ re-encoding occurs, so a single unescape pass is always correct.
4081+
4082+ 9.2 inline elements
4083+
4084+ *bold* bold
4085+ _italic_ italic
4086+ ~strikethrough~ strikethrough
4087+ `code` inline code; monospace; no further parsing inside
4088+ ||spoiler|| hidden until client action reveals it
4089+ [text](url) hyperlink
4090+ :name: standard unicode emoji by CLDR short name
4091+ :gid/name: custom community emoji (see %-section_10-%)
4092+ @uid:name user mention; uid is stable, name is display hint
4093+ @rolid:name role mention; rolid is stable, name is display hint
4094+ @everyone shorthand for @everyone:everyone (built-in role)
4095+ @here shorthand for @here:here (built-in role)
4096+ #cid:name channel mention
4097+ ^mid message reference / link
4098+
4099+ @everyone and @here are syntactic sugar. they expand to the standard
4100+ @rolid:name form with the reserved literal rolids 'everyone' and
4101+ 'here'. clients that render them SHOULD look up their ROLE op=appearance
4102+ data (color, emoji) from the role cache exactly as for any
4103+ role mention.
4104+
4105+ for all @-mentions, clients use the id (before ':') to look up cached
4106+ appearance and render accordingly. the :name portion is a display
4107+ hint for L0/dumb clients only. clients MUST NOT use :name as an
4108+ identity; always use the id.
4109+
4110+ if a message body contains @everyone or @here but rolmentions= does
4111+ not include the corresponding built-in rolid, no PING is generated.
4112+ the markup is rendered as plain highlighted text or ignored.
4113+
4114+ 9.3 block elements
4115+
4116+ block elements depend on decoded LF characters (step 2 above).
4117+ they are defined here for clients that implement block rendering.
4118+
4119+ ```lang\n...\n``` fenced code block; lang is optional hint
4120+ content between fences is literal; no markup
4121+ > text blockquote (single level; no nesting)
4122+ # heading h1
4123+ ## heading h2
4124+ - item unordered list item
4125+ 1. item ordered list item
4126+ --- horizontal rule (must be on its own decoded line)
4127+
4128+ context rules:
4129+ board, forum: full markup including headings
4130+ text: inline + blockquote + code block; headings ignored
4131+ voice: no markup context
4132+
4133+ clients MAY render blocks in any context; servers do not enforce.
4134+
4135+ 9.4 emoji references
4136+
4137+ :name: -- standard unicode emoji
4138+ :gid/name: -- custom community emoji
4139+
4140+ custom emoji resolution: see %-section_10-%.
4141+
4142+ 9.5 attachments
4143+
4144+  inline image attachment
4145+ [name](aid:<aid>) inline file attachment
4146+
4147+ upload flow: see %-section_12-%.
4148+
4149+%- custom_emoji = "Sect. 10" -%
4150+========================================================================
4151+%-custom_emoji-% -- CUSTOM EMOJI MANAGEMENT
4152+========================================================================
4153+
4154+ custom emoji are community-scoped named image assets.
4155+ they are referenced in markup as :gid/name: (see %-section_9_4-%).
4156+ emoji belong to a community (gid). there is no global emoji namespace.
4157+
4158+ 10.1 declaring emoji
4159+
4160+ COMM op=emoji.add gid=<gid> name=<n> mime=<mimetype> [animated=0|1] aid=<aid>
4161+
4162+ name: [a-z0-9_], 1-32 chars, unique within the community.
4163+ aid: must refer to a previously UPLOADED asset (%-section_12-%).
4164+ mime: the MIME type of the asset (e.g. image/png, image/gif,
4165+ image/webp). servers MUST validate mime= matches the asset
4166+ recorded at UPLOAD time and reject mismatches with
4167+ ERR code=BADARG. mime= is percent-encoded per %-section_2_3-%.
4168+ animated: 1 if the asset contains animation (GIF/APNG/animated WebP);
4169+ default 0. clients MAY derive this from mime= but animated=
4170+ is provided as a convenience shortcut so clients that only
4171+ need a boolean need not inspect MIME subtype details.
4172+
4173+ server replies:
4174+ OK verb=COMM op=emoji.add gid=<gid> name=<n> eid=<eid>
4175+
4176+ eid is a server-assigned ULID. it is the stable reference.
4177+ names may change; eids never do.
4178+
4179+ COMM op=emoji.rename gid=<gid> eid=<eid> name=<newname>
4180+ COMM op=emoji.delete gid=<gid> eid=<eid>
4181+
4182+ server replies for rename and delete:
4183+ OK verb=COMM op=emoji.rename gid=<gid> eid=<eid>
4184+ OK verb=COMM op=emoji.delete gid=<gid> eid=<eid>
4185+
4186+ server broadcasts to all community members:
4187+ EMOJI gid=<gid> eid=<eid> op=add|rename|delete name=<n>
4188+ [url=<url>] [mime=<mimetype>] [animated=0|1]
4189+
4190+ op=add: all fields present (url=, mime=, animated=)
4191+ op=rename: url=, mime=, and animated= omitted; name carries new value
4192+ op=delete: url=, mime=, and animated= omitted
4193+
4194+ 10.2 listing community emoji
4195+
4196+ COMM op=emoji.list gid=<gid> [offset=<n>] [limit=<n>]
4197+
4198+ offset default: 0. limit default: 100. limit max: 500.
4199+ servers MUST clamp limit to 500.
4200+
4201+ server replies with one EMOJI line per result, then OK:
4202+ EMOJI gid=<gid> eid=<eid> op=info name=<n> url=<url> mime=<mimetype> animated=0|1
4203+ ts=<unix-ms>
4204+ EMOJI ...
4205+ OK verb=COMM op=emoji.list gid=<gid> count=<n> total=<n>
4206+
4207+ count: number of lines in this page.
4208+ total: total emoji in the community (for pagination).
4209+
4210+ 10.3 resolving a single emoji
4211+
4212+ EMOJI op=get gid=<gid> name=<n>
4213+ EMOJI op=get gid=<gid> eid=<eid>
4214+
4215+ server replies:
4216+ EMOJI gid=<gid> eid=<eid> op=info name=<n> url=<url> mime=<mimetype> animated=0|1
4217+ ts=<unix-ms>
4218+ OK verb=EMOJI gid=<gid>
4219+ or
4220+ ERR code=NOTFOUND :emoji not found in community
4221+
4222+ EMOJI verb directionality:
4223+ EMOJI is used in two directions. as a client query (%-section_10_3-%):
4224+ the client sends EMOJI op=get with gid= and either name= or eid=,
4225+ and the server replies with EMOJI op=info. as a server event
4226+ (%-section_10_1-%): the server broadcasts EMOJI with op=add|rename|delete.
4227+ as a server response to listing (%-section_10_2-%): the server sends
4228+ EMOJI op=info lines. the op= tag unambiguously identifies the kind;
4229+ parsers dispatch on verb then op=. a client-sent EMOJI line MUST
4230+ carry op=get; servers MUST reject a client-sent EMOJI with any op=
4231+ value other than get with ERR code=BADARG, and MUST reject a
4232+ client-sent EMOJI with no op= with ERR code=BADARG.
4233+ note: EMOJI previously used op= presence (client-sent = no op=;
4234+ server-sent = op= always present) as its direction discriminant,
4235+ making it the only verb in the protocol inconsistent with the uid=
4236+ discriminant pattern. op=get on the client send form removes this
4237+ asymmetry: all bidirectional verbs now use either uid= presence or
4238+ a distinct op= value to distinguish direction. the client-sent
4239+ op=get value is distinct from all server-sent op= values
4240+ (info, add, rename, delete); parsers dispatch on op= value, not
4241+ op= presence, with no special-case branch needed.
4242+
4243+ EMOJI query pipelining (normative):
4244+ EMOJI op=get requests for DIFFERENT emoji within the same community
4245+ (differing eid= or name=) MAY be pipelined; each EMOJI op=info reply
4246+ line is self-identifying by eid=, so the client can route responses
4247+ without pending-request disambiguation. the same-resource pipeline
4248+ prohibition (%-section_2_5-%) applies at (gid, eid) or (gid, name) granularity,
4249+ not at the gid level. clients MUST NOT pipeline two EMOJI op=get
4250+ queries for the same (gid, eid) pair; those are same-resource and
4251+ indistinguishable.
4252+
4253+ 10.4 server push on subscribe
4254+
4255+ when a client SUBs to a channel within a community, the server MAY
4256+ proactively send the full emoji list (same format as emoji.list)
4257+ before the SUB OK. clients SHOULD cache per (gid, eid).
4258+ cache is invalidated by EMOJI op=delete or op=rename broadcasts.
4259+ cache entries SHOULD be treated as stale after 24h.
4260+
4261+ EMOJI pre-SUB-OK push routing (normative):
4262+ EMOJI op=info lines arriving before a pending SUB OK are cache-warm
4263+ push lines. parsers MUST route them by gid= to the emoji cache for
4264+ that community. the pending SUB request provides the frame context;
4265+ %-section_2_5-% pending-request state covers these lines as part of the SUB
4266+ response. this is the same rule as the ROLE op=info pre-SUB-OK push
4267+ (%-section_8_2a-%); both are governed by the cache-warm push principle
4268+ defined there.
4269+
4270+ 10.5 upload flow for emoji assets
4271+
4272+ UPLOAD gid=<gid> name=<urlencoded-name> size=<bytes>
4273+ mime=<mimetype> purpose=emoji
4274+
4275+ purpose=emoji applies emoji-specific size/dimension limits.
4276+ typical: 256KB / 128x128px static; 512KB animated.
4277+ server replies with standard UPLOAD OK (%-section_12-%).
4278+ after UPLOADED confirmation the aid is valid for emoji.add.
4279+
4280+ 10.6 cross-community emoji (cap: xemoji)
4281+
4282+ servers advertising cap=xemoji allow :origin-gid/name: references
4283+ from any community the user belongs to. resolution is unchanged;
4284+ server proxies the lookup if uid is a member of origin-gid.
4285+ servers without xemoji return ERR code=NOPERM on cross-gid lookups.
4286+
4287+ 10.7 permissions
4288+
4289+ emoji.add, emoji.rename, emoji.delete require:
4290+ community.admin OR emoji.manage on any assigned role
4291+
4292+ all members may use emoji in message bodies by default (governed by
4293+ msg.send). to restrict emoji use in reactions specifically, deny
4294+ react.use on the relevant role or channel. emoji in markup
4295+ (:name: references in MSG bodies) are part of the message content
4296+ and are not separately gateable beyond msg.send.
4297+
4298+%- boards_forums_threads = "Sect. 11" -%
4299+========================================================================
4300+%-boards_forums_threads-% -- BOARDS, FORUMS, AND THREADS
4301+========================================================================
4302+
4303+ boards are channel type=board. forums are channel type=forum.
4304+ both use MSG as the transport verb; title= and tags= tags carry
4305+ structured metadata. text channels (type=text) also support threads
4306+ via tid=. there is no POST verb; THREAD is the lifecycle management
4307+ verb for rename, lock, unlock, and info operations on threads.
4308+
4309+ 11.1 board posts
4310+
4311+ a board post is a MSG with title= present on a type=board channel.
4312+ tags= is optional and used for filtering (see LIST, %-section_11_4-%).
4313+ the body carries the full post content and MAY be empty (title= alone
4314+ is a valid board post, e.g. a link post where the title is the link).
4315+
4316+ servers MUST reject MSG without title= on type=board channels with
4317+ ERR code=BADARG :title required on board channels.
4318+ servers MUST reject MSG with tid= on type=board channels with
4319+ ERR code=BADARG :threads not supported on board channels.
4320+ servers MUST reject MSG by a uid without board.post permission with
4321+ ERR code=NOPERM.
4322+
4323+ board posts may be edited and deleted normally (%-section_6_4-%, 6.5).
4324+ editing a board post may update both body and tags=; title= is fixed
4325+ after creation and MUST NOT be changed via EDIT. servers MUST ignore
4326+ title= in EDIT payloads.
4327+
4328+ 11.2 forum threads
4329+
4330+ a forum thread is started by sending MSG with title= and without
4331+ tid= on a type=forum channel. the server assigns a new tid= and
4332+ includes it in both the OK and the broadcast. all subsequent replies
4333+ to the thread are MSG with tid=<assigned-tid>. replies MUST NOT
4334+ include title=.
4335+
4336+ servers MUST reject MSG with both tid= and title= present with
4337+ ERR code=BADARG :title not permitted on thread replies.
4338+ servers MUST reject MSG without title= and without tid= on type=forum
4339+ channels with ERR code=BADARG :title required to start a forum thread.
4340+
4341+ fetching thread replies:
4342+ HIST cid=<cid> tid=<tid> [before=<mid>] [after=<mid>] [limit=<n>]
4343+ returns all messages in the thread in mid order (%-section_6_6-%).
4344+ the opening message (which carries title=) is included.
4345+
4346+ 11.2a thread lifecycle (forum and text channels)
4347+
4348+ threads on both forum and text channels support a small set of
4349+ management operations via the THREAD verb.
4350+
4351+ renaming a thread (forum channels only):
4352+
4353+ THREAD op=rename cid=<cid> tid=<tid> :new title
4354+
4355+ text-channel threads have no title (%-section_11_3-%); servers MUST
4356+ reject THREAD op=rename on type=text channels with ERR code=BADARG.
4357+
4358+ only the thread's original author (the uid whose MSG created the
4359+ thread) or any member with thread.manage permission may rename.
4360+ servers MUST reject otherwise with ERR code=NOPERM.
4361+ title payload: encoded per %-section_2_4-%; max 256 chars decoded.
4362+
4363+ server replies:
4364+ OK verb=THREAD op=rename cid=<cid> tid=<tid>
4365+
4366+ server broadcasts to all cid subscribers:
4367+ THREAD op=rename cid=<cid> tid=<tid> uid=<uid> ts=<unix-ms>
4368+ :new title
4369+
4370+ locking a thread (forum and text channels):
4371+
4372+ THREAD op=lock cid=<cid> tid=<tid>
4373+
4374+ a locked thread accepts no new replies. existing messages are
4375+ unaffected. only the thread's author (the uid stored as author= in
4376+ the thread record; see THREAD op=info) or a member with thread.manage
4377+ may lock. servers MUST reject new MSG tid= on a locked thread with
4378+ ERR code=FORBIDDEN :thread is locked.
4379+
4380+ server stores the locking uid internally as locked_by=<uid>.
4381+
4382+ server replies:
4383+ OK verb=THREAD op=lock cid=<cid> tid=<tid>
4384+
4385+ server broadcasts to all cid subscribers:
4386+ THREAD op=lock cid=<cid> tid=<tid> uid=<uid> ts=<unix-ms>
4387+
4388+ unlocking a thread:
4389+
4390+ THREAD op=unlock cid=<cid> tid=<tid>
4391+
4392+ unlock permission rules:
4393+ - if locked_by == requesting uid: allowed unconditionally.
4394+ an author who locked his own thread may reopen it freely.
4395+ - if locked_by != requesting uid (i.e. a moderator locked it):
4396+ thread.manage is required. the original author (author= in the
4397+ thread record) CANNOT override a moderator lock without
4398+ thread.manage.
4399+ - community.admin holders may always unlock (per %-section_8_2-%).
4400+
4401+ this rule has no trap: an author who self-locks can always
4402+ self-unlock. a moderator lock is protected from author override.
4403+
4404+ server replies:
4405+ OK verb=THREAD op=unlock cid=<cid> tid=<tid>
4406+
4407+ server broadcasts to all cid subscribers:
4408+ THREAD op=unlock cid=<cid> tid=<tid> uid=<uid> ts=<unix-ms>
4409+
4410+ THREAD verb directionality (normative):
4411+ THREAD is used in two roles: as a client-initiated lifecycle command
4412+ (op=rename, op=lock, op=unlock) and as a query (op=info).
4413+
4414+ for op=info: THREAD op=info is always a query-reply verb. it is
4415+ never sent as a live event. parsers MUST route THREAD op=info lines
4416+ via %-section_2_5-% pending-request state (the pending THREAD op=info request).
4417+ THREAD op=info MUST NOT appear as a server-initiated unsolicited line.
4418+
4419+ for op=rename, op=lock, op=unlock: these are bidirectional. the
4420+ client sends the command (no uid=, no ts=); the server broadcasts
4421+ the event (carries uid= and ts=). parsers MUST use uid= presence
4422+ to distinguish direction: a client-sent THREAD op=rename/lock/unlock
4423+ line carries cid= and tid= only (and a payload for rename); a server-
4424+ broadcast line carries uid= and ts= in addition. servers MUST NOT
4425+ broadcast THREAD op=rename/lock/unlock without uid=; clients MUST
4426+ NOT send these with uid=. this is the same normative rule used by
4427+ EDIT (%-section_6_4-%), DEL (%-section_6_5-%), and PIN (%-section_11_4-%).
4428+
4429+ querying thread metadata:
4430+
4431+ THREAD op=info cid=<cid> tid=<tid>
4432+
4433+ server replies:
4434+ THREAD op=info cid=<cid> tid=<tid> opener=<mid> author=<uid>
4435+ locked=0|1 reply_count=<n> ts=<unix-ms> [:title]
4436+ OK verb=THREAD op=info cid=<cid> tid=<tid>
4437+ or
4438+ ERR code=NOTFOUND :thread not found
4439+
4440+ opener= mid of the message that created the thread.
4441+ for forum threads: the mid of the title-bearing MSG that
4442+ started the thread (server-assigned; equals the tid).
4443+ for text-channel threads: the mid of the root message
4444+ (the message whose mid equals the tid). the server
4445+ determines this at implicit thread creation time (first
4446+ MSG with that tid= accepted) and stores it permanently.
4447+ author= uid of the thread's original author. this is the uid of
4448+ the message at opener=. servers MUST store author= on the
4449+ thread record at implicit creation time so that lock and
4450+ unlock permission checks (%-section_11_2a-%) do not require
4451+ a message lookup at auth-check time.
4452+ locked= current lock state.
4453+ reply_count= number of live (non-deleted) replies, excluding the
4454+ opening message. DEL tombstones are NOT counted; only
4455+ replies for which no DEL tombstone exists contribute.
4456+ clients querying HIST cid=<cid> tid=<tid> will observe a
4457+ count= that includes tombstone lines (DEL events) and may
4458+ therefore exceed reply_count=. this divergence is
4459+ intentional: reply_count= is a display counter for user-
4460+ facing "N replies" indicators; HIST count= is a raw event
4461+ count for pagination arithmetic. they measure different
4462+ things and must not be conflated.
4463+ ts= the last-modified time of the thread entity: the unix-ms
4464+ of the most recent THREAD op=rename, op=lock, or op=unlock
4465+ event on this thread; equals the thread creation time (the
4466+ ts= of the opening MSG) if no such event has occurred.
4467+ this is the "last-modified time of the entity" semantic
4468+ used by every op=info ts= in the protocol. it is NOT the
4469+ time at which this response line was generated. parsers
4470+ MUST NOT treat this ts= as a reply-generation timestamp.
4471+ this is a normative definition; implementors MUST store
4472+ and update this value on every thread lifecycle event.
4473+ title current title (payload); absent for text-channel threads
4474+ (which have no title).
4475+
4476+ THREAD events in HIST:
4477+ THREAD op=rename, op=lock, op=unlock events ARE included in HIST
4478+ responses for the channel (not filtered by tid=). clients replay
4479+ them to reconstruct current thread state. THREAD op=info is a
4480+ query-reply only and does not appear in HIST.
4481+
4482+ 11.3 threads on text channels
4483+
4484+ type=text channels support lightweight threading via tid=. a thread
4485+ is implicitly created the first time a MSG with a new tid= value is
4486+ accepted on the channel. thread.create permission rules are defined
4487+ in %-section_8_2-%; see that section for the distinction between text
4488+ and forum channel handling.
4489+
4490+ at implicit thread creation time, the server MUST store on the thread
4491+ record:
4492+ - tid= (equals the mid of the root message)
4493+ - author= (the uid of the MSG that triggered creation; used for
4494+ lock/unlock permission checks without a message lookup)
4495+ - opener= (same as tid= for text-channel threads: the mid of the
4496+ root message is the thread's opening message)
4497+
4498+ root message annotation (normative):
4499+ when the server implicitly creates a text-channel thread, it MUST
4500+ retroactively annotate the stored root message record with
4501+ tid=<tid>. this ensures that subsequent HIST cid=<cid> tid=<tid>
4502+ queries return the root message alongside its replies, giving
4503+ clients a complete thread view from a single HIST request.
4504+
4505+ the original live broadcast of the root message (which carried no
4506+ tid= because the thread did not yet exist) is NOT re-broadcast.
4507+ clients that received the root message live will see it without
4508+ tid=; they learn the root is the thread opener because its mid
4509+ equals the tid= of the first threaded reply they receive.
4510+
4511+ consequently, MSG lines returned by HIST may carry tid= even if
4512+ the original live broadcast of that message did not. clients MUST
4513+ handle this. the live-vs-HIST divergence is intentional: the
4514+ alternative (requiring clients to separately fetch the root on
4515+ every HIST tid= query) produces worse client ergonomics for no
4516+ protocol gain. see %-section_24-% rationale for further discussion.
4517+
4518+ text-channel threads do NOT have titles. THREAD op=rename is
4519+ not applicable and is rejected (see %-section_11_2a-% for the normative rule).
4520+ THREAD op=lock and op=unlock are valid on text-channel threads.
4521+
4522+ clients typically display text-channel threads as an expandable
4523+ reply chain below the root message. the root message's mid IS its
4524+ tid if the client created the thread by setting tid=<mid-of-root>;
4525+ servers MUST accept this pattern and treat it as starting a new
4526+ thread.
4527+
4528+ servers MUST reject MSG with tid= on a text channel if the uid
4529+ lacks thread.create permission and the tid does not already exist
4530+ (i.e. no prior MSG with that tid= has been accepted on this cid).
4531+
4532+ cross-channel tid= injection rejection (normative):
4533+ servers MUST reject MSG cid=<cid> tid=<X> on a text channel if X
4534+ is not an existing mid in this cid, regardless of the sender's
4535+ permissions. the rejection response is:
4536+ ERR code=NOTFOUND :thread root not found in this channel
4537+ this rule applies even when the uid has thread.create permission.
4538+ the purpose is to prevent a client from implicitly creating a thread
4539+ rooted at a mid from a different channel or from a non-existent mid,
4540+ which would produce a thread record with an unresolvable opener=.
4541+ when tid=<X> refers to the sender's own just-sent message in this
4542+ cid, the root mid is already stored before the reply MSG arrives;
4543+ no exception is required.
4544+
4545+ 11.4 pins
4546+
4547+ PIN mid=<mid>
4548+ UNPIN mid=<mid>
4549+
4550+ requires msg.pin permission (or board.manage on board channels).
4551+ server broadcasts:
4552+ PIN mid=<mid> cid=<cid> uid=<uid> ts=<unix-ms>
4553+ UNPIN mid=<mid> cid=<cid> uid=<uid> ts=<unix-ms>
4554+
4555+ server replies to the originating client:
4556+ OK verb=PIN mid=<mid> cid=<cid>
4557+ OK verb=UNPIN mid=<mid> cid=<cid>
4558+
4559+ cid= is echoed in the OK so the originating client can route the
4560+ confirmation without pending-request state when pipelining PIN/UNPIN
4561+ across channels. the server derives cid from its mid->cid index at
4562+ validation time, consistent with EDIT, DEL, REACT, and UNREACT (%-section_2_5-%).
4563+
4564+ idempotency (normative):
4565+ PIN and UNPIN are idempotent. servers MUST accept PIN on an
4566+ already-pinned message and return OK verb=PIN mid=<mid> silently
4567+ without re-broadcasting the PIN event. servers MUST accept UNPIN
4568+ on a message that is not pinned and return OK verb=UNPIN mid=<mid>
4569+ silently without broadcasting. this is consistent with PING
4570+ op=dismiss and COMM op=member.untimeout idempotency.
4571+
4572+ PIN/UNPIN verb directionality (normative):
4573+ client-sent forms: PIN mid=<mid> and UNPIN mid=<mid> (operate on a
4574+ message; carry mid= only); PIN op=list cid=<cid> (query; carries
4575+ cid= only, no mid= tag). server-broadcast: PIN/UNPIN carrying uid=,
4576+ cid=, and ts= in addition to mid= (no op= tag). server query
4577+ response: PIN op=info lines (op= present; never a live broadcast).
4578+ parsers MUST use uid= to distinguish client-sent bare PIN/UNPIN (no
4579+ uid=) from server-broadcast (uid= present). parsers MUST use op= to
4580+ distinguish PIN op=list and PIN op=info from bare PIN/UNPIN.
4581+ servers MUST NOT send bare PIN/UNPIN with no uid=; clients MUST NOT
4582+ send PIN/UNPIN with uid=. this is the same normative rule used by
4583+ EDIT (%-section_6_4-%), DEL (%-section_6_5-%), and KVAL (%-section_7_5-%).
4584+
4585+ querying the current pinned message set:
4586+
4587+ PIN op=list cid=<cid> [offset=<n>] [limit=<n>]
4588+
4589+ returns the current set of pinned messages in a channel. any member
4590+ subscribed to the channel may call this. requires community membership
4591+ for community channels; requires DM participation for DM channels.
4592+
4593+ offset default: 0. limit default: 50. limit max: 200.
4594+ servers MUST clamp limit to 200.
4595+
4596+ server replies with one PIN op=info line per pinned message, in
4597+ pin-time order (oldest pin first), then OK:
4598+
4599+ PIN op=info mid=<mid> cid=<cid> uid=<uid> ts=<unix-ms>
4600+ ...
4601+ OK verb=PIN op=list cid=<cid> count=<n> total=<n>
4602+
4603+ uid= the uid of the member who pinned the message.
4604+ ts= the unix-ms at which the message was pinned.
4605+ count: number of lines in this page. total: total pinned messages
4606+ in this channel.
4607+
4608+ the pin index is the server-side store that makes PIN idempotency
4609+ (no re-broadcast on double-PIN) possible; PIN op=list surfaces it
4610+ as a query. clients MUST NOT reconstruct pin state solely by
4611+ replaying HIST; they MUST use PIN op=list on channel join to warm
4612+ their pin display state.
4613+
4614+ PIN op=info is listed in %-section_2_5-% as a routable response verb. parsers
4615+ route PIN op=info lines via pending-request state (the outstanding
4616+ PIN op=list request). PIN op=info is never sent as an unsolicited
4617+ live broadcast; live pin events use the bare PIN broadcast form
4618+ (uid= present, no op= tag).
4619+
4620+ 11.5 listing
4621+
4622+ LIST cid=<cid> [before=<mid>] [after=<mid>] [limit=<n>]
4623+ [tags=<csv>] [locked=0|1]
4624+
4625+ returns top-level content only:
4626+ board: all posts (MSG with title=). tags= filters to posts
4627+ that contain ALL listed tags (intersection). locked= is
4628+ not applicable to board and MUST be ignored.
4629+ forum: thread-opening messages only (no replies). tags= is
4630+ not applicable to forums and MUST be ignored. locked=
4631+ filters to locked (1) or unlocked (0) threads.
4632+ if a thread's opening message has been deleted, the server
4633+ MUST include a DEL tombstone line (DEL mid=<opener-mid>
4634+ uid=<uid> ts=<unix-ms>) in place of the MSG line so
4635+ clients can render "original post deleted" consistently.
4636+ THREAD op=info remains queryable for the thread regardless
4637+ of opener deletion.
4638+ text: all top-level messages: those whose mid does not appear
4639+ as a reply (tid=) under any other message. the server
4640+ determines this from its thread index, not from the
4641+ stored tid= field on the message itself (a message that
4642+ was later referenced as a thread root by a reply is
4643+ treated as top-level). tags= is not applicable to text
4644+ channels and MUST be ignored. locked= filters to locked
4645+ (1) or unlocked (0) threads, consulting the same per-tid
4646+ lock state stored for THREAD op=lock/unlock (%-section_11_2a-%); servers MUST maintain a thread lock index for
4647+ text-channel threads to satisfy this query.
4648+
4649+ before= and after= are mutually exclusive; servers MUST reject
4650+ a request containing both with ERR code=BADARG. default behavior
4651+ (no before= or after=) returns the newest limit= entries in
4652+ ascending mid order.
4653+
4654+ limit default: 20. limit max: 100. servers MUST clamp to 100.
4655+ server replies with MSG lines in mid order, then:
4656+ OK verb=LIST cid=<cid> count=<n> has_more=0|1
4657+
4658+%- attachments = "Sect. 12" -%
4659+========================================================================
4660+%-attachments-% -- ATTACHMENTS
4661+========================================================================
4662+
4663+ 12.1 upload negotiation
4664+
4665+ UPLOAD [cid=<cid>] [gid=<gid>] name=<urlencoded-name>
4666+ size=<bytes> mime=<mimetype> [purpose=<purpose>]
4667+
4668+ purpose values:
4669+ message (default) -- a file or image to be attached to a MSG.
4670+ cid= is required. gid= is ignored.
4671+ emoji -- an emoji asset to be registered with a
4672+ community. gid= is required. cid= is ignored.
4673+ emoji-specific size/dimension limits apply
4674+ (see %-section_10_5-%).
4675+ avatar -- a user avatar image to be used with IDENTIFY
4676+ avatar= (%-section_4_5-%). neither cid= nor gid=
4677+ is required or used; both are ignored.
4678+ servers SHOULD enforce avatar-specific size
4679+ and dimension limits (RECOMMENDED: 256KB /
4680+ 512x512px maximum; square aspect preferred).
4681+
4682+ if purpose= is absent, message is assumed and cid= is required.
4683+ servers MUST reject an UPLOAD with purpose=message and no cid= with
4684+ ERR code=BADARG. servers MUST reject an UPLOAD with purpose=emoji
4685+ and no gid= with ERR code=BADARG.
4686+ servers MUST reject an UPLOAD with an unrecognized purpose= value
4687+ with ERR code=BADARG :unknown upload purpose. unknown values cannot
4688+ be silently ignored because purpose= drives server-side validation
4689+ policy (size limits, required context tags); forward-compatible
4690+ clients MUST negotiate a known purpose or use a capability check
4691+ before sending a non-standard purpose= value.
4692+
4693+ server replies:
4694+ OK verb=UPLOAD aid=<aid> name=<urlencoded-name> size=<bytes>
4695+ mime=<mimetype> url=<upload-url> method=PUT
4696+ [headers=<urlencoded-json>]
4697+ or
4698+ ERR code=TOOBIG :max size is N bytes
4699+
4700+ method=PUT is the only defined upload method in 0.8. servers MUST
4701+ send method=PUT; clients MUST use HTTP PUT to the url= provided.
4702+ future revisions MAY define additional method values; clients MUST
4703+ treat an unrecognised method= value as ERR code=UNKNOWN and
4704+ re-negotiate rather than attempting an unsupported HTTP method.
4705+
4706+ client uploads via HTTP PUT to the returned url.
4707+ after the HTTP upload completes, the server sends asynchronously:
4708+ UPLOADED aid=<aid>
4709+
4710+ if the HTTP upload fails, no UPLOADED is sent.
4711+ client may retry by restarting from UPLOAD negotiation.
4712+
4713+ UPLOAD op=status aid=<aid>
4714+
4715+ polls the upload state for a previously negotiated aid. this is
4716+ the synchronous alternative to waiting for the UPLOADED event;
4717+ it is the correct path for CLI/TUI/ACME clients that cannot
4718+ maintain a parallel HTTP connection and WIRE connection cleanly.
4719+
4720+ any authenticated uid may query an aid it owns.
4721+ servers MUST return ERR code=NOTFOUND for unknown or expired aids.
4722+
4723+ server replies:
4724+ UPLOAD op=status aid=<aid> purpose=<purpose> state=pending|complete|failed
4725+ [cid=<cid>] [gid=<gid>] [url=<upload-url>] [expires=<unix-ms>]
4726+ OK verb=UPLOAD op=status aid=<aid>
4727+
4728+ purpose= echoes the purpose tag from the original UPLOAD negotiation.
4729+ cid= is echoed when purpose=message; gid= is echoed when
4730+ purpose=emoji; when purpose=avatar, neither cid= nor gid= is
4731+ present -- avatar assets are uid-scoped and carry no channel or
4732+ community context. clients managing multiple in-flight uploads
4733+ across different channels, communities, or purposes MUST use
4734+ these tags to route status replies without tracking out-of-band
4735+ state.
4736+
4737+ state=pending: HTTP PUT not yet received by server.
4738+ state=complete: UPLOADED; aid is valid for use in MSG and emoji.add.
4739+ an aid in state=complete MUST remain valid for at
4740+ least 300 seconds after the state transition, giving
4741+ the client a guaranteed window to compose and send
4742+ the MSG. once an aid is referenced in an accepted
4743+ MSG (the MSG OK is returned), the aid is permanently
4744+ referenced and servers MUST NOT expire it. unreferenced
4745+ aids (state=complete but never used in a MSG) MAY be
4746+ expired by the server after a server-defined TTL
4747+ (RECOMMENDED: 24 hours minimum). a client that receives
4748+ WARN code=ATTACH_STRIPPED on a MSG it believed had a
4749+ valid complete aid MUST re-upload and retry.
4750+
4751+ aid permanence and message deletion (normative):
4752+ if the MSG that references an aid is subsequently
4753+ deleted (DEL), the aid's permanence is preserved only
4754+ as long as at least one other non-deleted MSG on the
4755+ server also references it. if deletion of a MSG removes
4756+ the last reference to an aid, the server MAY expire
4757+ that aid at its discretion; the permanence guarantee
4758+ no longer applies. clients MUST NOT assume an aid
4759+ remains resolvable after all referencing messages have
4760+ been deleted.
4761+
4762+ there is no protocol operation to update or remove the
4763+ attachments= structural tag on a sent message. EDIT
4764+ replaces body only (%-section_6_4-%); attachments= is
4765+ immutable after acceptance. a user who wishes to
4766+ retract an attachment must delete the message (DEL)
4767+ and re-send without it. clients that receive
4768+ ERR code=NOTFOUND from ATTACH on a referenced aid
4769+ SHOULD display an "attachment unavailable" placeholder.
4770+ state=failed: HTTP PUT failed or was not received within the
4771+ server-defined upload window (RECOMMENDED: 300s).
4772+ the aid is invalid; client must re-negotiate.
4773+
4774+ note for CLI clients: url= in state=pending is the HTTP PUT upload
4775+ URL, not a download URL. after confirming state=complete, a client
4776+ needing to verify the download URL must issue ATTACH aid=<aid>
4777+ (%-section_12_3-%). for the typical CLI flow (upload then attach to MSG),
4778+ no download URL is needed: the aid is referenced in the MSG body and
4779+ recipients resolve it via their own ATTACH call.
4780+
4781+ url= is present only in state=pending and carries the same upload
4782+ URL from the original UPLOAD OK, allowing a client that lost the
4783+ URL to re-obtain it without full re-negotiation. the upload URL
4784+ is valid until the unix-ms deadline carried in expires=; clients
4785+ MUST NOT attempt the HTTP PUT after that deadline.
4786+ expires= is present only in state=pending and carries the unix-ms
4787+ deadline after which the upload slot will be marked failed.
4788+
4789+ servers MUST keep aid status queryable for at least 60 seconds
4790+ after state transitions to complete or failed.
4791+
4792+ 12.2 referencing in messages
4793+
4794+ after UPLOADED, reference in markup:
4795+  image
4796+ [name](aid:<aid>) file
4797+
4798+ 12.3 download resolution
4799+
4800+ ATTACH aid=<aid>
4801+ server replies:
4802+ ATTACH aid=<aid> name=<urlencoded-name> size=<bytes> mime=<mimetype>
4803+ url=<download-url>
4804+ OK verb=ATTACH aid=<aid>
4805+ or
4806+ ERR code=NOTFOUND :unknown aid
4807+
4808+ download URLs are time-limited. clients MUST NOT persist them.
4809+
4810+ ATTACH follows the same dual-direction pattern as EMOJI (%-section_10_3-%):
4811+ client sends ATTACH with aid= only; server replies with ATTACH carrying
4812+ the full metadata. parsers MUST use tag presence to distinguish direction:
4813+ a client-sent ATTACH line carries only aid=; a server reply carries
4814+ name=, size=, mime=, and url= in addition to aid=. this is the same
4815+ normative rule used by EMOJI (%-section_10_3-%) and KVAL (%-section_7_5-%).
4816+
4817+%- reactions = "Sect. 13" -%
4818+========================================================================
4819+%-reactions-% -- REACTIONS
4820+========================================================================
4821+
4822+ REACT mid=<mid> eid=<eid|cldr-name>
4823+ UNREACT mid=<mid> eid=<eid|cldr-name>
4824+
4825+ for standard unicode emoji: eid is the CLDR short name (e.g. thumbsup)
4826+ for custom emoji: eid is the server-assigned ULID.
4827+
4828+ server replies to the originating client:
4829+ OK verb=REACT mid=<mid> cid=<cid> eid=<eid> ts=<unix-ms>
4830+ OK verb=UNREACT mid=<mid> cid=<cid> eid=<eid> ts=<unix-ms>
4831+
4832+ server broadcasts:
4833+ REACT mid=<mid> cid=<cid> uid=<uid> eid=<eid> name=<n> ts=<unix-ms>
4834+ UNREACT mid=<mid> cid=<cid> uid=<uid> eid=<eid> ts=<unix-ms>
4835+
4836+ cid= in both OK and broadcast forms makes every REACT and UNREACT line
4837+ self-identifying by channel, consistent with PIN, UNPIN, DEL, and EDIT
4838+ (%-section_2_5-%). the server derives cid from its mid->cid index at validation time;
4839+ no additional lookup is required. cid= in the OK allows the originating
4840+ client to route the confirmation without pending-request state when
4841+ pipelining REACTs across channels.
4842+ the client-sent forms carry only mid= and eid=; both cid= and uid= are
4843+ absent. parsers MUST use uid= presence as the primary direction
4844+ discriminant: a client-sent REACT or UNREACT line carries only mid=
4845+ and eid= (no uid=, no cid=, no ts=); a server-sent line (broadcast or
4846+ OK) carries cid= in addition, and a broadcast carries uid= and ts= as
4847+ well. servers MUST reject a client-sent REACT or UNREACT that carries
4848+ cid= or uid= with ERR code=BADARG.
4849+
4850+ name=: CLDR name or custom emoji name; carried on REACT broadcast for
4851+ display convenience so receivers can render the emoji name without a
4852+ cache lookup. UNREACT broadcast intentionally omits name=: a client
4853+ always receives the corresponding REACT before it can receive an UNREACT
4854+ for the same (mid, eid, uid) pair, so the name is already in the client's
4855+ reaction cache. in a truncated HIST window where the prior REACT is not
4856+ present, the UNREACT is a floor-at-zero no-op on the reaction count and
4857+ name= is irrelevant. this asymmetry is intentional and permanent.
4858+
4859+ mid existence rules (normative):
4860+ servers MUST return ERR code=NOTFOUND if the mid is entirely unknown
4861+ (no live record and no DEL tombstone on this server).
4862+ servers MUST return ERR code=GONE if mid has a DEL tombstone
4863+ (message was deleted). existing REACT/UNREACT events recorded before
4864+ the deletion remain in HIST and are replayed correctly; clients see
4865+ the DEL tombstone and floor their reaction counts accordingly.
4866+
4867+ clients build reaction counts from the event stream.
4868+ servers MUST NOT aggregate or deduplicate reaction events.
4869+ servers MUST include REACT and UNREACT events in HIST (%-section_6_6-%).
4870+ clients replay HIST in order to reconstruct reaction state:
4871+ for each (mid, eid, uid) tuple:
4872+ REACT adds one. UNREACT removes one. floor at zero.
4873+
4874+%- voice_video = "Sect. 14" -%
4875+========================================================================
4876+%-voice_video-% -- VOICE AND VIDEO (OPTIONAL EXTENSION, cap: voice)
4877+========================================================================
4878+
4879+ voice and video are optional. clients that do not implement them MUST
4880+ ignore all VOICE, VSTREAM, and VFRAME lines. servers that do not
4881+ implement them return ERR UNKNOWN on VOICE and VSTREAM verbs.
4882+
4883+ 14.0 overview
4884+
4885+ design: orchestration-only relay + direct peer-to-peer.
4886+
4887+ the main WIRE connection carries voice and video control: joining,
4888+ leaving, participant status, key exchange. the server broadcasts peer
4889+ addresses and authentication tokens.
4890+
4891+ audio and video frames travel directly between clients on separate
4892+ binary-framed connections (not WIRE protocol). clients establish direct
4893+ TCP/TLS connections, authenticate via tokens, and exchange encrypted
4894+ frames (if enc=1). the server does not relay frame bytes.
4895+
4896+ this model prioritizes:
4897+ - simplicity: no relay bandwidth cost, direct paths, one way to do each thing.
4898+ - orthogonality: orchestration (WIRE) separate from frames (binary).
4899+ - efficiency: zero base64 overhead, no server frame processing.
4900+ - encryption: same Sender Key mechanism as MSG (Sect. %-section_29_2b-%).
4901+
4902+ 14.1 orchestration: joining a call
4903+
4904+ sent on the main WIRE connection:
4905+
4906+ VOICE op=join cid=<cid> [codec=<audio-codec>] [streams=<csv>]
4907+
4908+ tags:
4909+ codec=<opus|pcm16le>: client's preferred audio codec (optional).
4910+ streams=<csv>: comma-separated list of streams to send (optional).
4911+ default: camera. examples: camera, screen:0, screen:0,screen:1.
4912+
4913+ permission check (normative):
4914+ servers MUST verify that the requesting uid is a member of the
4915+ community that owns the voice channel. non-members MUST receive
4916+ ERR code=NOPERM.
4917+
4918+
4919+ 14.1a orchestration: leaving a call
4920+
4921+ sent on the main WIRE connection:
4922+
4923+ VOICE op=leave cid=<cid>
4924+
4925+ server broadcasts to remaining participants:
4926+
4927+ VOICE op=leave cid=<cid> uid=<departing-uid> ts=<unix-ms>
4928+
4929+ when the count of participants in a channel reaches 0, the server
4930+ clears the audio codec selection (codec reset). on the next VOICE
4931+ op=join for that cid, a new codec is negotiated.
4932+
4933+ if a participant's WIRE connection drops, the server MUST treat
4934+ it as an implicit VOICE op=leave for all cids that uid was
4935+ participating in.
4936+
4937+ server replies to the requesting client:
4938+
4939+ OK verb=VOICE op=join cid=<cid> codec=<selected-codec>
4940+ vaddr=<host:port> vsid=<token> [pubkey=<b64url>]
4941+ [media_codecs=<csv>]
4942+
4943+ tags:
4944+ codec=<opus|pcm16le>: audio codec selected for this channel session.
4945+ all participants use the same audio codec.
4946+ vaddr=<host:port>: address for direct P2P connection. in this spec, vaddr
4947+ points to the server's voice endpoint. clients behind NAT may not
4948+ be able to establish direct connections; NAT traversal (STUN, TURN,
4949+ hole-punching) is implementation-defined and not required by this spec.
4950+ vsid=<token>: authentication token (32 bytes, opaque).
4951+ valid for 30s, scoped to (uid, cid).
4952+ pubkey=<b64url>: client's Ed25519 public key (for X3DH key derivation).
4953+ media_codecs=<csv>: server-supported video codecs (e.g., vp8,h264).
4954+
4955+ server also broadcasts to existing participants on the main WIRE connection:
4956+
4957+ VOICE op=join cid=<cid> uid=<new-uid> vaddr=<new-addr:port>
4958+ vsid=<new-token> pubkey=<new-pubkey-b64url>
4959+ [streams=<csv>] ts=<unix-ms>
4960+
4961+ existing participants use this to:
4962+ 1. learn the new participant's address and pubkey.
4963+ 2. establish direct P2P connection to the new participant.
4964+ 3. derive shared encryption keys (if enc=1 channel).
4965+
4966+ 14.1b audio activity indicator
4967+
4968+ sent on the main WIRE connection (fire-and-forget):
4969+
4970+ VOICE op=talk cid=<cid> active=<0|1>
4971+
4972+ tags:
4973+ active=1: uid is currently speaking (has audio activity).
4974+ active=0: uid is no longer speaking (activity ended or muted).
4975+
4976+ server broadcasts to all participants with subscriptions in the cid:
4977+
4978+ VOICE op=talk cid=<cid> uid=<uid> active=<0|1> ts=<unix-ms>
4979+
4980+ clients use this to update speaker indicators in the UI. the
4981+ activity indicator is ephemeral and not persisted; old VOICE op=talk
4982+ events are not stored in HIST.
4983+
4984+
4985+ 14.2 direct peer-to-peer connection
4986+
4987+ after receiving VOICE op=join OK and peer broadcasts, the client
4988+ establishes a direct TCP/TLS connection to each peer's vaddr.
4989+
4990+ connection handshake:
4991+
4992+ CLIENT sends: [token: 32 bytes] [enc_flag: 1 byte]
4993+ (token is vsid from VOICE op=join broadcast)
4994+ (enc_flag: 0x00 = unencrypted, 0x01 = encrypted)
4995+
4996+ SERVER (peer) validates token, verifies (uid, cid) match.
4997+ PEER sends: ACK [0x00]
4998+
4999+ If enc_flag=0x01 (encrypted channel):
5000+ CLIENT sends: [key_version: 4 bytes (big-endian)]
5001+ PEER: derives Sender Key, reads key_version.
5002+ frame exchange begins with enc_flag=0x01.
5003+
5004+ frame format (binary, no WIRE protocol, no base64):
5005+
5006+ audio frame (VFRAME):
5007+ [0x01] [flags: 1B] [key_epoch: 1B] [seq: 4B] [length: 2B] [payload: N bytes]
5008+
5009+ flags: bit 0 = encrypted (0=no, 1=yes)
5010+ bits 1-7 = reserved
5011+
5012+ key_epoch: tracks the current Sender Key generation (mod 256).
5013+ used for key rotation detection. incremented on each
5014+ PUBKEY op=keysync that rotates the key. receivers that
5015+ receive frames with a stale key_epoch should try both
5016+ the current key and the previous key (in case of in-flight
5017+ rotation). if neither decrypts, frame is silently dropped.
5018+
5019+ seq: sequence number (big-endian), starts at 0 per sender.
5020+ used for loss detection and ordering.
5021+
5022+ length: payload size in bytes (big-endian).
5023+
5024+ video frame (VSTREAM):
5025+ [0x02] [flags: 1B] [key_epoch: 1B] [sid_len: 1B] [stream_id: M bytes]
5026+ [codec: 1B] [seq: 4B] [length: 2B] [payload: N bytes]
5027+
5028+ flags: bit 0 = encrypted
5029+ bit 1 = keyframe (0=delta, 1=keyframe)
5030+ bits 2-7 = reserved
5031+
5032+ stream_id: ASCII string (e.g., "camera", "screen:0", "screen:1").
5033+ length given by sid_len (1-16 bytes expected).
5034+
5035+ codec: 0x01 = VP8, 0x02 = H.264, 0x03 = AV1
5036+
5037+ seq: sequence number per stream per sender (big-endian).
5038+
5039+ clients handle frame loss gracefully (silent drop, no retransmit).
5040+
5041+ 14.3 audio (VFRAME)
5042+
5043+ codec selection (normative):
5044+
5045+ servers MUST support pcm16le and SHOULD support opus.
5046+
5047+ per-channel consistency: all participants in a channel use the
5048+ same audio codec for that session. the codec is selected when
5049+ the first participant joins:
5050+
5051+ - if first joiner specifies codec= and server supports it: use that codec.
5052+ - otherwise: server selects from its supported set
5053+ (RECOMMENDED: opus if available, else pcm16le).
5054+
5055+ subsequent joiners receive the already-selected codec in OK.
5056+ they MUST accept it or leave the channel.
5057+
5058+ codec reset: when the last participant leaves (count = 0), the
5059+ codec selection clears. the next session re-negotiates.
5060+
5061+ codec incompatibility: a client that cannot support the channel's
5062+ selected audio codec MUST send VOICE op=leave. no ERR is returned
5063+ to the client; codec incompatibility is handled by the client
5064+ leaving the channel.
5065+
5066+ payload:
5067+
5068+ opus: one Opus packet per VFRAME. 20ms frames, 48kHz mono or stereo.
5069+ pcm16le: one 20ms chunk of 48kHz mono 16-bit little-endian PCM.
5070+ no codec library required for decoding.
5071+
5072+ 14.4 video (VSTREAM)
5073+
5074+ codec selection (normative):
5075+
5076+ servers MUST support vp8. servers SHOULD support h264.
5077+ servers MAY support av1.
5078+
5079+ per-stream flexibility (unlike audio): each client chooses codec
5080+ per stream, per frame. there is no per-channel consensus.
5081+
5082+ example: a client may send camera in VP8 and screen:0 in H.264.
5083+
5084+ stream identifiers:
5085+
5086+ camera: primary camera stream (usual first stream).
5087+ screen:0: first screen share.
5088+ screen:1: second screen share (if client has multiple monitors).
5089+ format: [a-z]+ optionally followed by :[0-9]+
5090+
5091+ payload:
5092+
5093+ vp8: VP8 keyframe or delta frame (RFC 6386 format).
5094+ h264: H.264 NAL units in byte-stream format.
5095+ av1: AV1 open bitstream format (OBU).
5096+
5097+ 14.5 encryption and key rotation (requires cap: e2e)
5098+
5099+ key derivation (normative, enc=1 channels only; cap: e2e required):
5100+
5101+ uses Sender Keys (Layer 2, Sect. %-section_29_2b-%).
5102+ same key derivation as MSG on the same channel.
5103+ clients establish Sender Key via X3DH + Sender Key protocol
5104+ (full details in Sect. %-section_29_2b-%).
5105+
5106+ frame encryption:
5107+
5108+ if enc_flag=0x01 (encrypted channel):
5109+
5110+ [24 bytes: XChaCha20-Poly1305 nonce (random per frame)]
5111+ [N bytes: plaintext payload]
5112+ [16 bytes: auth tag]
5113+
5114+ cipher: XChaCha20-Poly1305 (same as MSG encryption).
5115+ nonce: generated fresh per frame (do not reuse).
5116+
5117+ key version tracking (normative):
5118+
5119+ clients track key_version for each (uid, cid).
5120+ when server generates new Sender Key (member removal),
5121+ clients receive update via PUBKEY op=keysync on main WIRE.
5122+
5123+ frames include implicit key_version (encoded in encryption state).
5124+ if a frame's decryption fails (wrong key_version), frame is
5125+ silently dropped (no error, no retry). the peer will send a
5126+ PUBKEY op=keysync if they detect the receiver is out-of-sync.
5127+
5128+ key rotation on member removal (normative):
5129+
5130+ when a member is removed from an enc=1 channel:
5131+
5132+ 1. server generates new Sender Key (fresh seed).
5133+ 2. server broadcasts PUBKEY op=keysync to all remaining members
5134+ (on main WIRE connection).
5135+ 3. clients derive new Sender Key and update key_version.
5136+ 4. next frame sent uses new key_version.
5137+ 5. old frames (from removed member or from before rotation)
5138+ decrypt with old key -> decryption fails -> silently dropped.
5139+
5140+ 14.6 participant status (orchestration)
5141+
5142+ clients announce stream status on the main WIRE connection (orchestration, not frame data):
5143+
5144+ VSTREAM op=active cid=<cid> sid=<stream-id>
5145+ VSTREAM op=idle cid=<cid> sid=<stream-id>
5146+
5147+ tags:
5148+ cid=<cid>: voice channel identifier.
5149+ sid=<stream-id>: stream identifier (camera, screen:0, etc).
5150+
5151+ server broadcasts to all participants with subscriptions in the cid:
5152+
5153+ VSTREAM op=active cid=<cid> uid=<uid> sid=<stream-id> ts=<unix-ms>
5154+ VSTREAM op=idle cid=<cid> uid=<uid> sid=<stream-id> ts=<unix-ms>
5155+
5156+ op=active: stream became active (client started sending frames).
5157+ op=idle: stream went idle (client stopped sending frames).
5158+
5159+ clients use these broadcasts to:
5160+ - render gallery view (who has camera active).
5161+ - highlight screen shares (show badge "screen share in progress").
5162+ - update participant list (who is sending what).
5163+ - mark speakers (combined with VOICE op=talk for audio activity).
5164+
5165+ these are WIRE protocol messages (text, orchestration only).
5166+ they require no frame processing.
5167+
5168+ 14.7 stream and connection limits
5169+
5170+ per-user active streams (normative):
5171+
5172+ servers SHOULD enforce a maximum number of concurrent streams
5173+ per user joining a channel. RECOMMENDED maximum: 3.
5174+
5175+ when limit is reached, next VSTREAM op=active attempt results in
5176+ frame transmission with enc_flag bit set, but server may not relay.
5177+ client receives no explicit error (silent drop). this is graceful
5178+ degradation, not a protocol error.
5179+
5180+ typical limits:
5181+ 1 camera + 2 screen shares
5182+ or 2 cameras + 1 screen share
5183+ or 1 audio-only + 2 screen shares (accessibility)
5184+
5185+ per-channel aggregate (implementation note, not normative):
5186+
5187+ servers may enforce limits on total concurrent video senders per
5188+ channel to prevent relay bandwidth exhaustion (if server chooses
5189+ to relay). since 0.8 specifies direct P2P, servers do not need
5190+ to enforce this, but MAY document it.
5191+
5192+ connection limits:
5193+
5194+ servers MAY enforce maximum concurrent voice channel connections
5195+ per user (RECOMMENDED: 10). when limit is reached, VOICE op=join
5196+ is rejected: ERR code=TOOBIG :maximum voice connections reached.
5197+
5198+ participant list query (VOICE op=list unchanged):
5199+
5200+ VOICE op=list cid=<cid>
5201+
5202+ server replies with current participants:
5203+
5204+ VOICE op=info cid=<cid> uid=<uid> joined=<unix-ms>
5205+ ...
5206+ OK verb=VOICE op=list cid=<cid> count=<n> total=<n>
5207+
5208+ count and total are always equal (no pagination).
5209+ servers MUST report actual participant count.
5210+
5211+%- server_to_server_federation = "Sect. 15" -%
5212+========================================================================
5213+%-server_to_server_federation-% -- SERVER-TO-SERVER / FEDERATION (EXPERIMENTAL)
5214+========================================================================
5215+
5216+ federation is EXPERIMENTAL. cap: s2s. nothing in this section is
5217+ normative for 0.8. it is a design sketch to constrain future revisions.
5218+
5219+ servers advertising cap=s2s MAY federate with peers.
5220+ s2s auth uses Ed25519 pubkey; same state machine as %-section_4_2-%,
5221+ method=S2S_PUBKEY.
5222+
5223+ federated resource paths are prefixed with server hostname:
5224+ wire.example.org/community/channel
5225+
5226+ federated MSG lines carry origin=<hostname> identifying source.
5227+
5228+ open problems that MUST be resolved before s2s is normative:
5229+
5230+ mid collision:
5231+ two servers generating ULIDs within the same millisecond for
5232+ the same channel may produce identical mid values. candidates:
5233+ server-namespace prefix on mid (e.g. 'sid:mid'), or reserving
5234+ bits in the ULID random component for a server identifier.
5235+
5236+ ordering:
5237+ per-cid ordering guarantee (%-section_6_3-%) cannot be provided
5238+ across servers without coordination. last-write-wins is rejected.
5239+ causal ordering via explicit parent=<mid> tag is a candidate.
5240+
5241+ loop prevention:
5242+ a message federated from A to B and then back to A must be
5243+ detected and dropped. loop detection via origin chain tags is
5244+ a candidate.
5245+
5246+ trust model:
5247+ ban state, mute state, and moderation actions do not propagate
5248+ automatically across federated servers. cross-server moderation
5249+ is undefined.
5250+
5251+ duplicate handling:
5252+ if a server receives a mid it already knows, it MUST silently
5253+ drop the message without re-broadcasting.
5254+
5255+%- capability_negotiation = "Sect. 16" -%
5256+========================================================================
5257+%-capability_negotiation-% -- CAPABILITY NEGOTIATION
5258+========================================================================
5259+
5260+ CAPS and USE are distinct verbs with distinct directions.
5261+
5262+ querying server capabilities (before or after AUTH):
5263+
5264+ CAPS
5265+ server replies:
5266+ CAPS list=<csv-of-cap-names>
5267+
5268+ parsers MUST use list= presence to distinguish direction: a CAPS line
5269+ carrying list= is always server-sent (the capability reply); a bare
5270+ CAPS line with no tags is always client-sent (the query). this is the
5271+ same normative MUST pattern used by ATTACH (%-section_12_3-%) for
5272+ tag-presence-based dual-direction verb disambiguation.
5273+ note: EMOJI (%-section_10_3-%) and KVAL (%-section_7_5-%) use op= value as
5274+ the discriminant rather than tag presence; both are valid patterns.
5275+ servers MUST NOT send a bare CAPS line; clients MUST NOT send
5276+ CAPS list=.
5277+
5278+ declaring client intent (after AUTH; optional):
5279+
5280+ USE caps=<csv-of-cap-names>
5281+
5282+ server replies:
5283+ OK verb=USE
5284+
5285+ USE is advisory. servers MUST still accept all verbs and return
5286+ ERR code=UNKNOWN for unimplemented ones. unknown cap names in
5287+ USE caps= MUST be ignored by servers. USE may be sent at any time
5288+ after AUTH; sending it multiple times is permitted and replaces the
5289+ previous declaration.
5290+
5291+ defined cap names:
5292+ voice -- VOICE extension (%-section_14-%)
5293+ s2s -- federation (%-section_15-%, experimental)
5294+ boards -- board channel type (MSG with title= on type=board)
5295+ forums -- forum channel type (MSG with title= on type=forum)
5296+ reactions -- REACT / UNREACT verbs
5297+ threads -- tid= tag on MSG; thread context
5298+ upload -- UPLOAD / ATTACH verbs
5299+ emoji -- custom emoji (COMM op=emoji.*)
5300+ xemoji -- cross-community emoji (%-section_10_6-%)
5301+ kval -- scoped key-value store (KVAL verb; %-section_7_5-%).
5302+ covers both community branding (scope=comm; %-section_8_6-%)
5303+ and user profile decoration (scope=user; %-section_7_4-%).
5304+ replaces the retired branding and uprofile caps.
5305+ servers not advertising kval MUST return ERR code=UNKNOWN
5306+ on all KVAL lines.
5307+ purge -- HIST op=purge (%-section_29_1-%); server-side permanent
5308+ history deletion. servers that do not advertise purge
5309+ MUST reject HIST op=purge with ERR code=UNKNOWN.
5310+ e2e -- end-to-end encryption (%-section_29_2-%); PUBKEY op=prekey,
5311+ op=prekey.fetch, op=keysync; COMM op=channel.e2e;
5312+ CHAN op=e2e; MSG enc=skdm; e2e= tag on CHAN op=info,
5313+ COMM op=channel.create, and SUB (DM creation).
5314+
5315+%- error_format = "Sect. 17" -%
5316+========================================================================
5317+%-error_format-% -- ERROR FORMAT
5318+========================================================================
5319+
5320+ ERR code=<CODE> [verb=<VERB>] [rid=<rid>] [mid=<mid>] :reason
5321+
5322+ WARN [mid=<mid>] code=<WARNCODE> :reason
5323+
5324+ ERR signals a rejected or failed operation. WARN signals an accepted
5325+ operation that was modified by the server before execution. clients
5326+ MUST NOT treat WARN as an error. see %-section_26_0-% for WARN codes.
5327+
5328+ mid= is present only when the WARN is associated with a specific
5329+ message. advisory WARNs (OPK_DEPLETED, SKDM_PENDING) carry no mid=;
5330+ parsers MUST handle WARN with mid= absent.
5331+
5332+ standard ERR codes:
5333+
5334+ UNKNOWN -- unrecognized verb
5335+ NOPERM -- permission denied
5336+ NOTAUTH -- not authenticated; verb requires auth (%-section_4_4-%)
5337+ NOTFOUND -- resource does not exist
5338+ TOOLONG -- line exceeds 4096 bytes (%-section_1-%)
5339+ AUTHFAIL -- authentication failed
5340+ RATELIMIT -- rate limit exceeded; retry= tag present
5341+ TOOBIG -- upload size exceeds server limit
5342+ BADARG -- malformed tag value, bad payload encoding,
5343+ missing required tag, or invalid ID format
5344+ CONFLICT -- resource already exists (e.g. duplicate name)
5345+ GONE -- resource existed but was deleted or expired
5346+ FORBIDDEN -- operation is structurally disallowed (distinct from
5347+ NOPERM: NOPERM means the user lacks a permission;
5348+ FORBIDDEN means the operation cannot be performed
5349+ regardless of permission, e.g. sole owner leaving)
5350+ CHALLENGE -- operation requires out-of-band verification before it
5351+ may proceed. url= tag carries a server-provided HTTPS
5352+ URL where the user completes the challenge (web form,
5353+ email link, CAPTCHA, etc.). the protocol carries only
5354+ the redirect; the challenge itself is not on the wire.
5355+
5356+ reason payload is human-readable and MUST NOT be used by clients
5357+ for programmatic logic. clients MUST use code= for control flow.
5358+
5359+%- rate_limiting = "Sect. 18" -%
5360+========================================================================
5361+%-rate_limiting-% -- RATE LIMITING
5362+========================================================================
5363+
5364+ servers MUST rate limit per-connection and per-uid.
5365+
5366+ on limit hit:
5367+ ERR code=RATELIMIT verb=<verb> scope=conn|uid retry=<unix-ms> :reason
5368+
5369+ scope=conn -- this connection is throttled regardless of uid
5370+ scope=uid -- this uid is throttled globally across all connections
5371+
5372+ retry is the earliest unix-ms at which the client SHOULD retry the
5373+ throttled verb. this is normative: clients MUST NOT retry before retry.
5374+
5375+ additional normative rules:
5376+ - servers MUST implement per-(uid, verb) sliding window tracking.
5377+ - servers SHOULD apply stricter limits to MSG than to PING or CAPS.
5378+ - servers MAY close the connection after repeated limit violations.
5379+
5380+ non-normative implementation parameters (server-defined):
5381+ - window duration
5382+ - message count thresholds per window
5383+ - escalation policy
5384+
5385+%- client_conformance = "Sect. 19" -%
5386+========================================================================
5387+%-client_conformance-% -- CLIENT CONFORMANCE LEVELS
5388+========================================================================
5389+
5390+ L0 minimal
5391+ -- HELLO / AUTH / CAPS / [USE] / MSG / SUB / UNSUB / HIST / PING / IDENTIFY
5392+ -- SRVADM op=policy (pre-auth server policy query; %-section_27_1-%)
5393+ -- USE is optional at L0; L0 clients MAY omit it and receive all
5394+ verbs without declaring capability intent
5395+ -- raw text output; no markup rendering
5396+ -- escape sequences may be passed through or decoded client-side
5397+
5398+ L1 text client
5399+ -- L0 + TYPING + PRESENCE + REACT + UNREACT + EDIT + DEL
5400+ -- KEYROTATE (key rotation; %-section_4_6-%)
5401+ -- PING receipt, notification, PING op=list, and PING op=dismiss
5402+ -- READSTATE op=mark and op=query (read state tracking)
5403+ -- WARN receipt and display (%-section_26_0-%)
5404+ -- escape decoding (%-section_2_4-%, step 2)
5405+ -- inline markup rendering (%-section_9_2-%) including @mention highlight
5406+
5407+ L2 feature client
5408+ -- L1 + UPLOAD + EMOJI + THREAD verb + PIN + UNPIN
5409+ -- KVAL op=set / op=get scope=user (user profile decoration; %-section_7_4-%)
5410+ -- role mention rendering with ROLE op=appearance color/emoji (%-section_8_2a-%)
5411+ -- block markup rendering (%-section_9_3-%)
5412+ -- emoji cache and resolution
5413+ -- READSTATE cursor= handling (%-section_6_9-%)
5414+ -- CHAN op=* events (channel list, live channel updates)
5415+ -- COMM op=channel.list / channel.info (%-section_8_4-%)
5416+ -- HIST tid= filter; thread metadata via THREAD op=info
5417+
5418+ L3 full client
5419+ -- L2 + VOICE + board/forum channels (title=, tags= on MSG) + COMM management
5420+ -- COMM op=update (community name and open= flag; %-section_8_0-%)
5421+ -- COMM op=channel.rename (channel display name update; %-section_8_4-%)
5422+ -- LIST verb (%-section_11_5-%): board/forum/text top-level content browsing
5423+ -- community branding display, query, and update (KVAL scope=comm;
5424+ %-section_8_6-%)
5425+ -- full emoji management UI
5426+ -- board tag filtering (LIST tags=); forum lock filtering (LIST locked=)
5427+ -- invite management (COMM op=invite.list / invite.revoke; INVITE op=info)
5428+ -- SRVADM (%-section_27-%)
5429+ -- E2E encryption participation: PUBKEY op=prekey publish and fetch,
5430+ SKDM distribution and receipt, keysync catch-up, Sender Key
5431+ rotation on member removal (%-section_29_2-%)
5432+
5433+ clients MUST declare level in HELLO:
5434+ HELLO version=1 level=<n> :clientname/version
5435+
5436+ conformance level is informational. servers MUST NOT gate verbs on
5437+ level. it is for debugging and operator tooling only.
5438+
5439+ level= is ephemeral and intentionally unqueryable (normative):
5440+ servers MUST NOT persist level= to durable storage. it is a
5441+ per-connection hint, not uid-scoped state. there is no query
5442+ surface for level= and none is defined. this is the same design
5443+ as USE caps= (%-section_16-%): both are client declarations about
5444+ rendering intent that affect nothing on the server and therefore
5445+ do not constitute "server-maintained state" subject to the
5446+ "no hidden state" invariant (%-section_2_6-%). operator tooling
5447+ wishing to audit client capabilities MUST use out-of-band logging
5448+ of HELLO lines; the protocol provides no query path for this.
5449+
5450+%- example_session_l0 = "Sect. 20" -%
5451+========================================================================
5452+%-example_session_l0-% -- EXAMPLE SESSION (L0)
5453+========================================================================
5454+
5455+ client: HELLO version=1 level=0 :wireshell/1.0
5456+ server: HELLO sid=01JX000000000000000000000 ts=1700000000000
5457+ :wire.example.org/0.8
5458+ client: AUTH method=TOKEN uid=01JXAAA000000000000000000 :s3cr3t
5459+ server: OK uid=01JXAAA000000000000000000 :welcome, xplshn
5460+ client: SUB rid=/example/general
5461+ server: OK verb=SUB rid=/example/general cid=01JXBBB000000000000000000
5462+ server: MSG cid=01JXBBB000000000000000000 mid=01JXCCC000000000000000000
5463+ uid=01JXDDD000000000000000000 ts=1700000000000 :hello wire
5464+ client: MSG cid=01JXBBB000000000000000000 :hi from shell
5465+ server: OK verb=MSG cid=01JXBBB000000000000000000 mid=01JXEEE000000000000000000 ts=1700000001000
5466+ server: MSG cid=01JXBBB000000000000000000 mid=01JXEEE000000000000000000
5467+ uid=01JXAAA000000000000000000 ts=1700000001000 :hi from shell
5468+
5469+%- example_session_l1 = "Sect. 21" -%
5470+========================================================================
5471+%-example_session_l1-% -- EXAMPLE SESSION (L1, PUBKEY AUTH + MULTILINE + REACTIONS)
5472+========================================================================
5473+
5474+ // uid shown below is illustrative base32 derived from a pubkey hash.
5475+ // actual uids look like any 26-char Crockford base32 string.
5476+
5477+ // pubkey auth: clean two-line flow; no repeated pubkey
5478+ client: HELLO version=1 level=1 :wireclient/0.8
5479+ server: HELLO sid=01JX000000000000000000000 ts=1700000000000
5480+ :wire.example.org/0.8
5481+ client: AUTH method=PUBKEY uid=7ZNKQ5XMVP2R4ABCDE01JXAAA :AAAA...pubkey
5482+ server: CHALLENGE nonce=a3f9e2d1c0b7a6f5...64hexchars...9e8d7c6b
5483+ // client signs: nonce_bytes(32) || "01JX000000000000000000000"(26) ||
5484+ // ts_uint64_be(8) = 66 bytes total
5485+ client: AUTH method=PUBKEY sig=BBBB...base64url-sig-no-padding
5486+ server: OK uid=7ZNKQ5XMVP2R4ABCDE01JXAAA :welcome
5487+
5488+ // declare caps intent
5489+ client: USE caps=reactions,threads,upload
5490+ server: OK verb=USE
5491+
5492+ // first-time display name
5493+ client: IDENTIFY name=xplshn
5494+ server: OK verb=IDENTIFY uid=7ZNKQ5XMVP2R4ABCDE01JXAAA ts=1700000001000
5495+ server: PROFILE uid=7ZNKQ5XMVP2R4ABCDE01JXAAA name=xplshn ts=1700000001000
5496+
5497+ // subscribe and multiline message
5498+ client: SUB rid=/example/general
5499+ server: OK verb=SUB rid=/example/general cid=01JXBBB000000000000000000
5500+ client: MSG cid=01JXBBB000000000000000000 :line one\nline two
5501+ server: OK verb=MSG cid=01JXBBB... mid=01JXFFF... ts=1700000002000
5502+ server: MSG cid=01JXBBB... mid=01JXFFF... uid=7ZNKQ5XMVP2R4ABCDE01JXAAA
5503+ ts=1700000002000 :line one\nline two
5504+ // OK arrives first with the server-assigned mid; broadcast follows
5505+
5506+ // mark read: originating session gets OK; other sessions get broadcast
5507+ client: READSTATE op=mark mid=01JXFFF000000000000000000 cid=01JXBBB000000000000000000
5508+ server: OK verb=READSTATE op=mark mid=01JXFFF000000000000000000 cid=01JXBBB000000000000000000
5509+ // (other session of same uid receives the READSTATE broadcast separately)
5510+
5511+ // reaction
5512+ client: REACT mid=01JXFFF000000000000000000 eid=thumbsup
5513+ server: OK verb=REACT mid=01JXFFF... cid=01JXBBB... eid=thumbsup ts=1700000003000
5514+ server: REACT mid=01JXFFF... cid=01JXBBB... uid=7ZNKQ5XMVP2R4ABCDE01JXAAA
5515+ eid=thumbsup name=thumbsup ts=1700000003000
5516+ // broadcast carries cid= (self-identifying per %-section_2_5-%)
5517+
5518+ // history (reactions included; EDIT would carry rev= and cid= if present)
5519+ client: HIST cid=01JXBBB000000000000000000 limit=10
5520+ server: MSG cid=01JXBBB... mid=01JXFFF... uid=7ZNKQ5XMVP2R4ABCDE01JXAAA
5521+ ts=1700000002000 :line one\nline two
5522+ server: REACT mid=01JXFFF... cid=01JXBBB... uid=7ZNKQ5XMVP2R4ABCDE01JXAAA
5523+ eid=thumbsup name=thumbsup ts=1700000003000
5524+ server: OK verb=HIST cid=01JXBBB... count=2 has_more=0
5525+
5526+ // query pending PINGs explicitly
5527+ client: PING op=list gid=01JXGGG000000000000000000
5528+ server: PING mid=01JXHHH... cid=01JXBBB... gid=01JXGGG... uid=01JXDDD...
5529+ ts=1700000000500 kind=user
5530+ server: OK verb=PING op=list count=1 total=1
5531+
5532+%- example_session_l2 = "Sect. 22" -%
5533+========================================================================
5534+%-example_session_l2-% -- EXAMPLE SESSION (L2, CHANNEL DISCOVERY + RECONNECT)
5535+========================================================================
5536+
5537+ // demonstrates two scenarios:
5538+ // A) first-time connect: discover channels and subscribe.
5539+ // B) reconnect: server restores subscriptions automatically; client
5540+ // only needs to fetch history from last read cursor.
5541+
5542+ // auth phase (abbreviated; see %-section_21-% for full pubkey flow)
5543+ client: HELLO version=1 level=2 :wireclient/0.8
5544+ server: HELLO sid=01JX000000000000000000000 ts=1700000010000
5545+ :wire.example.org/0.8
5546+ // ... AUTH handshake ...
5547+ server: OK uid=7ZNKQ5XMVP2R4ABCDE01JXAAA :welcome
5548+ // server sends any pending PINGs immediately after OK, then restores
5549+ // subscriptions: broadcasts are live on all previously subscribed channels
5550+ // before the client issues any verb.
5551+
5552+ // SCENARIO A: first-time client (no prior subscriptions)
5553+
5554+ // step 1: find communities I belong to
5555+ client: COMM op=list
5556+ server: COMM op=info gid=01JXGGG000000000000000000 slug=example
5557+ name=Example%20Community open=0 members=42
5558+ owner=01JXZZZ000000000000000000 ts=1700000000000
5559+ server: OK verb=COMM op=list count=1 total=1
5560+
5561+ // step 2: discover channels
5562+ client: COMM op=channel.list gid=01JXGGG000000000000000000
5563+ server: CHAN op=info gid=01JXGGG... cid=01JXBBB... slug=general
5564+ name=general type=text e2e=0 ts=1700000000000
5565+ server: CHAN op=info gid=01JXGGG... cid=01JXCCC... slug=announcements
5566+ name=announcements type=text e2e=0 ts=1699000000000 :Welcome to Example
5567+ server: OK verb=COMM op=channel.list gid=01JXGGG... count=2 total=2
5568+
5569+ // step 3: subscribe --- durable; server will restore on next connect
5570+ client: SUB cid=01JXBBB000000000000000000
5571+ server: OK verb=SUB rid=/example/general cid=01JXBBB000000000000000000
5572+ client: SUB cid=01JXCCC000000000000000000
5573+ server: OK verb=SUB rid=/example/announcements cid=01JXCCC000000000000000000
5574+
5575+ // step 4: fetch history
5576+ client: HIST cid=01JXBBB000000000000000000 limit=50
5577+ server: MSG cid=01JXBBB... mid=01JXFFF... uid=... ts=... :hello wire
5578+ server: OK verb=HIST cid=01JXBBB... count=1 has_more=0
5579+
5580+ // SCENARIO B: reconnect (subscriptions already stored server-side)
5581+
5582+ // after AUTH OK and PING burst, server has already restored broadcasts
5583+ // on 01JXBBB and 01JXCCC. client only needs to catch up on missed messages.
5584+
5585+ // step 1: get read cursors
5586+ client: READSTATE op=query gid=01JXGGG000000000000000000
5587+ server: READSTATE op=query cid=01JXBBB... count=3
5588+ oldest=01JXFFF... cursor=01JXEEE...
5589+ server: READSTATE op=query cid=01JXCCC... count=0
5590+ server: OK verb=READSTATE op=query gid=01JXGGG...
5591+
5592+ // step 2: fetch missed messages from cursor
5593+ client: HIST cid=01JXBBB000000000000000000 after=01JXEEE... limit=50
5594+ server: MSG cid=01JXBBB... mid=01JXFFF... uid=... ts=... :first unread
5595+ server: MSG cid=01JXBBB... mid=01JXGGG2... uid=... ts=... :second unread
5596+ server: MSG cid=01JXBBB... mid=01JXHHH2... uid=... ts=... :third unread
5597+ server: OK verb=HIST cid=01JXBBB... count=3 has_more=0
5598+
5599+ // no local storage was used. all state was recovered from server queries.
5600+
5601+%- implementation_notes = "Sect. 23" -%
5602+========================================================================
5603+%-implementation_notes-% -- IMPLEMENTATION NOTES
5604+========================================================================
5605+
5606+ Go reference implementations for key primitives (key generation,
5607+ uid derivation, payload unescaping, line parsing, server data store
5608+ sketch) are in IMPL.md. all examples are non-normative.
5609+
5610+%- design_rationale = "Sect. 24" -%
5611+========================================================================
5612+%-design_rationale-% -- DESIGN RATIONALE
5613+========================================================================
5614+
5615+ why mentions are structural tags and not parsed from markup?
5616+ if PINGs depended on parsing @uid:name markup, every server would
5617+ need a markup parser on the hot path, violating the dumb-relay
5618+ design. separating mention metadata (tags) from mention rendering
5619+ (markup) keeps servers dumb. the sender declares who they mention;
5620+ the server validates membership and delivers PINGs. an L0 minimal client
5621+ that never renders markup still participates correctly by including
5622+ the tags. the body and the tags are allowed to diverge; the protocol
5623+ does not enforce consistency between them. this is intentional.
5624+
5625+ why PING is out-of-band and not in the channel stream?
5626+ a user subscribed to 50 channels would have to consume all channel
5627+ traffic to find the 2 messages that mentioned them. PING delivers
5628+ the needle directly. the client then fetches context via HIST only
5629+ if needed. this inverts the correct flow: mention delivery is O(1)
5630+ per recipient, not O(channel-volume).
5631+
5632+ why READSTATE instead of SEEN/UNSEEN?
5633+ SEEN (mark) and UNSEEN (query) were two verbs operating on the same
5634+ state from opposite directions with asymmetric naming. READSTATE
5635+ op=mark / op=query is a single verb, single state surface, explicit
5636+ direction via op=. the same pattern used by PING op=list and COMM
5637+ op=*. orthogonality over naming convenience.
5638+
5639+ why READSTATE op=mark sends OK to the originator, not just a broadcast?
5640+ the originating session needs to confirm the server accepted the mark
5641+ before it can safely update its local unseen count. if only the
5642+ broadcast were sent (to other sessions), the originating session would
5643+ have no confirmation: it could not distinguish "server accepted" from
5644+ silence caused by a dropped connection. every other mutating verb has
5645+ an OK. READSTATE op=mark is consistent with that convention. the
5646+ broadcast to other sessions is sent after the OK, as with all
5647+ multi-target operations.
5648+
5649+ why WHO returns PROFILE?
5650+ WHO (pull) and IDENTIFY-triggered broadcast (push) both describe the
5651+ same entity state. using two verbs (USER for pull, PROFILE for push)
5652+ means clients need two handlers for identical data. a single PROFILE
5653+ verb with consistent fields means one handler, one cache path,
5654+ regardless of whether the data was requested or delivered
5655+ unsolicited. KEYROTATE also broadcasts PROFILE with prev_uid= so
5656+ clients can merge cache entries on key rotation.
5657+
5658+ why CAPS query / USE declare are separate verbs?
5659+ CAPS with no args and CAPS use=... shared a verb but had opposite
5660+ directionality: one is a server query, one is a client declaration.
5661+ same verb, different semantics, distinguished by tag presence alone.
5662+ USE caps=... is unambiguous. CAPS remains a pure server query.
5663+ the split costs one additional verb name and gains clarity.
5664+
5665+ why ROLE is the single verb for all role operations?
5666+ CHAN uses one verb for both query replies (CHAN op=info) and events
5667+ (CHAN op=create, op=delete, etc.). EMOJI does the same. the earlier
5668+ design used ROLEAPP as a separate event verb alongside ROLE for
5669+ queries, which broke this pattern. a client parser dispatches on
5670+ verb first; if role events arrive as ROLEAPP while queries come
5671+ back as ROLE, the client needs two dispatch paths for the same
5672+ logical entity. unifying under ROLE op=* means one dispatch path,
5673+ one cache-update handler, no confusion about which verb carries
5674+ which kind of event. the ROLEAPP name is retired.
5675+
5676+ why ROLE op=create is broadcast (not just the OK to the requester)?
5677+ without ROLE op=create, a client that is online when a new role is
5678+ created only learns of it when it receives a ROLE op=assign or
5679+ ROLE op=appearance for that rolid --- at which point it must do a
5680+ COMM op=role.list round-trip to learn the role name. this is the
5681+ same problem that motivated CHAN op=create broadcasts. the fix is
5682+ the same: broadcast on creation so all connected clients stay
5683+ coherent without a round-trip. ROLE op=create carries name, color,
5684+ and perms --- everything needed to cache the role fully.
5685+
5686+ why CHAN for channel events and queries?
5687+ channel management (COMM op=channel.*) writes; channel events and
5688+ queries are read-path. CHAN op=info is the query reply (mirrors ROLE
5689+ op=info and MEMBER op=info). CHAN op=create/delete/rename/topic/perm
5690+ are broadcasts (mirrors ROLE op=create/delete, MEMBER, KVAL). this
5691+ gives every entity type the same two-concern split: one management
5692+ verb (COMM) and one read/event verb (CHAN / ROLE / MEMBER / etc).
5693+ the pattern is consistent and composable.
5694+
5695+ why COMM op=update broadcasts on COMM instead of a separate event verb?
5696+ every other community entity has a dedicated event verb: CHAN, ROLE,
5697+ MEMBER, EMOJI, KVAL, INVITE. COMM op=update is the only place where
5698+ the management verb also carries the broadcast. this is an intentional
5699+ design exception. introducing a COMMUNITY event verb for a single op
5700+ would expand the verb surface without orthogonality gain. the uid=
5701+ discriminant makes direction unambiguous. the exception is documented
5702+ here so that implementors do not mistake it for a pattern to repeat;
5703+ all future entity additions MUST follow the COMM/EVENT split.
5704+
5705+ why only role and emoji get pre-SUB-OK cache-warm pushes?
5706+ the cache-warm push principle (%-section_8_2a-%) restricts pre-SUB-OK pushes to
5707+ entity data required for rendering incoming MSG lines: roles (mention
5708+ display, hoisting, colour) and emoji (inline emoji rendering). branding,
5709+ member lists, channel permission lists, and channel lists are NOT
5710+ required to render a message line. pushing them pre-SUB-OK would make
5711+ subscribe latency a function of community size (unbounded). those
5712+ entities are queried on demand. the CHAN op=perm push MAY be included
5713+ in the pre-SUB-OK burst (%-section_8_4-%) because permission state is needed
5714+ to render the correct input UI before the first message arrives; this
5715+ is a narrow, bounded set (one CHAN op=perm line per role override).
5716+
5717+ why COMM op=channel.perm.list exists (was not in pre-0.8 versions)?
5718+ channel-level permission overrides (CHAN op=perm) were broadcast as
5719+ live events but had no query surface. a reconnecting client that missed
5720+ the broadcast could not reconstruct the override state. the protocol's
5721+ core invariant is that state is fully reconstructible from query +
5722+ event stream (%-section_24-% event log model). channel perm overrides violated
5723+ that invariant. COMM op=channel.perm.list closes the gap. its reply
5724+ format (CHAN op=perm lines without uid=) reuses the existing broadcast
5725+ format minus the event-specific uid= and ts=, keeping the verb set DRY.
5726+
5727+ why SRVADM op=perm.grant/revoke instead of op=grant/revoke?
5728+ SRVADM has two conceptually different operations: role assignment
5729+ (op=role.assign / op=role.revoke, mirrors COMM op=role.assign) and
5730+ per-uid permission grants (op=perm.grant / op=perm.revoke for
5731+ community.create). the original op=grant / op=revoke names were
5732+ ambiguous --- they looked like they could apply to roles too. the perm.
5733+ namespace prefix distinguishes role operations from direct permission
5734+ operations and makes the asymmetry explicit: server.admin is a role
5735+ (assigned/revoked); community.create is a per-uid flag (granted/revoked).
5736+ future per-uid server permissions would use op=perm.grant/revoke as well.
5737+
5738+ why SRVADM op=info uid=<own-uid> is permitted without server.admin?
5739+ a uid granted community.create (but not server.admin) has server-level
5740+ state (their own grant record) that directly affects protocol behaviour
5741+ (whether COMM op=create succeeds). without the self-query exception,
5742+ that uid has no protocol path to confirm the grant is present, active,
5743+ or has been revoked. this is a clean violation of the "no hidden state"
5744+ principle (%-section_2_6-%). the exception is narrow: it permits a uid to inspect
5745+ only its own SRVADM record, which contains no other uid's data. it does
5746+ not permit calling SRVADM op=list, SRVADM op=role.assign, or any other
5747+ SRVADM mutating op without server.admin. the self-query exception
5748+ follows the same principle as WHO uid=<self>, which returns the real
5749+ (possibly invisible) presence status to the owning uid without general
5750+ presence-inspection permission.
5751+
5752+ why HIST EDIT lines must carry rev=?
5753+ a client replaying HIST to reconstruct message state needs to know
5754+ whether an EDIT is the latest revision or an intermediate one. in
5755+ practice HIST returns only the latest revision body, but rev= allows
5756+ the client to validate this and handle any future history-expansion
5757+ extension without protocol changes. omitting rev= from HIST EDIT
5758+ was an oversight; it is now normative.
5759+
5760+ why HIST has has_more=?
5761+ count alone (number of MSG lines) was ambiguous for pagination: a
5762+ result of count=2 could be a reaction-dense window with 2 messages
5763+ and 48 events, or a sparse window at the beginning of history. the
5764+ client cannot distinguish "there's more" from "you've hit the end"
5765+ without knowing total event count relative to the requested limit.
5766+ has_more=0|1 is a single bit that answers the only question the
5767+ client actually has: keep paginating or stop?
5768+
5769+ why does bare HIST (no anchor) return the NEWEST messages?
5770+ a client opening a channel always wants to see the most recent
5771+ messages. returning the oldest messages by default would require
5772+ every client to fabricate a sentinel mid at the supremum of the ULID
5773+ space just to get the initial view --- a fragile dependency on
5774+ implementation details of the ID encoding. "newest by default" is
5775+ the correct and only useful default. scroll-up pagination via
5776+ before=<oldest-loaded-mid> is the natural complement.
5777+
5778+ why seen state is client-reported and not inferred from delivery?
5779+ delivery does not imply reading. the client knows when the user
5780+ views content; the server does not. server-side inference (seen on
5781+ delivery) is wrong for any non-trivial client. explicit READSTATE
5782+ op=mark from the client is the only correct model. READSTATE is
5783+ also intentionally NOT broadcast to other channel members; it is
5784+ private per-uid state, synced only to other sessions of the same uid.
5785+
5786+ why COMM op=create returns default_cid=?
5787+ the server atomically creates a general channel on community creation.
5788+ without default_cid= in the OK, the creator must immediately issue
5789+ COMM op=channel.list just to find the channel they were just placed
5790+ into. this is a wasted round-trip for information the server already
5791+ has. including default_cid= in the creation reply closes the loop
5792+ at zero cost: one OK line, client is immediately ready to SUB.
5793+
5794+ why COMM op=role.create returns rolid=?
5795+ rolids are server-assigned ULIDs. the client has no way to learn the
5796+ rolid of a role it just created without a follow-up COMM op=role.list.
5797+ returning rolid= in the OK is consistent with how aid= is returned
5798+ from emoji.add and cid= from channel.create. the client needs the id
5799+ immediately to do anything with the new entity.
5800+
5801+ why identity-based uid (key-derived, not server-assigned)?
5802+ a server-assigned uid requires the server to exist before identity
5803+ does. a key-derived uid means the identity exists before any server
5804+ knows about it. this enables:
5805+ - same uid across all servers without coordination
5806+ - zero-step "registration": first auth creates the account
5807+ - federation: messages from uid X mean the same person everywhere
5808+ - key rotation without losing identity (%-section_4_6-% alias chain)
5809+ the SHA-256 + base32 derivation is trivial to implement in any
5810+ language with a standard crypto library. it is not a ULID (no time
5811+ component) but fits in the same 26-char slot.
5812+
5813+ why include sid and ts in the signed challenge message?
5814+ nonce alone prevents replay within the TTL window. adding sid binds
5815+ the signature to this specific server: a signature captured from
5816+ server A cannot be replayed against server B even if they share a
5817+ nonce collision. adding ts (the server's own timestamp, not the
5818+ client's) binds it to the time window without requiring the client
5819+ to have a synchronized clock. the client signs what the server told
5820+ it; the server verifies using its own values. no clock trust required.
5821+
5822+ why two AUTH lines instead of one?
5823+ the challenge-response pattern is necessary for security: the server
5824+ must present fresh randomness before the client signs. a single-line
5825+ auth would require the client to sign a value it chooses (insecure)
5826+ or repeat the pubkey in the second line (what 0.2 did, redundant).
5827+ 0.3 removes the redundancy: pubkey in line 1, sig in line 2, no
5828+ repeat. the server binds them via the connection state machine.
5829+
5830+ why token auth at all if pubkey is the primary method?
5831+ tokens serve real use cases that key-based auth handles poorly:
5832+ - bot accounts running on servers without key storage
5833+ - session tokens so the long-term key is not repeatedly used
5834+ - API access from shell scripts (no crypto primitives needed)
5835+ tokens are always tied to a uid that has a keypair backing it.
5836+ they are second-class credentials, not primary identity.
5837+
5838+ why line-oriented?
5839+ line-oriented protocols are trivially debuggable: netcat or a Go
5840+ bufio.Scanner is sufficient. no parser library needed; a single
5841+ strings.Index call splits the payload. log files are human-readable.
5842+
5843+ why ULID?
5844+ time-sortable, globally unique, no coordination, no UUID noise.
5845+ prefix scans replace range queries.
5846+
5847+ why server-assigned ULIDs?
5848+ clock skew and adversarial clients. the server controls sort order
5849+ and enforces the monotonic increment rule.
5850+
5851+ why \n escaping instead of percent-encoding for payload?
5852+ percent-encoding (%0A) is correct for tag values where the valid
5853+ character set is tightly constrained. for human-readable message
5854+ bodies, \n is the universal convention. it reads better in raw
5855+ logs. it requires less allocation. percent-encoding payload would
5856+ create a second encoding context that conflicts with how operators
5857+ read wire traffic. \n wins on legibility and convention.
5858+
5859+ why deny-wins in permission resolution?
5860+ explicit deny must mean deny. if allow-wins, revoking a permission
5861+ granted by a lower-priority role requires editing that role.
5862+ deny-wins makes revocation a local operation on the denying role.
5863+ community.admin is the only absolute override, and it is explicit.
5864+
5865+ why not aggregate reactions server-side?
5866+ aggregation is a rendering decision. a server that aggregates
5867+ introduces server-side rendering state, which contradicts the
5868+ transparent/dumb-relay design. clients replay REACT/UNREACT from
5869+ HIST and build counts locally.
5870+
5871+ why does HIST tid= on text channels include the root message via
5872+ retroactive annotation rather than requiring clients to fetch it
5873+ separately?
5874+ when a text-channel thread is created implicitly (%-section_11_3-%),
5875+ the root message has already been broadcast without tid=. if HIST
5876+ tid= did not include the root, clients would need a separate HIST
5877+ or fetch for every thread view -- a wasted round-trip for data the
5878+ server already has. retroactively annotating the stored root record
5879+ with tid= is a bounded server-side mutation (one field, one record,
5880+ at implicit creation time) that eliminates this penalty. the
5881+ live-vs-HIST divergence (live broadcast has no tid=; HIST does) is
5882+ accepted as the lesser cost. clients that received the root live can
5883+ identify it as the thread opener when they receive the first reply
5884+ (its mid == the tid of the reply). clients that only see HIST get
5885+ the complete thread in one request.
5886+
5887+ why does VOICE op=join support a client codec preference?
5888+ the original design had the server pick the codec unilaterally.
5889+ a minimal client (pcm16le only, no Opus) on a server that defaults
5890+ to opus would fail to join any voice channel unless it happened to
5891+ join first. adding [codec=<preferred-codec>] to the send form lets
5892+ the client express capability without requiring a separate
5893+ negotiation verb. the server retains final authority (OK codec=
5894+ carries the actual selection). the invariant that servers MUST
5895+ support pcm16le ensures any conforming client can always join.
5896+ per-channel codec locking (all participants share one codec)
5897+ avoids server-side transcoding, which would be expensive and
5898+ contradict the dumb-relay design.
5899+
5900+ why markup stored verbatim?
5901+ servers are routers, not renderers. a dumb client gets readable
5902+ raw text. no server-side HTML ever touches the wire.
5903+
5904+ why no binary framing?
5905+ framing adds complexity. attachments use HTTP. the chat bus does
5906+ one thing: route text lines.
5907+
5908+ why caps advisory and not mandatory?
5909+ mandatory caps create negotiation deadlocks and break minimal
5910+ clients. advisory caps let clients declare intent without the
5911+ server gatekeeping on declared level.
5912+
5913+ why no bulk subscriber list verb (like IRC NAMES)?
5914+ IRC needs NAMES because the server is the sole source of truth for
5915+ channel membership. WIRE inverts this: identity is key-derived and
5916+ server-agnostic; channels have subscribers (transient), not members
5917+ (durable). durable membership is a community concept (gid). clients
5918+ build their uid cache organically: lazy WHO on first encounter,
5919+ reactive PROFILE updates on identity change. a bulk subscriber
5920+ snapshot would be stale immediately and tempts clients to use it as
5921+ a presence oracle. community membership enumeration is COMM
5922+ op=member.list (available to any community member, paginated).
5923+
5924+ why READSTATE cursor= instead of a separate SYNC verb?
5925+ SYNC was a second durable store for the "last read position" per
5926+ (uid, cid), motivated by READSTATE's seen-bits being per-message
5927+ rather than per-channel. but READSTATE op=mark with upto= already
5928+ makes seen-state channel-level: the server records the high-water
5929+ mid. that high-water mid IS the cursor. adding cursor= to
5930+ READSTATE op=query exposes data the server already holds from the
5931+ same store, in the same verb, with no second store and no new cap.
5932+ a client reconnecting after being offline issues
5933+ READSTATE op=query gid=<gid> and gets count + oldest + cursor for
5934+ every channel in one sweep. SYNC was solving a convenience problem
5935+ (one round-trip resume) that cursor= solves identically. removing
5936+ SYNC eliminates a cap, a section, a store, and a rationale paragraph
5937+ defending its existence.
5938+
5939+ why no reference definitions for role appearance?
5940+ reference definitions (CommonMark-style [id]: scheme:value in message
5941+ bodies) were intended to let message authors suggest per-message
5942+ styling for roles and users. but ROLE op=appearance already delivers
5943+ server-authoritative role appearance to clients on SUB, and
5944+ COMM op=role.list covers cache misses. an in-body styling mechanism
5945+ that is always overridden by ROLE op=appearance data adds parsing
5946+ burden to every message in every client without adding correctness.
5947+ the @rolid:name markup already carries a display hint for L0 clients.
5948+ two mechanisms (ROLE op=appearance for authority, name hint for L0)
5949+ are sufficient. a third is not needed.
5950+
5951+ why EDIT, DEL, REACT, and UNREACT OKs carry cid=?
5952+ every mutating verb has an OK. MSG got its OK in 0.8 for exactly
5953+ this reason (see "why MSG has an OK response?"). EDIT and DEL were
5954+ the remaining exceptions. the same argument applies: the originating
5955+ client has no reliable confirmation until the broadcast arrives, and
5956+ correlating the broadcast to a sent EDIT or DEL requires matching by
5957+ mid (since there is nothing else to correlate on). an OK carrying
5958+ rev= (for EDIT) or ts= (for DEL) closes the loop at zero cost and
5959+ makes every mutating verb uniformly confirmable. consistency is
5960+ worth more than the two extra OK lines per operation.
5961+
5962+ cid= is included in all four OKs (EDIT, DEL, REACT, UNREACT) for
5963+ the same reason it is included in MSG OK: a client pipelining these
5964+ operations across multiple channels cannot route the OK without
5965+ pending-request state unless the OK carries cid=. the server derives
5966+ cid from its mid->cid index at validation time --- it already performs
5967+ this lookup to authorize the operation --- so echoing cid= in the OK
5968+ costs nothing. this makes all four OKs self-routing, consistent with
5969+ MSG OK and every other operation in the protocol.
5970+
5971+ why attachments= on MSG instead of a separate ATTACH event in HIST?
5972+ a client rendering an inline image in a live MSG or in HIST needs
5973+ the MIME type to choose a renderer (image/webp vs image/gif) or
5974+ to decide whether to show a file icon rather than inline-render.
5975+ the only current path is a separate ATTACH aid= round-trip per
5976+ attachment, per message. for a channel with 50 messages each
5977+ carrying one attachment, that is 50 extra round-trips before the
5978+ feed is renderable. attachments= mirrors the mentions= pattern
5979+ exactly: structural metadata carried alongside the body so the
5980+ client has everything it needs in the message line itself. servers
5981+ already store mime= at UPLOAD time; surfacing it here costs nothing.
5982+ ATTACH aid= remains the correct path for resolving a download URL
5983+ (which is time-limited and must not be cached); attachments= only
5984+ carries the type information needed for rendering decisions.
5985+
5986+ why mime= on EMOJI lines?
5987+ emoji assets are typed: a static PNG, an animated GIF, an animated
5988+ WebP, and a static WebP are all different rendering cases. the
5989+ animated= boolean collapses them to a single bit, which is
5990+ sufficient for the common case but forces clients that care about
5991+ the distinction (e.g. for codec selection or fallback) to fetch the
5992+ asset URL to read the Content-Type header. mime= makes the type
5993+ explicit at cache-fill time (EMOJI op=info on SUB or emoji.list)
5994+ with no extra round-trip. animated= is retained as a convenience
5995+ shortcut; it is derivable from mime= but saves clients that only
5996+ need the boolean from implementing MIME subtype parsing.
5997+
5998+ why UPLOAD OK echoes name=, size=, mime=?
5999+ the client sends these values to propose what it is uploading. the
6000+ server may normalize or reject them (e.g. reject a text/plain mime
6001+ for an image-only upload slot). echoing the server-canonical values
6002+ back in the OK gives the client a single confirmation that what it
6003+ sent was accepted as-is, without requiring a follow-up ATTACH query
6004+ before the HTTP PUT. this is the same principle as COMM op=create
6005+ returning default_cid= and COMM op=role.create returning rolid=:
6006+ the client should not need a round-trip to learn the canonical form
6007+ of data it just submitted.
6008+
6009+ why READSTATE uses tag absence instead of the 'none' sentinel?
6010+ the grammar (%-section_2_1-%) defines tag values as 1*TCHAR: a tag
6011+ value MUST be at least one character. an absent value is expressed
6012+ by omitting the tag, not by encoding it as the string 'none'.
6013+ using 'none' as a sentinel conflates "tag is present with a
6014+ placeholder value" with "tag is absent". receivers that follow the
6015+ grammar already treat absent tags as having no value; 'none' adds
6016+ a second code path for the same semantic. tag absence is the
6017+ canonical representation of no-value throughout the protocol
6018+ (optional tags on MSG, EMOJI, PROFILE, etc.); READSTATE should
6019+ follow the same rule.
6020+
6021+ why COMM op=list includes owner= in COMM op=info lines?
6022+ the list form and the single-query form describe the same entity.
6023+ omitting owner= from the list form forces clients that need
6024+ ownership information (e.g. to display a crown, to decide whether
6025+ to offer a "transfer ownership" action) to issue a COMM op=info
6026+ for each community after listing. this is a needless N+1 pattern.
6027+ the owner= field is a single uid stored in the community record;
6028+ including it in list responses costs one tag per line and eliminates
6029+ the follow-up queries entirely.
6030+
6031+ why THREAD verb instead of COMM op=thread.*?
6032+ COMM is community-scoped management (gid=). thread operations are
6033+ channel-scoped (cid= + tid=). mixing them under COMM would require
6034+ gid= on every THREAD op just to route the permission check, even
6035+ though the server can derive gid from cid. a separate THREAD verb
6036+ keeps the scope correct and avoids polluting COMM with non-community
6037+ operations. the pattern is consistent: MSG for content, CHAN for
6038+ channel metadata, THREAD for thread metadata, COMM for community
6039+ structure.
6040+
6041+ why tid= on text channels uses mid-of-root as tid?
6042+ a thread on a text channel is started by a message with no parent.
6043+ the natural stable identifier for that thread is the message itself.
6044+ requiring the client to remember a separate tid= assigned by the
6045+ server (as on forum channels) would add a round-trip: send MSG,
6046+ wait for OK, learn tid, then others can reply. using mid-as-tid
6047+ allows the client to start accepting replies as soon as it has
6048+ its own mid= from the MSG OK, with no second round-trip. forum
6049+ channels need a server-assigned tid= because the tid= IS the thread
6050+ and it must be stable even if the opening message is deleted;
6051+ text-channel threads are ephemeral reply chains where this
6052+ distinction does not matter.
6053+
6054+ why LIST has tags= filter?
6055+ the purpose of tags= on board posts is discoverability. a LIST
6056+ that cannot filter by tag forces every client to download all posts
6057+ and filter client-side --- defeating the point of tags entirely. the
6058+ server already stores tags= on each MSG; the filter is a prefix
6059+ scan on the indexed tag set, not a full-table scan. it costs nothing
6060+ to expose. for forums, locked= fills the analogous role: a moderator
6061+ or a user who only wants open threads should not have to download
6062+ all threads to separate them.
6063+
6064+ why HIST has tid= filter?
6065+ without it, fetching all replies in a forum thread requires downloading
6066+ the entire channel history and filtering client-side by tid=. this
6067+ is O(channel) instead of O(thread). for a forum with thousands of
6068+ threads, the difference is decisive. tid= on HIST is a server-side
6069+ range scan on the (cid, tid) index, which the server must maintain
6070+ anyway to enforce the tid= tag on broadcast delivery. no new storage
6071+ is needed; the filter just exposes what is already indexed.
6072+
6073+ why board posts cannot be replied to with tid=?
6074+ board channels are a flat list of standalone posts, analogous to
6075+ a subreddit or bulletin board. each post is its own unit. allowing
6076+ tid= on board channels would create an ambiguous model: is a reply
6077+ a nested comment (not visible in LIST) or a separate post? the
6078+ spec avoids this by making board posts strictly flat and using
6079+ ref= for the "quote this post" pattern. if a community wants nested
6080+ discussion on board posts, type=forum is the correct channel type.
6081+
6082+ why THREAD op=unlock requires thread.manage only for moderator locks?
6083+ the original spec required thread.manage for all unlocks, which
6084+ trapped authors: self-close a question, realize it was a mistake,
6085+ unable to reopen without a moderator. the fix is to track locked_by
6086+ on the server. if locked_by == requesting uid, the author locked it
6087+ and can freely reopen it. if locked_by != requesting uid, a moderator
6088+ locked it and the author cannot override --- only someone with
6089+ thread.manage can. this is exactly the right model: self-service
6090+ closure with protected moderator locks. the locked_by field is a
6091+ single uid stored in the thread record; no additional store needed.
6092+
6093+ why no POST verb? why does MSG carry title= and tags= instead?
6094+ a board post is a MSG with a title and optional tags on a type=board
6095+ channel. a forum thread opener is a MSG with a title on a type=forum
6096+ channel where the server assigns the tid=. the structured metadata
6097+ (title=, tags=) is carried as tags on MSG, exactly as mentions= and
6098+ rolmentions= are. separate verbs POST and THREAD would have duplicated
6099+ the MSG broadcast path with no new semantics. one verb (MSG) composes
6100+ with channel type to produce the correct behaviour. LIST replaces the
6101+ old POST/THREAD listing verb with a uniform interface across channel
6102+ types.
6103+
6104+ why invisible broadcasts as offline to others?
6105+ the entire purpose of invisible is to appear offline to others while
6106+ remaining connected. if the server does not explicitly broadcast
6107+ status=offline on transition to invisible, any subscriber who already
6108+ cached the user's prior status (online, idle, etc.) will see a stale
6109+ status until their next reconnect. broadcasting status=offline
6110+ immediately on PRESENCE status=invisible ensures all subscribers
6111+ converge to the correct view instantly. the server stores the real
6112+ status internally only; it never emits 'invisible' to anyone but
6113+ the owning uid's own sessions. the 'here' role excludes invisible
6114+ users for the same reason: they are offline as far as the community
6115+ is concerned.
6116+
6117+ why board.manage replaces msg.delete/msg.pin on board channels?
6118+ msg.delete and msg.pin are chat-channel concepts operating on a
6119+ stream of messages. board channels are a flat post list; the
6120+ moderation model is different (boards have curators, not message
6121+ moderators). using a separate board.manage permission allows a
6122+ community to have, for example, a "board editor" role with
6123+ board.manage but no msg.delete on text channels --- a clean separation
6124+ of moderation scopes. the rule is simple: on board channels,
6125+ board.manage is the sole gating permission for actions on others'
6126+ content; msg.delete and msg.pin are not consulted for that case.
6127+
6128+ why CHAN op=info carries topic as payload, not a tag?
6129+ topics are wire markup and can contain \n-encoded newlines. tags
6130+ must fit within the 4096-byte line limit alongside name=, type=,
6131+ cid=, gid=, ts=. markup in a tag also requires percent-encoding,
6132+ making raw logs unreadable. payload is the correct location for
6133+ human-readable markup: it is always the last field, not subject to
6134+ TCHAR restrictions, and trivially extractable by shell tooling via
6135+ the ' :' delimiter. this is consistent with MSG body, THREAD
6136+ op=rename title, and all other markup-bearing content in the spec.
6137+
6138+ why INVITE list replies carry op=info?
6139+ every entity list in the protocol follows VERB op=info...:
6140+ CHAN op=info / ROLE op=info / MEMBER op=info / EMOJI op=info
6141+ a bare INVITE gid= code= without op= breaks this pattern and forces
6142+ parsers to special-case the INVITE verb. op=info costs one tag and
6143+ makes INVITE consistent: dispatch on verb, then dispatch on op=.
6144+ no special cases anywhere in the parser.
6145+
6146+ why emoji.list needs pagination?
6147+ a community with thousands of custom emoji sending an unbounded list
6148+ blocks the parser for the duration of the dump and can consume
6149+ megabytes on constrained clients. the same problem motivated
6150+ pagination on member.list and COMM op=list. offset= / limit= with
6151+ total= in the OK bounds the worst case at 500 per page while being
6152+ generous for any real community. the proactive push on SUB (%-section_10_4-%) handles the common case of warming the emoji cache; pagination
6153+ is for management UIs and large-community clients needing the full
6154+ set explicitly.
6155+
6156+ why a line-oriented voice relay instead of WebRTC?
6157+ WebRTC requires SDP parsing, ICE, STUN/TURN negotiation, and DTLS-SRTP.
6158+ implementing this is a substantial undertaking even in Go, and is orders of
6159+ magnitude more complex than a TLS socket. the WIRE voice relay design uses a
6160+ second TCP/TLS connection carrying VFRAME lines with base64-encoded audio
6161+ chunks. the control plane (join, leave, who is talking) stays on the main
6162+ WIRE connection. pcm16le requires no codec library --- just decode base64 and
6163+ write PCM bytes to the audio device. Opus is supported for quality-sensitive
6164+ clients; libopus has Go bindings. the server acts as a relay mixer, not a
6165+ P2P signaling hub; this is the Mumble model, which is simpler, more
6166+ firewall-friendly, and implementable in a few hundred lines of Go.
6167+
6168+ why SRVADM is a separate verb from COMM?
6169+ COMM ops are community-scoped: they carry gid= and operate on
6170+ community state. SRVADM ops are server-scoped: they operate on
6171+ server-level permissions and the server.admin role. mixing them
6172+ under COMM would require a special sentinel gid value (e.g.
6173+ gid=server) and complicate the permission check on every COMM op.
6174+ a distinct verb makes the scope unambiguous and keeps the
6175+ server.admin permission check out of the community permission
6176+ resolution algorithm.
6177+
6178+ why challenges are out-of-band?
6179+ in-protocol challenges (CAPTCHA, email verification, quizzes) require
6180+ the server to implement a response-evaluation state machine, the client
6181+ to implement a challenge-type-specific UI, and the spec to enumerate or
6182+ extensibly define challenge types. none of this belongs on the wire.
6183+ the challenge is fundamentally a human interaction with the server
6184+ operator's policy; the wire protocol's job is to signal that one is
6185+ required (ERR code=CHALLENGE url=<url>) and then accept the result
6186+ (COMM op=create succeeds after the uid is granted community.create
6187+ out-of-band). L0 minimal clients display the URL. GUI clients open it
6188+ in a browser. the server integrates with any existing identity system.
6189+ the protocol stays clean and does not couple itself to any specific
6190+ verification mechanism.
6191+
6192+ why PING op=dismiss instead of relying on READSTATE to clear notifications?
6193+ READSTATE op=mark advances the read cursor but is a different semantic:
6194+ a user may want to dismiss a notification (stop being interrupted) without
6195+ marking the underlying message as read. the two operations are orthogonal.
6196+ PING op=dismiss also explicitly syncs notification state across sessions of
6197+ the same uid via a broadcast to other sessions --- READSTATE op=mark already
6198+ does this for read state, and PING op=dismiss follows the exact same pattern.
6199+ without op=dismiss, a notification cleared on one device silently persists
6200+ on all others until the next reconnect delivers the same PING again.
6201+
6202+ why READSTATE op=query mid= (single-message query)?
6203+ READSTATE op=mark mid= marks a single message seen. without a matching
6204+ op=query mid=, there is no way to confirm the server recorded it, or to
6205+ check the status of a specific message without fetching the entire channel's
6206+ read state. the single-message query completes the mark/query symmetry that
6207+ exists at every other granularity: one cid and one gid already had query
6208+ forms; mid= was the only missing level. both the mark and query forms
6209+ require cid= because mid is only unique within a cid (%-section_3-%); the server
6210+ stores seen state keyed on (cid, mid). this contrasts with PING state,
6211+ which is keyed on (uid, mid) alone for reasons explained in %-section_6_8-%.
6212+
6213+ why SUB op=list?
6214+ subscriptions are server-side state that the client depends on to understand
6215+ what events it will receive. without a query form, subscriptions are the
6216+ only hidden state in the protocol --- violating the "transparent, no hidden
6217+ state" design goal. SUB op=list costs one line of server implementation and
6218+ closes that gap entirely.
6219+
6220+ why does SUB op=list need pagination?
6221+ every other list in the protocol is paginated. an unpaginated SUB op=list
6222+ means a uid subscribed to many channels produces an unbounded reply that
6223+ blocks the parser for the duration of the dump. the pagination parameters
6224+ and defaults are identical to every other list: offset=0, limit=50, max 200.
6225+
6226+ why subscriptions are durable (uid-scoped, not connection-scoped)?
6227+ subscriptions express which channels a user wants to receive broadcasts
6228+ from. this is a property of the user, not of a terminal or connection.
6229+ the same user on phone and desktop wants the same channels --- they are
6230+ one identity. treating subscriptions as ephemeral per-connection state
6231+ was an inconsistency: every other uid-scoped state (community membership,
6232+ read cursors, PING queue, roles) is server-persisted and survives
6233+ reconnection. subscriptions were the sole exception, which forced every
6234+ client to maintain local storage solely to remember which channels to
6235+ re-subscribe to after a disconnect. making subscriptions durable closes
6236+ that gap: authentication alone is sufficient to fully resume. the server
6237+ GC rules (clear subscriptions on leave, kick, ban, channel delete,
6238+ community delete) keep the stored set consistent with membership reality
6239+ without client involvement.
6240+
6241+ why UNSUB is now necessary as a first-class verb?
6242+ when subscriptions were ephemeral, "unsubscribing" was implicit:
6243+ the connection closed and the subscription vanished. with durable
6244+ subscriptions, a client that wants to stop receiving broadcasts from
6245+ a channel must explicitly remove it from the uid's stored set. UNSUB
6246+ cid= is the symmetric counterpart to SUB cid=, following the same
6247+ pattern as REACT/UNREACT, PIN/UNPIN, and mark/unmark idioms throughout
6248+ the protocol.
6249+
6250+ why are multi-session SUB/UNSUB uid-scoped rather than session-scoped?
6251+ the subscription set belongs to the uid, not the connection. a user
6252+ who subscribes to #general from their phone is expressing intent for
6253+ their identity, not their device. all sessions of that uid share one
6254+ set and all receive broadcasts. notification suppression (muting) is
6255+ stored server-side via KVAL scope=mute (%-section_7_5b-%) for multi-device
6256+ sync; the client interprets mute preferences by suppressing notification
6257+ sounds and desktop alerts. the protocol models intent and persistence;
6258+ the client models presentation.
6259+
6260+ why COMM op=invite.info instead of restoring COMM op=invite.use?
6261+ invite.use tried to solve gid-resolution by making the server accept
6262+ a code-only join and derive the gid internally, producing a different
6263+ OK verb in the process --- breaking the claimed identity with op=join.
6264+ the correct solution is to separate the two concerns: resolving a code
6265+ is a read operation (query -> metadata); joining is a write operation
6266+ (mutate -> membership). COMM op=invite.info is the read half, following
6267+ the same pattern as every other single-item info query in the protocol
6268+ (COMM op=channel.info, COMM op=member.info, THREAD op=info, etc.).
6269+ it requires no permission, returns community metadata including gid,
6270+ and lets the client show a join-preview UI before committing. the join
6271+ itself then uses the canonical COMM op=join gid= code= with a known gid.
6272+
6273+ why remove COMM op=invite.use?
6274+ it was declared "identical" to COMM op=join gid= code= but had a different
6275+ OK verb (verb=COMM op=invite.use vs verb=COMM op=join). "must behave
6276+ identically" is false when the server emits different confirmations. the
6277+ gid-resolution problem it tried to solve is now handled correctly and
6278+ orthogonally by COMM op=invite.info.
6279+
6280+ why COMM op=channel.list and COMM op=role.list now have pagination?
6281+ every other list operation (member.list, invite.list, COMM op=list,
6282+ emoji.list, SRVADM op=list) is paginated with offset=, limit=, and total=.
6283+ channel.list and role.list were the only exceptions, which means a community
6284+ with many channels or roles would produce an unbounded reply that blocks the
6285+ parser. the fix is the same pagination pattern applied everywhere else.
6286+
6287+ why COMM op=member.info uid=?
6288+ every other entity supports both a list query and a single-item query:
6289+ COMM op=channel.info cid=, COMM op=info gid=, THREAD op=info tid=, WHO uid=.
6290+ members were the only entity where list existed but single-item lookup did
6291+ not. a client that wants to display one member's roles and join date should
6292+ not have to paginate through the full member list to find them.
6293+ creation and administration of invite codes are different trust levels.
6294+ a community may want moderators to be able to invite friends (invite.create)
6295+ without giving them oversight of all outstanding codes (invite.manage).
6296+ separating them follows the same deny-wins permission model: invite.manage
6297+ can be withheld from invite.create holders without touching the role
6298+ definition, just by not granting it (per %-section_8_2-% super-permission rule).
6299+
6300+ why mention.everyone and mention.here are hard rejects (ERR) while
6301+ mention.role is a soft drop (WARN)?
6302+ fan-out for 'everyone' or 'here' can be community-wide; the server
6303+ cannot silently discard such a request the way it can drop a
6304+ user-defined role mention. the cost must be rejected at the source
6305+ before fan-out is attempted. user-defined role mentions can be
6306+ quietly dropped because the damage is bounded; built-in role mentions
6307+ are not. the three tokens (mention.role, mention.everyone, mention.here)
6308+ are all in the defined permset so clients can grant or deny them
6309+ individually like any other permission.
6310+
6311+ why SYNC instead of relying solely on READSTATE broadcast?
6312+ (this rationale is superseded. SYNC was removed in 0.8; see
6313+ "why READSTATE cursor= instead of a separate SYNC verb?" above.)
6314+
6315+ why COMM op=role.perm instead of delete+recreate to change permissions?
6316+ every other mutable field on a role has an update operation: rename
6317+ changes name=; appearance changes color=, emoji=, hoist=. permissions
6318+ (perms=) were the only field settable at role.create time with no
6319+ subsequent update path. requiring delete+recreate to change permissions
6320+ on a live role breaks all existing role assignments (rolids are
6321+ server-assigned ULIDs; the new role gets a new rolid) and forces all
6322+ clients to invalidate their role cache. COMM op=role.perm updates
6323+ permissions in-place, preserving the rolid and all assignments.
6324+
6325+ why role.perm and channel.perm have different "clear by omission" semantics?
6326+ the two operations have structurally different data models:
6327+
6328+ COMM op=role.perm operates on a flat permission set (one dimension:
6329+ a token is either in the set or not). absent perms= = clear all
6330+ because a flat set has only one state to clear.
6331+
6332+ COMM op=channel.perm operates on a two-axis sparse override matrix:
6333+ allow= and deny= are independent. a role may have only an allow
6334+ override, only a deny override, or both. absent allow= means "no
6335+ change to (or clear of) the allow axis"; absent deny= means "no
6336+ change to (or clear of) the deny axis". to clear both axes, omit
6337+ both tags. this per-axis clearing is not achievable with a single
6338+ flat "clear all" sentinel without introducing a magic token value,
6339+ which would violate the 1*TCHAR constraint (%-section_2_1-%).
6340+
6341+ the asymmetry is structural and correct. it is a consequence of the
6342+ underlying data models, not an inconsistency. implementors MUST NOT
6343+ apply the role.perm clearing rule to channel.perm or vice versa.
6344+
6345+ why ROLE op=info includes perms=?
6346+ every other entity list returns the full entity field set. ROLE op=info
6347+ was the sole list response that omitted a key field: perms=. the ROLE
6348+ op=create broadcast carried perms=; the query did not. a fresh-connecting
6349+ client doing COMM op=role.list could learn role names and visual appearance
6350+ but not what any role is permitted to do --- forcing it to rely on its
6351+ create-event cache, which is unavailable on first connect. adding perms=
6352+ to op=info makes the query response self-contained and consistent with
6353+ every other entity query in the protocol.
6354+
6355+ why ROLE op=create broadcast now carries the full entity field set?
6356+ ROLE op=info defines the canonical field set for a role entity (name,
6357+ color, emoji, hoist, perms). every live ROLE event that describes a role
6358+ state change should carry the same complete picture, so a client
6359+ receiving op=create can fully cache the role without a follow-up query.
6360+ the create broadcast previously omitted emoji= and hoist=. the info and
6361+ create formats are now identical in field set, matching the pattern used
6362+ by CHAN op=create vs CHAN op=info.
6363+
6364+ why the proactive role push uses ROLE op=info instead of op=appearance?
6365+ the proactive push on SUB is intended to warm the client's role cache so
6366+ it can render mentions and member lists without a separate COMM op=role.list
6367+ round-trip. ROLE op=appearance carries only the visual subset (color, emoji,
6368+ hoist). a client that receives appearance-only pushes has display data but
6369+ not role names or permissions --- it cannot answer "can this uid send messages
6370+ here?" without a follow-up. ROLE op=info lines carry the full entity.
6371+ this is the same reason the emoji push uses EMOJI op=info lines (%-section_10_4-%) rather than EMOJI op=add events: the push should give the client
6372+ a complete, self-sufficient cache entry.
6373+
6374+ why KVAL op=get instead of a dedicated query verb per scope?
6375+ every server-persisted key-value store needs a read path. the
6376+ alternative is a scope-specific query verb: COMM op=brand.get for
6377+ communities, UPROFILE op=get for users. but the query contract is
6378+ identical in both cases: single-key returns one op=info line; all-keys
6379+ returns N op=info lines; NOTFOUND on missing single key. KVAL op=get
6380+ with scope= selected by tag expresses the same query without forking
6381+ the verb surface. parsers handle the response with one handler
6382+ regardless of which scope was queried.
6383+
6384+ why KVAL op=del instead of empty-payload op=set for key deletion?
6385+ the empty-payload convention (line ends with ' :' immediately before
6386+ LF) is a side-channel that overloads the set operation with delete
6387+ semantics based on payload length. this means the server must inspect
6388+ the payload to decide whether to write or delete --- two distinct
6389+ mutations with different broadcast shapes (op=info for a set,
6390+ op=del for a deletion) sharing one op= value. op=del is an explicit,
6391+ dedicated operation: unambiguous to parse, distinct in the broadcast,
6392+ and idempotent by spec (deleting a non-existent key is a silent OK
6393+ with no broadcast). parsers never need to inspect payload length to
6394+ decide what happened.
6395+
6396+ why COMM op=update instead of KVAL scope=comm key=name for the display name?
6397+ KVAL scope=comm key=name is explicitly rejected (ERR BADARG) to
6398+ prevent a second write path for the community display name. COMM
6399+ op=update name= is the single source of truth: the same field set
6400+ on op=create is updated by op=update and returned by op=info and
6401+ op=list. allowing KVAL to set it would create two stores for the
6402+ same field with no clear authority. the same op covers open= because
6403+ that flag is part of the same community record and would otherwise
6404+ be equally immutable after creation.
6405+
6406+ why COMM op=channel.rename?
6407+ channel display names were immutable after creation; delete+recreate
6408+ was the only path. this broke the pattern established by COMM op=role.rename:
6409+ every other named entity (community via op=update, role via op=rename)
6410+ supports a display name update. the channel slug is kept stable by design
6411+ (rid paths must be bookmarkable), so rename is always display-name-only.
6412+ the same permission gate (channel.manage) and broadcast pattern (CHAN
6413+ op=rename to all members) as op=topic and op=perm.
6414+
6415+ why UPLOAD op=status?
6416+ the UPLOADED event is asynchronous: the server sends it after the HTTP
6417+ PUT completes. this is the correct model for GUI clients, which manage
6418+ an HTTP client and a WIRE connection independently. CLI/TUI/ACME clients
6419+ typically use a shell script or simple program that issues the WIRE
6420+ negotiation, shells out to curl or hget for the PUT, then returns to the
6421+ WIRE connection. in this flow, the client has no receive loop active
6422+ during the HTTP PUT; it will never see the UPLOADED event. UPLOAD op=status
6423+ is the synchronous poll alternative: after the PUT completes, the client
6424+ issues UPLOAD op=status aid= on the WIRE connection and receives a
6425+ deterministic state reply. the design mirrors READSTATE op=query: the
6426+ event (UPLOADED) and the query (op=status) operate on the same server-side
6427+ state; one is push, the other is pull. both paths are needed for full
6428+ client diversity.
6429+
6430+ why are community links in the description markup document instead of structured keys?
6431+ the previous revision used a JSON array (rejected: requires a JSON parser
6432+ in all clients) and then structured key=value entries (link.N.label,
6433+ link.N.url). the structured approach was also rejected: it has no deletion
6434+ path (empty values violate %-section_2_1-% 1*TCHAR), no reorder operation, and
6435+ requires N-index management for what is fundamentally an authored list.
6436+ community links are human-authored content, not machine-structured data.
6437+ wire markup's [label](url) inline syntax is the protocol's designated
6438+ representation for hyperlinks in readable content (%-section_9_2-%). embedding
6439+ links in the description document requires no new grammar, no new key names,
6440+ no deletion operation (rewrite the document), and no ordering operation
6441+ (document order). the description document is already the community's public
6442+ face; link bars naturally live there. the five remaining brandkeys
6443+ (description, icon, banner, color, rules) are all genuinely scalar machine-
6444+ consumed fields. no scalar brandkey has a deletion or ordering problem.
6445+
6446+ why do all single-item queries now have terminal OKs?
6447+ multi-item queries (op=list forms) have always had terminal OKs to delimit
6448+ the response stream. single-item queries returned exactly one line and were
6449+ considered self-delimiting. but a parser cannot know a response is complete
6450+ until it has received all of it; for single-item queries this means the
6451+ parser must recognise the specific response verb and consider it terminal.
6452+ this special-cases every single-item query in the parser. a uniform rule ---
6453+ every query ends with OK --- means the parser's frame-completion logic is the
6454+ same for all query types. it also makes the query surface consistent with
6455+ the rest of the spec where every mutating operation already produces an OK.
6456+ the cost is one extra line per query; the benefit is a simpler, more
6457+ testable parser: frame-completion logic is identical for all query types.
6458+ clients reading line-by-line (L0 minimal clients, bots, ACME editors)
6459+ benefit especially from not needing to special-case per-verb terminators.
6460+
6461+ why is PING op=list now paginated?
6462+ every other list in the protocol is paginated. an unpaginated PING op=list
6463+ exposes a specific DoS vector: a user offline for weeks receives an unbounded
6464+ burst on reconnect and again if he calls PING op=list explicitly. for a
6465+ CLI/TUI/ACME client running in a 24-line terminal or a shell pipe, an
6466+ unbounded dump blocks the read loop for an unspecified duration. the 50/200
6467+ defaults and the total= field in the OK match every other paginated list in
6468+ the spec; no new pattern is introduced.
6469+
6470+ why does UNSUB require cid= (and reject without it)?
6471+ SUB already rejects without rid= or cid=; UNSUB had no matching rule. an
6472+ UNSUB with no arguments is structurally ambiguous --- it cannot mean "unsubscribe
6473+ from everything" because that would conflict with the GC rules (which handle
6474+ mass-unsubscribe on leave, kick, ban, delete). the symmetric rejection keeps
6475+ UNSUB's argument requirement identical to SUB's, and an explicit BADARG is
6476+ better than silently accepting a no-op or producing undefined behaviour.
6477+
6478+ why can EDIT on board channels update tags= without a body?
6479+ tags on a board post are a categorisation signal, not message content. a
6480+ moderator recategorising a post should not need to re-transmit the full post
6481+ body to update its tags. requiring body alongside tags= is wasteful (the
6482+ body may be kilobytes of markup) and potentially lossy if the client's cache
6483+ is stale. the orthogonal rule --- body and tags= are independently optional
6484+ in EDIT on board channels --- is consistent with how other tag-bearing fields
6485+ work in the protocol: only the fields that change are sent.
6486+
6487+ why exactly one owner rather than at least one?
6488+ "at least one owner" permits two simultaneous owners and makes the transfer
6489+ semantics ambiguous: if uid A transfers to uid B while uid C also holds
6490+ owner (from a prior grant), nothing changes meaningfully. the community
6491+ owner concept is a trust anchor, not a role: it must be uniquely identifiable
6492+ for moderation escalation and community deletion. exactly-one makes the
6493+ invariant enforceable by the server in a single atomic swap. the transfer
6494+ becomes a pure assignment: B gets owner, A loses it, atomically, with no
6495+ intermediate state. server implementations have a simple test for the
6496+ invariant: count of owner holders is exactly 1 before and after every
6497+ owner-related operation.
6498+
6499+ why does THREAD op=info carry author= in addition to opener=?
6500+ opener= identifies the opening message mid. deriving the author requires a
6501+ message lookup (fetch the MSG at opener= and read its uid=). for text-channel
6502+ threads created implicitly, this lookup is the only path to the author uid
6503+ unless the server stores it separately. lock and unlock permission checks
6504+ run on every MSG send to a locked thread and on every THREAD op=lock/unlock
6505+ request; doing a message lookup in a hot path is avoidable. storing author=
6506+ on the thread record at creation time --- when the MSG is already in hand ---
6507+ costs one field. all subsequent permission checks are O(1) against the
6508+ thread record. the same pattern is used for locked_by= (already stored) and
6509+ for ts= (stored at last event).
6510+
6511+ why is pcm16le constrained to mono?
6512+ stereo pcm16le at 48kHz 16-bit produces 3840 bytes per 20ms frame. base64
6513+ encoding at 4/3x overhead yields 5120 bytes, which exceeds the 4096-byte
6514+ line limit (%-section_1-%). opus is immune: stereo opus frames at 20ms are
6515+ typically 40-160 bytes encoded (~215 bytes base64), safely within the limit.
6516+ pcm16le's value is as a zero-dependency fallback: a client needs only a
6517+ base64 decoder and a PCM audio output --- both trivial in Go. stereo pcm16le
6518+ has no plausible use case that opus does not serve better; constraining it
6519+ to mono eliminates a latent line-limit violation and removes any temptation
6520+ to define stereo pcm16le in a future revision.
6521+
6522+ why document the MEMBER op=delete delivery gap explicitly?
6523+ the delivery rule (MEMBER events go only to subscribed clients) is stated
6524+ in %-section_8_0b-% but its consequence for the community-destruction event was
6525+ not. a member who has unsubscribed from all channels has no live path to
6526+ receive MEMBER op=delete and no way to know the community was deleted until
6527+ his next active query. making the fallback (ERR code=NOTFOUND on COMM op=info
6528+ triggers purge) normative closes the gap: the client has a deterministic
6529+ recovery path regardless of whether it received the broadcast.
6530+
6531+ why add a normative CAPS disambiguation rule?
6532+ every dual-direction verb in the protocol has a normative MUST stating how
6533+ parsers distinguish client-sent from server-sent lines. ATTACH uses
6534+ tag-presence as the discriminant (%-section_12_3-%). EMOJI and KVAL use op= value
6535+ (%-section_10_3-%, %-section_7_5-%). CAPS was the only dual-direction verb without an explicit
6536+ MUST. the rule (list= present = server reply; absent = client query) is
6537+ obvious from context but should be testable without tracking protocol
6538+ state. making it a MUST is zero-cost and closes the last gap in the
6539+ dual-direction verb documentation.
6540+
6541+ why TYPING and PRESENCE events are fire-and-forget (no OK)?
6542+ typing and presence are ephemeral status signals, not mutations of durable
6543+ server state. an OK reply would arrive after the signal is already stale:
6544+ typing stops after 3 seconds of inactivity; presence transitions happen
6545+ multiple times per session. making the client wait for confirmation before
6546+ considering each state change sent would add a round-trip to every keystroke
6547+ indicator and every status toggle. both verbs are closer in nature to UDP
6548+ datagrams than to RPC calls. the server MUST NOT reply to event lines;
6549+ clients MUST NOT wait. ERR is still valid for malformed lines (ERR
6550+ code=BADARG); only valid event lines receive no reply. the op=list query
6551+ forms (TYPING op=list, PRESENCE op=list) are not events and do receive
6552+ OK replies; they are not fire-and-forget.
6553+
6554+ why DM channels are created implicitly on first SUB?
6555+ an explicit COMM op=dm.create verb would require the client to learn the
6556+ cid before sending the first message, adding a round-trip. SUB rid=@<uid>
6557+ is already the correct expression of intent ("I want to talk to this person");
6558+ having the server create the DM atomically on first SUB closes the loop with
6559+ zero extra protocol surface. the symmetric cid guarantee means both sides
6560+ reach the same channel regardless of who subscribes first. this mirrors how
6561+ IRC private messaging works conceptually while keeping the server as the
6562+ authority on cid assignment.
6563+
6564+ why HIST op=purge instead of a DELCHAT or WIPE verb?
6565+ a dedicated DELCHAT or WIPE verb would be DM-only and would need to
6566+ address channel-object deletion (breaking cid permanence), cascading
6567+ reference invalidation (mid, tid, aid, READSTATE, PING), and
6568+ pagination cursor breakage. HIST is the existing read/replay surface
6569+ for the event log; op=purge is the symmetric write (erase) surface.
6570+ using the HIST verb keeps the scope unambiguous: purge operates on
6571+ the event log, not on the channel object. the channel object, its
6572+ cid, its subscriptions, and its future messages are entirely
6573+ unaffected. this is orthogonal with the existing HIST op surface
6574+ and consistent with the COMM op=delete pattern (community deleted;
6575+ its content erased; references retired). before= makes the same
6576+ primitive serve both "wipe everything" and "time-bounded retention"
6577+ without a second verb.
6578+
6579+ why delete_policy is immutable after DM creation?
6580+ a participant who agreed to mutual consent before sharing sensitive
6581+ information must be able to rely on that agreement for the life of
6582+ the channel. if policy were mutable, a bad actor could: open a DM,
6583+ negotiate mutual consent, share damaging information about the other
6584+ party, then flip to unilateral and purge the evidence before the
6585+ other party can act. immutability makes the policy a verifiable
6586+ contract: clients can query the policy (CHAN op=info carries it;
6587+ %-section_8_4-%) and display a trust indicator to users. the cost of
6588+ immutability is low --- users who want different terms must start a
6589+ new DM channel.
6590+
6591+ why E2E uses X25519 separately from Ed25519?
6592+ See %-section_29_2f-% for the authoritative rationale.
6593+
6594+ why X3DH and Double Ratchet are RECOMMENDED but not normative?
6595+ See %-section_29_2f-% for the authoritative rationale on algorithm agility,
6596+ interoperability, and privacy implications.
6597+
6598+ why PUBKEY op=prekey.fetch consumes an OPK atomically?
6599+ See %-section_29_2f-% for the authoritative rationale on single-use guarantees
6600+ and graceful degradation.
6601+
6602+ why PIN/UNPIN need a normative uid= discriminant?
6603+ every dual-direction verb in the spec that carries different tags in each
6604+ direction has a normative MUST rule for parser disambiguation. EDIT uses
6605+ uid= presence (%-section_6_4-%); DEL uses uid= presence (%-section_6_5-%); KVAL uses op= value
6606+ (%-section_7_5-%); ATTACH uses tag presence (%-section_12_3-%). PIN and UNPIN follow
6607+ the same pattern: client-sent lines carry only mid=; server-broadcast lines
6608+ carry uid=, cid=, and ts=. without a stated normative rule, a parser has no
6609+ contract to test against. the rule is trivially derivable from the formats
6610+ but must be stated as a MUST to be normative.
6611+
6612+ why HIST has_more= is not derivable from count alone?
6613+ a client might assume has_more = (count == limit). this fails for
6614+ reaction-dense windows: a window of limit=50 messages may return 2 MSG
6615+ lines and 98 REACT/UNREACT lines for a total count of 100 event lines,
6616+ none of which tells the client whether more messages exist beyond the window.
6617+ has_more is a single boolean emitted by the server after evaluating its
6618+ event log; it is the only correct answer to "should I paginate further?"
6619+
6620+ why UPLOAD op=status echoes purpose=/cid=/gid=?
6621+ a client managing multiple concurrent uploads cannot route a bare
6622+ UPLOAD op=status aid= reply to the correct context without out-of-band
6623+ state. purpose= tells the client what kind of asset this is; cid= or gid=
6624+ tells it which channel or community it belongs to. echoing routing context
6625+ is consistent with every other query reply in the protocol: COMM op=channel.info
6626+ echoes cid=; THREAD op=info echoes cid= and tid=; COMM op=member.info echoes
6627+ gid= and uid=. UPLOAD op=status should not be the lone exception.
6628+
6629+ why purpose=avatar is a distinct upload purpose?
6630+ avatar assets are not attached to any message (no cid=) and not registered
6631+ with any community (no gid=). they are uid-scoped identity assets. without
6632+ a distinct purpose=avatar, a client uploading an avatar either has no valid
6633+ cid= to provide (rejected by purpose=message rules) or must use a sentinel
6634+ gid= (meaningless for a server-wide identity asset). a separate purpose value
6635+ allows the server to apply avatar-specific size and dimension policies and
6636+ makes the intent explicit at upload time.
6637+
6638+ why TYPING op=list despite state expiring within seconds?
6639+ the "no hidden state" design goal (%-section_2-%, %-section_23-%) holds that every ephemeral
6640+ server state with a write path MUST have a read path. TYPING state=start
6641+ is a write. before op=list existed, the server silently accumulated
6642+ per-cid typing maps with no query surface. a client reconnecting mid-
6643+ typing had no way to learn who was typing. the result is a query whose
6644+ answer is stale the moment it arrives --- but this is also true of WHO
6645+ (a user can disconnect between the WHO request and the reply) and we
6646+ accept it for PRESENCE op=list by the same reasoning. the best-effort
6647+ caveat applies; the alternative (no query) violates the design goal
6648+ permanently. the server-side cost is a memory map identical in structure
6649+ to PresenceStore; it already existed implicitly.
6650+
6651+ why PRESENCE op=list?
6652+ WHO (%-section_7_1-%) provides per-uid presence read access: the PROFILE reply
6653+ includes status= from the server's presence store. this satisfies the
6654+ "no hidden state" invariant's letter (every write path has a read path)
6655+ but not its spirit: a client connecting to a community of N members has
6656+ no efficient way to bootstrap presence state. issuing WHO for each member
6657+ is O(N) round-trips. PRESENCE op=list provides the same snapshot
6658+ semantics as TYPING op=list: a best-effort bulk query scoped to a
6659+ community (gid=). the result is stale the moment it arrives --- the same
6660+ caveat accepted for WHO and TYPING op=list. invisible members are omitted
6661+ (they appear offline to all other uids, consistent with the broadcast
6662+ rule). the spec-correct default for a user with no presence entry is
6663+ offline (%-section_7_2-%); op=list gives clients the snapshot needed to
6664+ initialize presence accurately on connect. the server-side cost is zero:
6665+ PresenceStore already exists.
6666+
6667+ why COMM op=member.list has no permission gate?
6668+ the previous gate (community.admin or moderation permission) was
6669+ inconsistent: COMM op=member.info (single-uid lookup) already returned
6670+ identical fields --- including banned_until= and timeout_until= --- to any
6671+ community member. the list form exposed no data that per-uid queries
6672+ did not. the gate was protecting against bulk enumeration, which is a
6673+ rate-limit concern (%-section_18-%), not a permission concern. an inconsistent
6674+ gate that does not protect against a real threat while breaking L3
6675+ ergonomics (Discord-style member sidebars require the list) is worse
6676+ than no gate. removed.
6677+
6678+ why EDIT, REACT, and UNREACT server broadcasts carry cid=?
6679+ %-section_2_5-% states "multi-line response verbs carry sufficient identifying
6680+ tags in each response line." before this change, EDIT, REACT, and
6681+ UNREACT in HIST had no cid= --- they were context-dependent on the
6682+ pending HIST request. this made pipelined HIST for multiple channels
6683+ require purely pending-request routing, with no fallback. adding cid=
6684+ costs a 26-char ULID per event line (within all framing limits) and
6685+ makes every event line in the protocol self-identifying, consistent
6686+ with PIN and UNPIN which already carried cid=. the client-sent forms
6687+ are unchanged.
6688+
6689+ why PIN op=list instead of reconstructing from HIST?
6690+ replaying full HIST to reconstruct pin state is O(channel history)
6691+ for a property (current pinned set) that is O(small). Discord exposes
6692+ /channels/{id}/pins for exactly this reason. the pin index is already
6693+ implicitly required by PIN idempotency (a server cannot return OK
6694+ silently on a double-PIN without tracking current pin state). PIN
6695+ op=list surfaces an index the server already maintains.
6696+
6697+ why INVITE op=info routing uses %-section_2_5-% only (no creator= discriminant)?
6698+ the previous discriminant (creator= present = list reply, absent =
6699+ info reply) was fragile: a buggy server emitting invite.list without
6700+ creator= was indistinguishable from a correct invite.info reply.
6701+ %-section_2_5-% pending-request state already provides unambiguous routing --- that
6702+ is its purpose. tag-presence discriminants are secondary checks that
6703+ add complexity without adding reliability when the primary mechanism
6704+ is already sound. the permission-derived field set (creator= and ts=
6705+ present when caller has invite.manage) makes the two forms naturally
6706+ differ in optional tags, not in mandatory ones.
6707+
6708+ why MENTION_ROLE uses three distinct WARN codes?
6709+ the original MENTION_ROLE_TRUNCATED covered all three cases: noperm,
6710+ invalid rolid, and threshold exceeded. these are distinct server
6711+ actions with different client remediation paths. NOPERM means the
6712+ sender lacks the permission and should not retry. INVALID means one
6713+ or more rolids do not exist in the community; the client should fix
6714+ the rolmentions= list. TRUNCATED means the list was too long and was
6715+ cut; the client could retry with fewer entries. collapsing all three
6716+ into one code forced clients to parse the human-readable reason payload
6717+ to distinguish them --- a violation of the invariant that clients MUST
6718+ use code= for control flow and MUST NOT use the reason for programmatic
6719+ logic (%-section_17-%). three codes, one handler per case.
6720+
6721+ why MEMBER op=info carries ts=?
6722+ MEMBER op=info is an entity query reply. per %-section_2_6-%, all op=info
6723+ replies carry ts= as the unix-ms of the most recent mutation.
6724+ member record mutations include role assignment/revoke, ban, timeout,
6725+ unban, and untimeout. a client rebuilding state after a period offline
6726+ needs to know whether the record it is caching is current; ts= is the
6727+ cheapest way to provide that signal. joined= is the creation timestamp
6728+ and answers a different question. both fields are needed: joined= for
6729+ \"how long has this member been here?\", ts= for \"has anything changed
6730+ since I last saw this record?\".
6731+
6732+ why ROLE op=info carries ts=?
6733+ same rationale as MEMBER op=info: ROLE op=info is an entity query
6734+ reply subject to the %-section_2_6-% ts= invariant. role mutations include
6735+ rename, perm change, and appearance change. ROLE op=create already
6736+ carried ts=; omitting it from op=info was an oversight. the pre-SUB
6737+ role cache warm push uses op=info lines; clients that compare ts=
6738+ values to detect stale cache entries need ts= present in the format
6739+ they receive, not only in the create/rename/perm broadcast events.
6740+
6741+ why EMOJI op=info carries ts=?
6742+ same rationale: EMOJI op=info is an entity query reply subject to the
6743+ %-section_2_6-% ts= invariant. emoji mutations include rename. the cache-warm
6744+ push on SUB uses EMOJI op=info lines; ts= lets clients validate
6745+ cache freshness. omitting ts= from op=info while carrying it on
6746+ op=add/rename events was an oversight.
6747+
6748+ why KVAL instead of per-entity ad-hoc key-value verbs (BRAND, UPROFILE)?
6749+ both community branding and user profile decoration are key-value
6750+ stores: a bounded set of named fields, each independently settable,
6751+ gettable, and deletable, with a uniform broadcast on write. the
6752+ original design gave each a different verb (BRAND for communities,
6753+ UPROFILE for users), duplicating the same op= surface, broadcast
6754+ shape, NOTFOUND semantics, and key-limit rules under two names.
6755+ KVAL unifies them under one verb with a mandatory scope= tag that
6756+ selects the store. the verb is a pure primitive: op= selects the
6757+ operation, scope= selects the store, id= identifies the entity,
6758+ key= names the field. no field of KVAL changes meaning depending on
6759+ another field. parsers dispatch on verb then op= only. adding a new
6760+ scope in a future revision (e.g. scope=server for server-level
6761+ metadata) requires a new registry entry and no verb changes.
6762+ the cost is that KVAL is less self-documenting than a named verb:
6763+ a reader must consult the scope registry (%-section_7_5b-%) to know which keys
6764+ are valid. this is the correct trade-off for a primitive: scope
6765+ documentation belongs in the registry, not in the verb name.
6766+
6767+%- future_optional_extensions = "Sect. 25" -%
6768+========================================================================
6769+%-future_optional_extensions-% -- FUTURE / OPTIONAL EXTENSIONS
6770+========================================================================
6771+
6772+ POLL -- community polls on messages or boards
6773+ SCHED -- scheduled events with RSVP
6774+ AUDIT -- structured mod-log event stream (replaces implicit logging).
6775+ in 0.8, ban, kick, and timeout reasons are stored server-side
6776+ but have no protocol query surface. AUDIT will provide a
6777+ paginated query verb for stored moderation events.
6778+ SEARCH -- server-side full-text search over message history
6779+ HISTREV -- message edit history retrieval (HIST rev= parameter);
6780+ the rev= counter on EDIT events is already normative; this
6781+ extension would expose prior revisions via HIST. servers that
6782+ retain edit history SHOULD reserve storage for this.
6783+ KEYSTATE -- query the expiry date of an old-uid alias created by
6784+ KEYROTATE (%-section_4_6-%). in 0.8 there is no protocol
6785+ path for a user to confirm whether his old-uid grace period
6786+ is still active or has expired.
6787+ CHAN op=order -- user-controlled channel ordering (drag-and-drop
6788+ reorder within a community). in 0.8 COMM op=channel.list
6789+ returns channels in ULID (creation) order, which is the
6790+ canonical and only defined order. this extension would add
6791+ CHAN op=order gid=<gid> order=<csv-cid> as a management
6792+ command (requires channel.manage or community.admin) that
6793+ sets an explicit position array, broadcast as a CHAN op=order
6794+ event to all subscribers. clients that receive a CHAN op=order
6795+ event MUST use the supplied order array instead of cid order
6796+ for display. the extension does not alter COMM op=channel.list
6797+ return order; it adds a separate display-order store.
6798+ from= tag -- reserved for disambiguation of bidirectional verbs where
6799+ uid= identifies different principals in each direction
6800+ (%-section_29_2c-%). trigger condition: if a second verb joins
6801+ PUBKEY op=keysync in requiring this asymmetry, from= MUST
6802+ be adopted for all such verbs simultaneously. a from=
6803+ extension would carry the originating uid explicitly,
6804+ making direction unambiguous without session-context tracking.
6805+
6806+ extensions gate on CAPS. unknown verbs always return ERR UNKNOWN.
6807+ no extension may alter the semantics of any core verb.
6808+
6809+ purge and e2e are defined in %-section_29-% and are gated on cap=purge
6810+ and cap=e2e respectively. they are not listed as future stubs here
6811+ because they are fully normative in 0.8.
6812+
6813+%- adversarial_behavior = "Sect. 26" -%
6814+========================================================================
6815+%-adversarial_behavior-% -- ADVERSARIAL BEHAVIOR AND PRESSURE MITIGATIONS
6816+========================================================================
6817+
6818+ this section specifies normative server behavior against adversarial
6819+ clients and high-load conditions. implementations ignoring this section
6820+ are correct but operationally fragile.
6821+
6822+ 26.0 WARN verb
6823+
6824+ when the server modifies a client's MSG (truncating mention lists,
6825+ applying cooldowns, or dropping invalid entries) it MUST inform the
6826+ sending client via a WARN line sent back on the same connection.
6827+ WARN is distinct from ERR: the MSG was accepted and broadcast; WARN
6828+ describes what the server changed before doing so.
6829+
6830+ WARN format:
6831+
6832+ WARN [mid=<mid>] code=<WARNCODE> :human-readable description
6833+
6834+ mid= identifies the MSG that was modified. mid= is present only when
6835+ the WARN is associated with a specific message; advisory WARNs
6836+ (OPK_DEPLETED, SKDM_PENDING) carry no mid= (see %-section_26_0-%, WARN code table).
6837+ code is machine-readable; clients use it for UI decisions.
6838+ payload is human-readable; clients MAY display it verbatim.
6839+
6840+ defined WARN codes:
6841+
6842+ MENTION_UID_TRUNCATED -- uid entries in mentions= were dropped
6843+ because the count exceeded
6844+ MENTION_UID_DROP_THRESHOLD (%-section_26_1-%)
6845+ MENTION_UID_INVALID -- one or more uids were not community members
6846+ and were dropped (%-section_26_2-%)
6847+ MENTION_ROLE_NOPERM -- entire rolmentions= tag dropped because
6848+ the sender lacks mention.role permission
6849+ (%-section_8_2-%)
6850+ MENTION_ROLE_INVALID -- one or more rolids were unknown in the
6851+ community; those entries were dropped
6852+ (%-section_6_2-%)
6853+ MENTION_ROLE_TRUNCATED -- rolid entries beyond
6854+ MENTION_ROLE_DROP_THRESHOLD were dropped
6855+ (%-section_26_1-%)
6856+ MENTION_COOLDOWN -- high-cardinality role cooldown active;
6857+ PING fan-out suppressed for this MSG
6858+ MENTION_COALESCED -- PINGs to one or more targets were coalesced
6859+ into a single delivery (see 26.4)
6860+ ATTACH_STRIPPED -- one or more aid= references were removed
6861+ from the message body because the sender
6862+ lacks attach.upload permission
6863+ OPK_DEPLETED -- this uid's one-time prekey pool is exhausted
6864+ (%-section_29_2a-%). no mid= tag is present.
6865+ the uid SHOULD upload new OPKs immediately
6866+ via PUBKEY op=prekey.
6867+ SKDM_PENDING -- delivered to a sender on AUTH OK when the
6868+ server has one or more stored PUBKEY
6869+ op=keysync requests queued for that uid.
6870+ the server delivers the stored keysync
6871+ lines immediately after this WARN. the
6872+ sender MUST issue the corresponding SKDMs.
6873+ no mid= tag is present on this WARN.
6874+
6875+ WARN lines are sent only to the originating client, not broadcast.
6876+ clients MUST NOT treat WARN as a protocol error.
6877+ L0 clients that do not parse WARN lines are unaffected; they will
6878+ simply not see the notification.
6879+
6880+ 26.1 mention storms
6881+
6882+ a malicious or buggy client may include an unbounded number of uids
6883+ or rolids in a single MSG, or mention a high-cardinality role
6884+ repeatedly across many messages in rapid succession.
6885+
6886+ mitigations (normative):
6887+
6888+ mention processing order (normative):
6889+ servers MUST process uid entries in mentions= in the following
6890+ order; deviation will produce divergent WARN emission:
6891+
6892+ 1. VALIDATE: remove all uid entries that are not members of
6893+ the community (gid). emit WARN code=MENTION_UID_INVALID
6894+ if any were removed.
6895+ 2. CAP: if the post-validation entry count exceeds the
6896+ MENTION_UID_DROP_THRESHOLD, drop entries beyond the cap
6897+ from the surviving (post-validation) set. emit WARN
6898+ code=MENTION_UID_TRUNCATED if any were dropped.
6899+
6900+ the cap applies to the post-validation set. a sender is not
6901+ penalized against the cap for entries that are invalid members;
6902+ invalid entries are removed first, then the cap is applied to
6903+ the remaining real mentions. if both conditions apply to a
6904+ single MSG, both WARN lines MUST be sent.
6905+
6906+ the same validate-first ordering applies to rolid entries in
6907+ rolmentions= (validate role membership / existence, then apply
6908+ MENTION_ROLE_DROP_THRESHOLD cap).
6909+
6910+ MENTION_UID_DROP_THRESHOLD:
6911+ servers MUST enforce a cap on the number of uid entries in
6912+ mentions= per MSG. RECOMMENDED cap: 50.
6913+ entries beyond the threshold are dropped.
6914+ server MUST send WARN mid=<mid> code=MENTION_UID_TRUNCATED
6915+ :truncated to N entries to the originating client.
6916+
6917+ MENTION_ROLE_DROP_THRESHOLD:
6918+ servers MUST enforce a cap on the number of rolid entries in
6919+ rolmentions= per MSG. RECOMMENDED cap: 10.
6920+ entries beyond the threshold are dropped.
6921+ server MUST send WARN mid=<mid> code=MENTION_ROLE_TRUNCATED
6922+ :truncated to N entries to the originating client.
6923+
6924+ HIGH_CARDINALITY_COOLDOWN:
6925+ applies to any role (user-defined or built-in) whose current
6926+ membership cardinality exceeds a server-defined threshold
6927+ (RECOMMENDED: 50 members). the cooldown is per (rolid, cid)
6928+ pair, not per rolid alone: the same role can be mentioned in
6929+ different channels at its natural rate.
6930+
6931+ after a successful high-cardinality role mention in a channel,
6932+ subsequent mentions of the same role in the same channel within
6933+ the cooldown window (RECOMMENDED: 60 seconds) have their PING
6934+ fan-out suppressed. the MSG is still accepted and broadcast to
6935+ channel subscribers.
6936+
6937+ server MUST send WARN mid=<mid> code=MENTION_COOLDOWN
6938+ :@rolname ping suppressed; cooldown active until <unix-ms>
6939+ to the originating client.
6940+
6941+ built-in roles 'everyone' and 'here' always satisfy the
6942+ cardinality threshold (its cardinality is the full community
6943+ or online subset). they are not special-cased; the same
6944+ HIGH_CARDINALITY_COOLDOWN rule applies to them as to any other
6945+ large role.
6946+
6947+ PING_FANOUT_ASYNC:
6948+ servers MUST dispatch PING fan-out for roles whose cardinality
6949+ exceeds the HIGH_CARDINALITY_COOLDOWN threshold out of the MSG
6950+ ingestion hot path. the MSG broadcast to channel subscribers
6951+ proceeds immediately; PINGs are queued and dispatched
6952+ asynchronously. clients MUST tolerate PING arriving after MSG
6953+ on a subscribed channel.
6954+
6955+ 26.2 adversarial clients: invalid tag injection
6956+
6957+ a client may send mentions= containing uids that are valid ULIDs
6958+ but not members of the community, attempting to trigger spurious
6959+ PINGs for users in other communities or on other servers.
6960+
6961+ mitigation (normative):
6962+ servers MUST validate every uid in mentions= is a current member
6963+ of the community (gid) associated with the cid. invalid uids are
6964+ dropped. the MSG proceeds with the remaining valid entries.
6965+ server MUST send WARN mid=<mid> code=MENTION_UID_INVALID
6966+ :N uid(s) dropped; not community members
6967+ to the originating client if any were dropped.
6968+ servers MUST NOT forward PINGs to users outside the community.
6969+
6970+ 26.3 adversarial clients: PING replay / spoofing
6971+
6972+ a client cannot forge PING events directly (server generates them
6973+ from MSG tags). however a client may attempt to cause the server to
6974+ re-dispatch PINGs by re-sending a MSG with the same content.
6975+
6976+ mitigation: mid is server-assigned and unique. each MSG gets a new
6977+ mid. PING deduplication at the client is on mid. there is no replay
6978+ vector at the protocol level.
6979+
6980+ for offline PING persistence: servers MUST store PINGs keyed by
6981+ (uid, mid). duplicate delivery on reconnect is prevented by keying;
6982+ a PING for a given (uid, mid) is delivered at most once.
6983+
6984+ 26.4 PING flood via rapid MSG
6985+
6986+ a sender may send many messages per second each mentioning the same
6987+ target, flooding his PING queue.
6988+
6989+ mitigations (normative):
6990+ per-(uid, cid) MSG rate limit applies (%-section_18-%). PING fan-out
6991+ is bounded by MSG rate. no additional PING-specific rate limit is
6992+ required beyond %-section_18-% MSG limits.
6993+
6994+ servers SHOULD implement per-(sender-uid, target-uid) PING
6995+ coalescing: if sender A has already generated a PING for target B
6996+ within a rolling window (RECOMMENDED: 5 seconds), additional PINGs
6997+ from A to B in the same window are held and delivered as one event
6998+ with count=<n> (see canonical PING event format, %-section_6_8-%).
6999+ count is the number of individual PINGs coalesced into this one.
7000+ clients SHOULD display count if > 1 (e.g. '5 mentions from user').
7001+
7002+ server MUST send WARN mid=<latest-mid> code=MENTION_COALESCED
7003+ :N pings to <target-uid> coalesced
7004+ to the originating client for each target where coalescing occurred.
7005+
7006+ 26.5 adversarial clients: READSTATE manipulation
7007+
7008+ a client may send READSTATE op=mark for messages it has not received
7009+ (guessing mids) or bulk-mark a channel to hide unread counts from
7010+ audit trails.
7011+
7012+ mitigation:
7013+ READSTATE is a local user preference; there is no integrity
7014+ requirement. a user may mark his own messages seen in any order.
7015+ no other user can observe another's READSTATE. there is no security
7016+ surface here. server implementations MAY validate that a mid exists
7017+ before recording seen state, returning ERR code=NOTFOUND for unknown
7018+ mids.
7019+
7020+ 26.6 connection-level resource exhaustion
7021+
7022+ a client may open many connections, subscribe to many channels, or
7023+ send oversized mention lists to exhaust server memory or CPU.
7024+
7025+ mitigations (normative):
7026+ max connections per uid: server-defined (RECOMMENDED: 5).
7027+ max SUB count per connection: server-defined (RECOMMENDED: 100).
7028+ 4096-byte line limit (%-section_1-%) bounds per-line CPU cost.
7029+ mention list caps (%-section_26_1-%) bound per-MSG fan-out cost.
7030+ servers MAY impose a max members-per-community limit beyond which
7031+ 'everyone' mentions are rejected with ERR code=TOOBIG.
7032+
7033+ 26.7 cross-shard PING delivery (federation note)
7034+
7035+ in a federated deployment (%-section_15-%, experimental), a PING for a
7036+ uid on a remote server must be forwarded cross-shard. this is an
7037+ open problem. current guidance:
7038+
7039+ - PINGs MUST NOT be forwarded to remote servers in 0.8.
7040+ - federated messages that mention users on other servers are
7041+ valid but generate PINGs only for local users.
7042+ - cross-server PING delivery is reserved for a future s2s
7043+ normative revision once the federation trust model is settled.
7044+
7045+%- server_admin = "Sect. 27" -%
7046+========================================================================
7047+%-server_admin-% -- SERVER ADMINISTRATION (SRVADM)
7048+========================================================================
7049+
7050+ server administration governs two things that are scoped to the
7051+ server itself rather than to any community: who may create communities,
7052+ and who holds elevated server-level authority. these are distinct from
7053+ community-level roles (%-section_8_2-%) in scope only; the mechanism is
7054+ the same (roles, permission tokens, assignment).
7055+
7056+ 27.1 server create policy
7057+
7058+ servers have a create policy governing who may issue COMM op=create:
7059+
7060+ open -- any authenticated user may create a community.
7061+ this is the default if the server does not configure
7062+ a policy explicitly.
7063+ granted -- only users explicitly granted the community.create
7064+ permission may create a community. all others receive
7065+ ERR code=NOPERM on COMM op=create.
7066+ servers MAY return ERR code=CHALLENGE instead (%-section_17-%)
7067+ to redirect the user through a verification flow.
7068+
7069+ clients query the server policy:
7070+
7071+ SRVADM op=policy
7072+ server replies:
7073+ SRVADM op=policy create=open|granted
7074+ OK verb=SRVADM op=policy
7075+
7076+ this query is permitted before AUTH (alongside HELLO, CAPS).
7077+
7078+ 27.2 server.admin built-in role
7079+
7080+ the server has one built-in server-scoped role with the reserved
7081+ literal rolid 'server.admin'. a user holding server.admin may:
7082+
7083+ - grant or revoke community.create for any uid
7084+ - assign or revoke server.admin for any uid
7085+ - view SRVADM op=list (server member/permission overview)
7086+ - override community moderation in any community (implies
7087+ community.admin in every community on this server)
7088+
7089+ there MUST be at least one server.admin at all times after initial
7090+ server setup. servers MUST enforce this invariant on revocation:
7091+ SRVADM op=role.revoke MUST be rejected with ERR code=FORBIDDEN if
7092+ it would leave zero server.admin holders.
7093+
7094+ unlike the community owner role (%-section_8_0a-%, exactly-one invariant),
7095+ server.admin permits multiple simultaneous holders. this asymmetry
7096+ is intentional: community ownership is a trust-anchor and legal
7097+ accountability concept (exactly one responsible party); server
7098+ administration is an operations concept (a team may share it). the
7099+ transfer pattern for server.admin is add-then-remove: grant the new
7100+ holder with op=role.assign, then revoke the former holder with
7101+ op=role.revoke when ready. no atomic swap is required.
7102+
7103+ the first server.admin is established out-of-band at server
7104+ provisioning time (e.g. server config file, first-run setup).
7105+ this is the only out-of-band operation in the WIRE lifecycle.
7106+ all subsequent admin grants are protocol operations.
7107+
7108+ 27.3 SRVADM operations
7109+
7110+ all SRVADM ops require server.admin unless noted. exception:
7111+ SRVADM op=info uid=<own-uid> (self-query) may be called by any
7112+ authenticated uid; see op=info below for the full normative rule.
7113+
7114+ broadcast behaviour (normative):
7115+ SRVADM role grants and permission changes produce no protocol
7116+ broadcast. the grantee learns of their new server.admin status
7117+ on their next SRVADM op=list query or via an out-of-band channel.
7118+ this is intentional: server administration changes are infrequent,
7119+ high-trust operations. a broadcast would require all server.admin
7120+ holders to receive notifications about each other's grants and
7121+ revocations -- an operationally undesirable coupling. unlike
7122+ community role assignments (which broadcast to all community
7123+ members), server.admin grants are scoped to the server operator
7124+ tier and are not a community-visible event.
7125+
7126+ SRVADM op=role.assign uid=<uid> rolid=server.admin
7127+
7128+ grants server.admin to the target uid. the acting uid MUST
7129+ already hold server.admin.
7130+
7131+ server replies:
7132+ OK verb=SRVADM op=role.assign uid=<uid> rolid=server.admin
7133+
7134+ SRVADM op=role.revoke uid=<uid> rolid=server.admin
7135+
7136+ revokes server.admin from the target uid. servers MUST reject
7137+ if this would leave zero server.admin holders.
7138+
7139+ server replies:
7140+ OK verb=SRVADM op=role.revoke uid=<uid> rolid=server.admin
7141+
7142+ SRVADM op=perm.grant uid=<uid> perm=community.create
7143+
7144+ grants the community.create permission to the target uid.
7145+ effective only when server create policy is 'granted'.
7146+ on policy 'open', all users have community.create implicitly
7147+ and this grant is a no-op (server MAY store it for policy
7148+ transitions).
7149+
7150+ the perm. prefix distinguishes per-uid permission operations from
7151+ role operations (op=role.assign / op=role.revoke). server.admin is
7152+ a role (assigned/revoked); community.create is a per-uid flag
7153+ (granted/revoked). see %-section_24-% for rationale.
7154+
7155+ server replies:
7156+ OK verb=SRVADM op=perm.grant uid=<uid> perm=community.create
7157+
7158+ SRVADM op=perm.revoke uid=<uid> perm=community.create
7159+
7160+ revokes the community.create permission from the target uid.
7161+
7162+ server replies:
7163+ OK verb=SRVADM op=perm.revoke uid=<uid> perm=community.create
7164+
7165+ SRVADM op=list [offset=<n>] [limit=<n>]
7166+
7167+ enumerates uids with any server-level grant. requires server.admin.
7168+ offset default: 0. limit default: 50. limit max: 200.
7169+ servers MUST clamp limit to 200.
7170+ server replies with one line per matching uid, then OK:
7171+ SRVADM op=info uid=<uid> name=<urlencoded-name>
7172+ roles=<csv-rolid> perms=<csv-perm> ts=<unix-ms>
7173+ ...
7174+ OK verb=SRVADM op=list count=<n> total=<n>
7175+
7176+ roles= includes 'server.admin' if applicable.
7177+ perms= lists explicit per-uid grants (e.g. 'community.create').
7178+ ts= is the unix-ms of the most recent grant or revoke for this uid.
7179+
7180+ SRVADM op=info uid=<uid>
7181+
7182+ queries server-level grant data for a single uid. requires
7183+ server.admin, with one exception (see below).
7184+
7185+ server replies:
7186+ SRVADM op=info uid=<uid> name=<urlencoded-name>
7187+ roles=<csv-rolid> perms=<csv-perm> ts=<unix-ms>
7188+ OK verb=SRVADM op=info uid=<uid>
7189+ or
7190+ ERR code=NOTFOUND :uid has no server-level grants
7191+
7192+ NOTFOUND is returned for uids with no server-level role or explicit
7193+ grant. this is consistent with COMM op=member.info NOTFOUND for
7194+ uids not in a community.
7195+ SRVADM op=info is listed in %-section_2_5-% as a routable response verb; it
7196+ appears in both SRVADM op=list responses (per-line) and as the
7197+ single-line reply to this query. parsers route via %-section_2_5-% pending-
7198+ request state; no tag discriminant is needed.
7199+
7200+ self-query exception (normative):
7201+ any authenticated uid MAY call SRVADM op=info uid=<own-uid>
7202+ without holding server.admin. this is an explicit carve-out from
7203+ the "all SRVADM ops require server.admin" rule, for the following
7204+ reason: a uid granted community.create (but not server.admin) has
7205+ server-level state that affects what they can do (COMM op=create),
7206+ yet has no query path to confirm whether that grant is present or
7207+ has been revoked. this violates the "no hidden state" invariant
7208+ (%-section_2_6-%). the self-query exception closes this gap at minimal cost:
7209+ a uid learns only their own grants, not any other uid's. the
7210+ server MUST treat uid=<self> as equivalent to uid=<authenticating-
7211+ uid> when applying this exception. requests where uid= does not
7212+ match the authenticated uid still require server.admin.
7213+ this precedent mirrors WHO: a uid can query their own invisible
7214+ status (WHO uid=<self> returns real status) without the general
7215+ presence-inspection permission that would allow querying others.
7216+
7217+ 27.4 out-of-band challenges for community.create
7218+
7219+ servers MAY require a user to complete an out-of-band challenge
7220+ before receiving community.create. the entire challenge flow
7221+ (CAPTCHA, email verification, account age check, payment, etc.)
7222+ happens outside the WIRE protocol, on a server-provided web page
7223+ or other external resource.
7224+
7225+ the protocol surface is minimal:
7226+
7227+ when COMM op=create is attempted by a uid without community.create
7228+ on a create=granted server, the server returns:
7229+
7230+ ERR code=CHALLENGE url=<https-url> :human-readable description
7231+
7232+ url= is a percent-encoded HTTPS URL. the client SHOULD present
7233+ it to the user (open in browser, display as a link, etc.). the
7234+ human-readable payload describes what is required.
7235+
7236+ after the user completes the challenge out-of-band, the server
7237+ grants community.create to the uid. the client retries
7238+ COMM op=create and receives OK.
7239+
7240+ clients MUST NOT attempt to automate or scrape the challenge URL.
7241+ the URL is a hint for the human operator, not a machine endpoint.
7242+
7243+ this approach gives server operators full flexibility (integrate
7244+ with any existing identity or payment system) while keeping the
7245+ protocol clean. an L0 minimal client displays the URL and instructs the
7246+ user to open it. an L0 client that ignores ERR code=CHALLENGE is
7247+ simply unable to create communities on gated servers; this is
7248+ correct behaviour.
7249+
7250+%- invite_permissions = "Sect. 28" -%
7251+========================================================================
7252+%-invite_permissions-% -- INVITE PERMISSIONS
7253+========================================================================
7254+
7255+ invites (COMM op=invite.create, %-section_8_5-%) are the primary mechanism
7256+ for controlled community growth. by default only users with
7257+ community.admin may create invite codes. the invite.create and
7258+ invite.manage permissions allow finer delegation without granting
7259+ full admin.
7260+
7261+ 28.1 invite.create
7262+
7263+ a role with invite.create in its allow set grants its holders the
7264+ ability to issue COMM op=invite.create for the community. this is
7265+ the Discord "Create Invite" permission analogue.
7266+
7267+ servers MUST reject COMM op=invite.create from a uid who holds
7268+ neither community.admin nor invite.create with ERR code=NOPERM.
7269+
7270+ invite.create does NOT imply invite.manage. a member who can create
7271+ codes cannot see or revoke codes issued by others.
7272+
7273+ note: redeeming a code uses COMM op=join gid=<gid> code=<code>
7274+ (%-section_8_0-%). when the gid is not yet known, the client first calls
7275+ COMM op=invite.info code=<code> (%-section_8_5-%) to resolve the gid,
7276+ then issues COMM op=join. neither operation requires invite.create;
7277+ any authenticated user may call invite.info or redeem a valid code.
7278+
7279+ 28.2 invite.manage
7280+
7281+ a role with invite.manage grants:
7282+ - COMM op=invite.list : list all active invite codes for a gid
7283+ - COMM op=invite.revoke: revoke any invite code for a gid,
7284+ including codes issued by other members
7285+
7286+ a member without invite.manage may still revoke his own codes.
7287+
7288+ 28.3 COMM op=invite.list and the INVITE verb
7289+
7290+ INVITE is the read verb for invite codes, following the same pattern
7291+ as CHAN (channels), ROLE (roles), MEMBER (membership), and EMOJI (emoji).
7292+ COMM ops are the write surface; INVITE op=info is the server's response
7293+ to both single-code lookup (COMM op=invite.info, %-section_8_5-%) and list
7294+ queries (COMM op=invite.list). parsers dispatch on verb then op=.
7295+
7296+ INVITE op=info serves two contexts with caller-permission-dependent
7297+ tag sets. routing is always via %-section_2_5-% pending-request state:
7298+
7299+ - COMM op=invite.info reply (%-section_8_5-%): carries gid=, code=,
7300+ name=, open=, members=, and optionally ttl=, uses=, used=.
7301+ creator= and ts= are absent. available to any authenticated user.
7302+ - COMM op=invite.list reply (this section): carries gid=, code=,
7303+ creator=, uses=, used=, ts=, and optionally ttl=.
7304+ name= and members= are absent.
7305+ requires invite.manage or community.admin.
7306+
7307+ the two forms differ only in which optional tags are present,
7308+ determined by the caller's permission level. parsers MUST route
7309+ INVITE op=info lines via %-section_2_5-% pending-request state (the outstanding
7310+ COMM op=invite.info or COMM op=invite.list request); no tag
7311+ discriminant is required or defined. a parser MUST NOT use the
7312+ presence or absence of any single tag to infer context; only the
7313+ pending-request state is authoritative.
7314+
7315+ the invite.list form omits name= and members= intentionally: invite
7316+ management UIs operate within a community that the caller already
7317+ belongs to and therefore already has in his local community cache
7318+ (fetched via COMM op=info or COMM op=list). duplicating community
7319+ metadata on every invite line is wasteful. clients that need
7320+ name= or open= for display purposes MUST use their cached COMM op=info
7321+ data for the gid; they MUST NOT issue a COMM op=info per code.
7322+
7323+ recommended sequencing (non-normative):
7324+ clients calling COMM op=invite.list SHOULD have previously fetched
7325+ COMM op=info gid=<gid> (or received the gid via COMM op=list) to
7326+ populate their community name and open= cache for that gid before
7327+ presenting invite management UI. the invite.list reply deliberately
7328+ omits name= to avoid duplicating data the caller already has; a
7329+ client that has not yet fetched COMM op=info will be missing context
7330+ it needs for display. this is a UI convenience guideline, not a
7331+ protocol requirement: the server has no mechanism to enforce prior
7332+ ordering, and no other operation in the protocol imposes a
7333+ protocol-level call-ordering prerequisite.
7334+
7335+ no unsolicited INVITE broadcasts exist in 0.8; invite codes are
7336+ administrative data, not live-event data.
7337+
7338+ COMM op=invite.list gid=<gid> [offset=<n>] [limit=<n>]
7339+
7340+ offset default: 0. limit default: 50. limit max: 200.
7341+ servers MUST clamp limit to 200.
7342+
7343+ requires invite.manage or community.admin.
7344+ server replies with one INVITE line per active (non-expired,
7345+ non-exhausted) code in this page, then OK:
7346+
7347+ INVITE op=info gid=<gid> code=<code> creator=<uid> [uses=<n>] used=<n>
7348+ [ttl=<seconds>] ts=<unix-ms>
7349+ ...
7350+ OK verb=COMM op=invite.list gid=<gid> count=<n> total=<n>
7351+
7352+ uses= is absent if the code has unlimited redemptions; present with a
7353+ positive integer if it has a finite limit. this is identical to the
7354+ invite.info reply convention (%-section_8_5-%). ttl= is seconds remaining;
7355+ absent if no expiry. used= is how many times the code has been redeemed.
7356+ ts= is the unix-ms at which the code was created.
7357+ count: number of lines in this page. total: total active codes.
7358+
7359+ 28.4 COMM op=invite.revoke
7360+
7361+ COMM op=invite.revoke gid=<gid> code=<code>
7362+
7363+ revokes an invite code immediately. subsequent COMM op=join with
7364+ this code returns ERR code=GONE.
7365+
7366+ permission rules:
7367+ - community.admin or invite.manage: may revoke any code.
7368+ - invite.create (without invite.manage): may revoke only codes
7369+ he himself created (creator uid matches).
7370+ - anyone else: ERR code=NOPERM.
7371+
7372+ server replies:
7373+ OK verb=COMM op=invite.revoke gid=<gid> code=<code>
7374+
7375+ 28.5 typical flow (Discord-like)
7376+
7377+ // server admin creates a Moderator role with invite.create
7378+ COMM op=role.create gid=<gid> name=Moderator
7379+ perms=invite.create,member.kick
7380+ OK verb=COMM op=role.create gid=<gid> rolid=<rolid>
7381+
7382+ // assigns it to a trusted member
7383+ COMM op=role.assign gid=<gid> uid=<mod-uid> rolid=<rolid>
7384+ OK verb=COMM op=role.assign gid=<gid> uid=<mod-uid> rolid=<rolid>
7385+
7386+ // that member can now issue invite codes
7387+ COMM op=invite.create gid=<gid> uses=10 ttl=86400
7388+ OK verb=COMM op=invite.create gid=<gid> code=AbCd1234
7389+
7390+ // a new user receives the code (e.g. via a link); resolves gid and
7391+ // previews community before joining
7392+ COMM op=invite.info code=AbCd1234
7393+ INVITE op=info gid=<gid> code=AbCd1234 name=My%20Community
7394+ open=0 members=42 ttl=86312 uses=10 used=1
7395+
7396+ // new user joins
7397+ COMM op=join gid=<gid> code=AbCd1234
7398+ OK verb=COMM op=join gid=<gid>
7399+
7400+ // admin inspects all codes; mod can revoke only his own
7401+ COMM op=invite.list gid=<gid> // requires invite.manage
7402+ COMM op=invite.revoke gid=<gid> code=AbCd1234
7403+ OK verb=COMM op=invite.revoke gid=<gid> code=AbCd1234
7404+
7405+%- history_purge_e2ee = "Sect. 29" -%
7406+========================================================================
7407+%-history_purge_e2ee-% -- HISTORY PURGE AND END-TO-END ENCRYPTION
7408+========================================================================
7409+
7410+ 29.1 HIST op=purge --- permanent server-side history deletion
7411+
7412+ cap: purge. servers not advertising purge MUST return ERR code=UNKNOWN
7413+ for HIST op=purge.
7414+
7415+ HIST op=purge deletes all stored events for a channel permanently.
7416+ the channel object itself is never deleted; cid, subscriptions,
7417+ and future messages are unaffected. only the event history is erased.
7418+
7419+ send form (client):
7420+
7421+ HIST op=purge cid=<cid> [before=<unix-ms>]
7422+
7423+ before= is optional. when present, only events with ts <= before
7424+ are deleted. when absent, all events in the channel are deleted.
7425+ before= allows time-bounded retention (e.g. "purge events older
7426+ than 30 days") without wiping the entire channel.
7427+
7428+ permission model:
7429+
7430+ community channels:
7431+ the requesting uid MUST hold msg.delete permission in the channel,
7432+ OR board.manage on type=board channels (%-section_8_2-%: board.manage
7433+ replaces msg.delete for actions on others' content on board channels;
7434+ this replacement applies to purge as well).
7435+ servers MUST reject with ERR code=NOPERM otherwise.
7436+
7437+ DM channels:
7438+ policy=unilateral: either participant may purge immediately.
7439+ server executes on first HIST op=purge from either uid.
7440+ policy=mutual: purge requires confirmation from BOTH participants.
7441+ first participant's HIST op=purge places the channel in
7442+ purge_pending state. the server emits:
7443+
7444+ HIST op=purge_pending cid=<cid> by=<uid> ts=<unix-ms>
7445+ [before=<unix-ms>]
7446+
7447+ delivered to both participants. the second participant confirms:
7448+
7449+ HIST op=purge cid=<cid> [before=<unix-ms>]
7450+
7451+ the server validates that:
7452+ 1. the cid is in purge_pending state.
7453+ 2. the confirming uid is the OTHER participant (not the same
7454+ uid who issued the initial request).
7455+ 3. if before= is present in both requests, both values must
7456+ match within 1000ms tolerance; if they differ beyond that
7457+ the server MUST reject with ERR code=BADARG
7458+ :before= mismatch; re-initiate purge to agree on a cutoff.
7459+
7460+ if the second participant does not confirm within a server-defined
7461+ window (RECOMMENDED: 72 hours), the pending state is discarded
7462+ silently. either participant may re-initiate.
7463+
7464+ mutual-policy purge_pending cancellation: either DM participant
7465+ may cancel a pending purge:
7466+
7467+ HIST op=purge_cancel cid=<cid>
7468+
7469+ server replies:
7470+ OK verb=HIST op=purge_cancel cid=<cid>
7471+
7472+ server broadcasts:
7473+ HIST op=purge_cancelled cid=<cid> by=<uid> ts=<unix-ms>
7474+
7475+ by= in the broadcast identifies which uid cancelled (initiator
7476+ or the other participant), allowing clients to display appropriate
7477+ UI context ("you cancelled the purge" vs "the other participant
7478+ declined the purge").
7479+
7480+ HIST op=purge_cancel on a cid not in purge_pending state MUST
7481+ return ERR code=NOTFOUND :no pending purge for this channel.
7482+
7483+ server execution (once permission is satisfied):
7484+
7485+ 1. delete all MSG, EDIT, DEL, REACT, UNREACT, PIN, UNPIN, THREAD
7486+ event rows for the cid (bounded by before= if present).
7487+ 2. for each aid that was referenced only by messages in the deleted
7488+ set, the server MAY expire the aid immediately. aids that are
7489+ still referenced by messages outside the deleted window are
7490+ unaffected.
7491+ 3. READSTATE seen-state entries for (uid, cid, mid) pairs in the
7492+ purged set MUST be deleted. the read cursor (READSTATE cursor=)
7493+ MUST be reset to absent for the cid if its referenced mid falls
7494+ in the purged set.
7495+ 4. PING entries for mids in the purged set MUST be deleted. servers
7496+ MUST NOT deliver PINGs referencing purged mids on reconnect.
7497+ 5. thread metadata (ThreadStore records) for threads whose opener
7498+ mid is in the purged set MUST be deleted.
7499+
7500+ OK and broadcast:
7501+
7502+ server replies to the originating client FIRST (commit-before-OK,
7503+ %-section_2_6-%):
7504+
7505+ OK verb=HIST op=purge cid=<cid> [before=<unix-ms>] ts=<unix-ms>
7506+ count=<n>
7507+
7508+ count= is the number of event rows deleted. ts= is the unix-ms of
7509+ the purge operation.
7510+
7511+ server then broadcasts to ALL current subscribers of cid:
7512+
7513+ HIST op=purged cid=<cid> [before=<unix-ms>] by=<uid> ts=<unix-ms>
7514+
7515+ clients receiving HIST op=purged MUST purge their local message cache
7516+ for the cid (bounded by before= if present). subscription state,
7517+ read cursor, and PING state for the cid are ALSO cleared by the
7518+ client for any entries in the purged window.
7519+
7520+ HIST op=purge verb directionality (normative):
7521+ parsers MUST use op= value to distinguish variants:
7522+ op=purge -- client-sent request.
7523+ op=purge_pending -- server-broadcast (mutual policy, first step).
7524+ op=purge_cancel -- client-sent cancellation request.
7525+ op=purge_cancelled -- server-broadcast cancellation confirmation.
7526+ op=purged -- server-broadcast (purge complete).
7527+ by= is present only in server-sent lines and MUST NOT be sent
7528+ by clients. ts= is present only in server-sent lines.
7529+
7530+ interaction with HIST queries in flight:
7531+
7532+ if a HIST query is in progress on cid at the time HIST op=purge
7533+ is processed, the server MUST complete the in-flight HIST response
7534+ before executing the purge. this follows the same rule as COMM
7535+ op=delete (%-section_8_0-%). a HIST query that begins after the purge
7536+ commit MUST return an empty result for the purged window.
7537+
7538+ interaction with before= cursor in HIST queries:
7539+
7540+ after a purge with before=<T>, HIST queries using before= or after=
7541+ cursors that fall within the purged window return zero events for
7542+ that window. has_more=0 is returned when no events exist beyond the
7543+ window in the requested direction.
7544+
7545+ adversarial considerations:
7546+
7547+ a malicious uid with msg.delete permission on a community channel
7548+ could issue HIST op=purge to destroy evidence of their own misconduct.
7549+ server operators SHOULD implement out-of-band audit logs that are
7550+ not subject to the purge operation, or restrict msg.delete (and
7551+ therefore purge) to community.admin holders only via role configuration.
7552+ the server's own append-only audit trail (outside the WIRE protocol)
7553+ is the appropriate long-term evidence store; WIRE does not guarantee
7554+ tamper-evidence of its event log.
7555+
7556+ ==========================================================================
7557+
7558+ 29.2 end-to-end encryption (E2E)
7559+
7560+ cap: e2e. servers not advertising e2e MUST relay MSG enc= lines
7561+ opaquely (verbatim, without ERR). servers advertising e2e support the
7562+ full surface defined in this section: PUBKEY op=prekey, op=prekey.fetch,
7563+ op=keysync; COMM op=channel.e2e; CHAN op=e2e; MSG enc=skdm; and
7564+ offline keysync pending delivery (%-section_29_2c-%).
7565+
7566+ design goals:
7567+ - one ciphertext on the wire per message, regardless of group size.
7568+ the server never needs to know member count to relay a message.
7569+ - servers relay ciphertext without ever seeing plaintext.
7570+ - key distribution is on-protocol; no out-of-band PKI.
7571+ - DMs and community channels use the same primitives and the same
7572+ wire surface. the server cannot distinguish them; both are opaque.
7573+ - enc= is opaque metadata at the routing and storage layer.
7574+ routing verbs (DEL, REACT, PIN, THREAD) apply to encrypted
7575+ messages without modification; HIST excludes enc=skdm lines
7576+ (%-section_29_2b-%); EDIT requires the replacement line to carry
7577+ enc=xchacha20 and cannot change enc= type (%-section_29_2e-%).
7578+ - forward secrecy: compromise of current key material does not
7579+ expose past messages.
7580+ - break-in recovery: compromise is healed automatically as ratchet
7581+ state advances.
7582+ - membership change security: when a member is removed, they cannot
7583+ decrypt future messages after senders distribute new keys to the
7584+ updated member set.
7585+
7586+ dumb-relay invariant (normative): the server stores and relays
7587+ ciphertext verbatim. it MUST NOT decrypt, re-encrypt, or transform
7588+ stored ciphertext for any reason, including policy changes, history
7589+ operations, or administrative actions.
7590+
7591+ two-layer model:
7592+
7593+ Layer 1 --- pairwise sessions (X3DH + Double Ratchet):
7594+ used to establish a secure 1:1 channel between any two uids. used
7595+ directly for DM encryption AND as the secure transport for
7596+ distributing group Sender Keys to individual members.
7597+
7598+ Layer 2 --- Sender Keys (for channels with a gid):
7599+ each sender maintains one Sender Key per (uid, cid). the sender
7600+ encrypts the message payload once; recipients decrypt independently
7601+ using their locally-cached copy of that sender's key for that
7602+ channel. the Sender Key is distributed to each member via a
7603+ pairwise Layer 1 session. this is Signal's SenderKey protocol.
7604+
7605+ DMs (no gid) use Layer 1 directly: X3DH session setup + Double
7606+ Ratchet for all messages. no Sender Keys are used.
7607+
7608+ community channels (gid present, e2e=1) use Layer 2. one ciphertext
7609+ per message on the wire regardless of member count.
7610+
7611+ the wire surface is identical for both: MSG enc=xchacha20. the server
7612+ cannot and need not distinguish them.
7613+
7614+ ==========================================================================
7615+
7616+ 29.2a identity and pairwise session keys (Layer 1)
7617+
7618+ key types:
7619+
7620+ Ed25519 identity keys are the source of truth for uid derivation
7621+ (%-section_3-%). for key agreement, WIRE uses X25519 (Diffie-Hellman over
7622+ Curve25519). Ed25519 and X25519 use the same underlying curve but
7623+ different point representations; they MUST NOT be mixed.
7624+
7625+ each uid that participates in E2E maintains:
7626+
7627+ identity key pair: long-term Ed25519 key (required by %-section_3-% and %-section_4-%).
7628+ X25519 key pair: independently generated for ECDH. servers
7629+ MUST NOT derive X25519 keys from Ed25519 keys.
7630+ clients MUST generate X25519 keys independently
7631+ using a CSPRNG.
7632+ one-time prekeys: a set of ephemeral X25519 key pairs (OPKs).
7633+ each OPK is used for exactly one session setup
7634+ and then discarded. clients SHOULD maintain
7635+ at least 20 OPKs on the server at all times.
7636+
7637+ prekey bundle format (stored on server, distributed on fetch):
7638+
7639+ ik=<b64url-X25519-pubkey> -- long-term X25519 identity key
7640+ spk=<b64url-X25519-pubkey> -- signed prekey (rotated periodically)
7641+ spk_sig=<b64url-Ed25519-sig> -- Ed25519 sig over spk, signed by the
7642+ uid's Ed25519 identity key. binds the
7643+ X25519 spk to the uid's identity.
7644+ opk=<b64url-X25519-pubkey> -- one-time prekey (server picks one;
7645+ absent if OPK pool exhausted).
7646+
7647+ clients MUST verify spk_sig before using any prekey bundle.
7648+
7649+ PUBKEY op=prekey (client -> server, publish own bundle):
7650+
7651+ PUBKEY op=prekey ik=<b64url> spk=<b64url> spk_sig=<b64url>
7652+ [opk_count=<n>] :<newline-separated-b64url-OPKs>
7653+
7654+ publishes the sending uid's prekey bundle. any authenticated uid may
7655+ call this for its own uid; MUST NOT be called for another uid.
7656+ ik= and spk= are X25519 public keys (32 bytes, base64url, no padding).
7657+ spk_sig= is the Ed25519 signature of spk= bytes, signed with the
7658+ uid's Ed25519 private key.
7659+ payload contains zero or more OPK public keys (X25519, b64url), one
7660+ per \n-separated line (%-section_2_4-%).
7661+ a repeated call replaces ik= and spk= and APPENDS to the OPK pool.
7662+
7663+ server replies:
7664+ OK verb=PUBKEY op=prekey uid=<uid> opk_count=<n>
7665+
7666+ opk_count= is the total OPKs now stored for this uid after the update.
7667+ clients use this to decide whether to upload additional OPKs.
7668+
7669+ servers MUST reject spk_sig= that does not verify against the uid's
7670+ registered Ed25519 pubkey:
7671+ ERR code=AUTHFAIL :spk_sig verification failed
7672+
7673+ servers MAY enforce an OPK pool maximum (RECOMMENDED: 200). uploads
7674+ beyond the maximum are silently discarded; opk_count= reflects the
7675+ actual pool size.
7676+
7677+ directionality (normative): client-sent only, identified by presence
7678+ of ik=. servers MUST NOT send PUBKEY op=prekey unsolicited.
7679+
7680+ PUBKEY op=prekey.fetch (client -> server, fetch another uid's bundle):
7681+
7682+ PUBKEY op=prekey.fetch uid=<uid>
7683+
7684+ fetches one prekey bundle for the target uid. the server atomically
7685+ consumes one OPK (removes it from the pool so it is not reissued).
7686+ any authenticated uid may fetch any other uid's bundle.
7687+
7688+ server replies:
7689+ PUBKEY op=prekey uid=<target-uid> ik=<b64url> spk=<b64url>
7690+ spk_sig=<b64url> [opk=<b64url>]
7691+ OK verb=PUBKEY op=prekey.fetch uid=<target-uid>
7692+
7693+ opk= is absent if the pool is exhausted. the fetching client SHOULD
7694+ still proceed using only ik= and spk= (no-OPK X3DH variant; weaker
7695+ forward secrecy for that session only, but correct and interoperable).
7696+ the target uid is notified:
7697+ WARN code=OPK_DEPLETED :prekey pool exhausted; upload new OPKs
7698+ (defined in the WARN code table at %-section_26_0-%.)
7699+
7700+ directionality (normative): client sends op=prekey.fetch with uid= as
7701+ the target. server reply carries op=prekey with bundle tags. parsers
7702+ use op= to distinguish: op=prekey.fetch is always client-sent;
7703+ op=prekey with ik= in server context is the bundle reply. routing
7704+ via %-section_2_5-% pending-request state.
7705+
7706+ session setup (X3DH, non-normative summary):
7707+
7708+ initiator fetches target's bundle via PUBKEY op=prekey.fetch.
7709+ initiator performs X3DH using the bundle to derive a shared secret.
7710+ the first message carries ephemeral public key material inline in
7711+ the ciphertext header (Signal X3DH specification) so the target can
7712+ derive the same shared secret on receipt without a prior round-trip.
7713+ subsequent messages use the Double Ratchet algorithm for forward
7714+ secrecy and break-in recovery. key derivation and ratchet state are
7715+ entirely client-side; the server stores only ciphertext.
7716+
7717+ ==========================================================================
7718+
7719+ 29.2b Sender Keys (Layer 2 --- community channels)
7720+
7721+ Sender Keys are used for channels where e2e=1 and the channel has a
7722+ gid. DMs (no gid) always use Layer 1 directly.
7723+
7724+ sender key concept:
7725+
7726+ each sender (uid) maintains one Sender Key per (uid, cid):
7727+
7728+ SKS_id -- Sender Key ID: 4 random bytes (base64url). used by
7729+ recipients as a lookup hint for the correct key.
7730+ SKS_chain -- current chain key (32 bytes). advanced per message
7731+ via HKDF to derive per-message keys (one-way ratchet).
7732+ compromising the chain at message N does not expose
7733+ messages 0..N-1.
7734+ SKS_sig -- Ed25519 signing key pair. the sender signs Sender Key
7735+ distribution messages and per-message plaintext to
7736+ authenticate the source and prevent key substitution.
7737+
7738+ sender key distribution message (SKDM):
7739+
7740+ before sending encrypted messages on a channel, the sender must
7741+ distribute their Sender Key to every current member who does not
7742+ already hold it. distribution uses a pairwise Layer 1 session per
7743+ recipient.
7744+
7745+ an SKDM is a MSG with enc=skdm:
7746+
7747+ MSG cid=<cid> enc=skdm target=<uid> :<ciphertext-b64url>
7748+
7749+ the payload is a pairwise-encrypted (Layer 1) blob carrying
7750+ (SKS_id, SKS_chain_seed, SKS_sig_pubkey), signed with the sender's
7751+ Ed25519 identity key. the target decrypts using their Layer 1 session
7752+ with the sender.
7753+
7754+ enc=skdm rules (normative):
7755+
7756+ target= is REQUIRED. servers MUST reject MSG enc=skdm without
7757+ target= with ERR code=BADARG :enc=skdm requires target=.
7758+ servers MUST reject MSG enc=skdm where target= is not a current
7759+ community member with ERR code=BADARG :target= is not a member.
7760+
7761+ enc=skdm lines are relayed to ALL subscribers of cid. only the
7762+ target uid can decrypt the payload (pairwise-encrypted). non-target
7763+ subscribers MUST ignore enc=skdm lines where target= does not
7764+ match their own uid.
7765+
7766+ enc=skdm lines are NOT stored in HIST. servers MUST NOT return
7767+ enc=skdm lines in HIST responses. SKDMs are ephemeral key-
7768+ distribution events, not conversation history.
7769+
7770+ enc=skdm lines do NOT generate PING events. servers MUST suppress
7771+ PING generation for enc=skdm lines.
7772+
7773+ enc=skdm lines do NOT participate in READSTATE, REACT, PIN,
7774+ THREAD, or EDIT. servers MUST reject REACT, PIN, THREAD, or EDIT
7775+ referencing a mid assigned to an enc=skdm message:
7776+ ERR code=BADARG :cannot reference a key-distribution message.
7777+
7778+ enc=skdm lines receive a server-assigned mid= (needed for ordering
7779+ and to enforce the above rejections).
7780+
7781+ SKDM payload size: the pairwise-encrypted payload is approximately
7782+ 200 bytes raw (~267 bytes base64url), well within the 4096-byte limit.
7783+
7784+ sending an encrypted message (Layer 2):
7785+
7786+ once the Sender Key has been distributed per %-section_29_2c-%:
7787+
7788+ MSG cid=<cid> enc=xchacha20 :<ciphertext-b64url>
7789+
7790+ recommended ciphertext structure (non-normative):
7791+ [4 bytes] SKS_id (recipient lookup hint; not a security parameter)
7792+ [4 bytes] chain iteration counter (uint32 big-endian)
7793+ [24 bytes] XChaCha20-Poly1305 nonce
7794+ [N+16 bytes] XChaCha20-Poly1305 ciphertext + 16-byte auth tag
7795+
7796+ the enc=xchacha20 tag is identical for DM (Layer 1) and channel
7797+ (Layer 2) messages. the server dumb-relays both identically.
7798+
7799+ ==========================================================================
7800+
7801+ 29.2c key distribution lifecycle
7802+
7803+ initial distribution (sender first sending encrypted on a channel):
7804+
7805+ the client MUST:
7806+ 1. generate a fresh Sender Key for (uid, cid).
7807+ 2. for every current member of the channel who does not already
7808+ hold the sender's current Sender Key: fetch their prekey bundle
7809+ via PUBKEY op=prekey.fetch (these requests MAY be pipelined per
7810+ %-section_2_5-%: distinct uid= targets may be pipelined freely).
7811+ 3. send MSG enc=skdm target=<uid> for each such member.
7812+ 4. begin sending MSG enc=xchacha20.
7813+
7814+ steps 2--3 are pipelined: the client MAY send all SKDMs before the
7815+ first encrypted MSG. the server relays them in order.
7816+
7817+ receiving a Sender Key (recipient):
7818+
7819+ on receiving MSG enc=skdm target=<own-uid>:
7820+ 1. decrypt the payload using the pairwise Layer 1 session with the
7821+ sender (uid= of the MSG line).
7822+ 2. verify the Ed25519 signature over the SKDM content.
7823+ 3. store (SKS_id, SKS_chain_seed, SKS_sig_pubkey) keyed by
7824+ (cid, sender-uid, SKS_id) in the client's local key store.
7825+
7826+ on receiving MSG enc=xchacha20 on an e2e=1 channel:
7827+ 1. parse the SKS_id hint from the payload header.
7828+ 2. look up the cached Sender Key for (cid, sender-uid, SKS_id).
7829+ 3. advance the chain to the counter position; derive per-message key.
7830+ 4. decrypt and verify the Ed25519 signature.
7831+ 5. if no matching Sender Key found: display "waiting for key" and
7832+ send PUBKEY op=keysync (see catch-up below).
7833+
7834+ out-of-order messages: clients SHOULD cache skipped per-message keys
7835+ up to a window of 2000 skipped keys. beyond this window, messages are
7836+ unrecoverable. this matches Signal's SenderKey specification.
7837+
7838+ catch-up (member who missed SKDMs):
7839+
7840+ a member who was offline, freshly joined, or whose client was wiped
7841+ may receive MSG enc=xchacha20 lines they cannot decrypt. catch-up:
7842+
7843+ PUBKEY op=keysync cid=<cid> uid=<target-sender-uid>
7844+ (client -> server: "I need this sender's Sender Key for this channel")
7845+
7846+ server handling:
7847+ 1. replies immediately to the requesting client:
7848+ OK verb=PUBKEY op=keysync cid=<cid> uid=<target-sender-uid>
7849+ 2. routes the keysync to the target sender's active connections:
7850+ PUBKEY op=keysync cid=<cid> uid=<requesting-uid>
7851+ (uid= is rewritten to the requester; sender knows who needs SKDM)
7852+ 3. if the target sender has no active connections, the server MUST
7853+ store the keysync request and deliver it on the sender's next
7854+ AUTH OK (before the PING burst), preceded by WARN
7855+ code=SKDM_PENDING (%-section_26_0-%). stored requests are keyed by
7856+ (requesting-uid, sender-uid, cid). the server clears the stored
7857+ request when the sender issues MSG enc=skdm cid=<cid>
7858+ target=<requesting-uid>.
7859+
7860+ the target sender responds by issuing:
7861+ MSG cid=<cid> enc=skdm target=<requesting-uid> :...
7862+
7863+ directionality (normative): PUBKEY op=keysync is bidirectional.
7864+ client-sent: cid= and uid= identify the sender whose key is needed.
7865+ server-routed delivery: same verb, same tags, but uid= is rewritten
7866+ to the requester (uid= asymmetry; see rationale in %-section_29_2f-%).
7867+ parsers MUST use session context (pending-request state for the
7868+ client-sent OK; unsolicited for the server-routed delivery) to
7869+ distinguish direction. servers MUST NOT send PUBKEY op=keysync to
7870+ clients that did not send it first, except as routed deliveries for
7871+ keysync requests those clients are the target of.
7872+
7873+ new member joining an e2e=1 channel:
7874+
7875+ on receiving MEMBER op=join gid=<gid> uid=<new-uid>, each active
7876+ sender SHOULD proactively issue an SKDM to the new member for their
7877+ current Sender Key on that channel. this is a SHOULD: senders may
7878+ be offline. offline senders are reached via PUBKEY op=keysync when
7879+ the new member first encounters a message they cannot decrypt.
7880+
7881+ member removal --- forward secrecy:
7882+
7883+ on receiving MEMBER op=kick, MEMBER op=ban, or MEMBER op=leave for
7884+ any uid in a gid, each remaining sender MUST rotate their Sender Key
7885+ for all channels in that gid before sending the next encrypted message:
7886+ 1. generate a new Sender Key (new SKS_id, new SKS_chain).
7887+ 2. distribute the new Sender Key to all REMAINING members (not the
7888+ removed uid) via new SKDMs.
7889+ 3. use the new Sender Key for all subsequent messages.
7890+
7891+ clients MAY batch: send all channel SKDMs for the gid before resuming
7892+ messaging. senders that have no remaining messages to send need not
7893+ rotate immediately, but MUST rotate before their next encrypted message.
7894+
7895+ the removed uid retains cached copies of old Sender Keys and can
7896+ decrypt messages they received before rotation. messages sent after
7897+ rotation are unreadable to them. this is the intended security property.
7898+
7899+ periodic rotation (RECOMMENDED): clients SHOULD also rotate their
7900+ Sender Key after 1 week or 1000 messages on a channel, whichever
7901+ comes first. this bounds break-in recovery time independently of
7902+ membership changes.
7903+
7904+ ==========================================================================
7905+
7906+ 29.2d channel E2E policy
7907+
7908+ E2E policy is a property of the CHANNEL RECORD. a community may have
7909+ some channels with e2e=1 and others with e2e=0. policy is per-channel,
7910+ not per-community.
7911+
7912+ setting E2E policy at channel creation:
7913+
7914+ COMM op=channel.create accepts e2e=0|1 (default: 0; full verb
7915+ definition in %-section_8_4-%).
7916+
7917+ e2e=1: all MSG on this channel MUST carry enc=xchacha20 or enc=skdm.
7918+ e2e=0 (default): unencrypted MSG is permitted; senders may still use
7919+ enc=xchacha20 voluntarily and the server relays it opaquely.
7920+
7921+ servers MUST reject e2e=1 on type=voice channels:
7922+ ERR code=BADARG :voice channels do not carry message content; e2e= is not applicable
7923+ (voice audio travels on a separate VFRAME connection. the WIRE channel
7924+ for voice carries only signaling; signaling is not content to encrypt.)
7925+
7926+ changing E2E policy after creation:
7927+
7928+ COMM op=channel.e2e gid=<gid> cid=<cid> e2e=0|1
7929+
7930+ requires channel.manage permission (%-section_8_2-%).
7931+ servers MUST verify gid+cid co-membership (%-section_8_4-% co-validation rule).
7932+
7933+ server reply:
7934+ OK verb=COMM op=channel.e2e gid=<gid> cid=<cid> e2e=0|1
7935+
7936+ server broadcast to all community members:
7937+ CHAN op=e2e gid=<gid> cid=<cid> e2e=0|1 uid=<acting-uid> ts=<unix-ms>
7938+
7939+ non-retroactivity (normative):
7940+ changing e2e= does not affect stored messages. HIST returns messages
7941+ verbatim: enc= lines stay enc=; plaintext lines stay plaintext. the
7942+ policy boundary is the ts= of the CHAN op=e2e event. clients
7943+ rendering HIST MUST respect enc= on each individual MSG line rather
7944+ than inferring encryption state from the channel's current policy.
7945+ servers MUST NOT re-encrypt or decrypt stored messages on policy
7946+ change (dumb-relay invariant, %-section_29_2-%).
7947+
7948+ CHAN op=info for channels:
7949+
7950+ e2e=0|1 is a first-class stored field on the channel record. servers
7951+ MUST include it in all CHAN op=info responses. it is never derived
7952+ from another record.
7953+
7954+ CHAN op=info gid=<gid> cid=<cid> slug=<slug> name=<urlencoded-name>
7955+ type=text|voice|forum|board e2e=0|1 ts=<unix-ms>
7956+ [:topic]
7957+
7958+ DM channel E2E policy:
7959+
7960+ DM channels (no gid) set e2e= at creation via the SUB call (%-section_5-%,
7961+ DM creation policy fields). e2e= is immutable after creation; the same
7962+ immutability, rejection, and SUB OK echo rules that apply to
7963+ delete_policy= apply to e2e= identically. rationale: a participant who
7964+ opened an encrypted DM before sharing sensitive content made a trust
7965+ agreement; immutability makes the policy a verifiable contract for the
7966+ lifetime of the channel. users who want different terms must open a new
7967+ DM channel.
7968+
7969+ servers MUST reject e2e= on SUB for a non-DM cid:
7970+ ERR code=BADARG :use COMM op=channel.e2e for community channels
7971+
7972+ DM E2E key model: DMs use Layer 1 (X3DH + Double Ratchet) only.
7973+ enc=skdm is NOT used on DM channels. a DM with e2e=1 requires all
7974+ MSG to carry enc=xchacha20; plaintext MSG is rejected by the server.
7975+
7976+ client enforcement (normative): clients reading e2e=1 from CHAN
7977+ op=info MUST refuse to send plaintext MSG on that channel. this
7978+ applies regardless of whether the server advertises cap=e2e; servers
7979+ that do not cannot enforce the policy server-side, making client
7980+ enforcement the last line of defense.
7981+
7982+ ==========================================================================
7983+
7984+ 29.2e server enforcement
7985+
7986+ on MSG without enc= on a channel where e2e=1:
7987+ ERR code=NOPERM :this channel requires end-to-end encryption
7988+
7989+ on EDIT for a mid that was originally enc=xchacha20, if EDIT
7990+ omits enc= or changes it to a different value:
7991+ ERR code=BADARG :enc= type cannot change on edit
7992+
7993+ on MSG enc=skdm without target=:
7994+ ERR code=BADARG :enc=skdm requires target=
7995+
7996+ on MSG enc=skdm where target= is not a current community member:
7997+ ERR code=BADARG :target= is not a member of this community
7998+
7999+ on REACT, PIN, THREAD, or EDIT referencing a mid from an enc=skdm MSG:
8000+ ERR code=BADARG :cannot reference a key-distribution message
8001+
8002+ enc=skdm relay (normative): servers MUST relay MSG enc=skdm to ALL
8003+ current subscribers of cid (not only target= uid). client-side
8004+ filtering on target= is normative. the server is a dumb relay and
8005+ MUST NOT attempt selective delivery based on target=.
8006+
8007+ servers NOT advertising cap=e2e MUST relay MSG enc= lines verbatim
8008+ without error (opaque relay rule, unchanged). they cannot enforce
8009+ e2e= channel policy; client-side enforcement is normative (%-section_29_2d-%).
8010+
8011+ enc= interactions with other verbs:
8012+
8013+ enc= and HIST:
8014+ HIST returns enc=xchacha20 MSG lines with the ciphertext payload
8015+ verbatim (dumb-relay invariant, %-section_29_2-%).
8016+ HIST MUST NOT return enc=skdm lines (excluded per %-section_29_2b-%).
8017+
8018+ enc= and EDIT:
8019+ EDIT may replace the ciphertext payload of an encrypted message.
8020+ the EDIT line MUST carry enc=xchacha20. clients MUST NOT switch
8021+ enc= values. servers enforce per %-section_29_2e-% above.
8022+
8023+ enc= and DEL:
8024+ DEL operates on encrypted messages identically to plaintext.
8025+ the server tombstones the mid; encrypted content is permanently
8026+ lost since the server never held the plaintext.
8027+
8028+ enc= and HIST op=purge:
8029+ HIST op=purge operates on enc=xchacha20 messages identically to
8030+ plaintext messages. ciphertext rows are deleted. unrecoverable.
8031+
8032+ enc= and REACT / PIN / THREAD:
8033+ reactions, pins, and thread replies may reference enc=xchacha20
8034+ mids without restriction. the server does not require enc= on
8035+ those operations and does not parse the ciphertext.
8036+
8037+ enc= and line length:
8038+ clients MUST ensure total line length does not exceed 4096 bytes
8039+ (%-section_1-%). for Layer 2 (Sender Key) messages: 8 bytes header + 24-byte
8040+ nonce + 16-byte auth tag = 48 bytes crypto overhead. derivation:
8041+ 4096 bytes total - 1 LF - ~55 bytes line prefix ("MSG cid=<26-char-
8042+ ulid> enc=xchacha20 :") = ~4040 bytes for base64url ciphertext;
8043+ base64url decodes at 3 bytes per 4 chars = ~3030 bytes raw; minus
8044+ 48 bytes overhead = ~2980 bytes theoretical maximum. clients MUST
8045+ use a conservative limit of 2710 bytes to accommodate variable-
8046+ length cid values and future tag additions. clients MUST split
8047+ messages exceeding this limit.
8048+
8049+ ==========================================================================
8050+
8051+ 29.2f rationale
8052+
8053+ why Sender Keys for channels instead of pairwise X3DH per recipient?
8054+
8055+ pairwise X3DH for channels requires the sender to encrypt the message
8056+ body N times and send N MSG lines per message. for a channel with 100
8057+ members, one message generates 100 wire lines: O(N) server storage,
8058+ O(N) relay bandwidth, O(N) encryption work. this violates the
8059+ "one line per logical unit" design goal (design goal 4, preamble).
8060+
8061+ Sender Keys solve this: the sender encrypts once, sends one MSG line.
8062+ all members decrypt independently from their cached key copy. O(1)
8063+ server work per message; O(N) only at key distribution time, amortised
8064+ over many messages. this is how Signal, WhatsApp, and iMessage group
8065+ chats work.
8066+
8067+ why does enc=skdm go to all subscribers and not just the target?
8068+
8069+ the server is a dumb relay. routing MSG lines selectively to a subset
8070+ of subscribers requires per-message recipient tracking and conditional
8071+ delivery suppression --- server-side logic that contradicts the dumb-
8072+ relay design and creates hidden state (%-section_2_6-%). target= gives clients
8073+ the information to self-filter. the payload is pairwise-encrypted;
8074+ non-target subscribers cannot decrypt it even if they receive it.
8075+ there is no privacy loss from broadcasting SKDMs to all subscribers.
8076+
8077+ why is enc=skdm excluded from HIST?
8078+
8079+ SKDMs are key-distribution events, not conversation content. a member
8080+ reconnecting later needs fresh SKDMs via the keysync path, not stale
8081+ ones from HIST. stale SKDMs in HIST would deliver historical key
8082+ material to a client that could not previously decrypt those messages
8083+ (privacy regression). the live distribution path always delivers
8084+ current keys. exclusion from HIST is therefore both a correctness
8085+ and a privacy requirement.
8086+
8087+ why is DM e2e= immutable but channel e2e= mutable?
8088+
8089+ DM immutability: see DM channel E2E policy in %-section_29_2d-%. users who want
8090+ different terms open a new DM.
8091+
8092+ channel mutability: an admin disabling E2E is a community-wide policy
8093+ decision, analogous to changing open=. the change is transparent via
8094+ CHAN op=e2e broadcast and non-retroactive. members see the event and
8095+ may choose to leave. this is acceptable; a unilateral change to a
8096+ 1:1 agreement is not.
8097+
8098+ why is Sender Key rotation a MUST on member removal?
8099+
8100+ a removed member must not be able to decrypt future messages. this is
8101+ the fundamental post-removal privacy guarantee. making it a MUST
8102+ ensures all compliant clients enforce it. note: the removed member
8103+ still holds old Sender Keys and can decrypt messages from before
8104+ removal --- this is unavoidable without retroactive re-encryption,
8105+ which the server cannot perform (no plaintext). the forward property
8106+ (future messages unreadable) is what matters and what MUST rotation
8107+ achieves.
8108+
8109+ why is periodic rotation a SHOULD and not a MUST?
8110+
8111+ periodic rotation is a break-in-recovery mechanism. if a current chain
8112+ key is compromised, rotation limits the window of exposure. making it
8113+ MUST would require clients to emit bursts of SKDMs on a schedule even
8114+ on idle channels. the protocol provides the mechanism; the schedule is
8115+ an application-level quality-of-security commitment. rotation on
8116+ member events (MUST) is the hard correctness requirement.
8117+
8118+ why X3DH + Double Ratchet for DMs, not Sender Keys?
8119+
8120+ Sender Keys require distributing an SKDM before the first message.
8121+ for a DM this adds latency: fetch prekey, generate Sender Key, send
8122+ SKDM, then send first message. X3DH solves this naturally: the
8123+ initiator includes ephemeral key material inline in the first
8124+ ciphertext (Signal X3DH format) so the recipient derives the shared
8125+ secret on receipt with no prior round-trip. DMs have exactly two
8126+ parties; the O(N) problem that motivates Sender Keys does not arise.
8127+ X3DH + Double Ratchet is strictly superior for 2-party conversations.
8128+
8129+ why PUBKEY op=keysync instead of distributing SKDMs via HIST?
8130+
8131+ SKDMs are excluded from HIST (see above). a catch-up mechanism is
8132+ therefore necessary. keysync is a targeted pull: the recipient asks
8133+ a specific sender for their current Sender Key. this is better than
8134+ a broadcast request (all senders respond unnecessarily) or polling.
8135+ the persistent delivery model (stored if sender is offline) mirrors
8136+ PING: the server holds state until the target is available, then
8137+ delivers on reconnect. keysync is precise, low-noise, and privacy-
8138+ preserving: only the two parties involved learn that a key was needed.
8139+
8140+ why does PUBKEY op=keysync use uid= to mean different things in each direction?
8141+
8142+ PUBKEY op=keysync is the only bidirectional verb in the protocol
8143+ where uid= identifies different principals in each direction: the
8144+ target sender in the client-sent form; the requester in the server-
8145+ routed form. all other bidirectional verbs use uid= to identify the
8146+ acting user in both directions.
8147+
8148+ the tradeoff: a from= tag on the server-routed form would eliminate
8149+ the asymmetry, but it would require a protocol change, extra server
8150+ logic for one verb, and a new tag with no other consumer. the session-
8151+ context discriminant (pending-request vs. unsolicited) is sufficient
8152+ for all conforming parsers. a future from= extension is reserved; it
8153+ MUST be adopted if a second verb requires the same convention
8154+ (see %-section_25-%).
8155+
8156+ why E2E uses X25519 separately from Ed25519?
8157+
8158+ Ed25519 and X25519 are both Curve25519 operations but in different
8159+ groups and with different point representations. converting an Ed25519
8160+ private key to X25519 is mathematically defined (cofactor clearing,
8161+ clamp), but doing so ties the long-term signing key to the key
8162+ agreement key. if the key agreement key is compromised via oracle or
8163+ side-channel, the signing key and therefore the uid is compromised too.
8164+ using an independently generated X25519 key pair limits blast radius:
8165+ a broken session key does not threaten uid integrity. this is the same
8166+ rationale used by Signal and TLS 1.3.
8167+
8168+ why X3DH and Double Ratchet are RECOMMENDED but not normative?
8169+
8170+ the protocol defines what goes on the wire (prekey bundle format, enc=
8171+ tag, ciphertext payload as opaque base64url). the key derivation and
8172+ ratchet algorithm are client-to-client contracts; the server never
8173+ touches plaintext. mandating a specific KDF would force all future
8174+ client implementations to implement Signal's exact math, including
8175+ future key types and post-quantum variants. keeping the algorithm non-
8176+ normative allows algorithm agility while keeping the transport protocol
8177+ stable.
8178+
8179+ scope of this non-normativity (privacy vs interoperability):
8180+ algorithm non-normativity is an INTEROPERABILITY concern, not a
8181+ security concern. a client that deviates from X3DH + Double Ratchet
8182+ will fail to establish sessions with conforming clients --- the X3DH
8183+ handshake produces different shared secrets and ciphertext becomes
8184+ undecryptable. messages fail silently; no plaintext is exposed to
8185+ the server, to other participants, or to anyone else. deviation
8186+ breaks the deviating client's own sessions. it does not weaken
8187+ any other participant's privacy.
8188+
8189+ the one privacy concern the protocol cannot address is a client that
8190+ correctly implements the cryptography but exfiltrates plaintext or
8191+ leaks private keys after decryption. this is outside the threat model
8192+ of any E2E protocol, including Signal: once an attacker controls the
8193+ endpoint, the protocol offers no protection. this is equivalent to
8194+ a modified client install, not a protocol weakness.
8195+
8196+ why PUBKEY op=prekey.fetch consumes an OPK atomically?
8197+
8198+ one-time prekeys are designed to be single-use. if a server issued
8199+ the same OPK to two different initiators, both sessions would share
8200+ the same ephemeral key contribution and the "O" in OPK would be
8201+ void. atomic consumption prevents this. the no-OPK fallback (session
8202+ proceeds with only ik= and spk=) is explicitly specified so that OPK
8203+ exhaustion degrades gracefully rather than breaking E2E entirely.
8204+ the OPK_DEPLETED WARN prompts the target to replenish without
8205+ requiring the initiator to poll.
8206+
8207+ ==========================================================================
8208+
8209+ WARN codes for E2E:
8210+
8211+ OPK_DEPLETED and SKDM_PENDING are defined in the canonical WARN code
8212+ table at %-section_26_0-%. their normative definitions, mid= tag presence rules,
8213+ and client MUST requirements are authoritative there. the definitions
8214+ are not repeated here to avoid divergence.
8215+
8216+
8217+========================================================================
8218+END OF WIRE SPECIFICATION %-version-%
8219+========================================================================
8220+
8221+ this document is not final. implementation experience will revise it.
8222+ send corrections to the wire-wg issue tracker.
8223+ ISC license applies to all reference code in this document.