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> &bull;
12+  <a href="docs/IMPL.md">implementation notes</a> &bull;
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 ![alt](aid:<aid>) 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+    ![alt](aid:<aid>)     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+    ![alt](aid:<aid>)     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.