No central server, user-owned data, reverse-chronological feed. Rust core + Tauri desktop + Android app + plain HTML/CSS/JS frontend. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
303 lines
19 KiB
Markdown
303 lines
19 KiB
Markdown
# distsoc Architecture & Roadmap
|
|
|
|
## Context
|
|
Decentralized social media network — no central server, user-owned data, reverse-chronological feed, custom feeds without surveillance. Rust core library, iroh P2P networking, SQLite local storage, BLAKE3 content addressing.
|
|
|
|
## Guiding principles
|
|
- Our distributed network first, iroh relay as fallback only
|
|
- Social graph and friendly UX in front, infrastructure truth in back
|
|
- Privacy by design: public profile is minimal, private profiles are per-circle, social graph visibility is controlled
|
|
- Don't break content addressing (PostId = BLAKE3(Post), visibility is separate metadata)
|
|
|
|
## Network Terminology
|
|
|
|
**Connection types (by lifetime):**
|
|
- **Mesh connection** — long-lived routing slot (10 preferred + 71 local + 20 wide = 101 slots). Structural backbone for discovery and propagation. DB table: `mesh_peers`.
|
|
- **Session connection** — short-lived, held open for active interaction (response expectations, DM/group conversations). DB tracked in `sessions` HashMap.
|
|
- **Ephemeral connection** — single request/response, no slot allocation.
|
|
|
|
**Network knowledge layers:**
|
|
- **Mesh (N1)** — live connections. NodeIds shared (merged with social contacts) as "N1 share." No addresses.
|
|
- **Reach (N2 + N3)** — passive awareness, node IDs only. N2 = one-hop resolvable, N3 = chain-resolvable / search-only.
|
|
- **Social routes** — separate overlay caching addresses for follows/audience.
|
|
|
|
## Design constraints
|
|
- **Blob size limit: 256KB** — applies to both content blobs and visibility metadata
|
|
- **WrappedKey list cap: ~500 recipients** — 256KB / ~500 bytes JSON per WrappedKey. Larger audiences require group keys or sharding.
|
|
- **Public posts have zero encryption overhead** — no WrappedKeys, no sharding, unlimited audience. The scaling constraints only apply to encrypted content.
|
|
|
|
---
|
|
|
|
## Phase A: Core Model + Profiles — COMPLETE
|
|
|
|
Public profiles (display name, bio, updated_at), media-ready Post type with attachments, profile sync in protocol, first-run name prompt.
|
|
|
|
**Key files:** `types.rs` (PublicProfile, Post, Attachment), `storage.rs` (profiles table), `protocol.rs` (ProfileUpdate message), `node.rs` (set_profile, get_profile)
|
|
|
|
---
|
|
|
|
## Phase B: Gossip Discovery + Anchors — COMPLETE
|
|
|
|
Enhanced peer storage (addresses, last_seen, is_anchor, introduced_by), gossip exchange in sync protocol (public follows with addresses), anchor self-detection (public IP check), bootstrap.json for first startup, suggested peers UI.
|
|
|
|
**Key files:** `storage.rs` (PeerRecord, gossip list), `network.rs` (PeerGossip message), `node.rs` (sync_all with stored addresses)
|
|
|
|
---
|
|
|
|
## Phase C: Connectivity Cascade + shareat.app Anchor
|
|
|
|
### Connection resolution order
|
|
When syncing, nodes try peers in this order — first success wins:
|
|
|
|
1. **Direct LAN** — mDNS discovery, QUIC direct connect (current behavior)
|
|
2. **Direct WAN** — stored addresses from previous connections, port-forwarded or public IP peers
|
|
3. **Network anchors** — always-on nodes run by community members or power users (self-detected via public IP). Discovered through gossip, stored in peers table with `is_anchor = true`.
|
|
4. **shareat.app** — official fallback anchor, hardcoded as last-resort bootstrap. Just a distsoc node on a VPS running the same sync protocol — no special relay code, no iroh relay dependency.
|
|
|
|
### shareat.app anchor
|
|
- Standard distsoc node deployed on a stable VPS at `shareat.app`
|
|
- Acts as a **mailbox**: accumulates posts for offline peers, they pull on check-in
|
|
- Acts as **bootstrap**: new users connect here to discover the network and find other anchors
|
|
- **Last resort only** — the app prefers direct connections and community anchors first. shareat.app is the safety net, not the backbone.
|
|
- No special protocol — same ALPN, same sync, same pull-based filtering. It's just a peer that's always online.
|
|
|
|
### Profile-anchored discovery
|
|
|
|
Follows are private — never shared in gossip. Discovery uses anchors as public infrastructure, not social graph exposure.
|
|
|
|
#### Anchor lists in profiles
|
|
Each profile carries the user's **preferred anchor list** — the always-on nodes they check in with. Anchors are infrastructure, not social — listing them is like publishing a mail server address. When you sync a peer's profile, you cache their anchor list locally.
|
|
|
|
#### Discovery cascade (finding a specific peer)
|
|
When you want to reach a peer whose address you don't have:
|
|
|
|
1. **Direct** — stored address from previous connection (LAN mDNS or WAN)
|
|
2. **Their anchors** — check the peer's cached anchor list from their profile. Ask those anchors: "has this node checked in recently?" Anchors maintain recent-connection caches.
|
|
3. **Mutual contacts** — your other follows likely know this peer too (social clusters overlap). Ask them or their anchors.
|
|
4. **Your own anchors** — your anchors see broad traffic, may have a recent address.
|
|
5. **shareat.app** — last-resort bootstrap, always-on fallback.
|
|
|
|
#### Why this works without broadcast
|
|
- Follows stay private (never in gossip, never in profiles)
|
|
- Anchor lists are public (in profiles) — they're infrastructure addresses, not social signals
|
|
- Anchors' recent-connection lists are address caches, not social metadata
|
|
- Social graph creates routing redundancy *implicitly* — friend groups cluster around the same anchors, and mutual contacts provide backup paths
|
|
- **No DHT, no flood, no global routing table** — just "check their usual spots, ask mutual friends"
|
|
|
|
#### The "circle up" pattern
|
|
Most syncs require zero discovery. You already know your contacts' anchors from their profiles, your friend group shares anchors (because social clusters form around community nodes), and you have stored addresses from recent direct connections. The common case is just checking in with the usual spots.
|
|
|
|
#### Gossip changes (planned)
|
|
- **Current**: `build_gossip_list()` shares public follows with addresses — leaks social graph
|
|
- **Planned**: share only connected peers + anchor state — pure routing table exchange
|
|
- Follows are removed from gossip entirely. Discovery is anchor-based, not follow-based.
|
|
|
|
#### Data requirements
|
|
- **Profile**: add `anchors: Vec<NodeId>` field — user's preferred anchor list
|
|
- **Peer storage**: cache each follow's anchor list (from their synced profile)
|
|
- **Anchor nodes**: maintain recent-connection cache (node_id → address, last_seen) — already partially exists in peers table
|
|
|
|
#### Social-distance peer topology
|
|
As the network grows, the peer list must be bounded. Prioritize by social distance:
|
|
|
|
1. **Preferred anchors** — user-pinned always-on nodes
|
|
2. **Follows** — people you explicitly chose to see content from
|
|
3. **Nearby** — mDNS-discovered LAN peers (ephemeral, high-bandwidth)
|
|
4. **Pulled-from-us** — peers who synced our content (with a count cap)
|
|
|
|
Future considerations:
|
|
- **Bounce routing**: if A follows B follows C, B can relay content from C to A without A knowing C's address. Social graph becomes the routing table.
|
|
- **Peer count limits**: cap stored peers per tier to keep storage and sync bounded.
|
|
- **Reputation/priority**: peers who reliably relay and store content get priority (ties into Phase 2 reciprocity/QoS).
|
|
- **Anchors earn value**: the more people list you as an anchor, the more useful you are as a routing node. Feeds naturally into Phase 2 reciprocity.
|
|
|
|
### Settings in UI
|
|
- Manage bootstrap anchor list (add/remove community anchors)
|
|
- shareat.app is always present as final fallback (can be disabled but not removed)
|
|
- Connection priority display: show which path was used for each peer
|
|
|
|
---
|
|
|
|
## Phase C-2: Pull-Based Sync — COMPLETE
|
|
|
|
Sender-side filtering: only send posts the requester should see. Hello message includes follow list (ALPN `distsoc/sync/4`), Posts response filters through `should_send_post()`.
|
|
|
|
### What's implemented
|
|
- **Hello includes follow list** — `SyncMessage::Hello { post_ids, follows }`, follow list sent as `Vec<NodeId>`
|
|
- **`should_send_post()` filter** in `network.rs` — applied at Step 3 (Posts response) before sending each post:
|
|
1. Author is the requester → always send (own posts relayed back)
|
|
2. Public + author in requester's follows → send
|
|
3. Encrypted + requester in WrappedKey recipient list → send
|
|
4. Otherwise → skip
|
|
- **ALPN bump** from `distsoc/sync/3` to `distsoc/sync/4`
|
|
- **5 unit tests** covering all filter branches
|
|
|
|
### Design decisions
|
|
- Hello still sends ALL post IDs (filtering IDs would require knowing the peer's follows before sending, but Hello is sent before receiving). Requester may request posts that get filtered — harmless, they just don't arrive.
|
|
- Follow list sent is ALL follows (public + private) — needed for access control, not gossip. Bloom filters can replace later for privacy.
|
|
- No backward compat with sync/3 — clean ALPN bump, all nodes upgrade together.
|
|
|
|
**Key files:** `protocol.rs` (Hello with follows, ALPN sync/4), `network.rs` (should_send_post, filtered Posts response)
|
|
|
|
---
|
|
|
|
## Phase D-1: Envelope Encryption + Circles + DMs — COMPLETE
|
|
|
|
1-layer envelope encryption: random CEK per post, ChaCha20-Poly1305 content encryption, CEK wrapped per-recipient via X25519 DH derived from ed25519 identity keys. No new key distribution — any peer derives any other peer's X25519 public key from their NodeId.
|
|
|
|
### What's implemented
|
|
- **crypto.rs**: encrypt_post, decrypt_post, X25519 key conversion (ed25519 seed → X25519 scalar, ed25519 pubkey → montgomery point), BLAKE3 KDF for wrapping keys
|
|
- **PostVisibility**: `Public | Encrypted { recipients: Vec<WrappedKey> }` — stored in DB `visibility` column, separate from PostId
|
|
- **VisibilityIntent**: `Public | Friends | Circle(name) | Direct(vec)` — user-facing, resolved to recipients at post creation time
|
|
- **"Friends" = all public follows** — one-directional follows mean encryption gates who *can* read, follow-back gates who *does* receive. Intersection = mutual follows. No explicit friend-request protocol needed.
|
|
- **Circles**: named recipient lists with full CRUD (circles + circle_members tables)
|
|
- **Sync protocol**: ALPN `distsoc/sync/3`, `SyncPost { id, post, visibility }` carries visibility alongside posts
|
|
- **Recipient list is locked at post creation time** — new follows can't decrypt older "Friends" posts. Could be changed later (visibility is separate from PostId) but requires a visibility-update sync mechanism.
|
|
- **Revocation model** — removing a circle member excludes them from future posts automatically. For past posts, two operations handle revocation (see Phase D-2): *Sync Access List* (re-wrap same CEK, cheap) and *Re-encrypt* (new CEK, delete + repost, heavy). The app honors delete requests from authors by default, so re-encrypted posts seamlessly replace old versions for remaining recipients while revoked members lose access.
|
|
|
|
### Design decisions
|
|
- **Post struct unchanged** — for encrypted posts, `content` holds `base64(nonce || ciphertext || tag)`. PostId = BLAKE3(Post) covers ciphertext, excludes recipients.
|
|
- **No x25519-dalek** — uses curve25519-dalek directly (same pinned version as iroh: `=5.0.0-pre.1`)
|
|
- **rand 0.9 for RNG** — chacha20poly1305 re-exports OsRng from rand_core 0.6 which is incompatible; use `rand::rng()` instead
|
|
|
|
---
|
|
|
|
## Phase D-2: Access Revocation + Replica Maps
|
|
|
|
### Access revocation
|
|
|
|
Two operations for revoking access to encrypted posts after removing a circle/group member:
|
|
|
|
#### Sync Access List (cheap — header-only update)
|
|
- **When to use**: revoked member never synced the post (doesn't have the ciphertext). With pull-based sync (Phase C-2, now implemented), this is the common case — encrypted posts are only sent to recipients, so revoked members typically never had the blob.
|
|
- **How it works**: same CEK, re-wrap for remaining recipients only, propagate updated visibility headers
|
|
- **Cost**: one WrappedKey operation per remaining recipient, no content re-encryption
|
|
- **Result**: old headers replaced, revoked member can't unwrap CEK even if they later obtain the ciphertext
|
|
|
|
#### Re-encrypt (heavy — content replacement)
|
|
- **When to use**: revoked member already has the ciphertext and could unwrap the old CEK
|
|
- **How it works**:
|
|
1. Generate new CEK, re-encrypt content
|
|
2. Wrap new CEK for remaining recipients only
|
|
3. Push delete request for old post ID to all nodes
|
|
4. Repost with new content + headers but **same logical identity** (name/id/timestamp)
|
|
5. Well-behaved nodes honor the delete, remaining recipients pull the new version seamlessly
|
|
- **Cost**: full re-encryption + delete propagation + new post propagation
|
|
- **Result**: revoked member's node deletes old version (app honors author deletes by default), never receives new version (not a recipient)
|
|
|
|
#### Trust model
|
|
- The app honors delete requests from the content author by default — this is the enforcement layer
|
|
- A modified client could ignore deletes, but this is true of any decentralized system
|
|
- For legal purposes: the author has proof they issued the delete and revoked access, which is what courts care about
|
|
- Users can choose Sync Access List (casual cleanup) or Re-encrypt (contentious situations) based on threat level
|
|
|
|
### Replica maps + redundancy
|
|
|
|
Each encrypted object includes a **replica map** in its metadata: a list of node_ids that hold copies of the content blob. Enables targeted push updates without a network-wide routing table.
|
|
|
|
#### Design principles
|
|
- **Loss-risk network**, not a backup service. Best-effort redundancy, not durability guarantees. Users wanting strong archival should export their data.
|
|
- Keyholders are responsible for checking redundancy (only they can read the replica map inside the envelope).
|
|
- Liveness checks are **passive** — piggyback on existing periodic sync ("do you still have blob X?"), no separate protocol.
|
|
- "Low redundancy" is surfaced as info to the user, not a system-level block.
|
|
- Peers self-report store/delete — trust model works because lying about having a copy only hurts redundancy (detected passively), and lying about NOT having one wastes your own storage for no benefit.
|
|
|
|
#### Redundancy restoration cascade (when a replica drops off after timeout)
|
|
1. Push to a **keyholder/follower** (social + crypto incentive, works for private content)
|
|
2. Push to any **follower** (social incentive, public content only)
|
|
3. Push to a **random peer** (quota-based acceptance — they accept if they have spare 3x quota, refuse if full)
|
|
4. Accept reduced redundancy and inform the user
|
|
|
|
#### Private vs public placement
|
|
- Private content: replica map goes **inside** the envelope. Only keyholders know who has copies. Keyholders bear responsibility for monitoring redundancy.
|
|
- Public content: replica map goes **outside** (cleartext header). Any peer storing the blob can push updates to other holders.
|
|
|
|
---
|
|
|
|
## Phase D-3: Groups as Entities + Audience Scaling
|
|
|
|
### The scaling problem
|
|
Per-recipient key wrapping (Phase D-1) works well up to ~500 recipients (256KB visibility metadata limit). Beyond that, each post carries too much overhead. Public posts have no limit — this only affects encrypted content.
|
|
|
|
### Groups as entities
|
|
A group has its own key pair. Posts to the group wrap the CEK once for the group's public key. Members receive the group private key (wrapped for their personal key) when they join.
|
|
|
|
| Approach | Per-post overhead | Rekey on member removal |
|
|
|----------|------------------|------------------------|
|
|
| Per-recipient (D-1) | ~500 bytes/recipient | N/A (no shared key) |
|
|
| Group key (D-3) | ~500 bytes total | Re-wrap group key for N-1 members |
|
|
|
|
### Audience sharding for large groups
|
|
For audiences beyond ~250 members (e.g. paid celeb access), shard into multiple groups of 200-250:
|
|
- Each post is replicated to each shard group (one copy per shard)
|
|
- Subscriber removal rekeys only the affected shard (~250 propagations, not 100k)
|
|
- Sweet spot is 200-250 per shard: well under the 256KB cap, cheap rekey, manageable fanout
|
|
|
|
**100k subscriber example at 250/shard:**
|
|
- 400 shard groups
|
|
- 10 posts/day = 4,000 post copies/day
|
|
- Subscriber churn rekeys one shard of 249 members (~125KB)
|
|
|
|
### Group roles
|
|
Admin / Moderator / Poster / Viewer — role determines what operations are allowed, not what you can read (all members hold the group key).
|
|
|
|
---
|
|
|
|
## Phase D-4: Private Profiles
|
|
|
|
Different profile versions per circle, encrypted with the circle/group CEK. A peer sees the profile version for the most-privileged circle they belong to.
|
|
|
|
---
|
|
|
|
## Phase E: Visual Polish — COMPLETE
|
|
|
|
Identicons (deterministic 5x5 mirrored SVG from node ID), post card styling, auto-growing compose area with char count, empty states, tab badges, view transitions, toast notifications.
|
|
|
|
---
|
|
|
|
## Phase 2: Global Reciprocity / QoS
|
|
|
|
Pledge + audit + priority scheduling. See `project discussion.txt` for full spec. Key concepts:
|
|
- 3x hosting quota (local enforcement for MVP)
|
|
- Pinned serving bonus outside quota
|
|
- Anchor pin (host original) vs Fork pin (your copy)
|
|
|
|
---
|
|
|
|
## File map
|
|
|
|
```
|
|
crates/core/
|
|
src/
|
|
lib.rs — module registration, parse_connect_string, parse_node_id_hex
|
|
types.rs — Post, PostId, NodeId, PublicProfile, PostVisibility, WrappedKey,
|
|
VisibilityIntent, Circle, PeerRecord, GossipPeerInfo, Attachment
|
|
content.rs — compute_post_id (BLAKE3), verify_post_id
|
|
crypto.rs — X25519 key conversion, DH, encrypt_post, decrypt_post, BLAKE3 KDF
|
|
storage.rs — SQLite: posts, peers, follows, profiles, circles, circle_members,
|
|
mesh_peers, reachable_n2/n3, social_routes; auto-migration
|
|
protocol.rs — SyncMessage enum, SyncPost, ALPN (sync/4), length-prefixed JSON framing
|
|
network.rs — iroh Endpoint, accept loop, mesh connection management,
|
|
pull-based filtering (should_send_post), mDNS discovery, anchor detection
|
|
node.rs — Node struct (ties identity + storage + network), post CRUD,
|
|
follow/unfollow, profile CRUD, circle CRUD, encrypted post creation,
|
|
periodic sync, bootstrap
|
|
Cargo.toml — iroh 0.96, rusqlite 0.32, blake3, chacha20poly1305 0.10,
|
|
curve25519-dalek =5.0.0-pre.1, ed25519-dalek =3.0.0-pre.1, base64 0.22
|
|
|
|
crates/cli/
|
|
src/main.rs — interactive REPL: post, post-to, feed, circles, connect, sync, etc.
|
|
|
|
crates/tauri-app/
|
|
src/lib.rs — Tauri v2 commands (20 IPC handlers), DTOs (PostDto, PeerDto, CircleDto, etc.)
|
|
|
|
frontend/
|
|
index.html — single-page UI: 5 tabs (Feed / My Posts / People / Messages / Settings),
|
|
compose in My Posts, DM compose in Messages, circles in My Posts,
|
|
network diagnostics collapsed in Settings, setup overlay
|
|
app.js — Tauri invoke calls, rendering, identicon generator, circle CRUD,
|
|
DM send, message filtering (followed vs requests), tab switching,
|
|
social-distance-aware recipient dropdown (follows + other peers)
|
|
style.css — dark theme, post cards, visibility badges, circle cards, DM compose,
|
|
collapsible sections, message request actions, transitions
|
|
```
|