| 1 | # ๐ค Moderation under E2EE โ the suite, its trust model, and its honest limits |
| 2 | |
| 3 | Status: **shipped** (issue #578, the `mod-*` PR stack, 2026-07-12). This |
| 4 | documents what the moderation suite does, what the server learns for each |
| 5 | piece, how it interacts with access levels, and the two E2EE realities the |
| 6 | design is built around โ including the franking decision at the end. |
| 7 | |
| 8 | The one-line frame: **moderation here is report-driven by design.** Admins |
| 9 | hold no skeleton key โ they see only the conversations they're in, exactly |
| 10 | like any member. What they moderate is what members surface, plus |
| 11 | content-blind signals (rates, volumes, repeat counts) the server can compute |
| 12 | without reading anything. |
| 13 | |
| 14 | ## Member tools |
| 15 | |
| 16 | | tool | what it does | where it lives | |
| 17 | |---|---|---| |
| 18 | | **Mute a chat** | No notifications, no unread dot; messages keep arriving quietly. | Thread โฎ menu (web + iOS + Mac) | |
| 19 | | **Mute a person** | Their messages fold to a "Muted message" stub (tap to read); they never notify. Private โ they're not told. | Profile card, message long-press | |
| 20 | | **DM privacy** | "Who can message you directly": everyone in the space, or no one. Enforced server-side at DM creation. | Edit profile (web), Settings (native) | |
| 21 | | **End conversation** | Closes a DM for both parties; the server refuses new messages/edits/typing until someone reopens it. | DM โฎ menu | |
| 22 | | **Report** | Message or person โ category + note + quoted evidence, sealed to each admin's key. | Message long-press, profile card | |
| 23 | | **Block** | Mute + end DM + server-enforced no new contact: no fresh DMs either way, no group adds either way, no pushes from them. | Profile card, report sheet | |
| 24 | |
| 25 | ## Admin tools |
| 26 | |
| 27 | | tool | what it does | |
| 28 | |---|---| |
| 29 | | **Reports inbox** | The Admin app's queue: decrypted report content (sealed to each admin), repeat signals (how often the reporter files / the reported is named), invite lineage ("invited by X"). | |
| 30 | | **Warn** | A formal notice, sent as an ordinary E2EE DM with a `warn` flag inside the ciphertext โ clients render the "notice from an admin" framing; the server records only that the rung was used. | |
| 31 | | **Remove from group / access level / space** | The existing removal levers, wired as one-tap resolutions off a report. Removing an access level is the demote rung (see tiers, below). | |
| 32 | | **Remove & ban** | Removal plus a persistent denial keyed on the member's public key: that key can't re-register through any invite โ admin, member, or cohort. | |
| 33 | | **Audit log** | Append-only record of every moderation action: who, what, whom, when, off which report. Includes invite-pause flips. | |
| 34 | | **Invite pause** | The existing "Members can invite people" switch is the panic lever during a spam wave; flips are audited. | |
| 35 | | **Spam brakes** | Content-blind token buckets: messages (burst 30, ~30/min), new DMs (~10/h; day-one members ~3/h), invite minting (~20/day). Admins exempt. | |
| 36 | |
| 37 | ## What the server learns, per piece |
| 38 | |
| 39 | The E2EE rule holds throughout: every server-side moderation record is |
| 40 | connection/action **metadata** โ never content, never keys. |
| 41 | |
| 42 | - **Mute a chat** โ a per-user thread flag (`muted_threads`), the same class |
| 43 | as pinned/hidden. Deliberately server-visible so the server can *skip |
| 44 | pushes entirely* (an iOS NSE can't fully drop a delivered push without |
| 45 | Apple's filtering entitlement; not sending is strictly better). |
| 46 | - **Mute a person** โ nothing. The muted set lives in a blob sealed to the |
| 47 | member's own key (`users.prefs_blob`); the server syncs ciphertext it |
| 48 | cannot read. Notification suppression happens after decryption, on-device. |
| 49 | - **DM privacy** โ one enum on the user row. |
| 50 | - **Close DM / block / ban** โ connection records (`closed_dms`, `blocks`, |
| 51 | `bans`): who cut contact with whom, never why. The blocked/banned party |
| 52 | gets the same generic refusals a closed DM door gives. |
| 53 | - **Report** โ queue metadata (reporter, reported, thread id, status, |
| 54 | outcome) plus per-admin sealed blobs the server can't open. The category |
| 55 | and every word travel inside the blobs. |
| 56 | - **Warn** โ an audit row saying a warning was issued. The warning's words |
| 57 | are an ordinary E2EE DM. |
| 58 | - **Audit log** โ plaintext action metadata, admin-served only. The same |
| 59 | class as the roster. |
| 60 | |
| 61 | ## The tiers (access levels) tie-ins |
| 62 | |
| 63 | - **Demote is a rung on the ladder.** A report can be resolved by removing |
| 64 | one of the reported member's access levels (`unassignTier`) โ losing |
| 65 | `silver` without losing the space. Audited as `level.removed`. |
| 66 | - **Bans cover cohort invites.** A cohort invite assigns a level at claim, |
| 67 | but a claim only becomes a member when a public key registers โ and that |
| 68 | is where the ban check lives. One gate, every invite path. |
| 69 | - **Roles are not tiers, still.** Moderation powers ride `users.role` |
| 70 | (admin), never key material. `role='moderator'` remains reserved and |
| 71 | unenforced โ wiring moderators into the queue (they'd need report blobs |
| 72 | sealed to them too) is deliberate future work, noted below. |
| 73 | |
| 74 | ## The two hard E2EE realities (and what we did about them) |
| 75 | |
| 76 | ### Reports are testimony, not proof |
| 77 | |
| 78 | A report from an encrypted chat is a claim: the server holds only ciphertext, |
| 79 | so admins cannot cryptographically check that "they said X". The suite ships |
| 80 | the honest v1: |
| 81 | |
| 82 | - The report sheet says so: *"Reports can't be verified automatically โ |
| 83 | admins read them and use their judgment."* The queue repeats it. |
| 84 | - The queue shows **pattern signals** instead of pretending at proof: how |
| 85 | often this reporter files, how often this member is named, who invited |
| 86 | them. |
| 87 | - **Report abuse is a moderation problem of its own**: filings are capped |
| 88 | (5/hour, content-blind), reporter history is visible, and nothing ever |
| 89 | auto-punishes on report count. |
| 90 | |
| 91 | ### Admins can't see who anyone is server-side โ or read any thread they're not in |
| 92 | |
| 93 | Names and photos are E2EE, so the token-auth admin *console* sees an opaque |
| 94 | roster and only queue **metadata**. Everything that needs words or names โ |
| 95 | the report content, the audit log's names, the warn composer โ lives in the |
| 96 | **Admin app**, where the admin is a member holding keys. The console keeps |
| 97 | its key-blind operations only. |
| 98 | |
| 99 | ## Honest limits (say them out loud) |
| 100 | |
| 101 | - **A ban stops the key, not the person.** A determined person with a fresh |
| 102 | invite and a fresh keypair is a new stranger to the server. That's why the |
| 103 | ban button sits next to invite lineage and the invite pause โ the actual |
| 104 | countermeasures for re-entry. |
| 105 | - **Removal doesn't rekey.** A removed/banned member who exfiltrated keys |
| 106 | client-side plus a *later server compromise* could decrypt future traffic |
| 107 | in their old threads. Re-key-on-removal is #541 Q10 โ a real protocol |
| 108 | change, tracked there, not silently promised here. |
| 109 | - **Mute is one-sided by design.** "You stop seeing them", never "they stop |
| 110 | existing". The muted party can still read the group and see your profile โ |
| 111 | within one space, "can't see I exist" isn't enforceable and we don't |
| 112 | pretend otherwise. |
| 113 | - **Blocking hides nothing in shared groups.** It cuts contact (DMs, adds, |
| 114 | notifications) and folds their messages away *for you*; you still share |
| 115 | the room. |
| 116 | |
| 117 | ## The franking decision (envelope room: not needed) |
| 118 | |
| 119 | Message franking (the Messenger construction) would make reports |
| 120 | *cryptographically verifiable*: at send time the sender's client commits to |
| 121 | the plaintext (an HMAC whose key is revealed on report), the **server** |
| 122 | blindly countersigns the commitment at delivery, and on report the admins |
| 123 | check the revealed plaintext against the commitment the server saw. Fabricating |
| 124 | "they said X" then becomes detectable without the server ever reading anything. |
| 125 | |
| 126 | **Decision: build nothing now, reserve nothing now โ because nothing needs |
| 127 | reserving.** The issue asked us to decide early whether to hold room in the |
| 128 | payload envelope. We looked at the wire format and the answer is that both |
| 129 | places franking would touch are already extensible: |
| 130 | |
| 131 | 1. **The encrypted payload is JSON.** A future sender adds `frank: {c, โฆ}` |
| 132 | (the commitment material) as one more optional field โ exactly how |
| 133 | `warn`, `replyTo`, and `card` were added. Old clients ignore unknown |
| 134 | fields by construction; the event-vector suite is where the field gets |
| 135 | pinned when it becomes real. |
| 136 | 2. **The server frame is JSON too.** The countersignature (server MAC over |
| 137 | the commitment + timestamp) would ride the *stored message row / WS |
| 138 | frame* (`wsOut`), also additive. |
| 139 | |
| 140 | So the only genuinely new machinery is behavioural โ the server keeping a |
| 141 | countersigning key and stamping every accepted message, clients verifying on |
| 142 | report โ and none of it is made cheaper by reserving bytes today. What we |
| 143 | *have* done now is keep the report content shape versioned (`{"v":1, โฆ}`) so |
| 144 | a `v:2` report carrying franking material is a clean upgrade. |
| 145 | |
| 146 | Revisit franking when (a) spaces are big enough that fabricated reports are |
| 147 | an observed problem, not a theoretical one, and (b) #541's rekey questions |
| 148 | are settled โ the two share the "protocol change with client fan-out" cost |
| 149 | profile and should be scheduled together. |
| 150 | |
| 151 | ## Deliberate scoping |
| 152 | |
| 153 | - **The admin queue is web-only** (the Admin app). The native apps ship every |
| 154 | member tool; they deliberately track no admin surfaces yet โ the same |
| 155 | scoping as native access-level management. Native members still see warn |
| 156 | framing, report sheets, mute/block โ everything a non-admin touches. |
| 157 | - **Per-space only.** Cross-instance moderation (shared ban lists, platform |
| 158 | obligations) is #574's territory. |
| 159 | - **`role='moderator'`** stays unenforced. Wiring it up means: sealing report |
| 160 | blobs to moderators too, letting them warn/remove-from-thread but not |
| 161 | remove-from-space/ban, and deciding whether members can see who moderates. |
| 162 | Its own issue when wanted. |
| 163 | - **Brake ceilings are constants** (ratelimit.go), far above human pace. Make |
| 164 | them per-space config only if a real space actually needs different ones. |
| 165 | |