Minimal-State Server Architecture
Philosophy: Servers are Dumb Pipes with Traffic Shaping
Section titled “Philosophy: Servers are Dumb Pipes with Traffic Shaping”Traditional federated systems (like Matrix) require servers to:
- Store user accounts and authentication credentials
- Maintain room/channel state and membership
- Validate cryptographic operations
- Store message history
- Make trust decisions
Cryptid takes a different approach: servers act as encrypted message relays that forward MLS ciphertext without understanding content or relationships.
Dumb pipe means servers don’t:
- Decrypt messages
- Track who messages whom
- Know group memberships
- Build social graphs
- Make trust decisions
Traffic shaping means servers do:
- Track minimal behavioral patterns (account age, message volume)
- Enforce progressive rate limits
- Prevent spam and abuse
- Maintain minimal metadata for spam prevention
The Critical Boundary
Section titled “The Critical Boundary”Servers observe that communication happens (message volume, timing), not what (content) or between whom (servers don’t know who sent the message). This enables practical spam prevention while preserving the privacy guarantees that matter: content confidentiality and relationship privacy.
What Servers Store (Temporarily)
Section titled “What Servers Store (Temporarily)”Servers maintain three categories of state, all temporary and limited:
Routing State (Ephemeral)
Section titled “Routing State (Ephemeral)”/// A registered mailbox. Registration proves ownership of mailbox_public by/// challenge-response and carries no device identitystruct MailboxRegistration { // Full address (e.g., "7Q7BQXKQ5VHOW2ZV3STHZI7C@chat.example.com") delivery_address: String,
// Mailbox public key, verified against the address prefix at registration mailbox_public: X25519PublicKey,
// Where this device connects server_url: String,
// When this registration was created registered_at: u64,
// When the registration expires if not renewed expires_at: u64,}
/// Messages queued for offline devices (also expires)struct QueuedMessage { message_id: Uuid, recipient_address: String, // routing string encrypted_blob: Vec<u8>, // replaces mls_ciphertext + sender_signature timestamp: u64, expires_at: u64,}Registered mailboxes are mutually independent:
// Example server state// mailbox_registrations (persistent, 24h retention):mailbox_registrations: HashMap<String, MailboxRegistration> { "7Y62ANID3IFPKRYZL4QVCE37@chat.example.com" => MailboxRegistration { delivery_address: "7Y62ANID3IFPKRYZL4QVCE37@chat.example.com", mailbox_public: "...", // ... }, "MM5MBX3MEOORROLIXUBFTOGP@chat.example.com" => MailboxRegistration { delivery_address: "MM5MBX3MEOORROLIXUBFTOGP@chat.example.com", mailbox_public: "...", // No field relates these two entries }}Independent Mailboxes
Section titled “Independent Mailboxes”Key Properties:
- A device registers one mailbox per group, plus one for contact requests
- Registrations carry no device identity, so the server cannot group them
- Messages are delivered only to the exact address they name
- Each registration expires independently (renewed by re-registering)
Privacy-preserving design:
The device_id -> addresses mapping exists at any layer:
- Registration authenticates the mailbox keypair, never a device
- Addresses are derived from independent keypairs, so no two are relatable
- 24-hour automatic expiration (no long-term history)
- Not exposed via public API or to federation
Rate limiting:
- Servers MUST enforce rate limits through token issuance budgets, and MUST NOT attempt to attribute redemptions to a device
- Per-origin counters MUST be used for federated traffic
- Mailbox registration MUST cost one token, which is what bounds mailbox creation
// Mailbox registrations are bounded by token budget, not by a// server-side per-device count. The server cannot tell which// registrations belong to one deviceconst MAX_MAILBOXES_PER_TOKEN_BATCH = 1;
// Announcement rate (identity-bound, so still enforceable)const MAX_ANNOUNCEMENTS_PER_HOUR = 3;
// Queued envelopes for offline devices. Operators MAY lower this;// raising it extends how long a compromised server's disk is interestingconst MAX_MESSAGE_RETENTION = Duration::days(30);Retention
Section titled “Retention”Delivery mappings expire after 24 hours, message queues after 30 days (configurable).
MLS KeyPackage Storage
Section titled “MLS KeyPackage Storage”For group additions, servers store pre-uploaded KeyPackages:
/// Blind KeyPackage storage. The server holds ciphertext under a derived/// handle and cannot determine which device an entry belongs to.struct KeyPackageStore { handle: [u8; 32], entries: Vec<BlindKeyPackageEntry>, uploaded_at: u64, total_consumed: u32,}
// Indexed by handlekeypackage_storage: HashMap<Handle, KeyPackageStore>Privacy Considerations
Section titled “Privacy Considerations”Servers can observe:
- Total KeyPackages uploaded per device
- Total KeyPackages consumed (aggregate group additions)
- Approximate social activity level
See Contact Exchange and Trust for detailed privacy analysis and planned v2.0 improvements (blind KeyPackage storage).
Retention
Section titled “Retention”KeyPackages expire after 30 days if not consumed. Devices rotate KeyPackages when count drops below 20.
InfoPackages (Ephemeral Tokens)
Section titled “InfoPackages (Ephemeral Tokens)”For ephemeral contact and group invite sharing, servers store encrypted InfoPackages.
/// Encrypted identity or group invite packagesstruct EncryptedInfoPackage { // User who uploaded this package user_id: UserId,
// Type of package (determines content schema) package_type: InfoPackageType,
// Encrypted content (server cannot decrypt) ciphertext: Vec<u8>,
// Metadata for cache invalidation created_at: u64, expires_at: u64,
// Usage tracking (for one-time tokens) max_uses: Option<u32>, uses_remaining: Option<u32>,
deleted: bool}
enum InfoPackageType { Identity, GroupInvite { group_id: GroupId },}
// Indexed by random URL segment (not by user_id)info_packages: HashMap<String, EncryptedInfoPackage>What’s Stored:
- Ciphertext only: Server cannot decrypt (client holds key)
- Package type: For routing to correct parser
- TTL and Usage: For automatic cleanup and one-time enforcement
What’s not Stored:
- Identity contents (encrypted)
- Group contents (encrypted)
- Who fetched the package (anonymous fetch)
- Download history or statistics
Privacy Properties
Section titled “Privacy Properties”Server observes:
- URL segment created (no semantic meaning)
- When package expires or gets revoked
- Number of times fetched (if max_uses tracking enabled)
Server cannot observe:
- Identity data inside package
- Which device fetched the package (no auth required)
- Who is adding whom as contact
- Who is joining which groups
Retention
Section titled “Retention”InfoPackages automatically deleted when:
- Expiration:
expires_attimestamp passes (TTL from upload) - Usage limit:
uses_remainingreaches zero - Manual revocation: User calls DELETE endpoint (sets
deletedflag) - Server cleanup job runs hourly
Typical Retention Periods:
- Identity shares: 24 hours
- One-time links: 1 hour
- Group invites: 1 week
See InfoPackages for the complete specification.
What is NOT Stored
Section titled “What is NOT Stored”- Message content (always encrypted, never accessible)
- Message senders (addresses not included in messages)
- Group membership lists
- Social graphs or contact lists
- User profiles or identities beyond device_id
- Permanent account data
- Private cryptographic key material (only public KeyPackages stored)
- KeyPackage private keys (stored on devices only)
- InfoPackage plaintext (stored encrypted only)
Device Announcement Protocol
Section titled “Device Announcement Protocol”Devices must periodically “announce” themselves to servers to receive messages. Devices can announce multiple delivery addresses simultaneously.
Endpoint
Section titled “Endpoint”POST /api/v1/device/announceContent-Type: application/json{ "device_id": "device_id_goes_here", "signature": "ed25519_signature_hex_128_chars", "timestamp": 1760129277, "storage_preferences": { "max_retention_days": 30, "offline_message_limit": 1000 }}Field Descriptions
Section titled “Field Descriptions”device_id: Main device identity (Ed25519 public key, 32 bytes hex-encoded = 64 hex chars)signature: Ed25519 signature covering device_id + prefixes + timestamp (64 bytes = 128 hex chars)timestamp: Unix timestamp in seconds (prevents replay attacks)storage_preferences: Optional preferences for message queueing
Why prefixes instead of full address?
Section titled “Why prefixes instead of full address?”Mailbox registration only submits the prefix. The server domain is an implicit part of the API endpoint, so clients never specify it, which prevents claiming addresses on domains you don’t control.
Server Response
Section titled “Server Response”{ "status": "success", "device_id": "device_id_goes_here", "expires_at": 1758402063, "server_capabilities": { "max_message_size": 10000000, "federation_enabled": true, "supported_mls_versions": ["1.0"] }}What the Server Does
Section titled “What the Server Does”- Verifies timestamp is recent (within 5 minute window, prevents replay)
- Verifies signature using device_id (the public key):
fn verify_announcement( req: &AnnouncementRequest, server_domain: &str) -> Result<()> { // Construct signed message let message = format!("{}.{}", hex::encode(&req.device_id), req.timestamp );
// Verify with device_id (which IS the public key) if !ed25519_verify(&req.device_id, message.as_bytes(), &req.signature) { return Err("Invalid signature"); }
// Check timestamp freshness let now = current_unix_timestamp(); if req.timestamp < now - 300 || req.timestamp > now + 60 { return Err("Timestamp outside acceptable window"); }
Ok(())}- Registers the device record (expire in 24 hours). Announcement creates no mailboxes.
fn register_device_record(req: &AnnouncementRequest) -> Result<()> { self.device_records.insert( req.device_id, DeviceRecord { device_id: req.device_id, public_key: req.public_key, registered_at: req.timestamp, expires_at: req.timestamp + 24 * 3600, } ); Ok(())}-
Issue the device’s token batch, sized by account age (see Progressive Trust below)
-
Validate announcement limits:
fn validate_announcement_limits(req: &AnnouncementRequest) -> Result<()> { // Announcement is identity-bound, so this limit is enforceable. // Mailbox counts are not. The server cannot attribute registrations // to a device, and each one already costs a token let announced_today = self.count_announcements_today(req.device_id); if announced_today >= MAX_ANNOUNCEMENTS_PER_HOUR { return Err("Announcement rate limit exceeded"); }
Ok(())}Progressive Trust Rate Limiting
Section titled “Progressive Trust Rate Limiting”New devices earn larger issuance budgets over 24 hours. Because issuance is the only identity-bound step, trust tiers apply when tokens are issued, never when they are spent.
Budgets are token buckets rather than fixed hourly grants. Each tier sets two values:
- rate: sustained throughput, what a device can keep doing indefinitely
- capacity: burst allowance, what it can spend at once after a quiet period
| Trust Tier | Age | Rate | Capacity | Purpose |
|---|---|---|---|---|
| New | 0-6 hours | 50 / hour | 200 | Prevents bot spam |
| Established | 6-24 hours | 500 / hour | 2,000 | Normal usage |
| Trusted | 24+ hours | 2,000 / hour | 10,000 | Full access |
| Verified | Admin override | 2,000 / hour | 10,000 | Instant |
| Blocked | Operator decision after reports | 0 / hour | 0 | Spam blocked |
Why 24 hours?
Section titled “Why 24 hours?”This prevents spam bots (which operate at scale immediately) while minimizing friction for legitimate users. Server admins can grant instant verification for known community members.
Budget Sizing
Section titled “Budget Sizing”/// Per-device issuance budget. Lives at the issuance endpoint, where device_id/// is known. Nothing about it reaches redemption, which stays anonymous.struct TokenBucket { // Sustained throughput: tokens added per hour rate: u32,
// Burst allowance. The bucket never holds more than this. capacity: u32,
// Current level, lazily recomputed on access level: u32,
// When level was last updated. Refill is derived from elapsed time rather // than a timer, so idle devices cost the server no work last_refill: u64,}
impl TokenBucket { fn refill(&mut self, now: u64) { let elapsed_hours = (now - self.last_refill) as f64 / 3600.0; let earned = (self.rate as f64 * elapsed_hours) as u32; self.level = (self.level + earned).min(self.capacity); self.last_refill = now; }
// Grants what is available, up to what was requested. Partial grants are // normal. The client tops up again once the bucket refills. fn take(&mut self, requested: u32, now: u64) -> u32 { self.refill(now); let granted = requested.min(self.level); self.level -= granted; granted }
fn for_tier(tier: TrustTier) -> Self { TokenBucket { rate: tier.rate(), capacity: tier.capacity(), // New buckets start full, so a device is usable immediately level: tier.capacity(), last_refill: current_unix_timestamp(), } }}Trust tier determines the bucket’s parameters, not what a device may do with the tokens:
fn issue_tokens(device_id: &DeviceId, requested: u32) -> TokenResponseBatch { // Account age is known here because issuance is authenticated let tier = get_trust_tier(device_id); let bucket = self.buckets.entry(*device_id) .or_insert_with(|| TokenBucket::for_tier(tier));
let granted = bucket.take(requested, current_unix_timestamp()); mint_blinded_tokens(granted)}- Servers MUST enforce budgets at issuance and MUST NOT rate limit redemption
- Servers MUST grant partial batches when a bucket is short, rather than rejecting the request
- Servers SHOULD return the bucket’s current level and rate, so clients can top up before running dry
- Bucket state MUST NOT be consulted at redemption, where the device is unknown
Cycling mailboxes gains an attacker nothing: every registration spends a token from the same bucket, so mailbox count is bounded by issuance rather than by counting addresses.
Generous capacity does not weaken the spam story. Sustained abuse is governed by rate alone. Capacity only lets a device front-load a reserve it already had to accumulate.
Privacy Impact
Section titled “Privacy Impact”Server learns account age and how many tokens a device was issued. It does not learn what any token was spent on, how many messages a device sent, or which mailboxes belong to it.
Address System Design
Section titled “Address System Design”Format: {first_16_bytes_of_device_id_hex}@{server.domain}
Example: a1b2c3d4e5f61728394a5b6c7d8e9f10@chat.example.com
Key properties:
- Random generation: Address prefixes are randomly generated (NOT derived from device_id)
- No cryptographic relationship: Cannot link addresses to device_id through cryptographic analysis
- Privacy: Address rotation cannot be tracked through derivation patterns
Why 16 bytes?
-
Collision safety: 2^128 possible addresses - safe for trillions of users per servers
-
Reasonable length: 32 hex characters - manageable for humans if needed
-
Cryptographically derived: Cannot be guessed or enumerated
-
No PII: No phone numbers, emails, or any personally identifying information
Important: Delivery addresses are derived from per-group mailbox keypairs, not from device_id. Derivation is hardened, so no server-visible value links an address to a device identity or to another address.
Server Message Handling
Section titled “Server Message Handling”When a server receives a message:
fn handle_incoming_message(message: &IncomingMessage) -> Result<()> { // 1. Basic format validation only if message.recipient_address.is_empty() || message.encrypted_blob.is_empty() { return Err("Invalid message format"); }
// 2. Verify the PrivacyPass token. There is no sender to identify let token = request.token.ok_or("Token required")?; if !self.verify_token(&token)? { return Err("Invalid token"); } if !self.spend_nonce(&token.nonce)? { return Err("Token already spent"); }
// 3. Queue for delivery (server does NOT verify cryptographic correctness) let queued = QueuedMessage { recipient_address: message.recipient_address, encrypted_blob: message.encrypted_blob, received_at: current_unix_timestamp(), expires_at: current_unix_timestamp() + MAX_MESSAGE_RETENTION, };
queue_for_recipient(queued)?;
// 4. For federated recipients, forward to their servers if is_remote_address(&message.recipient_address) { forward_to_remote_server(message)?; }
Ok(())}Critical point: The server never validates signature correctness, MLS group membership, or makes any cryptographic trust decisions. It’s purely a routing service.
Delivery Transports
Section titled “Delivery Transports”Delivery is transport-agnostic. The server holds an opaque envelope and a routing address; how those bytes reach a client is a separate concern.
enum DeliveryTransport { // Foreground: one connection, subscribe list of active mailboxes, // server tags each frame with the arrival mailbox WebSocket,
// Background: contentless wakeup, client then fetches. // Endpoint is user-supplied: ntfy, gotify, or any UnifiedPush distributor UnifiedPush { endpoint: String },
// Fallback for clients that can hold neither Poll { interval_secs: u32 },}Wakeup notifications MUST be contentless and MUST NOT name the mailbox that received a message. Clients SHOULD register distinct push endpoints per mailbox where the distributor permits it.
Server State Privacy Analysis
Section titled “Server State Privacy Analysis”The Privacy Boundary
Section titled “The Privacy Boundary”| What Servers Observe | What Servers Never Know |
|---|---|
| Device addresses (pseudonymous) | User identities (no PII) |
| Recipient addresses for each message | Message content (E2EE) |
| Account creation time | Sender addresses (not transmitted) |
| Online/offline status | Conversation context |
| Federation domains contacted | Specific sender-recipient pairs |
| Delivery volume per recipient address | Group membership lists |
| Reports naming a device | Social relationships beyond delivery patterns |
| Token issuance counts per device | Which device sent anything |
Why This Preserves Privacy
Section titled “Why This Preserves Privacy”Content Confidentiality (Cryptographic)
Section titled “Content Confidentiality (Cryptographic)”All messages are MLS-encrypted before reaching the server and no plaintext content is ever stored.
Sender Privacy (Architectural)
Section titled “Sender Privacy (Architectural)”The server does not learn sender identity at the protocol level. Messages deliberately omit the sender_address field.
When routing a message, the server only sees:
-
recipient_address(e.g.,a1b2c3d4e5f61728394a5b6c7d8e9f10@server.com) which is just a destination. -
encrypted_blobwhich is just encrypted bytes.
The server does NOT see:
- Who the sender is (address not included in messages)
- Message content (MLS encrypted)
- What groups the recipient is in
- Who else received this message (if it’s a group message)
Social Graph Privacy (Design)
Section titled “Social Graph Privacy (Design)”No contact lists, no friend relationships, no group memberships are visible to servers.
The server cannot answer:
-
Who are Alice’s contacts?
-
What groups is Bob in?
-
Do Alice and Bob know each other?
The server knows delivery patterns but not content or full relationships:
-
Device X sent 50 messages to specific recipient addresses (for routing)
-
Device Y received 80 messages from unspecified senders (sender addresses are not transmitted)
Comparison with Other Privacy-Focused Systems
Section titled “Comparison with Other Privacy-Focused Systems”| System | Behavioral Tracking | Result |
|---|---|---|
| Signal | Phone number, message count, last seen | Similar to Cryptid |
| Matrix (E2EE rooms) | Room membership, user-to-room mapping | More metadata than Cryptid |
| Email (PGP) | Full email headers (To/From/Subject) | Much more metadata than Cryptid |
| Tor | No behavioral tracking | Maximum privacy, but high latency/complexity |
| Cryptid | Account age, message volume (aggregate) | Balanced: privacy + spam prevention |
Cryptid’s position: More private than Matrix or email (no recipient tracking), comparable to Signal (behavioral patterns only), more practical than Tor (lower latency, easier to use).
The Trade-off: Why Not Zero State?
Section titled “The Trade-off: Why Not Zero State?”Without behavioral tracking:
-
Spam bots can register unlimited devices instantly
-
No rate limiting possible (can’t distinguish bots from humans)
-
Network becomes unusable due to spam flooding
-
Legitimate users abandon the platform
With minimal behavioral tracking:
-
Spam bots are rate-limited (50 tokens/hour for new devices, capacity 200)
-
Takes time to build trust (24 hours to full rate limit)
-
Spam attacks are economically infeasible at scale
-
Platform remains usable for legitimate communication
What’s preserved: The privacy boundaries that matter:
-
Message content remains encrypted
-
Senders remain hidden from servers
-
Social graphs remain unknown to servers
-
No personal information required
What’s observable: Behavioral patterns that are already visible to network observers (traffic volume, timing) and necessary for abuse prevention.
This is what I’m calling the “dumb pipe with traffic shaping” model. Servers route encrypted messages without understanding them, while maintaining just enough metadata to prevent abuse.
Spam Prevention via Address Rotation
Section titled “Spam Prevention via Address Rotation”The Problem with Permanent Addresses
Section titled “The Problem with Permanent Addresses”Traditional messaging systems use permanent identifiers (email, phone numbers, etc):
- Spammers can target these indefinitely
- Only defense is reactive blocking after spam is received
- No proactive spam prevention
Cryptid’s Approach: Ephemeral Addresses
Section titled “Cryptid’s Approach: Ephemeral Addresses”Multiple delivery addresses enable proactive spam prevention:
Example Scenario
Section titled “Example Scenario”Alice’s device:
device_id: aabbccdd… (permanent, known to contacts and members in the same group chats)
Alice’s current addresses:
- work-addr@server.com (given to colleagues)
- friends-addr@server.com (given to friends)
- public-addr@server.com (posted in online forum)
If the public address gets spammed:
- Alice burns the compromised address locally
- Alice stops announcing it to the server
- Server mapping expires (24 hours)
- Spammer’s messages to that address -> 404 Not Found
- Alice creates new public address
- Spammer cannot find the new address
Spammers lose routing information when addresses rotate.
Address Rotation Strategies
Section titled “Address Rotation Strategies”Risk-based Rotation
Section titled “Risk-based Rotation”These are just some general suggestions. The actual values may vary as per need.
- High-risk addresses (public forums): Rotate weekly
- Medium-risk addresses (semi-public groups): Rotate monthly
- Low-risk addresses (private contacts): Rotate rarely or never
Recommended Practices
Section titled “Recommended Practices”- Context separation: Different addresses for different purposes
- One-time addresses: Create disposable address for each public interaction
- Group-specific addresses: Dedicate address per group, burn if group becomes spammy
- Regular rotation: Rotate public addresses proactively (don’t wait for spam)
Address Rotation and Blocking
Section titled “Address Rotation and Blocking”Rotation and blocking answer different problems, and only one of them is a server mechanism.
| Defense | Answers | Where it runs |
|---|---|---|
| Address rotation | An address that has attracted unwanted traffic. | Client. The recipient burns it; the mapping expires and the sender loses routing |
| Personal blocklist | A specific device you no longer want to hear from | Client, keyed by device_id after MLS decryption |
| Device report | A device abusing others, where an operator should intervene | The offender’s home server, at issuance, after operator review |
Servers MAY rate limit inbound volume per recipient address as a resource-protection measure. That is queue management, not moderation, and MUST NOT be driven by reports.
// Federated traffic aggregates per origin server, feeding federation policyfn get_origin_score(origin: &str) -> u32 { self.origin_reports.get(origin).unwrap_or(0)}Limitations
Section titled “Limitations”What address rotation does NOT prevent:
- Spam from contacts
- Spam within MLS groups (members will automatically get an updated delivery address when you rotate the one used for that group)
- Targeted persistent harassment (attacker keeps discovering new addresses)
- Server-level spam (malicious server operations)
For these cases:
- Block by device_id (contacts/group members)
- Leave group (group spam)
- Report to server admin (persistent targeting)
- Switch servers (malicious server operator)
The Privacy-Spam Trade-off
Section titled “The Privacy-Spam Trade-off”Address rotation provides:
- Proactive spam defense
- User control over exposure
- Context isolation
But requires:
- User management of address lifecycle
- Contacts handling address updates
- Server queries when addresses change
This is a deliberate design choice: more user agency, more user responsibility.
Server Announcement
Section titled “Server Announcement”Servers MAY restrict who can announce based on their policies:
Open Announcement (Default)
Section titled “Open Announcement (Default)”- Anyone can announce to any server (with Proof-of-work)
- Requires registration proof for the first announcement
- Good for publicly-run servers
Invite-Only
Section titled “Invite-Only”- New devices need an invite code
- Good for private/community servers
Federation Allowlist
Section titled “Federation Allowlist”- Only accept announcements from trusted servers
- Good for restricted environments
Multi Server Announcements
Section titled “Multi Server Announcements”Devices can announce to multiple servers simultaneously:
// Alice announces to 2 serversannounce_to_server("primary.com", device, ["addr1", "addr2"]);announce_to_server("backup.org", device, ["addr3"]);
// Alice now has 3 delivery addresses across 2 serversUse cases:
- Redundancy: Primary + backup servers
- Migration: Announce to new server before leaving the old one
- Context separation: Work, personal, public on different servers
Rate limiting implications:
Each server enforces rate limits independently.
Example:
For a device using 3 servers:
- Server A: 500 tokens/hour, capacity 2,000 (Established tier)
- Server B: 500 tokens/hour, capacity 2,000 (Established tier)
- Server C: 50 tokens/hour, capacity 200 (New tier - just announced)
Budgets are per server and do not combine into a single throughput figure: a token issued by one server redeems only there, so each address is fed by its own bucket.
Why independent limits?
- Simplicity: No server coordination or trust synchronization required
- Privacy: Servers don’t query each other about device activity
- Federation independence: Servers remain autonomous
- Practical impact: Most users only use 1-2 servers
Attack mitigation:
While sophisticated attackers could exploit multi-homing to bypass rate limits per-server, several mechanisms mitigate this:
- Server-level reputation: Servers track abuse per federated origin
- Spam report aggregation: Sustained per-origin abuse tightens limits and ultimately triggers defederation
- Progressive trust still applies: Attacker needs to wait 24h on EACH server
- Registration costs: Proof-of-work makes multi-server Sockpuppet attacks expensive
Trade-offs:
- Preserves privacy (no cross-server tracking)
- Maintains federation independence
- Simple implementation
- Allows multi-server rate limit bypass (acceptable for v1.0)