itsgoin/docs/peer-discovery-design.html
Scott Reimers 800388cda4 ItsGoin v0.3.2 — Decentralized social media network
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>
2026-03-15 20:23:09 -04:00

1116 lines
49 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>distsoc — Discovery Protocol v2 Design Review</title>
<style>
:root {
--bg: #1a1a2e;
--surface: #16213e;
--surface2: #0f3460;
--text: #e0e0e0;
--text-muted: #8892a0;
--accent: #53a8b6;
--warn: #e2a03f;
--idea: #4ecca3;
--danger: #e74c3c;
--feedback-bg: #1e2a45;
--feedback-border: #3a506b;
--border: #2a2a4a;
--layer1: #5b8def;
--layer2: #e8a838;
--layer3: #e05893;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
background: var(--bg); color: var(--text);
line-height: 1.7; padding: 2rem; max-width: 960px; margin: 0 auto;
}
h1 { font-size: 1.8rem; margin-bottom: 0.5rem; color: var(--accent); }
h2 { font-size: 1.4rem; margin: 1.5rem 0 0.5rem; color: var(--accent); border-bottom: 1px solid var(--border); padding-bottom: 0.3rem; }
h3 { font-size: 1.1rem; margin: 1rem 0 0.3rem; color: #c0c0d0; }
p { margin: 0.5rem 0; }
code { background: #0d1b2a; padding: 0.15rem 0.4rem; border-radius: 3px; font-size: 0.9em; color: #7ec8e3; }
pre { background: #0d1b2a; padding: 1rem; border-radius: 6px; overflow-x: auto; margin: 0.5rem 0; font-size: 0.85em; line-height: 1.5; color: #c0d0e0; }
ul, ol { margin: 0.3rem 0 0.5rem 1.5rem; }
li { margin: 0.2rem 0; }
a { color: var(--accent); }
details { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; margin: 0.8rem 0; }
details > summary { padding: 0.8rem 1rem; cursor: pointer; font-weight: 600; user-select: none; list-style: none; display: flex; align-items: center; gap: 0.5rem; }
details > summary::before { content: '\25B6'; font-size: 0.7em; transition: transform 0.2s; flex-shrink: 0; }
details[open] > summary::before { transform: rotate(90deg); }
details > summary::-webkit-details-marker { display: none; }
details > .content { padding: 0 1rem 1rem 1rem; }
details details { background: rgba(255,255,255,0.02); border-color: rgba(255,255,255,0.08); }
.callout { border-left: 4px solid; border-radius: 0 6px 6px 0; padding: 0.7rem 1rem; margin: 0.8rem 0; font-size: 0.92em; }
.callout-current { border-color: var(--accent); background: rgba(83,168,182,0.08); }
.callout-concern { border-color: var(--warn); background: rgba(226,160,63,0.08); }
.callout-idea { border-color: var(--idea); background: rgba(78,204,163,0.08); }
.callout-danger { border-color: var(--danger); background: rgba(231,76,60,0.08); }
.callout .label { font-weight: 700; font-size: 0.8em; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.3rem; }
.callout-current .label { color: var(--accent); }
.callout-concern .label { color: var(--warn); }
.callout-idea .label { color: var(--idea); }
.callout-danger .label { color: var(--danger); }
.feedback { background: var(--feedback-bg); border: 2px dashed var(--feedback-border); border-radius: 6px; padding: 0.8rem 1rem; margin: 0.8rem 0; min-height: 3rem; position: relative; }
.feedback::before { content: attr(data-label); position: absolute; top: -0.7rem; left: 0.8rem; background: var(--feedback-bg); padding: 0 0.4rem; font-size: 0.7em; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: var(--feedback-border); }
.feedback[contenteditable] { outline: none; cursor: text; }
.feedback[contenteditable]:focus { border-color: var(--accent); }
.feedback[contenteditable]:empty::after { content: 'Click to type your feedback...'; color: #4a5568; font-style: italic; }
.sequence { background: #0a0f1a; border: 1px solid var(--border); border-radius: 6px; padding: 1rem; margin: 0.5rem 0; font-family: 'Fira Code','Consolas',monospace; font-size: 0.8em; line-height: 1.4; overflow-x: auto; white-space: pre; color: #88a0b8; }
table { width: 100%; border-collapse: collapse; margin: 0.8rem 0; font-size: 0.9em; }
th, td { border: 1px solid var(--border); padding: 0.5rem 0.8rem; text-align: left; }
th { background: var(--surface2); color: var(--accent); font-weight: 600; }
td { background: rgba(0,0,0,0.15); }
.toolbar { display: flex; gap: 0.8rem; margin-bottom: 1.5rem; flex-wrap: wrap; }
.toolbar button { background: var(--surface2); color: var(--text); border: 1px solid var(--border); border-radius: 6px; padding: 0.5rem 1rem; cursor: pointer; font-size: 0.85em; transition: background 0.2s; }
.toolbar button:hover { background: var(--accent); color: #000; }
.status-bar { position: fixed; bottom: 0; left: 0; right: 0; background: var(--surface); border-top: 1px solid var(--border); padding: 0.4rem 1rem; font-size: 0.75em; color: var(--text-muted); display: flex; justify-content: space-between; z-index: 100; }
.badge { display: inline-block; padding: 0.1rem 0.5rem; border-radius: 3px; font-size: 0.75em; font-weight: 600; font-family: monospace; }
.badge-l1 { background: rgba(91,141,239,0.2); color: var(--layer1); }
.badge-l2 { background: rgba(232,168,56,0.2); color: var(--layer2); }
.badge-l3 { background: rgba(224,88,147,0.2); color: var(--layer3); }
.layer-tag { display: inline-block; padding: 0.05rem 0.4rem; border-radius: 3px; font-size: 0.7em; font-weight: 700; margin-right: 0.3rem; vertical-align: middle; }
.layer-tag-1 { background: var(--layer1); color: #000; }
.layer-tag-2 { background: var(--layer2); color: #000; }
.layer-tag-3 { background: var(--layer3); color: #000; }
body { padding-bottom: 3rem; }
</style>
</head>
<body>
<h1>distsoc Discovery Protocol v2 — Design Review</h1>
<p style="color:var(--text-muted); margin-bottom:0.3rem;">
Three-layer architecture: <span class="layer-tag layer-tag-1">L1</span> Peer Discovery
<span class="layer-tag layer-tag-2">L2</span> File Storage + Content Routing
<span class="layer-tag layer-tag-3">L3</span> Social Routing.
<strong>Click any section to expand.</strong>
</p>
<p style="color:var(--text-muted); font-size:0.85em; margin-bottom:1rem;">
101 persistent QUIC connections (81 social + 20 wide). Single ALPN. 2-min diffs. ~350K 3-hop map.
Your inline feedback is auto-saved to localStorage.
</p>
<div class="toolbar">
<button onclick="toggleAll(true)">Expand All</button>
<button onclick="toggleAll(false)">Collapse All</button>
<button onclick="exportDoc()">Export Annotated HTML</button>
<button onclick="clearFeedback()">Clear All Feedback</button>
</div>
<!-- ============================================================ -->
<!-- SECTION 1: OVERVIEW -->
<!-- ============================================================ -->
<details open>
<summary>1. Architecture Overview</summary>
<div class="content">
<details>
<summary>1.1 The Three Layers</summary>
<div class="content">
<table>
<tr><th>Layer</th><th>Purpose</th><th>Map contents</th><th>Update mechanism</th></tr>
<tr>
<td><span class="layer-tag layer-tag-1">L1</span> Peer Discovery</td>
<td>Find any node's address</td>
<td>NodeIds + hop distance + addresses (1-hop only). ~350K entries.</td>
<td>2-min diffs (1-hop forwarding), worm lookup</td>
</tr>
<tr>
<td><span class="layer-tag layer-tag-2">L2</span> File Storage</td>
<td>Content-addressed storage + author update propagation</td>
<td><code>node:postid</code> + media + <code>author_recent_posts</code> (256KB max)</td>
<td>File replication, piggybacked updates, staleness pulls</td>
</tr>
<tr>
<td><span class="layer-tag layer-tag-3">L3</span> Social Routing</td>
<td>Direct routes to follows / audience</td>
<td>Cached routes to socially-connected nodes</td>
<td>Push (audience), pull (follows), route validation</td>
</tr>
</table>
<div class="callout callout-current">
<div class="label">Design Principle</div>
<p><strong>Layer 1</strong> answers "where is this node?" Layer 2 answers "where is this content?"
Layer 3 answers "how do I reach the people I care about?" Each layer operates independently
but they reinforce each other — a file layer hit can bypass a Layer 1 worm entirely.</p>
</div>
<div class="feedback" contenteditable="true" data-id="v2-layers" data-label="Your thoughts on the 3-layer architecture"></div>
</div>
</details>
<details>
<summary>1.2 Follow vs Audience</summary>
<div class="content">
<table>
<tr><th></th><th>Follow</th><th>Audience</th></tr>
<tr><td>Initiation</td><td>Unilateral — no request needed</td><td>Requires request + author approval</td></tr>
<tr><td>Delivery</td><td><strong>Pull only</strong> — follower pulls updates</td><td><strong>Push</strong> — author pushes via push worm</td></tr>
<tr><td>Author awareness</td><td>Author does not know</td><td>Author knows (approved the request)</td></tr>
<tr><td>Latency</td><td>Minutes (pull cycle or file-chain propagation)</td><td>Seconds (direct push)</td></tr>
<tr><td>Resource cost</td><td>Follower bears cost</td><td>Author bears cost</td></tr>
<tr><td>Scale</td><td>Unlimited followers (pull is distributed)</td><td>Author pushes to approved list</td></tr>
</table>
<div class="callout callout-current">
<div class="label">Key Distinction</div>
<p>Follows are <strong>private and passive</strong> — the author never learns who follows them. Content
reaches followers via the file layer (author_recent_posts propagates through stored files)
or via periodic pull. Audience is <strong>consented and active</strong> — the author pushes in real-time.</p>
</div>
<div class="feedback" contenteditable="true" data-id="v2-follow-audience" data-label="Your thoughts on follow vs audience"></div>
</div>
</details>
<details>
<summary>1.3 Connection Model — 101 Persistent QUIC</summary>
<div class="content">
<pre>
┌──────────────────────────────────┐
│ This Node (101 conns) │
├──────────────────────────────────┤
│ 81 Social Peers │
│ ├─ Mutual follows │
│ ├─ Audience (granted) │
│ ├─ Users we follow (online) │
│ ├─ Recent sync partners │
│ └─ (evicted by priority) │
│ │
│ 20 Wide Peers │
│ ├─ Diversity-maximizing │
│ ├─ Re-evaluated every 10 min │
│ └─ At least 2 must be anchors │
└──────────────────────────────────┘
Mobile: 10 social + 5 wide = 15 connections.
</pre>
<p>All connections use a <strong>single ALPN</strong> (<code>distsoc/2</code>) with multiplexed message types.
One TLS handshake per peer. QUIC keep-alive every 20 seconds.</p>
<table>
<tr><th>Resource</th><th>Desktop (101)</th><th>Mobile (15)</th></tr>
<tr><td>Memory (connection state)</td><td>~1.5 MB</td><td>~250 KB</td></tr>
<tr><td>Keep-alive bandwidth</td><td>~22 MB/day</td><td>~3.2 MB/day</td></tr>
<tr><td>CPU</td><td>Negligible</td><td>Negligible</td></tr>
</table>
<div class="feedback" contenteditable="true" data-id="v2-connections" data-label="Your thoughts on connection model"></div>
</div>
</details>
<details>
<summary>1.4 Unified Protocol — Single ALPN</summary>
<div class="content">
<p>All communication over one ALPN <code>distsoc/2</code>. Message types via 1-byte header per QUIC stream:</p>
<pre>
Layer 1: Peer Discovery
0x01 RoutingDiff 2-min gossip diff
0x02 InitialMapSync Full map exchange on new connection
0x10 WormRequest Forwarded search
0x11 WormQuery Fan-out to peers (local check)
0x12 WormResponse Results to originator
0x20 AddressRequest Resolve NodeId → address
0x21 AddressResponse
Layer 2: File / Content
0x30 FileRequest Request a post by PostId
0x31 FileResponse
0x32 AuthorUpdateRequest Request fresh author_recent_posts
0x33 AuthorUpdateResponse
0x34 AuthorUpdatePush Push updated author_recent_posts
0x35 PostNotification Real-time new post notification
Layer 3: Social
0x40 PullSyncRequest Follower requests posts since seq N
0x41 PullSyncResponse
0x42 PushPost Audience push delivery
0x43 AudienceRequest Request to join audience
0x44 AudienceResponse
General
0x50 ProfileUpdate
0x51 DeleteRecord
0x52 VisibilityUpdate
</pre>
<div class="callout callout-idea">
<div class="label">Why Single ALPN</div>
<p>Previous design used 4 ALPNs (sync/6, addr/1, gossip/1, worm/1) requiring separate connections.
Single ALPN means one TLS handshake per peer, connection reuse for all message types,
simpler accept loop, easy to add new message types.</p>
</div>
<div class="feedback" contenteditable="true" data-id="v2-protocol" data-label="Your thoughts on protocol structure"></div>
</div>
</details>
</div>
</details>
<!-- ============================================================ -->
<!-- SECTION 2: LAYER 1 -->
<!-- ============================================================ -->
<details>
<summary><span class="layer-tag layer-tag-1">L1</span> 2. Peer Discovery — 3-Hop Map + Worm</summary>
<div class="content">
<details>
<summary>2.1 The 3-Hop Discovery Map</summary>
<div class="content">
<pre>
┌─────────────────────────────────────────────────────────┐
│ Hop 1: 101 direct peers │
│ Stored: NodeId + SocketAddr + is_anchor + is_wide │
│ Source: Direct QUIC connection observation │
│ │
│ Hop 2: ~5,500 unique nodes │
│ Stored: NodeId + reporter_peer_id + is_anchor │
│ Source: Peers' 1-hop diffs (their direct connections) │
│ │
│ Hop 3: ~350,000 unique nodes │
│ Stored: NodeId only │
│ Source: Peers' 2-hop diffs (their derived knowledge) │
└─────────────────────────────────────────────────────────┘
Storage: ~11.3 MB (101×96B + 5,500×66B + 350K×32B)
</pre>
<h3>Why ~350K with 20 Wide Peers</h3>
<table>
<tr><th>Source (2-hop)</th><th>Raw</th><th>After dedup</th></tr>
<tr><td>Social intra-cluster (81 × ~50)</td><td>4,050</td><td>~118 (rest of our cluster)</td></tr>
<tr><td>Social inter-cluster (81 × ~51)</td><td>4,131</td><td>~3,500</td></tr>
<tr><td>Wide intra-cluster (20 × ~50)</td><td>1,000</td><td>~1,000</td></tr>
<tr><td>Wide inter-cluster (20 × ~51)</td><td>1,020</td><td>~1,000</td></tr>
<tr><th colspan="2">Total 2-hop</th><th>~5,500</th></tr>
</table>
<p>3-hop: Each of ~5,500 2-hop nodes has ~100 connections not yet counted. Wide-wide-wide paths contribute
~80K+ unique nodes from completely different parts of the graph. Total after dedup: <strong>~350,000</strong>.</p>
<div class="callout callout-current">
<div class="label">Wide Peer Multiplier</div>
<p>Without dedicated wide peers, 101 random social connections in a clustered graph reach ~150-200K.
With 20 wide peers: ~350K. The wide peers cascade diversity — their wide peers escape <em>their</em>
neighborhoods, and so on through 3 levels.</p>
</div>
<div class="feedback" contenteditable="true" data-id="v2-3hop-map" data-label="Your thoughts on 3-hop map sizing"></div>
</div>
</details>
<details>
<summary>2.2 Diff-Based Gossip (2-min cycles)</summary>
<div class="content">
<pre>
Every 2 minutes, each node sends a diff to each of its 100 other peers:
RoutingDiff {
hop1_changes: [Added/Removed/AddressChanged], // our direct observations
hop2_changes: [Added/Removed], // derived from received diffs
seq: u64,
}
</pre>
<h3>How Diffs Propagate (1-hop forwarding only)</h3>
<div class="sequence">T=0 Node X goes offline.
X's 101 direct peers detect connection drop.
T=0-2m X's peers include "X removed" in their next 1-hop diff.
→ X's peers' neighbors learn "X gone from 2-hop"
T=2-4m Those neighbors include "X removed" in their 2-hop diff.
→ Nodes 3 hops from X learn "X gone from 3-hop"
T=4-6m Further propagation for deeper views.
3-hop propagation: ~6 min worst case, ~3 min average.</div>
<p><strong>No amplification:</strong> You don't re-forward received diffs. You compute your own view's
changes and report those. Each change is re-derived at every hop.</p>
<h3>Bandwidth</h3>
<table>
<tr><th>Churn rate</th><th>Diff size/peer</th><th>Per day (Layer 1)</th></tr>
<tr><td>1% hourly (low)</td><td>~200 bytes</td><td><strong>~50 MB</strong></td></tr>
<tr><td>5% hourly (mobile-heavy)</td><td>~700 bytes</td><td><strong>~122 MB</strong></td></tr>
</table>
<p>Previous design: ~318 MB/day. This is <strong>2.5-6x better</strong>.</p>
<div class="feedback" contenteditable="true" data-id="v2-gossip" data-label="Your thoughts on diff-based gossip"></div>
</div>
</details>
<details>
<summary>2.3 Worm Lookup with Fan-Out</summary>
<div class="content">
<p>Used when target NodeId is not in local 3-hop map.</p>
<div class="sequence">Worm arrives at node A looking for targets [T1, T2, T3]:
Step 1: LOCAL CHECK
A checks own 3-hop map (~350K entries). O(1) per target.
Found T2 → send WormResponse directly to originator.
Step 2: FAN-OUT CHECK (parallel, 500ms timeout)
A sends WormQuery to all 100 peers.
Each peer checks their ~350K map. O(1) per target.
Peer P7 finds T1 → resolves address → WormResponse to originator.
Step 3: FORWARD remaining targets
Select best forwarding peer (wide, not visited, not queried).
Forward WormRequest { ttl: ttl-1 }.
That peer repeats Steps 1-3.</div>
<h3>Coverage Per Hop</h3>
<table>
<tr><th>Component</th><th>Entries checked</th></tr>
<tr><td>Local 3-hop map</td><td>~350,000</td></tr>
<tr><td>100 peers' maps (fan-out)</td><td>100 × ~350,000 = ~35,000,000</td></tr>
<tr><td><strong>After overlap dedup</strong></td><td><strong>~25,000,000 (1.25% of 2B)</strong></td></tr>
</table>
<p>With iterative routing (each hop guided toward target):</p>
<ul>
<li><strong>TTL=3:</strong> ~75M entries — finds most socially-proximate targets</li>
<li><strong>TTL=5:</strong> ~125M entries — finds virtually any reachable target</li>
<li><strong>Expected resolution: 3-5 hops, 1.5-2.5 seconds</strong></li>
</ul>
<div class="callout callout-current">
<div class="label">vs Previous Design</div>
<p>Previous worm checked only local map (~2M per hop with 2-hop tables).
Fan-out to 100 peers gives <strong>12x more coverage per hop</strong>.</p>
</div>
<div class="feedback" contenteditable="true" data-id="v2-worm" data-label="Your thoughts on worm fan-out"></div>
</div>
</details>
<details>
<summary>2.4 Address Resolution Chain</summary>
<div class="content">
<pre>
1. DIRECT — T in 1-hop → have address (instant)
2. 2-HOP REF — T in 2-hop → ask reporter for address (1 RTT)
3. 3-HOP REF — T in 3-hop → ask peers who's closer → chain (2 RTT)
4. WORM — T not in map → worm search (1.5-5 sec)
5. ANCHOR — Worm fails → profile anchor or bootstrap (1-5 sec)
</pre>
<table>
<tr><th>Tier</th><th>Nodes covered</th><th>% of 2B</th></tr>
<tr><td>1-hop</td><td>101</td><td>0.000005%</td></tr>
<tr><td>2-hop</td><td>~5,500</td><td>0.00028%</td></tr>
<tr><td>3-hop</td><td>~350,000</td><td>0.018%</td></tr>
<tr><td>Worm (1 hop)</td><td>~25,000,000</td><td>1.25%</td></tr>
<tr><td>Worm (5 hops)</td><td>~125,000,000</td><td>6.25%</td></tr>
</table>
<div class="feedback" contenteditable="true" data-id="v2-resolution" data-label="Your thoughts on address resolution"></div>
</div>
</details>
</div>
</details>
<!-- ============================================================ -->
<!-- SECTION 3: LAYER 2 -->
<!-- ============================================================ -->
<details>
<summary><span class="layer-tag layer-tag-2">L2</span> 3. File Storage + Content Routing</summary>
<div class="content">
<details>
<summary>3.1 Core Concept — Files Carry Their Own Routing</summary>
<div class="content">
<p>Every stored file (post + media) carries a small metadata blob: <strong><code>author_recent_posts</code></strong>
(max 256 KB, author-signed). This blob lists the author's recent post IDs.</p>
<div class="callout callout-idea">
<div class="label">Key Insight</div>
<p>If you have <em>any</em> file by author X, you passively know X's recent posts. You can then
request specific posts from <em>any peer who has them</em> — you don't need to find author X.</p>
<p>This creates a <strong>natural CDN</strong>: popular authors' post updates propagate through the file
storage network as each copy of their files carries the latest post list.</p>
</div>
<pre>
StoredFile {
post: Post, // content-addressed, immutable
post_id: PostId, // blake3(content)
media_blobs: Vec&lt;MediaBlob&gt;,
author_recent_posts: AuthorRecentPosts {
author_id: NodeId,
posts: Vec&lt;RecentPostEntry&gt;, // newest first
updated_at: u64, // ms timestamp
signature: Signature, // author signs this blob
// Max 256 KB total → ~8,000 recent post entries
}
}
</pre>
<div class="feedback" contenteditable="true" data-id="v2-file-concept" data-label="Your thoughts on files as routing"></div>
</div>
</details>
<details>
<summary>3.2 Update Propagation — Three Paths</summary>
<div class="content">
<div class="sequence">Author A publishes a new post.
Path 1: DIRECT PUSH (audience, seconds)
A pushes new post to audience members via push worm.
Recipients update their stored copies of A's files
with the new author_recent_posts blob.
Path 2: FILE-CHAIN PROPAGATION (followers, &lt;12 min typical)
A's 101 persistent peers receive the update.
When ANY peer accesses a file by A, they see the fresh
author_recent_posts and can request the new post.
Propagates naturally as files are accessed/synced.
Path 3: STALENESS PULL (&gt;1 hour fallback)
If author_recent_posts.updated_at is older than 1 hour,
the holder triggers an update pull:
- Check Layer 3 social route to author
- Check other peers who hold author's files
- Worm request for latest author_recent_posts</div>
<div class="callout callout-current">
<div class="label">Result</div>
<p>Popular authors' updates reach most file holders within minutes.
Unpopular authors' updates reach followers within 1 hour (staleness pull).</p>
</div>
<div class="feedback" contenteditable="true" data-id="v2-update-propagation" data-label="Your thoughts on update propagation"></div>
</div>
</details>
<details>
<summary>3.3 Popular Author Scale (1M Audience)</summary>
<div class="content">
<div class="sequence">Author A has 1,000,000 audience members. Posts a new photo.
Layer 1: A has 101 persistent connections. PostNotification sent to all.
→ 101 audience members get it instantly.
→ 101 copies of updated author_recent_posts now exist.
Layer 2: Those 101 peers have files by A. Each has 101 peers.
→ T+2m: 101 × 100 = ~10,000 peers see fresh author_recent_posts
→ T+4m: 10,000 × 100 = ~1,000,000 peers reached
→ Natural file-chain propagation covers the full audience
No destination declared. The file layer IS the CDN.
Author does 101 pushes. O(log N) hops to reach everyone.</div>
<div class="callout callout-idea">
<div class="label">vs Previous Design</div>
<p>Previous design: author splits audience into chunks of 10, tries to push to each chunk leader.
1M audience = 100K chunks = author's machine saturated for hours.</p>
<p>New design: author does <strong>101 pushes total</strong>. File layer handles the rest via natural
propagation. O(1) work for the author, O(log N) time to reach everyone.</p>
</div>
<div class="feedback" contenteditable="true" data-id="v2-popular-author" data-label="Your thoughts on popular author scaling"></div>
</div>
</details>
<details>
<summary>3.4 Requesting Posts via File Layer</summary>
<div class="content">
<pre>
You see author A has new post P in author_recent_posts.
You don't have post P stored locally.
1. Check if any persistent peer has P:
→ Fan-out WormQuery to 100 peers (they check local storage)
→ Any peer with the file can serve it
2. Request posts newest-to-oldest:
→ Prioritize catching up on recent content
→ Older posts can wait or be skipped
3. No need to contact author A at all.
</pre>
<div class="callout callout-current">
<div class="label">File Authority Chain</div>
<p>Each node caches a route back to the author for each file they hold. When <code>author_recent_posts</code>
is stale (&gt;1 hour), follow the authority chain hop-by-hop toward the author. Each hop may have a
fresher copy — you don't need to reach the author, just a fresher copy.</p>
</div>
<div class="feedback" contenteditable="true" data-id="v2-file-requests" data-label="Your thoughts on file-layer content fetching"></div>
</div>
</details>
<details>
<summary>3.5 File Keep Priority</summary>
<div class="content">
<h3>Formula</h3>
<pre>priority = pin_bonus + (relationship × heart_recency × post_age / (peer_copies + 1))</pre>
<h3>Scoring Tables</h3>
<table>
<tr><th colspan="2">Relationship (to file's author)</th></tr>
<tr><td>Self (our own content)</td><td>&#8734; (never evicted)</td></tr>
<tr><td>We are audience of author</td><td>10</td></tr>
<tr><td>We follow author</td><td>8</td></tr>
<tr><td>Author has &gt;10 hearts from network</td><td>5</td></tr>
<tr><td>Author has &gt;3 hearts</td><td>3</td></tr>
<tr><td>Author has &gt;2 hearts</td><td>2</td></tr>
<tr><td>No relationship</td><td>1</td></tr>
</table>
<table>
<tr><th>Time Window</th><th>Heart Recency Score</th><th>Post Age Score</th></tr>
<tr><td>&lt; 72 hours</td><td>100</td><td>100</td></tr>
<tr><td>3-14 days</td><td>50</td><td>50</td></tr>
<tr><td>14-45 days</td><td>25</td><td>25</td></tr>
<tr><td>45-90 days</td><td>12</td><td>12</td></tr>
<tr><td>90-365 days</td><td>6</td><td>6</td></tr>
<tr><td>1-3 years</td><td>3</td><td>3</td></tr>
<tr><td>4-10 years</td><td>1</td><td>1</td></tr>
</table>
<p><strong>Peer Copies:</strong> Divides priority. More copies nearby = lower urgency to keep ours.
0 copies → full priority. 10 copies → 1/11 priority.</p>
<p><strong>Pin:</strong> 99,999 bonus. Even pins compete when storage is full.</p>
<h3>Examples</h3>
<pre>
YOUR OWN post:
∞ → never evicted
Audience author, yesterday, hearted today, 0 copies:
0 + (10 × 100 × 100 / 1) = 100,000 → very high
Followed author, last week, hearted 2 days ago, 2 copies:
0 + (8 × 100 × 50 / 3) = 13,333 → high
Popular stranger (&gt;10 hearts), yesterday, 20 copies:
0 + (5 × 100 × 100 / 21) = 2,381 → moderate
Random, 6 months old, 3 hearts, 8 copies:
0 + (3 × 6 × 6 / 9) = 12 → very low
Unknown, no hearts, old, many copies:
0 + (1 × 1 × 1 / 11) = 0.09 → first evicted</pre>
<div class="callout callout-current">
<div class="label">Storage Budget</div>
<p>Default: 10 GB. At 256 KB avg: ~40K files. At 1 MB avg (with media): ~10K files.
The formula ensures: own posts always kept, audience/follow prioritized, rare content preserved,
old/common/unrelated content evicted first.</p>
</div>
<div class="feedback" contenteditable="true" data-id="v2-keep-priority" data-label="Your thoughts on file keep priority"></div>
</div>
</details>
</div>
</details>
<!-- ============================================================ -->
<!-- SECTION 4: LAYER 3 -->
<!-- ============================================================ -->
<details>
<summary><span class="layer-tag layer-tag-3">L3</span> 4. Social Routing</summary>
<div class="content">
<details>
<summary>4.1 Purpose — Cached Routes to People You Care About</summary>
<div class="content">
<p>Layer 3 is a <strong>personal routing cache</strong> for follows and audience. It stores recently-working
routes so you can push/pull content without going through the Layer 1 worm every time.</p>
<pre>
SocialRoute {
target: NodeId,
relationship: Follow | Audience | Mutual,
last_route: Vec&lt;NodeId&gt;, // path that worked
last_success: u64,
address_hint: Option&lt;SocketAddr&gt;, // if direct worked
}
</pre>
<div class="feedback" contenteditable="true" data-id="v2-social-purpose" data-label="Your thoughts on social routing layer"></div>
</div>
</details>
<details>
<summary>4.2 Follow Pull Path</summary>
<div class="content">
<div class="sequence">Follower F wants updates from author A:
1. Is A a persistent peer? (Layer 1, 1-hop)
→ Yes: content flows in real-time. Done.
2. Check social route cache (Layer 3)
→ Have recent route? Follow it to A.
→ Pull author_recent_posts + new posts.
→ Update route cache.
3. Check file layer (Layer 2)
→ Have any of A's files? Check author_recent_posts freshness.
→ If &lt;1 hour old: up to date. Request missing posts via worm.
→ If &gt;1 hour old: follow file authority chain for fresher data.
4. Fall back to Layer 1 worm.
Typical: step 1 or 2 (fast, no worm needed).</div>
<div class="feedback" contenteditable="true" data-id="v2-follow-pull" data-label="Your thoughts on follow pull path"></div>
</div>
</details>
<details>
<summary>4.3 Audience Push Path</summary>
<div class="content">
<div class="sequence">Author A creates a post. Has approved audience members.
1. Audience members who are persistent peers (1-hop):
→ Push PostNotification on persistent connection. Instant.
2. Audience members with social routes (Layer 3):
→ Follow cached route. Push post via push worm.
→ Update route cache on success.
3. Audience members with no cached route:
→ Layer 1 worm to find address.
→ Push post. Cache route for next time.
For audience &gt;101: post also pushed to file layer.
File storage network handles further propagation.
No destination declared — the CDN effect takes over.</div>
<div class="feedback" contenteditable="true" data-id="v2-audience-push" data-label="Your thoughts on audience push path"></div>
</div>
</details>
<details>
<summary>4.4 Route Maintenance</summary>
<div class="content">
<ul>
<li>On successful push/pull: update route + address hint</li>
<li>On failure: clear route, fall back to Layer 1</li>
<li>Every 30 min: validate routes for top-priority follows/audience</li>
<li>Routes older than 2 hours without verification → stale</li>
</ul>
<div class="feedback" contenteditable="true" data-id="v2-route-maintenance" data-label="Your thoughts on route maintenance"></div>
</div>
</details>
</div>
</details>
<!-- ============================================================ -->
<!-- SECTION 5: HOW IT ALL FITS TOGETHER -->
<!-- ============================================================ -->
<details>
<summary>5. How It All Fits Together — Lifecycles</summary>
<div class="content">
<details>
<summary>5.1 Public Post — From Creation to Feed</summary>
<div class="content">
<div class="sequence">T=0 Author A creates post P. PostId = blake3(content).
Stores in local DB. Updates own author_recent_posts.
Persistent peers (Layer 1):
→ PostNotification on all 101 connections.
→ All persistent peers have P + updated author_recent_posts.
Audience push (Layer 3):
→ Persistent audience members: already done.
→ Social route audience: push via cached route.
→ No route: worm to find, then push.
T&lt;2m Peers' peers see updated author_recent_posts (Layer 2):
→ File-chain propagation begins.
T&lt;12m File layer reaches most file holders (Layer 2):
→ Anyone accessing any file by A sees new post listed.
→ Can request P from any peer who has it.
T=60m Pull cycle for distant followers (Layer 3 fallback):
→ Follower checks author_recent_posts → sees P → requests it.</div>
<div class="feedback" contenteditable="true" data-id="v2-lifecycle-public" data-label="Your thoughts on public post lifecycle"></div>
</div>
</details>
<details>
<summary>5.2 Encrypted Post (DM / Circle)</summary>
<div class="content">
<div class="sequence">T=0 Author A creates post with VisibilityIntent::Direct([R]).
1. Generate random CEK
2. Encrypt content with ChaCha20-Poly1305
3. Wrap CEK per-recipient via X25519 DH
4. PostId = blake3(encrypted_content)
Push to recipient R:
→ R is persistent peer? Push directly.
→ Social route? Push via route.
→ Worm to find R.
author_recent_posts updated (includes PostId + VisibilityHint::Encrypted).
Peers see there's a new post but can't read it without the wrapped key.
On receipt, R:
→ DH to derive shared secret
→ Unwrap CEK
→ Decrypt content</div>
<div class="feedback" contenteditable="true" data-id="v2-lifecycle-encrypted" data-label="Your thoughts on encrypted post lifecycle"></div>
</div>
</details>
<details>
<summary>5.3 Discovering a New User to Follow</summary>
<div class="content">
<div class="sequence">User has author X's NodeId (from out-of-band sharing).
1. Layer 1 map: X in 3-hop? (~350K entries)
→ If yes: resolve address via referral chain. Connect. Done.
2. Worm search (Layer 1): fan-out to 100 peers.
~25M entries checked per hop, 3-5 hops.
→ Found? Connect to X. Pull profile + recent posts. Done.
3. File layer (Layer 2): anyone we know have X's files?
→ Check if any peer has author_recent_posts for X.
→ If yes: get X's recent posts without finding X directly.
4. Anchor fallback: contact bootstrap anchors.
5. Once connected to X (or X's file holders):
→ Cache social route (Layer 3) for future pulls.
→ Store X's files → future updates via file layer.</div>
<div class="callout callout-current">
<div class="label">Three layers cooperate</div>
<p>Layer 1 finds the person. Layer 2 lets you get their content even without finding them.
Layer 3 remembers the route for next time. Each layer is a fallback for the others.</p>
</div>
<div class="feedback" contenteditable="true" data-id="v2-lifecycle-discover" data-label="Your thoughts on user discovery flow"></div>
</div>
</details>
<details>
<summary>5.4 Popular Author with 1M Audience</summary>
<div class="content">
<div class="sequence">Author A: 1,000,000 audience members. Posts a photo.
T=0s A pushes to 101 persistent peers.
Author's work: 101 sends. Done.
T=0-2s 101 audience members have post + fresh author_recent_posts.
T=2m 101 × 100 = ~10,000 peers see updated author_recent_posts
via file-chain propagation (Layer 2).
T=4m 10,000 × 100 = ~1,000,000 peers reached.
Full audience covered.
Total author effort: 101 pushes.
Total time to full coverage: ~4 minutes.
Total bandwidth (author): 101 × post_size.
No audience member list transmitted. No destination declared.</div>
<div class="callout callout-idea">
<div class="label">The File Layer IS the CDN</div>
<p>Popular content replicates because many peers have the author's files.
The <code>author_recent_posts</code> blob travels with every file copy. The author doesn't
need to know or manage the delivery — the storage network handles it.</p>
</div>
<div class="feedback" contenteditable="true" data-id="v2-lifecycle-1m" data-label="Your thoughts on 1M audience scaling"></div>
</div>
</details>
</div>
</details>
<!-- ============================================================ -->
<!-- SECTION 6: BANDWIDTH & NUMBERS -->
<!-- ============================================================ -->
<details>
<summary>6. Bandwidth &amp; Resource Budget</summary>
<div class="content">
<details>
<summary>6.1 Per-Node Summary</summary>
<div class="content">
<table>
<tr><th>Metric</th><th>Desktop (101 conns)</th><th>Mobile (15 conns)</th></tr>
<tr><td>Layer 1 map</td><td>~350K entries, ~11 MB</td><td>~15K entries, ~500 KB</td></tr>
<tr><td>Layer 2 files</td><td>10K-40K files, ~10 GB</td><td>1K-5K files, ~1 GB</td></tr>
<tr><td>Layer 3 routes</td><td>~200-500 entries, ~50 KB</td><td>same</td></tr>
<tr><td>Layer 1 bandwidth</td><td>~50 MB/day</td><td>~8 MB/day</td></tr>
<tr><td>Layer 2 bandwidth</td><td>~50-100 MB/day (varies)</td><td>~10-30 MB/day</td></tr>
<tr><td>Layer 3 bandwidth</td><td>~10-20 MB/day</td><td>~5-10 MB/day</td></tr>
<tr><td><strong>Total bandwidth</strong></td><td><strong>~110-170 MB/day</strong></td><td><strong>~23-48 MB/day</strong></td></tr>
<tr><td>Worm coverage/hop</td><td>~25M (1.25%)</td><td>~2M (0.1%)</td></tr>
<tr><td>Worm hops to find any target</td><td>3-5</td><td>5-8 (or anchor)</td></tr>
</table>
<div class="feedback" contenteditable="true" data-id="v2-bandwidth" data-label="Your thoughts on bandwidth budget"></div>
</div>
</details>
<details>
<summary>6.2 Comparison to Previous Design</summary>
<div class="content">
<table>
<tr><th>Aspect</th><th>Phase F (current code)</th><th>Protocol v2 (this spec)</th></tr>
<tr><td>Connections</td><td>Ephemeral (connect/sync/disconnect)</td><td>101 persistent</td></tr>
<tr><td>ALPNs</td><td>4</td><td>1</td></tr>
<tr><td>Gossip</td><td>Full peer list each time</td><td>2-min diffs, 1-hop forward</td></tr>
<tr><td>Map depth</td><td>2-hop (~5K)</td><td>3-hop (~350K)</td></tr>
<tr><td>Content delivery</td><td>Pull-only (60 min)</td><td>3 layers: push + pull + file propagation</td></tr>
<tr><td>File storage</td><td>Not managed</td><td>Priority-based with keep formula</td></tr>
<tr><td>Worm coverage/hop</td><td>~2M</td><td>~25M</td></tr>
<tr><td>Daily bandwidth</td><td>~318 MB</td><td>~110-170 MB</td></tr>
<tr><td>Popular author scale</td><td>Author pushes to all (O(N) work)</td><td>File layer propagates (O(log N) time)</td></tr>
<tr><td>First-contact latency</td><td>10-30 seconds</td><td>1-5 seconds</td></tr>
</table>
<div class="feedback" contenteditable="true" data-id="v2-comparison" data-label="Your thoughts on the improvements"></div>
</div>
</details>
<details>
<summary>6.3 Anchor Node Costs</summary>
<div class="content">
<p>A well-connected anchor (listed by 10,000 users as profile anchor):</p>
<table>
<tr><th>Activity</th><th>Per day</th></tr>
<tr><td>Persistent connections (~200)</td><td>~30 MB RAM</td></tr>
<tr><td>Gossip diffs (200 peers)</td><td>~10 MB</td></tr>
<tr><td>Map storage (larger map)</td><td>~50 MB disk</td></tr>
<tr><td>Worm forwarding (~100/hr)</td><td>~5 MB</td></tr>
<tr><td>Address lookups (~500/hr)</td><td>~2 MB</td></tr>
<tr><td>Content relay</td><td>~100 MB</td></tr>
<tr><td><strong>Total</strong></td><td><strong>~170 MB/day, ~80 MB RAM</strong></td></tr>
</table>
<p>A $5/month VPS handles this comfortably.</p>
<div class="feedback" contenteditable="true" data-id="v2-anchor-costs" data-label="Your thoughts on anchor economics"></div>
</div>
</details>
</div>
</details>
<!-- ============================================================ -->
<!-- SECTION 7: BOOTSTRAP -->
<!-- ============================================================ -->
<details>
<summary>7. Bootstrap — Entering the Network</summary>
<div class="content">
<div class="sequence">New node, first launch:
1. Read bootstrap anchors from anchors.json (shipped with app)
2. Connect to 1-2 bootstrap anchors
3. Exchange Layer 1 maps (InitialMapSync) — learn ~350K NodeIds
4. Begin wide peer selection from learned nodes
5. Connect to 20 wide peers
6. Fill social peer slots based on follow list
7. Worm search for followed users not yet found
8. Within ~10 minutes: fully operational with 101 connections</div>
<div class="callout callout-idea">
<div class="label">Lightweight Bootstrap (Future)</div>
<p>New nodes don't need full map exchange. "I'm new, give me 200 diverse peers" (~15 KB response).
Connect to received peers, build maps via normal gossip. Reduces anchor load from ~11 MB to ~15 KB per new node.</p>
</div>
<div class="feedback" contenteditable="true" data-id="v2-bootstrap" data-label="Your thoughts on bootstrap"></div>
</div>
</details>
<!-- ============================================================ -->
<!-- SECTION 8: IMPLEMENTATION ORDER -->
<!-- ============================================================ -->
<details>
<summary>8. Implementation Order</summary>
<div class="content">
<h3>Phase 1: Foundation</h3>
<ol>
<li>Single ALPN (<code>distsoc/2</code>) with message type multiplexing</li>
<li>Persistent connection manager (81 social + 20 wide slots)</li>
<li><code>discovery_map</code> table (Layer 1)</li>
<li>1-hop map population from persistent connections</li>
</ol>
<h3>Phase 2: Layer 1 Gossip + Worm</h3>
<ol start="5">
<li><code>RoutingDiff</code> and 2-min gossip cycle</li>
<li>2-hop + 3-hop derivation from diffs</li>
<li>Wide peer diversity scoring</li>
<li>Worm v2 with fan-out</li>
<li>Address resolution chain</li>
</ol>
<h3>Phase 3: Layer 2 File Storage</h3>
<ol start="10">
<li><code>stored_files</code> + <code>author_recent_posts</code> tables</li>
<li>File keep priority calculation + eviction</li>
<li><code>author_recent_posts</code> update propagation</li>
<li>File authority chain routing</li>
<li>Post request via file layer (fetch from any holder)</li>
</ol>
<h3>Phase 4: Layer 3 Social Routing</h3>
<ol start="15">
<li><code>social_routes</code> table</li>
<li>Follow pull via cached routes</li>
<li>Audience push via cached routes + worm fallback</li>
<li>Route maintenance</li>
</ol>
<h3>Phase 5: Integration + Optimization</h3>
<ol start="19">
<li>Popular author file-chain propagation</li>
<li>Lazy 3-hop streaming on connection</li>
<li>Mobile mode (15 conns, smaller maps)</li>
<li>Delta sync for content (sequence numbers)</li>
<li>Bloom filter caching (optional)</li>
</ol>
<div class="feedback" contenteditable="true" data-id="v2-impl-order" data-label="Your thoughts on implementation order"></div>
</div>
</details>
<!-- ============================================================ -->
<!-- SECTION 9: OPEN QUESTIONS -->
<!-- ============================================================ -->
<details open>
<summary>9. Open Questions &amp; Decisions Needed</summary>
<div class="content">
<h3>Q1: Peer Eviction Policy</h3>
<p>When all 81 social slots are full and a higher-priority peer comes online, which peer gets dropped?
Need to prevent thrashing (repeatedly connecting/disconnecting borderline peers).</p>
<div class="feedback" contenteditable="true" data-id="v2-q1-eviction" data-label="Your decision on eviction policy"></div>
<h3>Q2: <code>author_recent_posts</code> Authenticity</h3>
<p>The blob is author-signed, but a malicious peer could serve a stale (valid but old) blob.
Include sequence numbers? If you see seq 50 from one peer and seq 45 from another, seq 45 is stale.</p>
<div class="feedback" contenteditable="true" data-id="v2-q2-authenticity" data-label="Your decision on freshness verification"></div>
<h3>Q3: Peer Copy Counting for Keep Priority</h3>
<p>How do we learn <code>peer_copies</code>? Passively from worm responses and file requests.
Exact counting isn't needed — order-of-magnitude is sufficient.</p>
<div class="feedback" contenteditable="true" data-id="v2-q3-peer-copies" data-label="Your decision on copy counting"></div>
<h3>Q4: File Layer Bandwidth</h3>
<p>If every file carries 256 KB of <code>author_recent_posts</code>, that's substantial overhead on small posts.
Compact format (just PostIds at 32 bytes each = ~8000 entries per 256 KB) or fetch separately on demand?</p>
<div class="feedback" contenteditable="true" data-id="v2-q4-file-bandwidth" data-label="Your decision on file metadata format"></div>
<h3>Q5: Storage Quotas / 3x Hosting Rule</h3>
<p>Design spec mentions 3x hosting quota. How does this interact with the keep priority formula?
Quota sets overall budget, priority decides what fills it?</p>
<div class="feedback" contenteditable="true" data-id="v2-q5-quotas" data-label="Your decision on storage quotas"></div>
<h3>Q6: Global Lookup for Isolated Nodes</h3>
<p>Worms + anchors handle most cases. For truly isolated nodes (~0.01% of lookups that fail),
do we need a structured DHT layer? Or is anchor fallback sufficient at scale?</p>
<div class="feedback" contenteditable="true" data-id="v2-q6-global-lookup" data-label="Your decision on global lookup"></div>
<h3>Overall Notes</h3>
<div class="feedback" contenteditable="true" data-id="v2-overall" data-label="Overall thoughts, priorities, or direction"></div>
</div>
</details>
<!-- ============================================================ -->
<!-- SECTION 10: GLOSSARY -->
<!-- ============================================================ -->
<details>
<summary>10. Glossary</summary>
<div class="content">
<table>
<tr><th>Term</th><th>Definition</th></tr>
<tr><td>NodeId</td><td>ed25519 public key (32 bytes, 64 hex chars). Permanent identity.</td></tr>
<tr><td>Connect string</td><td><code>NodeId@host:port</code>. Enough to establish first contact.</td></tr>
<tr><td>Anchor</td><td>Node with stable public address. Network entry point + relay.</td></tr>
<tr><td>Wide peer</td><td>One of 20 peers selected for maximum graph diversity.</td></tr>
<tr><td>Social peer</td><td>One of 81 peers selected by social relationship priority.</td></tr>
<tr><td>3-hop map</td><td><span class="badge badge-l1">L1</span> ~350K NodeIds reachable within 3 hops of this node.</td></tr>
<tr><td>Worm</td><td><span class="badge badge-l1">L1</span> Bounded-depth search with fan-out. ~25M entries checked per hop.</td></tr>
<tr><td>author_recent_posts</td><td><span class="badge badge-l2">L2</span> 256KB signed blob listing author's recent posts. Travels with every stored file.</td></tr>
<tr><td>File authority chain</td><td><span class="badge badge-l2">L2</span> Cached route back to a file's author for freshness updates.</td></tr>
<tr><td>Keep priority</td><td><span class="badge badge-l2">L2</span> Score determining which files to keep vs evict when storage is limited.</td></tr>
<tr><td>Social route</td><td><span class="badge badge-l3">L3</span> Cached working path to a followed user or audience member.</td></tr>
<tr><td>Follow</td><td>Unilateral pull-only. Author doesn't know. Follower bears cost.</td></tr>
<tr><td>Audience</td><td>Consented push. Author knows + approves. Author pushes in real-time.</td></tr>
<tr><td>CEK</td><td>Content Encryption Key. Random per-post, ChaCha20-Poly1305.</td></tr>
<tr><td>RoutingDiff</td><td><span class="badge badge-l1">L1</span> 2-min gossip message. 1-hop + 2-hop changes only.</td></tr>
<tr><td>Push worm</td><td>Worm that delivers content (not just searches). Used for audience push.</td></tr>
<tr><td>Heart</td><td>User endorsement of a post. Affects file keep priority.</td></tr>
<tr><td>Peer copies</td><td>Number of copies of a file within 3-hop range. More copies = lower keep priority.</td></tr>
</table>
</div>
</details>
<!-- ============================================================ -->
<!-- JAVASCRIPT -->
<!-- ============================================================ -->
<div class="status-bar">
<span id="save-status">Feedback auto-saved</span>
<span id="feedback-count">0 feedback entries</span>
</div>
<script>
const STORAGE_KEY = 'distsoc-design-review-v2-feedback';
function loadFeedback() {
const saved = localStorage.getItem(STORAGE_KEY);
if (!saved) return;
try {
const data = JSON.parse(saved);
document.querySelectorAll('.feedback[data-id]').forEach(el => {
const id = el.getAttribute('data-id');
if (data[id]) el.innerHTML = data[id];
});
updateCount();
} catch (e) { console.warn('Failed to load feedback:', e); }
}
function saveFeedback() {
const data = {};
document.querySelectorAll('.feedback[data-id]').forEach(el => {
const id = el.getAttribute('data-id');
const content = el.innerHTML.trim();
if (content) data[id] = content;
});
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
document.getElementById('save-status').textContent = 'Saved ' + new Date().toLocaleTimeString();
updateCount();
}
function updateCount() {
const saved = localStorage.getItem(STORAGE_KEY);
const count = saved ? Object.keys(JSON.parse(saved)).length : 0;
document.getElementById('feedback-count').textContent = count + ' feedback entr' + (count === 1 ? 'y' : 'ies');
}
document.addEventListener('input', e => { if (e.target.classList.contains('feedback')) saveFeedback(); });
document.addEventListener('DOMContentLoaded', loadFeedback);
function toggleAll(open) { document.querySelectorAll('details').forEach(d => d.open = open); }
function exportDoc() {
const clone = document.documentElement.cloneNode(true);
const blob = new Blob(['<!DOCTYPE html>\n' + clone.outerHTML], { type: 'text/html' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'distsoc-v2-design-review-annotated.html';
a.click();
URL.revokeObjectURL(url);
}
function clearFeedback() {
if (!confirm('Clear all feedback? This cannot be undone.')) return;
localStorage.removeItem(STORAGE_KEY);
document.querySelectorAll('.feedback[data-id]').forEach(el => el.innerHTML = '');
updateCount();
document.getElementById('save-status').textContent = 'Feedback cleared';
}
</script>
</body>
</html>