eleven / ocho Public

Moderation 6/6: docs — the suite, its trust model, and the franking decision (#578) (#759)

* server+web: mute a chat, mute a person (#578)

server: muted_threads marker (pinned/hidden pattern) skipped by push
fan-out on both transports; sealed per-user prefs blob (/api/prefs, CAS)
for the muted-people set the server can't read.
web: kebab Mute chat, thread-list slashed bell + dot suppression, profile
Mute person, message-menu Mute/Unmute, muted-stub fold with per-session
reveal, SW notification gate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: changelog entry for mute

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* server+web: DM privacy + end conversation (#578)

server: users.dm_privacy door enforced at DM creation (admin+bot
carve-out); closed_dms state refuses msg/edit/typing on both transports,
kills queued scheduled deliveries, hides for both sides; reopen follows
the other side's door.
web: Edit-profile DM setting, DM kebab End conversation, closed banner
with Reopen replacing the composer, profile Message button says when a
door is closed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ios+mac: mute a chat, mute a person (#578)

Mute chat in thread menus/swipe; bell.slash replaces the unread dot;
NSE goes passive for muted threads + muted senders; muted-people set
synced via the sealed prefs blob (CAS, web-compatible shape); muted
bubbles fold to a Show stub.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* server+web: report to admins, moderation inbox, warn, audit log (#578)

server: reports queue metadata + per-admin sealed content blobs, 5/hour
filing brake, resolve-once semantics; audit_log appended by admin
removal, admin group-removal, level removal, warn, and resolution.
web: Report on message long-press + profile (category, note, quoted
evidence, also-mute), sealed to each admin via sealForPub; Admin app
Reports inbox (repeat signals, warn/remove/demote ladder, inline
expanders) + Audit log; warnings render with a notice framing (warn
flag pinned in the event vectors).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* server+web: block a person (#578)

server: blocks table (connection metadata); enforced at DM create +
reopen (both directions, admin/bot carve-out so notices land), group
adds both ways, push fan-out (incl. scheduled); blocking closes any
existing DM via the closed-DM machinery. Unblock restores possibility,
never the conversation.
web: profile Block/Unblock with honest copy, report sheet 'Also block',
Message button hidden for blocked people, block implies mute.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ios+mac: DM privacy + end conversation (#578)

DM door picker in Settings, End conversation in DM menus, closed banner
with Reopen replacing the composer, dm_closed/dm_reopened WS folds,
typed dms-closed refusal on new DMs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* server+web: ban, spam brakes, invite hygiene (#578)

server: bans keyed on the member's public key (the one durable E2EE
handle), enforced at pubkey registration so every invite path is
covered; removeUser now also sweeps the member's queued scheduled
messages; content-blind token-bucket brakes on messages (30 burst),
new DMs (day-one probation), and invite minting — admins exempt;
users.invited_by recorded at claim; invite-pause flips audited.
web: Remove & ban in the report queue; 'invited by' in report bylines.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* server: init moderation brakes in handler() (nil-safe, race-free)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: moderation under E2EE — suite, trust model, limits, franking decision (#578)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ios+mac: report to admins + warn framing (#578)

Report in message menus + profile sheet; shared ReportSheet seals the
web-shape content to each admin key (ECIES); MessagePayload.warn +
notice framing (EventVectorTests case for the admin_warning_message
vector).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ios+mac: block a person (#578)

Profile Block/Unblock (destructive, honest footnotes), report sheet
'Also block', blockedPeople synced in refresh, block implies mute +
mirrors the server's DM close.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* admin: expander actions keep the row — true meant refresh, wiping the expander just opened

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* admin: Remove & ban's cancelled confirm keeps the row too

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* admin: queue resolutions repaint the audit log too

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* web: live mute folds — mix muted state into the keyed-list rev

Found in the browser drive: keyedList skips rows whose rev didn't
change, so muting a person folded nothing until a reload.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* web: drop duplicated mutedRev after stack merge (module-level redeclare is a SyntaxError)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* web: align chat.js byte-identically with the base branch (stray hunk placement)

* web: restore mutedRev lost to a double-dedupe merge (align chat.js with base)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* web: size the report sheet's checkboxes — the width:100% input rule shoved their labels out of the card

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* server+web+sw: throttled admin notification when a report lands (#578)

One push per quiet window (15 min) however many reports arrive; each
banner carries the open count so the throttle loses nothing. Same
plaintext-operational class + transports as the changelog announcement
(Web Push + APNs), shown even while the app is open; live admin
sessions also repaint the queue on the report_filed frame.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* web: report sheet shows work while sending — form freezes, button spins

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* web: freeze the Also-block toggle with the rest of the sending report form

* server+web: report arrivals light the Admin thread + write a line in its stream

The throttled report_notice frame (once per burst, admins only) drives
the per-device dot + hidden-tab banner — a self-posted bubble can't
light your own dot — while one visible session writes the durable
'⚠️ A report came in' line into the Admin app's Chat stream; the
plaintext push keeps covering fully closed apps.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* web: report success replaces the sheet with a confirmation, not a buttonless frozen form

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* server: edits share the message brake — edit-churn is message spam wearing a hat

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* push.go: true union of the seam — main's id/at payload + the stack's muted/blocking drops

* re-graft #807's db.go/main.go/shell.js hunks lost to a stale superset assumption (main moved mid-fold)

* re-graft #808's db.go hunks (key transparency) lost to the seam resolution

---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 66d998a0cf2e1a576e7230398b8ee94289945b5a parent c19f8e3
1 file changed, +164 −0
  • docs/moderation.md +164 −0
diff --git a/docs/moderation.md b/docs/moderation.md
new file mode 100644
index 0000000..c401dbf
--- /dev/null
+++ b/docs/moderation.md
@@ -0,0 +1,164 @@
+# 🤖 Moderation under E2EE — the suite, its trust model, and its honest limits
+
+Status: **shipped** (issue #578, the `mod-*` PR stack, 2026-07-12). This
+documents what the moderation suite does, what the server learns for each
+piece, how it interacts with access levels, and the two E2EE realities the
+design is built around — including the franking decision at the end.
+
+The one-line frame: **moderation here is report-driven by design.** Admins
+hold no skeleton key — they see only the conversations they're in, exactly
+like any member. What they moderate is what members surface, plus
+content-blind signals (rates, volumes, repeat counts) the server can compute
+without reading anything.
+
+## Member tools
+
+| tool | what it does | where it lives |
+|---|---|---|
+| **Mute a chat** | No notifications, no unread dot; messages keep arriving quietly. | Thread ⋮ menu (web + iOS + Mac) |
+| **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 |
+| **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) |
+| **End conversation** | Closes a DM for both parties; the server refuses new messages/edits/typing until someone reopens it. | DM ⋮ menu |
+| **Report** | Message or person → category + note + quoted evidence, sealed to each admin's key. | Message long-press, profile card |
+| **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 |
+
+## Admin tools
+
+| tool | what it does |
+|---|---|
+| **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"). |
+| **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. |
+| **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). |
+| **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. |
+| **Audit log** | Append-only record of every moderation action: who, what, whom, when, off which report. Includes invite-pause flips. |
+| **Invite pause** | The existing "Members can invite people" switch is the panic lever during a spam wave; flips are audited. |
+| **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. |
+
+## What the server learns, per piece
+
+The E2EE rule holds throughout: every server-side moderation record is
+connection/action **metadata** — never content, never keys.
+
+- **Mute a chat** — a per-user thread flag (`muted_threads`), the same class
+ as pinned/hidden. Deliberately server-visible so the server can *skip
+ pushes entirely* (an iOS NSE can't fully drop a delivered push without
+ Apple's filtering entitlement; not sending is strictly better).
+- **Mute a person** — nothing. The muted set lives in a blob sealed to the
+ member's own key (`users.prefs_blob`); the server syncs ciphertext it
+ cannot read. Notification suppression happens after decryption, on-device.
+- **DM privacy** — one enum on the user row.
+- **Close DM / block / ban** — connection records (`closed_dms`, `blocks`,
+ `bans`): who cut contact with whom, never why. The blocked/banned party
+ gets the same generic refusals a closed DM door gives.
+- **Report** — queue metadata (reporter, reported, thread id, status,
+ outcome) plus per-admin sealed blobs the server can't open. The category
+ and every word travel inside the blobs.
+- **Warn** — an audit row saying a warning was issued. The warning's words
+ are an ordinary E2EE DM.
+- **Audit log** — plaintext action metadata, admin-served only. The same
+ class as the roster.
+
+## The tiers (access levels) tie-ins
+
+- **Demote is a rung on the ladder.** A report can be resolved by removing
+ one of the reported member's access levels (`unassignTier`) — losing
+ `silver` without losing the space. Audited as `level.removed`.
+- **Bans cover cohort invites.** A cohort invite assigns a level at claim,
+ but a claim only becomes a member when a public key registers — and that
+ is where the ban check lives. One gate, every invite path.
+- **Roles are not tiers, still.** Moderation powers ride `users.role`
+ (admin), never key material. `role='moderator'` remains reserved and
+ unenforced — wiring moderators into the queue (they'd need report blobs
+ sealed to them too) is deliberate future work, noted below.
+
+## The two hard E2EE realities (and what we did about them)
+
+### Reports are testimony, not proof
+
+A report from an encrypted chat is a claim: the server holds only ciphertext,
+so admins cannot cryptographically check that "they said X". The suite ships
+the honest v1:
+
+- The report sheet says so: *"Reports can't be verified automatically —
+ admins read them and use their judgment."* The queue repeats it.
+- The queue shows **pattern signals** instead of pretending at proof: how
+ often this reporter files, how often this member is named, who invited
+ them.
+- **Report abuse is a moderation problem of its own**: filings are capped
+ (5/hour, content-blind), reporter history is visible, and nothing ever
+ auto-punishes on report count.
+
+### Admins can't see who anyone is server-side — or read any thread they're not in
+
+Names and photos are E2EE, so the token-auth admin *console* sees an opaque
+roster and only queue **metadata**. Everything that needs words or names —
+the report content, the audit log's names, the warn composer — lives in the
+**Admin app**, where the admin is a member holding keys. The console keeps
+its key-blind operations only.
+
+## Honest limits (say them out loud)
+
+- **A ban stops the key, not the person.** A determined person with a fresh
+ invite and a fresh keypair is a new stranger to the server. That's why the
+ ban button sits next to invite lineage and the invite pause — the actual
+ countermeasures for re-entry.
+- **Removal doesn't rekey.** A removed/banned member who exfiltrated keys
+ client-side plus a *later server compromise* could decrypt future traffic
+ in their old threads. Re-key-on-removal is #541 Q10 — a real protocol
+ change, tracked there, not silently promised here.
+- **Mute is one-sided by design.** "You stop seeing them", never "they stop
+ existing". The muted party can still read the group and see your profile —
+ within one space, "can't see I exist" isn't enforceable and we don't
+ pretend otherwise.
+- **Blocking hides nothing in shared groups.** It cuts contact (DMs, adds,
+ notifications) and folds their messages away *for you*; you still share
+ the room.
+
+## The franking decision (envelope room: not needed)
+
+Message franking (the Messenger construction) would make reports
+*cryptographically verifiable*: at send time the sender's client commits to
+the plaintext (an HMAC whose key is revealed on report), the **server**
+blindly countersigns the commitment at delivery, and on report the admins
+check the revealed plaintext against the commitment the server saw. Fabricating
+"they said X" then becomes detectable without the server ever reading anything.
+
+**Decision: build nothing now, reserve nothing now — because nothing needs
+reserving.** The issue asked us to decide early whether to hold room in the
+payload envelope. We looked at the wire format and the answer is that both
+places franking would touch are already extensible:
+
+1. **The encrypted payload is JSON.** A future sender adds `frank: {c, …}`
+ (the commitment material) as one more optional field — exactly how
+ `warn`, `replyTo`, and `card` were added. Old clients ignore unknown
+ fields by construction; the event-vector suite is where the field gets
+ pinned when it becomes real.
+2. **The server frame is JSON too.** The countersignature (server MAC over
+ the commitment + timestamp) would ride the *stored message row / WS
+ frame* (`wsOut`), also additive.
+
+So the only genuinely new machinery is behavioural — the server keeping a
+countersigning key and stamping every accepted message, clients verifying on
+report — and none of it is made cheaper by reserving bytes today. What we
+*have* done now is keep the report content shape versioned (`{"v":1, …}`) so
+a `v:2` report carrying franking material is a clean upgrade.
+
+Revisit franking when (a) spaces are big enough that fabricated reports are
+an observed problem, not a theoretical one, and (b) #541's rekey questions
+are settled — the two share the "protocol change with client fan-out" cost
+profile and should be scheduled together.
+
+## Deliberate scoping
+
+- **The admin queue is web-only** (the Admin app). The native apps ship every
+ member tool; they deliberately track no admin surfaces yet — the same
+ scoping as native access-level management. Native members still see warn
+ framing, report sheets, mute/block — everything a non-admin touches.
+- **Per-space only.** Cross-instance moderation (shared ban lists, platform
+ obligations) is #574's territory.
+- **`role='moderator'`** stays unenforced. Wiring it up means: sealing report
+ blobs to moderators too, letting them warn/remove-from-thread but not
+ remove-from-space/ban, and deciding whether members can see who moderates.
+ Its own issue when wanted.
+- **Brake ceilings are constants** (ratelimit.go), far above human pace. Make
+ them per-space config only if a real space actually needs different ones.