Skip to content

Moderation

Draft

Capsule is end-to-end encrypted, so a server cannot scan content it holds — server-side content or CSAM scanning is impossible by design, and no server-side content scanner will be built. (Client-side, opt-in ML over a user’s own library — AI/ML — is a different thing entirely: it runs where the keys are, under the user’s control; its candidate shared-album-flagging classifiers are client-side and user-initiated, never a server scanner.) Moderation operates entirely on what is available: user reports, account-level signals, and federated peer reputation.

Implementation will live in capsule-server::moderation (a new sub-crate or service inside capsule-server). The boundary surfaces — report submission, federated report exchange, blocklist publication — are the eventual contract; this doc captures what they will need to do.

Naming this up front is load-bearing:

  • No content inspection. The server holds opaque ciphertext. There is no algorithm that can act on the content of an asset without a key.
  • No retroactive content takedown. Once a peer has fetched ciphertext, the home server cannot un-fetch it. Takedown is about future serving, not deletion-from-everywhere.
  • No silent operations. Every moderation action that affects user data must produce a provenance record the user can see in their audit log.

What Moderation Can Do (Operational Hooks)

Section titled “What Moderation Can Do (Operational Hooks)”

The actual policy surfaces that need design:

A report against alice@other.tld’s asset is routed to her home server’s administrators, since they are the only party that can act on her account. Three mechanics are fixed:

  • Authentication. A federated report MUST be signed by the reporting server’s signing key and is verified before it reaches the admin queue; an unsigned or invalid-signature report is dropped, never surfaced. This makes every report attributable — a server that submits false reports is itself identifiable and blockable.
  • Rate-limiting. Reports are bounded per (reporting_server, reported_user); exceeding the limit applies backpressure rather than amplifying. A receiving server also bounds a peer’s reports across all accountsreported_user is a string the peer chooses, so a per-account limit alone is one a peer refreshes by naming a different account — and bounds attempts per claimed origin before it looks the peer up at all, which is the only place a bound can sit on an unauthenticated route.
  • Unknown accounts are accepted and dropped. A report naming an account the receiving server does not host is answered exactly as an accepted one is, and filed nowhere: an unresolvable report is a permanent orphan in an operator’s queue, and a distinct refusal would turn intake into an account-enumeration surface for any peer holding a pinned key — which is not the same as being trusted with enumeration, since a peer key can be compromised and a peer may be adversarial toward its own users while remaining a legitimate partner. The receiving server logs it for its operator; the rate limits above still apply, so sweeping identifiers costs the peer its allowance. Together with signing, this defeats the false-flag / mass-report abuse vector (a flood of forged or spoofed reports against one user).
  • Wire form and what is signed. The report is JSON with seven members — reporting_server, reported_user, asset_hash, album_id, reason (optional), reported_at (RFC 3339), and signature — and the signature is Ed25519 over the canonical CBOR of the other six. One normalization rule, and only one: each of the six has surrounding whitespace trimmed and is otherwise signed exactly as sent. In particular reporting_server is signed as the peer wrote it — the receiving server folds case and strips a trailing dot to look the peer up, and that canonical form is not what the signature covers — and reported_at is signed as its RFC 3339 text, not as a re-rendered instant. A receiving server keeps the signed bytes verbatim beside the row so the signature can be re-verified later; rebuilding them from stored, normalized fields would produce different bytes and an unattributable report.
  • Content. A report carries the alleged asset’s content hash and album pointer — never plaintext or decryption material. This is the privacy-preserving, operable middle: the home-server admin can locate the asset and, if they already hold album access, fetch and view it to act; an admin without album access sees only opaque identifiers, exactly as the E2EE model requires. A report never widens who can read content.

Server-level blocklists, plus per-user blocks that federate:

  • Server-level blocklist. A server admin publishes a list of peer servers that this server refuses to accept federated requests from. Operates at the federation capability layer. Status: enforced, not yet writable. blocked_at is a column on the peer row and every federation boundary consults it — mint, capability presentation, refresh, report intake — but nothing in production can set it: the durable backend refuses to assemble until #403 lands its adapters, so an operator command that pinned or blocked a peer could only run against --memory, which forgets the moment it exits. The command is owed with #476. Until then this list is empty in every real deployment, and this document’s own rule cuts both ways: a blocklist nothing consults reads as protection, and so does one nothing can write.
  • Per-user block. A user can block another user; the block is enforced by the blocker’s home server — the blocked user is removed from albums shared with the blocker and cannot share new albums with them. Removal is an ordinary MLS Remove + AMK epoch bump applied at the blocked user’s next sync; the prior epochs’ keys they already hold are not retroactively clawed back (consistent with removal semantics). A per-user block is scoped to that user: it does not propagate as a server-wide federation block, so one user (or a coordinated group) cannot weaponize blocks to sever an entire peer server from the federation. Each home server enforces only its own users’ blocks. (The MLS Remove this rides has landed — OpenMlsAuthority::block_user, slice S-X4; see the MLS status note. It removes every device the blocked user holds in one commit, so the epoch bumps once.)
  • Blocklist exchange (v2). A peer-level mechanism for sharing server-level blocklists across federated servers (so a malicious server isn’t pure whack-a-mole) is deferred to v2, but its shape is fixed now: signed, versioned blocklist documents an admin opts into consuming from peers they already trust — never auto-applied, and deliberately distinct from per-user blocks (which never propagate). v1 ships only the manual server-level blocklist above.

Federation — Security Against Malicious Files names this as the front-line abuse control for content from servers Capsule does not trust. Moderation policy decides what “trusted” means and how trust is established/revoked.

A server admin can suspend a user account on their home server. Suspended accounts:

  • Cannot upload — POST /v1/upload session creation is refused with a structured 403 AccountSuspended code (distinct from quota and permission rejections, so the client surfaces the right remediation).
  • Cannot share new albums or create new share/upload links (existing share links keep serving until they expire or are revoked — share links have optional expiry plus revocation, no implicit TTL).
  • Can still secure the account: revoke_all_sessions remains available to a suspended user — it is gated by master-key proof, not by account standing, so it cannot be abused by a session-token thief, and a suspended user whose account may be compromised needs it most. Suspension removes upload and sharing capability, never the ability to evict sessions.

The user’s data is untouched — suspension is an access-level action, not a data-level one. Reversibility (a suspension can be lifted) is the default; permanent termination is a separate policy.

When a moderation action requires the home server to stop serving a specific asset (e.g. legal request, CSAM report verified by admin viewing in their album):

  • The asset is marked unservable on the home server — a serving hold on the asset row, carried on every blob reference the serving path reads, so a hold reaches every blob of the asset without ever being attached to a content address. That distinction is load-bearing: content addressing means two assets legitimately share a thumbnail, and holding an address would take down somebody else’s photo.
  • Federated peers fetching the asset receive 410 Gone. (This deliberately diverges from the share-link and drop serve paths, which return an indistinguishable 404 — those must not confirm a capability URL ever existed, while a takedown intends to signal removal of content whose existence the peer already knows. The per-surface rule: capability-URL serving → 404; takedown of known content → 410.)
  • The asset’s underlying blob is not deleted — the user owns the data, and a takedown is a serving constraint, not a destruction; the user can still restore from their own backup. A takedown is therefore reversible by default (an admin can lift it). A legal-hold variant marks the asset indefinitely unservable where law requires it — lifted only when the legal obligation ends, not at admin discretion — but even then never destroys the user’s bytes: the constraint is on the home server’s serving, not on the data the user holds.
  • Storage verification tells the truth about a held asset: every blob reports stored and not retrievable. That surface exists to answer one question — may a client release its only local copy? — and a server that called held bytes durable would be answering it wrong in the one direction that loses a photo. The honest pair is we have your bytes, and we will not serve them.
  • Status note. Suspension enforcement and the user’s own moderation record are served today by the Kynos surface, with slice S-C8: a suspended account’s upload session creation is refused with a structured code, and GET /v1/moderation/record is where the user reads what was done and why. Moderation actions deliberately have no wire surface — this doc names an admin throughout and specifies no way for one to authenticate, so the actions sit behind an operator-driven port, the same shape the garbage collector and the integrity scrub already use. Federated report intake and the server-level blocklist are built as of S-C49 (2026-09-09), on the federation-capability layer S-E2 landed — and neither is reachable in a real deployment yet, for one shared reason: peer keys are operator-pinned, and no operator command can pin one until the durable backend assembles (#403, then #476). Intake therefore answers 403 error.federation.peer_unknown to every peer today, and the blocklist is empty. The route stays mounted and its contract published, because a peer implementing against this document needs it and the operator command is what is missing rather than the surface. What is built: POST /v1/federation/reports verifies the reporting server’s Ed25519 signature against the key an operator pinned for it before charging the (reporting_server, reported_user) budget — so a third party spoofing reporting_server cannot spend a real peer’s allowance — drops anything unsigned or unattributable, and files an accepted report for an operator to read without touching the reported account’s standing. The blocklist is a column on the peer row, consulted at mint, at every capability presentation, at refresh and at intake; blocking also cuts and publishes every live grant the peer holds. Two things stay owed beyond the operator command (issue #476): nothing fetches a peer key — TOFU at intake is rejected on its own merits, because the first report from an unknown server is the wrong moment to decide whether to trust it — and the admin surface that would read the report queue still waits on the admin authentication model this doc leaves open.
  • The takedown emits a server-visible moderation provenance record the user sees in their audit log — what was taken down, when, and (where policy permits) why — honoring the “No silent operations” rule. A user whose asset stops serving is never left to guess why, and the moderation action is itself auditable after the fact.

Moderation crosses the federation boundary cleanly because federation is pull-only and capability-gated. A blocked peer cannot pull; a takedown asset returns 410 to every pull. The moderation policy decisions don’t require new federation primitives — they reuse the capability and revocation surfaces already there.

A suspended or taken-down user can appeal. The appeal is authenticated by master-key proof (the same mechanism as global session revoke) rather than a session token — the session may be the thing under dispute — and lands in the home-server admin queue. The admin’s decision is itself a moderation provenance record the user can see. Because suspension and takedown are reversible by default, a granted appeal simply lifts the constraint.

  • Federated report transport (smoke): send report from server A to server B; assert it reaches the admin queue with structured metadata.
  • Blocklist enforcement (smoke): blacklist a peer; assert federation pulls from that peer are refused.
  • Suspension enforcement (unit): a suspended account’s upload session creation is rejected with the right structural code.
  • Takedown serving (smoke): take down an asset; assert subsequent fetches return 410; assert the underlying blob is preserved; assert a moderation provenance record is appended and visible in the user’s audit log.
  • Federated-report authentication (unit): submit a report signed by a valid peer key; assert it reaches the admin queue. Submit an unsigned / invalid-signature report; assert it is dropped. Exceed the per-(reporting_server, reported_user) rate limit; assert backpressure.
  • Block scoping (unit): a per-user block removes the blocked user from the blocker’s shared albums; assert it does not appear as a server-level federation block against the blocked user’s home server.