Skip to content

Moderation Architecture

Cryptid implements a three-layer moderation system that enables spam prevention and community governance while preserving end-to-end encryption and user privacy. This allows different levels of control without compromising the protocol’s security guarantees.

Core Principle: Moderation happens at the layer with appropriate visibility and authority.

  • Server admins moderate infrastructure (federation, rate limits) without accessing message content
  • Group moderators moderate communities (member management, content removal) with visibility into their groups
  • Individual users moderate their own experience (blocking, filtering) with complete personal control

This separation ensures that:

  • End-to-end encryption is never compromised
  • Social graphs remain hidden from servers
  • Communities can self-govern
  • Users maintain autonomy
┌─────────────────────────────────────────────────────────────────┐
│ Layer 1: Server-Level (Infrastructure) │
│ Authority: Server administrators │
│ Scope: Server configuration and federation │
└─────────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────────┐
│ Layer 2: Group-Level (Community) │
│ Authority: Group admins/moderators │
│ Scope: Individual groups and their members │
└─────────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────────┐
│ Layer 3: Device-Level (Personal) │
│ Authority: Individual users │
│ Scope: Personal experience and preferences │
└─────────────────────────────────────────────────────────────────┘

Server administrators control:

  • Federation policies: Which servers to federate with

  • Rate limiting: Progressive trust and traffic shaping

  • Announcement requirements: VDF difficulty, invites, time delays

  • Server resources: Storage limits, message retention duration

POST /admin/v1/federation/block
{
"server_domain": "spam-factory.com",
"reason": "Excessive spam reports from users"
}
POST /admin/v1/trust/verify
{
"device_id": "aabbccddee...",
"reason": "Known community member"
}

Note: Verification is by device_id because trust tiers apply at token issuance - the one point where a server authenticates a device. Redemption is anonymous, so a verified device’s tokens are indistinguishable from anyone else’s when spent. Verification raises the issuance budget; it does not make the device’s activity observable.

GET /admin/v1/metrics
{
"total_devices": 50,
"messages_last_24h": 4500,
"spam_reports_last_24h": 12,
"federation_peers": 25
}
POST /admin/v1/trust/block
{
"device_id": "474351448d59e658e6588814e230194bb6ce894183e77c57e0fa48f44f558da4",
"reason": "Sustained spam, identified by the operator"
}

The inverse of verification, and possible for the same reason: trust tiers apply at issuance, the one point where a server authenticates a device. Blocking sets the device’s bucket rate to 0 and capacity to 0. It does not make past activity observable, and it cannot retract tokens already issued.

  • Read message content (always encrypted)

  • Know who messages whom

  • See group membership (managed client-side)

  • Track social relationships (no contact lists)

  • Ban users from specific groups (not in server scope)

  1. Federation Blocklists: Servers control which other servers they federate with.

  2. Progressive Trust Rate Limiting: New devices start with a token bucket refilling at 50 tokens/hour, earning up to 2,000 tokens/hour over 24 hours.

  3. Registration Anti-Spam: Servers can require one or more anti-spam measures:

    • A VDF proof (required on first announcement)

    • Invite Code (for trusted onboarding)

Hosts store encrypted blobs they cannot read, uploaded by devices they cannot identify. Reports are the only signal available to them.

POST /api/v1/blobs/{blob_handle}/report
{
"reason": "Free-text description from the reporter"
}

A reporter is necessarily someone who could decrypt the blob, since only group members ever learn a handle.

  • Reports MUST be advisory. Hosts MUST NOT auto-delete on report.
  • Hosts SHOULD require operator review, or a threshold of independent reports, before removal
  • Hosts MUST NOT log reporter identity or source address against a handle

Reports carry no proof about content. The host cannot verify a claim it cannot decrypt. What a report establishes is that someone with legitimate access objected, which is the most a host can learn without breaking the encryption model. Removal remains an operator decision, made under whatever policy and jurisdiction that operator answers to.

Group moderators control:

  • Member management: Add/remove members

  • Content moderation: Delete inappropriate messages

  • Role assignment: Promote/demote members

  • Group policies: Set permission thresholds

Groups use Discord-style roles with individual permission flags.

/// Individual permission bits (64-bit, allows 64 distinct permissions)
bitflags! {
pub struct Permissions: u64 {
// === Message Permissions ===
const SEND_MESSAGES = 1 << 0;
const ADD_REACTIONS = 1 << 1;
const ATTACH_FILES = 1 << 2;
const DELETE_OTHERS_MESSAGES = 1 << 3;
const PIN_MESSAGES = 1 << 4;
const MANAGE_THREADS = 1 << 5
const MANAGE_MESSAGES = 1 << 6;
// === Member Management ===
const INVITE_MEMBERS = 1 << 10;
const REMOVE_MEMBERS = 1 << 11;
const BAN_MEMBERS = 1 << 12;
const MANAGE_MEDIA_PACKS = 1 << 13;
const MANAGE_MEMBERS = 1 << 14;
// === Role Management ===
const MANAGE_ROLES = 1 << 20;
const ASSIGN_ROLES = 1 << 21;
// === Group Management ===
const CHANGE_GROUP_NAME = 1 << 30;
const CHANGE_GROUP_ICON = 1 << 31;
const CHANGE_GROUP_SETTINGS = 1 << 32;
const VIEW_AUDIT_LOG = 1 << 33;
const MANAGE_GROUP = 1 << 34;
// === Special Permissions ===
const ADMINISTRATOR = 1 << 63; // Bypass all checks
}
}
impl Permissions {
/// Default permissions for new members
pub fn default_member() -> Self {
Self::SEND_MESSAGES
| Self::ADD_REACTIONS
}
pub fn trusted_member() -> Self {
Self::default_member()
| Self::ATTACH_FILES
| Self::MANAGE_THREADS
}
/// Moderator preset
pub fn moderator() -> Self {
Self::truted_member()
| Self::DELETE_OTHERS_MESSAGES
| Self::PIN_MESSAGES
| Self::MANAGE_MESSAGES
| Self::REMOVE_MEMBERS
| Self::VIEW_AUDIT_LOG
}
/// Admin preset
pub fn admin() -> Self {
Self::moderator()
| Self::BAN_MEMBERS
| Self::MANAGE_MEMBERS
| Self::MANAGE_ROLES
| Self::ASSIGN_ROLES
| Self::CHANGE_GROUP_NAME
| Self::CHANGE_GROUP_ICON
| Self::CHANGE_GROUP_SETTINGS
}
/// Founder (all permissions)
pub fn founder() -> Self {
Self::ADMINISTRATOR
}
}
struct Role {
role_id: RoleId, // Wrapper around UuidV4
name: String,
permissions: Permissions,
// Display
color: Option<Color>, // Role color in UI
// Hierarchy
position: u32, // Higher = more authority
// System roles (can't be deleted)
is_system: bool, // e.g., @everyone, Founder
// Metadata
created_at: u64,
created_by: UserId,
}
struct Color {
r: u8,
g: u8,
b: u8,
}
/// Stored in MLS group context extension
struct CryptidPermissionExtension {
// Map device IDs to their permissions
device_permissions: HashMap<DeviceId, Permissions>,
// Founder
founder: DeviceId,
// Versioning for updates
version: u64,
last_updated_at: u64,
last_updated_by: DeviceId,
}
struct GroupMetadata {
group_id: GroupId,
// Roles (for UI display only, actual enforcement uses CryptidPermissionExtension)
roles: HashMap<RoleId, Role>,
default_role: RoleId, // @everyone role
// Members (can have multiple roles)
members: HashMap<DeviceId, GroupMember>,
// MLS state contains the authoritative permissions
mls_state: MlsGroup,
// Group settings
moderation_policy: GroupModerationPolicy,
}
struct GroupMember {
device_id: DeviceId,
// Multiple roles
roles: Vec<RoleId>,
// Computed permissions (union of all role permissions)
effective_permissions: Permissions,
// Metadata
joined_at: u64,
invited_by: Option<UserId>,
}
impl Group {
/// Check permissions from MLS group extension
fn has_permission(&self, device_id: &DeviceId, perm: Permissions) -> Result<bool> {
// Extract permissions from MLS group context extension
let perm_ext = self.get_permission_extension()?;
if let Some(member) = perm_ext.device_permissions.get(device_id) {
Ok(member.contains(perm))
} else {
Ok(false)
}
}
/// Helper to extract permissions from MLS group state
fn get_permission_extension(&self) -> Result<CryptidPermissionExtension> {
let extensions = self.mls_state.group_context().extensions();
let perm_data = extensions
.find(CRYPTID_PERMISSIONS_EXT)
.ok_or("Permission extension missing from group")?;
let permissions: CryptidPermissionExtension = serde_json::from_slice(perm_data.data())?;
Ok(permissions)
}
fn can_delete_others_messages(&self, device_id: &DeviceId) -> Result<bool> {
self.has_permission(device_id, Permissions::DELETE_OTHERS_MESSAGES)
}
fn can_kick_members(&self, device_id: &DeviceId) -> Result<bool> {
self.has_permission(device_id, Permissions::REMOVE_MEMBERS)
}
}

When a group is created, these roles are automatically created:

impl Group {
fn create_default_roles(founder_device_id: &DeviceId) -> HashMap<RoleId, Role> {
let mut roles = HashMap::new();
// @everyone (default for all members)
roles.insert(everyone_id, Role {
name: "@everyone".to_string(),
permissions: Permissions::default_member(),
color: None,
position: 0,
is_system: true,
});
// Moderator
roles.insert(mod_id, Role {
name: "Moderator".to_string(),
permissions: Permissions::moderator(),
color: Some(Color { r: 52, g: 152, b: 219 }), // Blue
position: 10,
is_system: false,
});
// Admin
roles.insert(admin_id, Role {
name: "Admin".to_string(),
permissions: Permissions::admin(),
color: Some(Color { r: 231, g: 76, b: 60 }), // Red
position: 20,
is_system: false,
});
// Founder
roles.insert(founder_role_id, Role {
name: "Founder".to_string(),
permissions: Permissions::founder(),
color: Some(Color { r: 241, g: 196, b: 15 }), // Gold
position: 100,
is_system: true,
});
roles
}
}
struct GroupModerationPolicy {
// Group settings
max_members: Option<u32>,
require_approval_for_joins: bool,
allow_member_invites: bool,
// Deletion policies
log_deletions: bool,
show_deleted_by: bool,
show_deleted_reason: bool,
keep_tombstones: bool,
tombstone_expiry: Option<Duration>,
}
  • Group Moderation Actions: All group moderation happens via MLS SystemOperation messages.

  • Content Moderation: Message deletion works through cooperative deletion. Well-behaved clients agree to delete messages when requests.

  • Moderation Bots: Automated moderation can be implemented as bots with elevated power levels.

Bot capabilities:

  • Fast spam detection (~20-50ms response time)
  • Pattern matching (regex blocklists)
  • Behavioral analysis (message frequency, content similarity)
  • Automatic deletion of detected spam
  • Escalation to human moderators

Bot limitations:

  • Bots see encrypted messages (must be in group)
  • Small window where spam might be visible before deletion (~100ms)
  • Modified clients can ignore bot deletions locally
  • Bots cannot prevent modified clients from viewing spam

Recommended bot setup:

struct ModBot {
device_id: DeviceId,
roles: Vec<RoleId>, // Needs to be assigned moderator role
permissions: Permissions, // Should compute to Permissions::moderator()
// Pre-compiled patterns for speed
spam_patterns: Vec<Regex>,
// Behavioral thresholds
max_messages_per_minute: 10,
max_repeated_content: 3,
// Response actions
auto_delete: bool, // Delete spam immediately
auto_warn: bool, // Warn user first
auto_kick_after: u32, // Kick after N violations
}

All clients must verify permissions before accepting MLS commits:

impl CryptidClient {
async fn process_incoming_commit(
&mut self,
commit: &MlsCommit,
group: &mut Group,
) -> Result<()> {
// 1. Extract sender's device ID from MLS
let sender_device_id = commit.sender().credential().identity();
// 2. Extract permissions from group context extensions
let perm_ext = group.get_permission_extension()?;
let sender_perms = perm_ext.device_permissions
.get(&sender_device_id)
.ok_or("Unknown device in group")?;
// 3. Validate based on commit type
match commit.operation() {
CommitOperation::Add(_) => {
if !sender_perms.contains(Permissions::INVITE_MEMBERS) &&
!sender_perms.contains(Permissions::ADMINISTRATOR) {
return Err("Unauthorized: lacks INVITE_MEMBERS permission");
}
}
CommitOperation::Remove(_) => {
if !sender_perms.contains(Permissions::REMOVE_MEMBERS) &&
!sender_perms.contains(Permissions::ADMINISTRATOR) {
return Err("Unauthorized: lacks REMOVE_MEMBERS permission");
}
}
_ => {}
}
// 4. Only merge if authorized
group.mls_state.merge_pending_commit(provider)?;
Ok(())
}
}

Both users and moderators can delete messages:

enum SystemOperation {
// ...existing operations
DeleteMessage {
// Which message to delete
message_id: MessageId,
// device_id of deleter
deleted_by: UserId,
// When deletion occurred
timestamp: u64,
// Optional reason
reason: Option<String>
}
}

Users can delete their own messages:

impl CryptidClient {
async fn delete_own_message(
&mut self,
group_id: &GroupId,
message_id: &MessageId,
) -> Result<()> {
let group = self.groups.get(group_id)?;
// 1. Verify ownership
let message = self.message_store.get(message_id)?;
if message.sender_device_id != self.device.device_id {
return Err("Can only delete your own messages");
}
// No permission check needed; users can always delete their own messages
// 2. Create deletion operation
let deletion = SystemOperation::DeleteMessage {
message_id: *message_id,
deleted_by: self.device.device_id,
timestamp: current_unix_timestamp(),
reason: Some(String::from("User requested deletion")),
};
// 3. Send to group (MLS encrypted)
let encrypted = self.encrypt_system_operation(&deletion)?;
self.send_to_group(group_id, encrypted).await?;
// 4. Delete locally immediately
self.message_store.delete_message(message_id)?;
Ok(())
}
}

UI Display:

[10:30] Alice: Hey everyone!
[10:31] Alice: [Message deleted by sender at 10:35]
[10:32] Bob: Thanks!

Moderators can delete any message:

impl CryptidClient {
async fn delete_message_as_moderator(
&mut self,
group_id: &GroupId,
message_id: &MessageId,
reason: String,
) -> Result<()> {
let group = self.groups.get(group_id)?;
// 1. Verify moderator permission from MLS extension
let perm_ext = group.get_permission_extension()?;
let my_perms = perm_ext.device_permissions
.get(&self.device.device_id)
.ok_or("Device not found in group permissions")?;
if !my_perms.contains(Permissions::DELETE_OTHERS_MESSAGES) &&
!my_perms.contains(Permissions::ADMINISTRATOR) {
return Err("Insufficient permissions to delete messages");
}
// 2. Create deletion operation
let deletion = SystemOperation::DeleteMessage {
message_id: *message_id,
deleted_by: self.device.device_id,
timestamp: current_unix_timestamp(),
reason: Some(String::from("Message deleted by Moderator")),
};
// 3. Send to group
self.send_to_group(group_id, deletion).await?;
// 4. Delete locally and log
self.message_store.delete_message(message_id)?;
self.log_moderation_action(ModAction::DeleteMessage {
message_id: *message_id,
moderator: self.device.device_id,
reason,
})?;
Ok(())
}
}

UI Display:

[10:30] Alice: Hey everyone!
[10:31] Bob: [Message deleted by moderator at 10:35]
Deleted by @Charlie (moderator) at 10:35
Reason: Spam
[10:32] Charlie: Cleaned up spam
struct GroupModerationPolicy {
// ...existing fields from above
// Deletion logging
log_deletions: bool, // Default: true
show_deleted_by: bool, // Show who deleted
show_deleted_reason: bool, // Show reason
// Tombstone behavior
keep_tombstones: bool, // Show "[Message deleted]"
tombstone_expiry: Option<Duration>, // Remove tombstone after time
}

Every layer above this one depends on someone else acting. This one does not, which makes it the layer that always works.

It is also the only layer that can see plaintext. Servers cannot filter what they cannot decrypt, and group moderators act only within their own group - but a recipient’s client holds the decrypted message and can apply whatever policy its user wants.

  • By device_id - durable, because a device_id is the Blake3 of a device key and cannot be reassigned or spoofed. A block holds until it is lifted.
  • By user_id - covers every device that user owns, including ones linked later. This is what people usually mean by “block someone”
  • By content - local filters over decrypted text: patterns, keywords, link policy.

An unsolicited message can only ever reach a device’s contact mailbox. Group mailboxes are distributed exclusively through AddressRotation inside MLS, so a stranger holds nothing that reaches any group.

That makes the contact mailbox a burnable perimeter: rotate it, and every address a spammer holds becomes dead routing. No blocklist is involved and no group is disturbed.

  • Clients SHOULD offer rotation as a first-class response to contact-address abuse
  • Clients SHOULD rotate the contact mailbox periodically regardless, since it is the one address strangers hold

This is the sharpest difference from systems with open inboxes, where a blocked party returning under a new identity regains reach immediately. Here they regain nothing, because the address they held no longer exists.

First contact is the only path a stranger can use, so it’s where client policy matters most.

  • Clients MUST verify a ContactRequest signature before recording its request_id, per ContactRequest
  • Clients SHOULD let users restrict who may reach them: anyone, only requests naming a known user_id, or nobody
  • Clients MUST NOT surface a request from a blocked device_id or user_id
  • Clients SHOULD discard silently rather than replying, since a reply confirms the address is live
  • Blocking MUST be enforced client-side after MLS decryption; servers are never told who is blocked
  • A block MUST survive address rotation by the blocked party, which device_id and user_id both do
  • Clients MUST NOT transmit a blocklist to a server
  • Clients SHOULD make blocking reversible and inspectable

Subscribing to a curator’s list is the most effective tool federated networks have. Fetching one from a URL would disclose the subscription to whoever hosts it, which is a political affiliation signal and exactly the kind of metadata the rest of the protocol withholds.

Cryptid puts the list in group state instead, as an MLS context extension alongside our custom media extension and the permission extension. Members already share group state, so a list distributed there tells no server anything at all.

Group Ban List
/// Extension type identifier for the group ban list
const CRYPTID_BAN_LIST_EXT: u16 = 0xF002;
/// Stored in the group context extension "ban list"
struct GroupBanList {
entries: Vec<BanEntry>,
}
struct BanEntry {
// The banned device.
device_id: Option<DeviceId>,
banned_by: DeviceId,
banned_at: u64,
reason: Option<String>,
}

Writing to this extension requires the BAN_MEMBERS permission. Banning and removing are one action rather than two.

  • A member with BAN_MEMBERS MAY add a device_id to the list
  • Adding a device to the list MUST be accompanied by removing it from the group, in the same commit
  • Members MUST reject a commit that adds a device that is already on the list
  • Members MUST reject a commit that modifies the list from a device lacking BAN_MEMBERS
  • Clients MUST refuse to propose adding a listed device, and SHOULD tell the inviter why

The list is group state, so it reaches new joiners in the Welcome and needs no separate distribution. It also means that a ban survives the banned device rotating every address it holds, since device_id is what the entry names.

Example: Spam Handling
  1. New device spammer@spam.com registers

  2. Gets a New-tier bucket: 50 tokens/hour, capacity 200

  3. Burns its capacity on spam in the first hour, then throttles to 50/hour

  4. Recipients block its device_id client-side; group moderators remove it from their groups

  5. The server acts only if the operator learns of the abuse independently

  1. Spammer joins a public group

  2. Sends spam message

  3. Moderator (with DELETE_OTHERS_MESSAGES permission) deletes message

  4. After x deletions, moderator removes spammer from group

  1. User receives spam message

  2. User blocks spammer device

  3. User reports spam (contributes to server metrics)

  4. Future messages from spammer are auto-ignored


Example: Federation Block
  1. Server A admin sees spam from spam-factory.com

  2. Admin blocks federation from spam-factory.com

  3. All messages from that server will be rejected

  4. Users on Server A are protected

Not affected because groups don’t know about federation

Not affected because users don’t see rejected messages


Example: Group Moderator Abuse

Cannot intervene because the server doesn’t know about groups

  1. Moderator abuses power (deletes valid messages)

  2. Group admin (with MANAGE_ROLES permission) removes moderator role from member

  3. Or group founder (with ADMINISTRATOR permission) removes the moderator from the group

  1. User disagrees with moderation

  2. User leaves group

Blocking Group Members: Special Considerations

Section titled “Blocking Group Members: Special Considerations”

Blocking in Cryptid has special constraints when the blocked device is a member of your groups, due to MLS group consensus requirements.

MLS requires all group members to agree on group state (membership, keys, epochs). If you completely ignore another member’s operations, your group state becomes desynchronized and the group breaks.

Example of what breaks:

Let’s say Moderator Bob removes spammer Charlie from the group.

If Alice had completely blocked Bob, the SystemOperation message that removes Charlie won’t be delivered to Alice, this leads to:

  • Alice still thinking that Charlie is in the group

  • Alice trying to encrypt messages to Charlie

  • Alice’s messages failing to decrypt for everyone else

  • Group state breaking for Alice

Cryptid implements 2 levels of blocking:

enum BlockLevel {
/// Block messages but process system operations (group state changes)
ContentOnly,
/// Block EVERYTHING (forces you to leave all shared groups)
Complete
}
struct BlockEntry {
device_id: DeviceId, // Block by device_id, not address
block_level: BlockLevel,
reason: String,
blocked_at: u64,
}

Blocking by device_id is critical:

  • Prevents bypass via address rotation
  • Blocked user can’t evade by creating new delivery addresses
  • Block persists across all addresses for a device

Address rotation does NOT bypass blocks. MLS reveals sender’s device_id in decrypted messages, enabling block enforcement regardless of which delivery address was used.

ContentOnly Block (Default for Group Members)

Section titled “ContentOnly Block (Default for Group Members)”
  • Their regular messages are hidden from you

  • System operations (removals, key rotations) are still processed

  • You stay synchronized with the group

  • You benefit from moderation actions

Use cases:

  • Annoying group members

  • Moderators/Admins you disagree with but can tolerate

  • Staying in a group while avoiding specific members

Complete Block (Requires Leaving Shared Groups)

Section titled “Complete Block (Requires Leaving Shared Groups)”
  • All messages ignored

  • All operations ignored

  • Cannot stay in groups with the blocked individual

Use cases:

  • Severe harassment

  • Complete avoidance necessary

  • The person is not in any of your groups

Special Case: Blocking Moderators / Admins

Section titled “Special Case: Blocking Moderators / Admins”

Moderators and Admins have elevated permissions in groups. You cannot completely block a moderator while staying in their group.

if self.is_moderator_in_shared_group(&device) && level == BlockLevel::Complete {
return Err(anyhow!(
"Cannot completely block a moderator. Use ContentOnly block or leave the group."
));
}
fn is_moderator_in_shared_group(&self, device_id: &DeviceId) -> Result<bool> {
// Check all groups this client is in
for group in &self.groups {
// Extract permissions from MLS group extension
let perm_ext = group.get_permission_extension()?;
// Check if device_id has any moderator-level permissions
if let Some(perms) = perm_ext.device_permissions.get(device_id) {
// Consider someone a moderator if they can:
// - Delete others' messages, OR
// - Remove members, OR
// - Manage roles
let mod_perms = Permissions::DELETE_OTHERS_MESSAGES
| Permissions::REMOVE_MEMBERS
| Permissions::MANAGE_ROLES;
if perms.intersects(mod_perms) {
return Ok(true);
}
}
}
Ok(false)
}

The rationale here is that moderators’ system operations (member removals, message deletions) are critical for group health. Ignoring them breaks the group for everyone.

impl Client {
async fn display_message(&self, msg: &IncomingMessage) {
// Decrypt MLS message to get sender's device_id
let decrypted = self.decrypt_mls_message(&msg.mls_ciphertext)?;
let sender_device_id = decrypted.sender_device_id; // From MLS, not from address
// Check blocklist by device_id
if let Some(block) = self.blocklist.blocks.get(&sender_device_id) {
if block.block_level == BlockLevel::ContentOnly {
match msg.message_type {
MessageType::Regular => {
// Don't display, but keep in local storage
self.store_hidden_message(msg).await;
return;
}
MessageType::SystemOperation(_) => {
// Process for group state, optionally hide details
self.process_system_operation_silently(msg).await;
self.ui.show_system_message(
"A moderation action occurred",
None // Don't show moderator name
);
return;
}
}
} else {
// Complete block: ignore fully
return;
}
}
// Normal display
self.ui.show_message(msg);
}
}

Critical implementation detail:

Blocking must check the decrypted message’s sender device_id (from MLS), not the delivery address (from routing).

impl Client {
async fn unblock_device(&mut self, device_id: &DeviceId) -> Result<()> {
self.blocklist.blocks.remove(device_id);
// Optionally: reveal previously hidden messages
let hidden = self.get_hidden_messages_from(device_id).await?;
self.ui.offer_to_show_hidden_messages(hidden.len());
Ok(())
}
}
  • MLS requires that all members agree on group state

  • Ignoring member removals breaks key agreement

  • Ignoring epoch changes breaks encryption

  • Split-brain scenarios destroy groups

  • Can hide annoying members’ messages

  • Still benefit from moderation (spam removal, harassment handling)

  • Clear choice: tolerate with hidden messages OR leave group entirely

  • Personal control over experience

  • Blocking is completely client-side

  • No server notification (blocked person doesn’t know)

  • No protocol messages sent (purely logical decision)

  • Blocking is reversible

End-to-end encryption provides maximum privacy but inherently limits server-side moderation capabilities.

Here’s what’s acutally possible:

Cryptographic Permission Enforcement:

  • Permissions stored in MLS group extensions (tamper-proof)
  • All clients verify permissions before accepting commits
  • Modified clients cannot forge permissions (MLS authentication prevents this)
  • Unauthorized operations are rejected by honest clients
  • Modified clients that bypass checks are isolated (epoch mismatch breaks encryption)

Client-Side Pre-Filtering:

  • Official clients can block messages before sending
  • Pattern matching (regex blocklists)
  • Link filtering, rate limiting
  • Immediate user feedback (“Cannot send: blocked pattern”)
  • Users with modified clients can bypass

Fast Bot Detection:

  • Automated bots can detect spam (~50ms)
  • Quick deletion before most users see it
  • Pattern matching, behavioral analysis
  • Small window where message might be visible (20-100ms)

Server-Side Enforcement:

  • Issuance budgets bound to device_id (identity-bound, and not bypassable by rotating addresses)
  • Progressive trust (new devices start at 50 tokens/hour, capacity 200)
  • Operator-initiated blocking, where an operator has identified a bad actor out of band
  • Per-origin limits and defederation for federated abuse

Social Accountability:

  • Vouch systems (voucher penalized if invitee spams)
  • Reputation scores (cross-group behavior tracking)
  • Community governance (members vote/moderate)
  • Visible moderation logs (transparency)

Client Verification:

  • Cannot reliably detect modified clients
  • Self-reported hashes are not trustworthy (clients can lie)
  • No way to force client behavior (users control their software)

Perfect Spam Prevention:

  • Cannot prevent modified clients from viewing spam
  • Cannot prevent users of modified clients from screenshotting before deletion
  • Cannot prevent sophisticated client mimicry

Forced Deletion:

  • Cannot cryptographically prove deletion occurred
  • Cannot force modified clients to delete messages
  • Cannot remove from user backups
  • Cannot unread messages that have already been read

Permission Bypass Prevention:

  • Cannot prevent modified clients from attempting unauthorized operations
  • Cannot force modified clients to verify permissions
  • Can only isolate them (their operations are rejected by honest clients)
  • Modified clients can corrupt their own group state but not others’
  1. E2EE Fundamental Property: Clients must be able to decrypt messages (otherwise E2EE doesn’t work). Once decrypted, clients control the data.

  2. Open Source Philosophy: Users can modify their clients. Verification is impossible without trusting the developers.

  3. Privacy First: Alternative (non-E2EE) would let servers see/filter everything, but destroys privacy for all users.

  • For most users (using spec-compliant well-behaved clients):

    • Client-side pre-filtering blocks spam before sending
    • Fast bots catch remaining spam within ~100ms
    • Cooperative deletion removes from group history
    • Experience is effectively spam-free
  • For sophisticated attackers (modified clients):

    • Can see spam briefly before deletion
    • Cannot spam the broader group (fast bot detection)
    • Cannot evade blocks (based on device_id, not address)
    • Eventually removed from group (behavioral detection)
  • Result:

    • Spam protection works for vast majority of users
    • Modified clients exist but have limited impact

All E2EE systems share these fundamental limitations. It comes with the paradigm.