eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1# ๐Ÿค– Moderation under E2EE โ€” the suite, its trust model, and its honest limits
2
3Status: **shipped** (issue #578, the `mod-*` PR stack, 2026-07-12). This
4documents what the moderation suite does, what the server learns for each
5piece, how it interacts with access levels, and the two E2EE realities the
6design is built around โ€” including the franking decision at the end.
7
8The one-line frame: **moderation here is report-driven by design.** Admins
9hold no skeleton key โ€” they see only the conversations they're in, exactly
10like any member. What they moderate is what members surface, plus
11content-blind signals (rates, volumes, repeat counts) the server can compute
12without 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
39The E2EE rule holds throughout: every server-side moderation record is
40connection/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
78A report from an encrypted chat is a claim: the server holds only ciphertext,
79so admins cannot cryptographically check that "they said X". The suite ships
80the 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
93Names and photos are E2EE, so the token-auth admin *console* sees an opaque
94roster and only queue **metadata**. Everything that needs words or names โ€”
95the 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
97its 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
119Message franking (the Messenger construction) would make reports
120*cryptographically verifiable*: at send time the sender's client commits to
121the plaintext (an HMAC whose key is revealed on report), the **server**
122blindly countersigns the commitment at delivery, and on report the admins
123check 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
127reserving.** The issue asked us to decide early whether to hold room in the
128payload envelope. We looked at the wire format and the answer is that both
129places franking would touch are already extensible:
130
1311. **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.
1362. **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
140So the only genuinely new machinery is behavioural โ€” the server keeping a
141countersigning key and stamping every accepted message, clients verifying on
142report โ€” 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
144a `v:2` report carrying franking material is a clean upgrade.
145
146Revisit franking when (a) spaces are big enough that fabricated reports are
147an observed problem, not a theoretical one, and (b) #541's rekey questions
148are settled โ€” the two share the "protocol change with client fan-out" cost
149profile 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