itsgoin/website/design.html
Scott Reimers e756fabb11 feat: v0.8 Iteration C — narrow mesh, N1-N4 uniques index, anchor convection
Topology rework. The mesh's job is width; depth now comes from knowledge.

Slots:
- Local/Wide collapse into ONE mesh pool: 20 desktop / 15 mobile
- Temp referral slots +4..+10 ABOVE the cap (10 desktop / 4 mobile), carry no
  knowledge exchange, graduate into a freed mesh slot or expire (5 min TTL),
  never evict an established mesh peer
- Fixes the Wide-never-fills bug: outbound paths hardcoded Local while the
  growth gate counted Local only, so growth stopped at 71 and 20 slots were
  inbound-only. Also closes register_connection paths that enforced no cap.

Knowledge — two-pool uniques announce (replaces N1/N2/N3 share lists):
- Pool 1 (forwardable): our N0-N2 uniques, deduped. Receiver stores shifted one
  bounce deeper as N1-N3, reporter-tagged.
- Pool 2 (terminal): our N3, deduped against pool 1. Receiver stores as N4,
  USED for search/resolution but NEVER re-announced. No N5 anywhere.
- Uniques merge mesh peers + social directs + CDN file authors + holder peers,
  so receivers cannot tell how we know an ID
- Anchor entries carry addresses; every other entry is a bare ID. The pools
  double as the anchor directory.
- Dedup at both store and announce; per-bounce caps; 2 MB payload ceiling
- Device-tiered depth: mobile holds 3 bounces and drops the terminal pool

Anchor convection (register loop retired — 0xC0 AnchorRegister deleted):
- Rolling in-memory caller window (no registration DB): a caller gets the 2
  most recent viable prior callers, its address goes to the next 2, then
  rotates out; still-connected callers preferred; RelayIntroduce coordinated
  when both ends are live
- Request classes: entry (<2 connections) always served; top-up refused
  cheaply under load, and the refusal feeds the adaptive weights
- Stochastic per-disconnect action {nothing | anchor intro | mesh intro} with
  weights from local anchor density + refusal feedback. No thresholds, no
  jitter timer. Recovery (mesh < 2) stays immediate and non-stochastic.
- Anchors mined from the uniques pools incl. retained pools of disconnected
  peers; known_anchors demoted to a bootstrap cache

Security fixes from review: remote address poisoning (hearsay anchors could be
dialled — known_anchors/is_anchor now written only from completed handshakes),
N4 leaking back into announcements via the anchor mirror (infinite propagation),
missing payload size caps, unbounded candidate scoring, and a bulk SQLite write
held under the ConnectionManager mutex.

deploy.sh: version regex now captures pre-release suffixes (0.8.0-alpha would
have truncated to 0.8.0 and broken every artifact path), announce channel
derives from the suffix, and the anchor runs --publish-registry at genesis.

228 core tests; a3 integration 9/9; new c_topology_test.sh 33/33 (both
independently re-run). EDM corpse, session-relay gating, Iteration A/B intact.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGiPD2cF75mnvneSCjdDC5
2026-07-30 13:03:12 -04:00

2238 lines
288 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>Design Document — ItsGoin</title>
<meta name="description" content="Full design document for ItsGoin: vision, architecture, protocol, encryption, content distribution, and roadmap. Version 0.8 target design.">
<link rel="stylesheet" href="style.css">
<style>
.toc { margin: 1.5rem 0; }
.toc a { display: block; padding: 0.3rem 0; color: var(--text-muted); font-size: 0.9rem; }
.toc a:hover { color: var(--text); text-decoration: none; }
.scorecard td:first-child { font-weight: 500; color: var(--text); }
.version-badge {
display: inline-block;
background: var(--accent-dim);
color: var(--accent);
padding: 0.3rem 0.8rem;
border-radius: 6px;
font-size: 0.85rem;
font-weight: 600;
margin-bottom: 0.75rem;
}
/* Third status badge for the v0.8 target doc: built today, but differently — migration required.
Complements .badge-complete (Implemented) and .badge-planned (Planned) in style.css. */
.badge-rework { background: var(--amber-dim); color: var(--amber); }
</style>
</head>
<body>
<nav>
<a href="index.html" class="logo">ItsGoin</a>
<button class="menu-toggle" onclick="this.parentElement.querySelector('.links').classList.toggle('open')" aria-label="Menu">&#9776;</button>
<div class="links">
<a href="index.html">About</a>
<a href="tech.html">How It Works</a>
<a href="design.html" class="active">Design</a>
<a href="download.html">Download</a>
<a href="contribute.html">Contribute</a>
<a href="https://discord.gg/pCjMbY9PmN">Discord</a>
</div>
</nav>
<div class="container wide">
<section>
<span class="version-badge">v0.8-design &mdash; 2026-07-29</span>
<h1 style="font-size: 2rem; font-weight: 800; letter-spacing: -0.03em; margin-bottom: 0.5rem;">Design Document</h1>
<p>This is the canonical technical reference for ItsGoin. It describes the vision, the architecture, and the target state of every subsystem &mdash; with full implementation detail. This edition describes the <strong>v0.8 target design</strong> as one coherent architecture; status badges &mdash; <span class="badge badge-complete">Implemented</span> <span class="badge badge-rework">Rework</span> <span class="badge badge-planned">Planned</span> &mdash; mark where the code has and hasn't caught up. See the <a href="download.html">download page</a> for the release changelog.</p>
</section>
<section>
<div class="toc">
<strong style="font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-muted);">Contents</strong>
<a href="#vision">1. The Vision</a>
<a href="#identity">2. Identity &amp; Bootstrap</a>
<a href="#anchors">3. Anchors &amp; Connection Convection</a>
<a href="#connections">4. Connections &amp; Slot Architecture</a>
<a href="#lifecycle">5. Connection Lifecycle</a>
<a href="#layers">6. Network Knowledge: N1&ndash;N4 Uniques</a>
<a href="#three-layers">7. Three-Layer Architecture (Mesh / Social / File)</a>
<a href="#social-routing">8. Social Routing</a>
<a href="#relay">9. Relay &amp; NAT Traversal</a>
<a href="#upnp">10. Port Mapping &mdash; UPnP-IGD + NAT-PMP + PCP</a>
<a href="#lan">11. LAN Discovery</a>
<a href="#worm">12. Worm Search</a>
<a href="#keep-alive">13. Update Cadence &amp; Keep-Alive</a>
<a href="#content">14. Content Propagation</a>
<a href="#files">15. Files &amp; Storage</a>
<a href="#erasure-cdn">16. Erasure-Coded CDN Replication</a>
<a href="#sync">17. Sync Protocol</a>
<a href="#encryption">18. Encryption</a>
<a href="#fof">19. Friend-of-Friend Visibility</a>
<a href="#deletes">20. Delete Propagation &amp; Comment Expiry</a>
<a href="#privacy">21. Social Graph Privacy &amp; Traffic Shaping</a>
<a href="#multidevice">22. Identity Management</a>
<a href="#phase2">23. Reciprocity (Reconsidered)</a>
<a href="#identity-architecture">24. Identity Architecture</a>
<a href="#http-delivery">25. HTTP Post Delivery</a>
<a href="#share-links">26. Share Links</a>
<a href="#discovery">27. Discovery &amp; First Contact</a>
<a href="#directory">28. Directory Trust Layer (Future)</a>
<a href="#timeouts">Appendix A: Timeout &amp; Interval Reference</a>
<a href="#constraints">Appendix B: Design Constraints</a>
<a href="#scorecard">Appendix C: Implementation Scorecard</a>
<a href="#roadmap">Appendix D: Roadmap &mdash; v0.8 Critical Path</a>
<a href="#filemap">Appendix E: File Map</a>
</div>
</section>
<section id="vision">
<h2>1. The Vision</h2>
<div class="card">
<p><em>"A decentralized fetch-cache-re-serve content network that supports public and private sharing without a central server. It replaces 'upload to a platform' with 'publish into a swarm' where attention creates distribution, privacy is client-side encryption, and availability comes from caching, not money."</em></p>
</div>
<p><strong>The honest promise</strong>: The CDN is an attention-driven delivery amplifier, not a storage guarantee. Hot content spreads naturally through demand; cold content decays unless intentionally hosted. Authors are responsible for their own content durability &mdash; a post backup/export tool is the author's safety net, not the network's job. The system is a loss-risk network &mdash; best-effort availability, not durability guarantees.</p>
<h3>Guiding principles</h3>
<ul style="padding-left: 1.25rem; margin: 0.5rem 0; color: var(--text-muted);">
<li>Our distributed network first, direct connections always preferred</li>
<li>Social graph and friendly UX in front, infrastructure truth in back</li>
<li>Privacy by design: public profile is minimal, private profiles are per-circle, social graph visibility is controlled, and traffic patterns are shaped so observers cannot tell where content originates</li>
<li>Don't break content addressing (<code>PostId = BLAKE3(post)</code>, visibility is separate metadata)</li>
<li>Your feed is yours: reverse-chronological by default, no algorithmic ranking, user-controlled discovery</li>
<li>Narrow mesh, deep knowledge: the mesh provides <strong>width</strong> (~20 diverse random connections), network knowledge provides <strong>reach</strong> (uniques known to 4 bounces), and the CDN provides <strong>depth</strong> (content storage and distribution). See <a href="#three-layers">the three-layer architecture</a>.</li>
</ul>
</section>
<!-- Identity & Bootstrap -->
<section id="identity">
<h2>2. Identity &amp; Bootstrap</h2>
<h3>First startup</h3>
<ol style="padding-left: 1.25rem; margin: 0.5rem 0; color: var(--text-muted);">
<li><strong>Identity</strong>: Load or generate ed25519 keypair from <code>{data_dir}/identity.key</code>. <code>NodeId</code> = 32-byte public key. This is the <strong>network</strong> identity only &mdash; posting personas use separate keys (see <a href="#identity-architecture">the identity architecture</a>). A unique <strong>device identity</strong> is also generated for multi-device coordination (see <a href="#multidevice">multi-device</a>).</li>
<li><strong>Storage</strong>: Open SQLite database (<code>distsoc.db</code>), auto-migrate schema.</li>
<li><strong>Blob store</strong>: Create <code>{data_dir}/blobs/</code> with 256 hex-prefix shards (<code>00/</code> through <code>ff/</code>).</li>
<li><strong>Port mapping</strong>: Attempt UPnP-IGD + NAT-PMP + PCP via the <code>portmapper</code> crate (parallel; first router response wins; ~3s wait). PCP adds IPv6 firewall pinholes and works on iOS without the multicast entitlement. Auto-renewal is internal to portmapper. On Android, WiFi/Ethernet links acquire a <code>WifiManager.MulticastLock</code> for the lifetime of the mapping (cellular skipped). See <a href="#upnp">port mapping</a>.</li>
<li><strong>NAT type detection</strong>: STUN probes to two public servers (3s timeout each). Classifies as Public/Easy/Hard/Unknown. Port-mapping success overrides to Public. Anchors skip probing. Result stored on <code>ConnectionManager</code>, shared in <code>InitialExchangePayload</code>, stored per-peer. See <a href="#relay">relay &amp; NAT traversal</a>.</li>
<li><strong>Stale knowledge sweep</strong>: Remove all stored network-knowledge entries tagged to peers not in the current mesh. Clears stale reach data from previous sessions (e.g., unclean shutdown). Today this sweeps the N2/N3 share-list tables; under the target model it sweeps the <a href="#layers">uniques store</a>.</li>
<li><strong>Bootstrap anchors</strong>: Load from <code>{data_dir}/anchors.json</code>. If missing, use hardcoded default anchor.</li>
<li><strong>Bootstrap</strong>: If the node has fewer than 5 connections at startup, probe known anchors &mdash; discovered (non-bootstrap) anchors first, bootstrap anchors only as a fallback phase when every discovered anchor failed. Probes run in batches of 3 (2s stagger between batches, 10s per-anchor timeout, no abort on first success). First success unblocks the bootstrap flow; remaining probes continue in background and fill peer connections naturally. Failed probes to anchors with <code>last_seen &gt; 3 days</code> are immediately pruned from <code>known_anchors</code>. From the connected anchor: run a NAT filter probe, then request <a href="#anchors">convection referrals</a> and begin the growth loop immediately.</li>
</ol>
<h3>Mesh target <span class="badge badge-rework">Rework</span></h3>
<p>The mesh target is <strong>~20 peers</strong> (plus +4&ndash;10 temporary referral slots that carry no knowledge exchange &mdash; see <a href="#connections">connections</a>). Knowledge reach comes from depth (uniques known to 4 bounces, <a href="#layers">N1&ndash;N4</a>), not from connection count.</p>
<div class="note">
<strong>v0.8 change</strong>: Replaces the 101-peer mesh (10 preferred / 71 local / 20 wide on desktop). That width was designed for the pre-CDN push-event world; per-client overhead was too high, and the CDN now carries depth. The preferred tier was deleted in Iteration B; current code runs 71 local + 20 wide (91 slots desktop, 12 mobile). Narrowing to the single ~20-slot pool remains target (Roadmap item 4).
</div>
<h3>Startup cycles</h3>
<p>Spawned after bootstrap completes:</p>
<table>
<tr><th>Cycle</th><th>Interval</th><th>Purpose</th><th>Status</th></tr>
<tr><td>Update cadence scheduler</td><td>Descending scale (minutes &rarr; days) per author, keyed on freshness &times; relationship tier, jittered</td><td>CDN update checks + uniques-list refresh; timing shaped to look like uniform network overhead. See <a href="#keep-alive">update cadence</a>.</td><td><span class="badge badge-rework">Rework</span> &mdash; today: 60s pull tick with a 4h stale-author threshold</td></tr>
<tr><td>Routing diff</td><td>120s (2 min)</td><td>Announce network knowledge to mesh peers as a full two-pool uniques snapshot; skipped when the content digest is unchanged</td><td><span class="badge badge-complete">Implemented</span></td></tr>
<tr><td>Rebalance</td><td>600s (10 min)</td><td>Clean dead connections, signal growth</td><td><span class="badge badge-rework">Rework</span> &mdash; preferred-peer Priority&nbsp;0 deleted (Iteration B); remaining priorities still target the wide model</td></tr>
<tr><td>Growth loop</td><td>Reactive (signal-driven on knowledge receipt)</td><td>Fill the single mesh pool toward 20 with the most diverse candidates (fewest reporters wins, shallower bounce breaks ties); secondary peer-finding path alongside convection</td><td><span class="badge badge-complete">Implemented</span></td></tr>
<tr><td>Convection trigger</td><td>Stochastic, per-disconnect</td><td>On each <em>mesh</em> disconnect (temp referral expiry does not count), random choice: do nothing / ask a random known anchor for an introduction / ask a mesh peer. See <a href="#anchors">anchors</a>.</td><td><span class="badge badge-complete">Implemented</span></td></tr>
<tr><td>Recovery loop</td><td>Reactive (mesh drops below 2)</td><td>Emergency reconnect: mine retained uniques pools for anchor addresses, then <code>known_anchors</code> cache, then any connected anchor peers. Non-stochastic &mdash; below 2 it always acts.</td><td><span class="badge badge-complete">Implemented</span></td></tr>
<tr><td>Anchor reachability watcher</td><td>Event-driven (portmapper watch channel)</td><td>Clears anchor candidacy after &gt;5min of no port mapping; restores it when the mapping comes back. Network roams self-heal without restart. Mobile never auto-anchors.</td><td><span class="badge badge-complete">Implemented</span></td></tr>
</table>
<div class="note">
<strong>v0.8 change</strong>: The 600s anchor register loop is removed. Nodes no longer maintain ongoing registration with anchors; the per-disconnect stochastic convection action replaces it, and mesh churn makes convection callbacks self-sustaining.
</div>
</section>
<!-- Anchors & Connection Convection -->
<section id="anchors">
<h2>3. Anchors &amp; Connection Convection</h2>
<h3>Intent</h3>
<p>An anchor is <strong>any directly-reachable, opted-in node</strong> &mdash; a standard ItsGoin node with a routable address, running the same code with no special protocol. Anchors are the network's entry and re-entry points: they serve <strong>bootstrap</strong> (first startup), <strong>recovery</strong> (mesh loss), and <strong>convection</strong> (topping up under-connected meshes), and they serve public posts over HTTP to browsers (see <a href="#http-delivery">HTTP delivery</a>). Anchors include VPS-deployed nodes (always-on) and any desktop with a working port mapping.</p>
<div class="note">
<strong>v0.8 change</strong>: An anchor is no longer a "well-connected" role &mdash; the old candidacy checklist (mesh &ge; 50 peers, 2h uptime) is gone. Candidacy is pure reachability + opt-in. The anchor's referral service changes from a use-count-tiered registration list to the rolling convection window described below.
</div>
<h3>Candidacy: reachability, not popularity</h3>
<p>A node is a viable anchor when a complete stranger can connect to it directly &mdash; no introduction, no hole punch, no relay. Candidacy has three parts:</p>
<ul style="padding-left: 1.25rem; margin: 0.5rem 0; color: var(--text-muted);">
<li><strong>Reachability watcher</strong> <span class="badge badge-complete">Implemented</span> &mdash; an event-driven watcher on the portmapper's external-address channel. If the mapping disappears for &gt;5 minutes, anchor candidacy is cleared; when the mapping comes back, it is restored. Mobile never auto-anchors (cellular IPs look public but aren't anchorable).</li>
<li><strong>Opt-in</strong> <span class="badge badge-planned">Planned</span> &mdash; anchor service must be explicitly enabled by the operator. Today anchor mode switches on automatically for any reachable desktop; the target adds an opt-in gate (mirroring the session-relay opt-in principle: the nodes most likely to pay for bandwidth get to choose).</li>
<li><strong>Self-verification probe</strong> <span class="badge badge-complete">Implemented</span> &mdash; external confirmation of cold reachability, described below.</li>
</ul>
<h3>Anchor self-verification</h3>
<p>A port mapping or public IPv6 address is a claim, not proof. The node selects a witness from its network knowledge that is NOT a current peer &mdash; a genuine stranger with no prior connection, no cached address, no warm path &mdash; reached via the peer that reported it (<code>AnchorProbeRequest</code>, <code>0xC3</code>). The witness performs a <strong>raw cold QUIC connect</strong> to the candidate's external address (15s timeout), deliberately skipping the entire resolution cascade &mdash; hole punch, introduction, or relay success would be a false positive.</p>
<p><strong>Asymmetric return path</strong>: on success the witness reports back over its fresh direct connection (<code>AnchorProbeResult</code>, <code>0xC4</code>); on failure there is by definition no direct path, so the result routes back through the reporting peer.</p>
<div class="note">
<strong>v0.8 (Iteration C)</strong>: the anchor register cycle the probe used to ride is gone. The probe now rides the <strong>convection loop's 10-minute maintenance tick</strong>. Note the consequence: <code>is_anchor_candidate</code> no longer <em>requires</em> a successful probe &mdash; candidacy is reachability + opt-in, and the probe is confirmation that runs alongside, not a gate in front. Triggering re-probes from the reachability watcher (on mapping acquisition/restoration) and on failed inbound connections remains <span class="badge badge-planned">Planned</span>.
</div>
<h3>Connection convection <span class="badge badge-complete">Implemented</span></h3>
<p>The anchor's referral service is a <strong>rotating referral chain</strong> &mdash; "connection convection." The anchor keeps only a small rolling window of recent callers; there is no peer database and no persistent registration.</p>
<ol style="padding-left: 1.25rem; margin: 0.5rem 0; color: var(--text-muted);">
<li>A caller connects and announces "I'm joining / I need peers."</li>
<li>The anchor returns the addresses of the <strong>2 most recent viable prior callers</strong>.</li>
<li>The caller's own address is handed out to the <strong>next 2 callers</strong>, then rotates out of the window.</li>
</ol>
<p><strong>Window parameters</strong> (previously open, now decided in code): the window holds <strong>16 entries</strong>; each entry is handed to <strong>2</strong> callers before rotating out; an entry older than <strong>15 minutes</strong> is not handed out at all &mdash; a stale address is worse than none, because it costs the next caller a hole-punch timeout. A caller with no routable address is admitted to nothing. Self-reported addresses are a caller's unverified claim: at most 2 are kept, and when the anchor has an observed address for the caller (essentially always over QUIC) self-reported entries are kept only if they share its IP &mdash; a different port is the legitimate endpoint-dependent-mapping case, a different host is not. Only the <em>observed</em> address is ever named in an anchor-initiated introduction; self-reported ones ride the referral payload as a hint the referred peer may try itself.</p>
<p><strong>Sparse-window supplement</strong>: a young anchor with an empty window may fill referrals from its own live mesh peers &mdash; but those peers never called and never offered themselves for redistribution, so the supplement is consent-shaped. It discloses <strong>no address</strong> (the referral carries an empty address list and relies on the coordinated introduction, so the target discloses its own addresses on its own terms), it is used only for peers the anchor can actually introduce, and each supplemented peer carries its own hand-out budget (2 per 15 minutes). Without that budget, unconditional entry-class service plus a reshuffle would be an enumeration oracle over the anchor's entire mesh membership.</p>
<p><strong>Request classes</strong>: convection requests carry a one-bit class &mdash; <em>entry</em> (bootstrap or recovery: fewer than 2 connections) versus <em>top-up</em> (growth with a working mesh). Anchors always serve entry; top-ups are served capacity-permitting and refused <em>cheaply</em> (a single refusal message, no timeout burned), so deprioritizing the non-needy is harmless. The refusal doubles as the load signal the adaptive weights consume. Top-ups are not freeloading: every caller freshens the anchor's referral window, so top-up traffic is the convection medium &mdash; a network where nobody tops up hands its next genuine joiner stale referrals.</p>
<p><strong>Referral quality</strong>: the anchor prefers callers it is still connected to &mdash; and when both the referred peer and the caller are live on the anchor, it coordinates a <code>RelayIntroduce</code> hole-punch introduction between them instead of handing out a cold address. A referred peer lands in whatever slot class is free: a <strong>mesh slot first</strong>, and only the <a href="#connections">temporary referral band</a> above the cap when the mesh is full &mdash; from there it graduates into a freed mesh slot or expires.</p>
<p><strong>Trigger &mdash; stochastic, per-disconnect</strong> <span class="badge badge-complete">Implemented</span>: each time a <strong>mesh</strong> peer disconnects &mdash; a temp referral slot expiring is not mesh churn and does not roll the dice &mdash; the node randomly chooses one of three actions: <em>do nothing</em>, <em>request an introduction from a random known anchor</em> (mined from the uniques pools &mdash; see bookkeeping below), or <em>request an introduction via a connected mesh peer</em> (the growth loop's diversity-scored path &mdash; see <a href="#lifecycle">connection lifecycle</a>). The randomness itself de-synchronizes the network's reaction to shared disconnection events &mdash; no jitter timer, no threshold. Because every caller becomes a referral for the next callers, ordinary churn keeps the chain circulating: convection. With IPv6 making anchors abundant (an estimated 20&ndash;30% of nodes), anchors rebuild the mesh among themselves and with their callers, absorbing even large disconnection events. The action-choice weights are <strong>adaptive, not fixed</strong>. The anchor ratio is locally observable &mdash; anchor entries are the ones carrying addresses in the pools, so anchor density among known uniques is a free statistic needing no census &mdash; and it sets the prior: anchor-rich networks lean anchor-heavy (which also keeps convection windows fresh), anchor-poor lean mesh-ward. Refusals then steer: an anchor refusing a growth request shifts the caller's weights toward mesh introductions and other anchors, drifting back as requests succeed. How many operators will announce addresses versus hide them (or lack public ones) is unknowable before deployment &mdash; the adaptive weights are designed to find that ratio rather than assume it. (The node-ID&ndash;to&ndash;persona ratio stays deliberately unknowable; this statistic never needs it.)</p>
<div class="note">
<strong>Done (Iteration C)</strong>: the older registration model is gone &mdash; <code>0xC0 AnchorRegister</code> and the periodic register cycle are removed along with the in-memory referral list, its tiered use caps and least-used-first selection. The rolling window replaced all of it, and recovery (below 2 mesh peers) stays non-stochastic: it always acts immediately.
</div>
<h3>Anchor bookkeeping <span class="badge badge-complete">Implemented</span></h3>
<p><strong>Target: the uniques pools ARE the anchor directory.</strong> Anchor entries carry addresses in the N0&ndash;N3 pools (see <a href="#layers">Network Knowledge</a>), so a node needing a connection mines its retained pools for a random anchor-flagged entry. Pool knowledge is <em>overwritten memory</em>: a slot's contributed entries are wiped only when a new handshake replaces them &mdash; so a node knocked down from 20 peers to 4 still holds 16 dead peers' pools full of anchor addresses to reconnect through. No separately curated anchor list is needed.</p>
<p>The shipped <code>known_anchors</code> table survives as the <strong>bootstrap cache</strong>, capped at 32 entries and ordered by <code>last_seen_ms</code> (freshest first). Its old <code>success_count</code> ranking solved an anchor-scarcity problem that pool-mined abundance retires. It is written <strong>only for anchors we have actually connected to</strong> &mdash; never from a peer's announce &mdash; which is both what keeps gossip from re-inflating a table the ruling deliberately demoted, and what makes its recency ordering mean something again (a cache refreshed by hearsay would evict the DNS-resolved bootstrap anchor first).</p>
<p>Selection order:</p>
<ol style="padding-left: 1.25rem; margin: 0.5rem 0; color: var(--text-muted);">
<li><strong>Uniques pools</strong> <span class="badge badge-complete">Implemented</span> &mdash; anchor-flagged entries, shallowest and freshest first, including entries from disconnected peers' retained pools.</li>
<li><strong><code>known_anchors</code> bootstrap cache</strong> <span class="badge badge-complete">Implemented</span> &mdash; ordered by <code>last_seen_ms</code> descending. Failed probes to entries not seen for 3 days are deleted on the spot (self-healing against stale data dirs).</li>
<li><strong>Hardcoded default anchor(s)</strong> &mdash; only if everything above is empty or exhausted. A brand-new node hits the hardcoded anchor once on first bootstrap, populates its caches from that session, and the hardcoded list recedes to pure fallback.</li>
</ol>
<h3>When anchors are used</h3>
<ul style="padding-left: 1.25rem; margin: 0.5rem 0; color: var(--text-muted);">
<li><strong>Bootstrap</strong>: First startup with an empty peers table &mdash; batched probing as described in <a href="#identity">Identity &amp; Bootstrap</a>, then a convection referral request.</li>
<li><strong>Recovery</strong>: When the mesh drops below 2 connections. Reconnect via anchor addresses mined from the retained uniques pools, then the <code>known_anchors</code> cache, then any still-connected anchor peers.</li>
<li><strong>Convection top-up</strong> <span class="badge badge-complete">Implemented</span>: the per-disconnect stochastic action described above. This is the ONLY ongoing anchor contact &mdash; there is no periodic registration.</li>
<li><strong>itsgoin.net node</strong>: A permanent, well-connected ItsGoin node runs on itsgoin.net as part of the share-link redirect infrastructure (see <a href="#share-links">share links</a>). It participates as a standard anchor &mdash; it bootstraps new nodes and serves convection referrals &mdash; and is not special-cased in the protocol. Its value comes from permanent uptime, not from any privileged role.</li>
</ul>
<h3>Session fallback for full anchors</h3>
<p>When an anchor's mesh is full, callers fall back to a session connection. The anchor serves convection requests over session connections, not just mesh &mdash; a full mesh never makes an anchor unreachable for referrals.</p>
</section>
<!-- Connections & Slot Architecture -->
<section id="connections">
<h2>4. Connections &amp; Slot Architecture <span class="badge badge-complete">Implemented</span></h2>
<h3>Connection types</h3>
<ul style="padding-left: 1.25rem; margin: 0.5rem 0; color: var(--text-muted);">
<li><strong>Mesh connection</strong> &mdash; long-lived routing slot in a single ~20-slot pool. Structural backbone for search and propagation; participates in the N1&ndash;N4 knowledge system's uniques announce (two pools: forwardable N0&ndash;N2 + terminal N3 &mdash; see <a href="#layers">Network Knowledge</a>). DB table: <code>mesh_peers</code>.</li>
<li><strong>Temp referral connection</strong> <span class="badge badge-complete">Implemented</span> &mdash; short-lived slot above the mesh cap, created by anchor convection or peer introductions. Carries NO knowledge exchange; exists purely to facilitate introductions. Graduates into a mesh slot or expires.</li>
<li><strong>Cadence-driven session</strong> &mdash; connection to a social or file-layer peer that isn't in the mesh pool, opened on demand by the update-cadence cycle and closed when idle (see <a href="#keep-alive">Update Cadence &amp; Keep-Alive</a>). There is no standing keep-alive pool &mdash; only the shaped check cycle recurs.</li>
<li><strong>Session connection</strong> &mdash; short-lived, held open for active interaction (DM conversations, group activity, anchor matchmaking). Tracks <code>remote_addr</code> so a relay can inject observed addresses during introductions.</li>
<li><strong>Ephemeral connection</strong> &mdash; single request/response, no slot allocation.</li>
</ul>
<h3>Slot architecture</h3>
<table>
<tr><th>Slot kind</th><th>Desktop</th><th>Mobile</th><th>Status</th><th>Purpose</th></tr>
<tr><td><strong>Mesh</strong> (single pool)</td><td><strong>20</strong></td><td><strong>15</strong></td><td><span class="badge badge-complete">Implemented</span></td><td>Long-lived routing backbone; every slot equal, filled by growth loop + inbound</td></tr>
<tr><td>Temp referral</td><td>+10</td><td>+4</td><td><span class="badge badge-complete">Implemented</span></td><td>Introduction facilitation above the cap; no knowledge exchange; graduate or expire. The +4..+10 ruling is a range; these are the shipped points in it.</td></tr>
<tr><td>Sessions (interactive)</td><td>20</td><td>5</td><td><span class="badge badge-complete">Implemented</span></td><td>Active DM, group interaction, anchor matchmaking. At capacity, the oldest idle session is evicted for a new one.</td></tr>
<tr><td>Relay pipes</td><td>10</td><td>2</td><td><span class="badge badge-complete">Implemented</span></td><td>Session-relay byte pipes. Serving others is opt-in and default OFF (see <a href="#relay">Relay &amp; NAT Traversal</a>).</td></tr>
</table>
<div class="note">
<strong>v0.8 change</strong>: The Preferred/Local/Wide slot taxonomy (10+71+20 = 101 desktop, 3+7+5 = 15 mobile) is replaced by one flat mesh pool of ~20 slots. Preferred peers are eliminated entirely &mdash; the CDN's <code>file_holders</code> model replaced the N+10 direct push/pull that preferred relationships existed to serve. Depth now comes from knowledge (4 bounces), not from connection count.
</div>
<div class="note">
<strong>Why 20</strong>: the old 101-peer mesh was sized for the pre-CDN push-event world, and its per-client overhead was too high. The mesh's job is <em>width</em> &mdash; diverse random connectivity for searches and post propagation. Knowledge depth (N1&ndash;N4) recovers the reach that raw connection count used to provide. If uniques-list RAM proves excessive at 20&nbsp;peers @ 4&nbsp;bounces, the fallback is fewer peers at 3&nbsp;bounces.
</div>
<h3>MeshConnection struct</h3>
<p>Each mesh connection tracks: <code>node_id</code>, <code>connection</code> (QUIC), <code>remote_addr</code> (captured from Incoming before accept), <code>last_activity</code> (AtomicU64, updated on every stream accept), <code>connected_at</code>. The per-slot <code>slot_kind</code> discriminator (Preferred/Local/Wide) is gone with the pool merge; a small enum distinguishes <code>Mesh</code> from <code>TempReferral { expires_at_ms }</code>. Allocation is mesh-first, then the temp band, then refuse &mdash; nothing in the rule can evict anything, so an established mesh peer is never displaced by an arriving stranger.</p>
<h3>Temp referral slots <span class="badge badge-complete">Implemented</span></h3>
<p>Nodes hold temporary connections <strong>above</strong> the mesh cap &mdash; <strong>+10 on desktop, +4 on mobile</strong>, within the +4..+10 ruling. These exist so that introductions (anchor convection referrals, growth-loop introductions) never fail for want of a free slot, without paying the knowledge-state cost of a full mesh peer:</p>
<ul style="padding-left: 1.25rem; margin: 0.5rem 0; color: var(--text-muted);">
<li><strong>No knowledge exchange</strong> &mdash; temp referral peers do not participate in the uniques announce exchange, in either direction, and the boundary is structural: a temp peer is never written to <code>mesh_peers</code>, which is the table the announce builder reads. The knowledge gate covers the <em>whole</em> initial exchange, not just the uniques pools: a temp referral peer is also sent an empty peer-address list, since up to 10 of our mesh peers with their addresses is knowledge by any reading. On the wire "no knowledge" is an explicit <code>null</code>, not empty vectors, so the peer can tell "nothing to share" from "not sharing".</li>
<li><strong>Graduate or expire</strong> &mdash; when a mesh slot is free, a temp connection can be promoted into it (and only then joins knowledge exchange). Otherwise it expires after its introduction purpose is served.</li>
<li>Graduation requires a free mesh slot &mdash; a temp referral never evicts an established mesh peer.</li>
</ul>
<h3>Mutual mesh blacklist <span class="badge badge-planned">Planned</span></h3>
<p>Targeted two-node stranger relationship. Both nodes opt in, maintaining genuine N2 stranger status indefinitely regardless of growth loop behavior. Stored in a local <code>mesh_blacklist { node_id }</code> table (table does not exist yet).</p>
<ul style="padding-left: 1.25rem; margin: 0.5rem 0; color: var(--text-muted);">
<li>Growth loop skips blacklisted nodes during candidate selection</li>
<li>Incoming mesh upgrade from a blacklisted node &rarr; respond with <code>RefuseRedirect</code> (<code>0x05</code>)</li>
<li>Both nodes must add each other &mdash; asymmetric blacklist is valid but only prevents the blacklisting side from upgrading</li>
<li>Full session/ephemeral interaction still works &mdash; messages, probes, routing participation; blacklisted nodes never consume each other's mesh slots</li>
</ul>
<h3>Mesh-cap override <span class="badge badge-complete">Implemented</span></h3>
<p>Topology control for testing, shipped as the <code>ITSGOIN_TEST_MESH_SLOTS</code> environment override rather than the originally-planned <code>--max-mesh &lt;n&gt;</code> CLI flag. It caps a node's mesh pool below the 20 default so the integration suite can reach the temp-referral boundary with four nodes instead of twenty-one, and so a node can be held permanently in other nodes' N2. Read once into a <code>OnceLock</code> at first use &mdash; the cap cannot change under a running node. Refusals reuse <code>RefuseRedirect</code> (<code>0x05</code>) &mdash; no new protocol machinery. <strong>Testing affordance only.</strong></p>
<h3>Keepalive <span class="badge badge-complete">Implemented</span></h3>
<ul style="padding-left: 1.25rem; margin: 0.5rem 0; color: var(--text-muted);">
<li><strong>Interval</strong>: 30 seconds (<code>MeshKeepalive</code> message, <code>0xE0</code>)</li>
<li><strong>Zombie detection</strong>: no stream activity for 600s (10 min) = zombie, removed in rebalance</li>
<li><code>last_activity</code> updated on every stream accept</li>
</ul>
</section>
<!-- Connection Lifecycle -->
<section id="lifecycle">
<h2>5. Connection Lifecycle</h2>
<h3>Growth Loop (reactive, signal-driven) <span class="badge badge-rework">Rework</span></h3>
<p>There is no periodic growth timer. The loop blocks on a signal channel, coalesces queued signals, then runs connection attempts until slots fill or it backs off. Signals fire on:</p>
<ul style="padding-left: 1.25rem; margin: 0.5rem 0; color: var(--text-muted);">
<li>Routing knowledge receipt with new N1 additions (initial exchange, routing diff)</li>
<li>A disconnect that leaves free mesh slots</li>
<li>Completion of bootstrap/recovery referral connects</li>
<li>Failed auto-reconnect after an unexpected disconnect</li>
<li>Rebalance-cycle backstop (every cycle ends by signaling growth)</li>
</ul>
<div class="note">
<strong>v0.8 change</strong>: growth targets the whole 20-slot pool. (Today the loop stops when the <em>Local</em> sub-pool is full and Wide slots only fill from inbound &mdash; a casualty of the old taxonomy that dies with it.) <span class="badge badge-rework">Rework</span>
</div>
<div class="note">
<strong>v0.8 &mdash; stochastic growth</strong> <span class="badge badge-complete">Implemented</span>: threshold triggers are replaced by a per-disconnect random choice among {do nothing | convection request to a random known anchor | introduction via a connected mesh peer}. The mesh path uses the diversity-scored candidate machinery below; the anchor path defers candidate choice to the anchor's convection window. Randomness replaces jitter for de-synchronization (see <a href="#anchors">Anchors &amp; Connection Convection</a>).
</div>
<p><strong>Candidate selection</strong> (N2 diversity scoring) <span class="badge badge-complete">Implemented</span>:</p>
<pre><code>score = 1.0 / reporter_count + (0.3 if not_in_N3)</code></pre>
<ul style="padding-left: 1.25rem; margin: 0.5rem 0; color: var(--text-muted);">
<li>Fewer reporters = higher diversity = better candidate. Diversity matters <em>more</em> at 20 slots than it did at 101 &mdash; each slot is 5&times; scarcer.</li>
<li>Bonus for peers not already visible in N3 (locally discovered, not transitive)</li>
<li>Sorted descending; already-connected, self, and known-unreachable peers filtered out</li>
<li>Blacklist filter (<span class="badge badge-planned">Planned</span>): skip nodes in <code>mesh_blacklist</code></li>
</ul>
<p><strong>Connection attempt cascade</strong>:</p>
<ol style="padding-left: 1.25rem; margin: 0.5rem 0; color: var(--text-muted);">
<li><strong>Direct connect</strong> (15s timeout) &mdash; stored address, or resolved via <code>AddressRequest</code> to the N2/N3 reporters who announced the candidate</li>
<li><strong>Introduction fallback</strong> &mdash; ask each connected reporter to relay-introduce us (hole-punch coordination)</li>
</ol>
<p><strong>Failure handling</strong>: consecutive-failure counter; after 3 consecutive failures the loop breaks and waits for the next signal. Failed candidates are marked unreachable and skipped in future scoring. After each success the counter resets, a routing diff is broadcast, and the loop pauses 500ms so the new peer's initial exchange can expand the candidate pool before the next pick.</p>
<h3>Rebalance Cycle (every 600s) <span class="badge badge-rework">Rework</span></h3>
<p>Executed in order:</p>
<ol style="padding-left: 1.25rem; margin: 0.5rem 0; color: var(--text-muted);">
<li><strong>Dead connection removal</strong>: connections with <code>close_reason()</code> set, or idle &gt; 600s (zombie)</li>
<li><strong>Stale knowledge pruning</strong>: entries tagged to a disconnected reporter are cleared immediately at disconnect time; the cycle adds an age-based sweep (5 hours) plus social-route watcher pruning (30 days)</li>
<li><strong>Reconnect recently dead</strong>: re-establish dropped mesh connections from stored addresses</li>
<li><strong>Signal growth loop</strong>: backstop signal to fill any remaining free slots</li>
<li><strong>Idle session cleanup</strong>: reap interactive sessions idle &gt; 300s (5 min). Sessions with a reason to live are exempt: anchor-side referral-list peers and client-side sessions to known anchors.</li>
<li><strong>Relay intro dedup pruning</strong>: clear <code>seen_intros</code> entries older than 30s once the map exceeds 500 entries</li>
</ol>
<div class="note">
<strong>v0.8 change</strong>: the cycle's old Priority&nbsp;0 &mdash; preferred-peer reconnection with eviction of low-diversity peers &mdash; is deleted along with preferred peers (<code>MeshPrefer</code>, the 7-day unreachable prune, and the 30-day reconnect watcher all die with it). That was the <em>only</em> eviction path in rebalance, so the target cycle evicts nothing: low diversity alone never disconnects a peer, and temp referrals graduate only into free slots.
</div>
<h3>Recovery Loop (reactive, mesh &lt; 2) <span class="badge badge-rework">Rework</span></h3>
<p><strong>Trigger</strong>: fires when the mesh drops below 2 connections &mdash; the one non-stochastic case: at the edge of isolation the node always acts, immediately.</p>
<ol style="padding-left: 1.25rem; margin: 0.5rem 0; color: var(--text-muted);">
<li>Debounce 2 seconds (let cascading disconnects settle), coalesce queued signals</li>
<li>Gather anchors: mine the retained uniques pools for anchor-flagged addresses (<span class="badge badge-complete">Implemented</span> &mdash; disconnected peers' pools survive as overwritten memory until new handshakes replace them), then the <code>known_anchors</code> bootstrap cache, then anchor-flagged entries in the peers table. Anchors that recently refused us are pushed to the back rather than dropped &mdash; a refusal is a load signal, not a blacklist.</li>
<li>Make a convection call to each anchor in turn: receive referrals, try direct connect to each, fall back to anchor-coordinated hole punch for unreachable referrals (15s per attempt)</li>
<li>Signal the growth loop as soon as any connection lands; stale anchors are pruned per the 3-day rule (see <a href="#anchors">Anchors &amp; Connection Convection</a>)</li>
</ol>
<div class="note">
<strong>v0.8 change</strong>: recovery today sends <code>AnchorRegister</code> and pulls from the anchor's registration-based referral list. The registration step dies with the convection redesign &mdash; the recovery call itself enrolls the caller in the anchor's rolling referral window.
</div>
<h3>Initial Exchange (on every new mesh connection) <span class="badge badge-complete">Implemented</span></h3>
<p>When two nodes connect into mesh slots, each sends an <code>InitialExchange</code> (<code>0x02</code>) carrying:</p>
<ul style="padding-left: 1.25rem; margin: 0.5rem 0; color: var(--text-muted);">
<li><strong>Knowledge share</strong>: N1 NodeIds (mesh peers + social contacts, merged) and deduplicated N2 NodeIds &mdash; no addresses</li>
<li><strong>Profile</strong>: PublicProfile (display name, bio, avatar CID, visibility flag)</li>
<li><strong>Delete records</strong>: signed post deletions</li>
<li><strong>Post IDs</strong>: local post IDs for replica tracking</li>
<li><strong>Peer addresses</strong>: connected peers with addresses, for social routing</li>
<li><strong>NAT profile</strong>: NAT type, mapping behavior (EIM/EDM), filtering behavior &mdash; feeds hole-punch strategy selection</li>
<li><strong>Observed address</strong>: what the sender sees as the receiver's address (STUN-like reflexive discovery)</li>
<li><strong>Anchor address</strong>: the sender's stable advertised address, if it is an anchor</li>
<li><strong>HTTP capability</strong>: whether the sender serves <code>/p/</code> over HTTP, plus its external HTTP address</li>
<li><strong>CDN posture</strong>: device role (intermittent/available/persistent) and cache-pressure score (0&ndash;255)</li>
</ul>
<p><strong>Processing</strong>: their N1 &rarr; our N2 table (tagged to reporter), their N2 &rarr; our N3 table (tagged to reporter). Store profile, apply deletes, record replica overlaps. New N1 knowledge signals the growth loop immediately.</p>
<div class="note">
<strong>v0.8 (Iteration C, done)</strong>: the knowledge-share component is now the <em>uniques announce</em> (two pools: forwardable N0&ndash;N2 + terminal N3; stored N4 is never transmitted), with the receiver storing each entry shifted one bounce deeper, our terminal pool becoming their N4 (see <a href="#layers">Network Knowledge</a>). The handshake's other fields carry forward unchanged, except that a temporary referral slot now sends <code>null</code> uniques and an empty peer-address list. <span class="badge badge-complete">Implemented</span>
</div>
<h3>Incremental Routing Diffs (every 120s + 4h full state) <span class="badge badge-rework">Rework</span></h3>
<p><code>NodeListUpdate</code> (<code>0x01</code>) carries N1 added/removed and N2 added/removed, sent to connected peers on a 120s cycle; every 4 hours a full-state re-broadcast catches missed diffs. Diffs are also pushed eagerly after each growth-loop connection. Receiver processing mirrors the initial exchange: their N1 adds &rarr; our N2 adds, their N2 adds &rarr; our N3 adds; diffs with N1 additions signal the growth loop.</p>
<div class="note">
<strong>v0.8 (Iteration C, done)</strong>: the 2-minute broadcast is now a full uniques announce under the N1&ndash;N4 model, skipped entirely when the content digest is unchanged. Folding its cadence into the update-cadence system's traffic shaping (see <a href="#keep-alive">Update Cadence &amp; Keep-Alive</a>) is <span class="badge badge-planned">Planned</span> for Iteration D.
</div>
</section>
<!-- Network Knowledge: N1N4 Uniques -->
<section id="layers">
<h2>6. Network Knowledge: N1&ndash;N4 Uniques <span class="badge badge-complete">Implemented</span></h2>
<p>Every node maintains a layered index of <strong>uniques</strong> &mdash; IDs it can help other nodes reach. The index spans four bounces (N1&ndash;N4) and is exchanged only between directly-connected mesh peers. Collectively, these per-node indexes form the network's <strong>distributed search index</strong>: a pull (see <a href="#sync">Sync Protocol</a>) is an exchange of uniques lists &mdash; "if you want these IDs, talk to me and I'll help you find them" &mdash; and answering "who can get me to ID X?" is a walk down reporter chains. Content never travels with the index; it lives in the CDN (see <a href="#files">Files &amp; Storage</a>).</p>
<div class="note">
<strong>v0.8 change:</strong> replaces the N1/N2/N3 share-list model (101-peer mesh, 3 bounces, mesh NodeIds only). The shipped code is the seed and survives: N1/N2 shares on connect (<code>build_n1_share</code>/<code>build_n2_share</code>), incremental diffs, reporter-tagged <code>reachable_n2</code>/<code>reachable_n3</code> tables &mdash; extended one bounce deeper and widened from "mesh NodeIds" to all uniques.
</div>
<h3>What is a unique</h3>
<p>A unique is any ID a node has a live path to, regardless of why it knows it:</p>
<ul style="padding-left: 1.25rem; margin: 0.5rem 0; color: var(--text-muted);">
<li><strong>Mesh peers</strong> &mdash; our ~20 direct mesh connections</li>
<li><strong>Directs</strong> &mdash; social contacts we hold routes for (follows, audience, DM partners)</li>
<li><strong>CDN file authors</strong> &mdash; authors of files we hold or recently fetched</li>
<li><strong>CDN file peers</strong> &mdash; holders we exchanged files with</li>
</ul>
<p>Sources are <strong>merged before announcing</strong> &mdash; a receiver cannot tell whether we know an ID from a mesh slot, a friendship, or a file transfer. Anonymous and throwaway IDs (temporary poster IDs in comment chains, greeting senders) are <strong>deliberately kept in the lists</strong>: they are network noise that protects anonymity and future hooks for anonymous encrypted messaging over temp IDs. Comment TTL expiry (see <a href="#deletes">Delete Propagation</a>) retires them naturally &mdash; when the content dies, the throwaway ID falls out of everyone's uniques.</p>
<p><strong>Our own posting identities are never in our own uniques</strong> <span class="badge badge-complete">Implemented</span>. Our node ID &mdash; and, when we are an anchor, our address &mdash; ride slice 0 of the same announce, so listing one of our personas at bounce 1 would be a signed-by-transport persona&rarr;device mapping handed to all ~20 mesh peers. It is also the easiest possible mapping to extract: our own personas are the invariant across every announce we send while everything else churns. Every persona is excluded (not just the default posting ID), as is the author of any comment we wrote locally &mdash; which covers the per-greeting throwaway IDs we mint, where we would otherwise be the first node in the network to announce the ID, at bounce 1, the instant the greeting was created. Our personas stay discoverable through everyone <em>else</em> who holds our content; we simply must not be the reporter. See <a href="#privacy">Graph Privacy</a>.</p>
<p>Temp referral slots (see <a href="#connections">Connections</a>) are the one class of direct connection that does <strong>not</strong> enter the exchange: they carry no knowledge state in either direction until they graduate into mesh slots.</p>
<h3>The four layers</h3>
<table>
<tr><th>Layer</th><th>Bounce</th><th>Source</th><th>Announced?</th><th>Stored in</th></tr>
<tr><td>N1</td><td>1</td><td>Our own uniques (merged from all sources above)</td><td>Yes</td><td>Derived at build time from <code>mesh_peers</code> + <code>social_routes</code> + <code>file_holders</code> + <code>post_replicas</code> + <code>cdn_manifests</code> + <code>comments</code></td></tr>
<tr><td>N2</td><td>2</td><td>Peers' announced N1, tagged to reporter</td><td>Yes</td><td><code>reachable</code>, <code>bounce = 2</code></td></tr>
<tr><td>N3</td><td>3</td><td>Peers' announced N2, tagged to reporter</td><td>Yes</td><td><code>reachable</code>, <code>bounce = 3</code></td></tr>
<tr><td>N4</td><td>4</td><td>Peers' announced N3, tagged to reporter</td><td><strong>Never</strong> &mdash; terminates</td><td><code>reachable</code>, <code>bounce = 4</code></td></tr>
</table>
<p>There is <strong>one</strong> <code>reachable</code> table, keyed <code>(reporter_node_id, reachable_id)</code>, with <code>bounce</code>, <code>id_class</code>, <code>is_anchor</code>, <code>addresses</code> and <code>updated_at</code> columns &mdash; not a table per layer. The composite key gives per-reporter set-merge for free, and the upsert keeps the <em>shallowest</em> bounce ever heard from that reporter, so one ID can never occupy two depths from one source (the ambiguity the old <code>reachable_n2</code>/<code>reachable_n3</code> pair papered over by sorting).</p>
<h3>Announce protocol <span class="badge badge-rework">Rework</span></h3>
<ul style="padding-left: 1.25rem; margin: 0.5rem 0; color: var(--text-muted);">
<li><strong>To/from N1 only.</strong> Uniques are exchanged exclusively with direct mesh peers &mdash; never forwarded on a node's behalf.</li>
<li><strong>Every announce is a full snapshot <span class="badge badge-complete">Implemented</span></strong> (<code>full: true</code>) that replaces the sender's rows wholesale. Incremental seq-numbered diffing across four layers with anchor metadata was rejected as not worth the divergence risk; instead the sender hashes the pools (ignoring <code>seq</code>) and <strong>skips the send entirely when nothing changed</strong>, which recovers most of the saving. Slice trimming is deterministic (anchors first, then by ID) so a stable index yields a stable digest rather than thrashing it.</li>
<li><strong>An announcement carries two explicitly separated pools.</strong> Pool 1 &mdash; the <em>forwardable pool</em>: our N0&ndash;N2 uniques (self + own uniques + two bounces), deduplicated internally. Pool 2 &mdash; the <em>terminal pool</em>: our N3 uniques, deduplicated against pool 1. The receiver stores each entry shifted one bounce deeper (pool 1 &rarr; their N1&ndash;N3, pool 2 &rarr; their N4), all tagged to us as reporter.</li>
<li><strong>The terminal pool is used, never forwarded.</strong> Entries received via pool 2 (stored as N4) may be searched and resolved through, but never appear in the receiver's own announcements. The explicit terminal tag is what stops eternal re-propagation &mdash; some data must be differentiated as use-only, and there is no N5 anywhere in the network. (Carrying stored N4 in the payload was considered and rejected: dedup math put the resulting stored horizon near ~760k entries per node.)</li>
<li><strong>Node-class and author-class IDs travel in separate packed arrays <span class="badge badge-complete">Implemented</span>.</strong> A slice is <code>{ nodes, authors, anchors }</code>: two base64 blobs of concatenated 32-byte IDs plus the address-bearing anchor array. The separation is load-bearing, not cosmetic &mdash; a receiver structurally cannot mistake a persona ID for a connectable node ID (so persona IDs never enter connect/hole-punch paths and never burn punch timeouts), and the anchor array is the only place an address can appear. An author-class entry is emitted as a bare ID even if its stored row carries an anchor flag.</li>
<li><strong>Anchor entries carry addresses; everything else is a bare ID.</strong> There is no point sharing an address that cannot be connected without an introduction, so pool entries carry addresses only when flagged as anchors (directly reachable). The uniques index thereby doubles as the network's <strong>anchor directory</strong> &mdash; and with IPv6 pushing an estimated 20&ndash;30% of nodes into anchor candidacy, every node's pools hold a deep reserve of re-entry addresses. A received anchor entry is an <strong>index row, not an address record</strong>: nothing has proven the address belongs to that node ID, so it stays in the uniques index (where the anchor mine reads it) and never mutates authoritative address state. Only an anchor we have completed a QUIC handshake to &mdash; iroh having proven the node ID owns that path &mdash; is promoted into <code>peers.is_anchor</code> and the <code>known_anchors</code> bootstrap cache. Without that rule a single mesh peer could overwrite our stored address for any node (including the DNS-resolved bootstrap anchor), aim thousands of entries at one victim IP, and have every node one bounce out re-announce them.</li>
<li><strong>Sizes are enforced, not merely budgeted <span class="badge badge-complete">Implemented</span>.</strong> The uniques opcodes read against a dedicated 2&nbsp;MB ceiling rather than the 64&nbsp;MB file-transfer cap; built slices are capped (4,000 shallow / 12,000 terminal); received slices are truncated to a per-bounce accept cap (8,000 at N2 and N3, 24,000 at N4) with a log rather than rejected, so a large-but-honest peer degrades gracefully and a hostile one cannot make us write six figures of rows per announce. A payload whose forwardable pool is not exactly three slices is rejected outright &mdash; nothing may ever be stored past bounce 4.</li>
<li><strong>Addresses are filtered on receipt as well as on send <span class="badge badge-complete">Implemented</span>.</strong> Inbound anchor addresses are normalized (IPv4-mapped IPv6 &rarr; v4) and dropped unless publicly routable; an anchor entry left with no usable address degrades to a bare node entry. Storing a peer-supplied <code>127.0.0.1</code> or RFC1918 address would turn the index into attacker-directed internal probing from our own host &mdash; and every such dial enters iroh's per-endpoint path store permanently.</li>
<li><strong>Disconnect retention <span class="badge badge-complete">Implemented</span>:</strong> when a peer drops, its reported entries are <strong>kept</strong>. Pool knowledge is <em>overwritten memory</em> &mdash; a reporter's rows are replaced (per reporter, per bounce) only when a new handshake from that reporter lands, which is exactly what lets a node knocked down from 20 peers to 4 mine 16 dead peers' pools for anchor addresses to reconnect through (see <a href="#anchors">Anchors</a>). Staleness is bounded by the 5-hour prune, not by disconnection. Wiping on disconnect would empty the index &mdash; and collapse the anchor-density prior that steers the adaptive convection weights &mdash; at precisely the moment the node needs both most.</li>
<li>Refreshes ride the regular update cadence (see <a href="#keep-alive">Update Cadence &amp; Keep-Alive</a>) so index traffic blends into uniform network overhead.</li>
</ul>
<h3>Deduplication</h3>
<p>Dedup happens at <strong>both store and announce</strong>. Store side: incoming uniques are set-merged per reporter &mdash; the same ID reported twice costs one row. Announce side: an ID already announced at a shallower layer is not repeated at deeper layers, so each announcement names each ID at most once, at its closest bounce. Duplication counts are never transmitted (topology leak); dedup keeps the lists to their unique-ID floor.</p>
<h3>Size budget</h3>
<p>Upper bounds at 20 mesh peers, pure fan-out (real graphs overlap heavily and dedup shrinks all of this):</p>
<table>
<tr><th>Layer</th><th>Bound</th><th>Raw size (32-byte IDs)</th></tr>
<tr><td>N1</td><td>~20 mesh + directs + CDN contacts</td><td>&lt; 10&nbsp;KB</td></tr>
<tr><td>N2</td><td>&le; 400</td><td>~13&nbsp;KB</td></tr>
<tr><td>N3</td><td>&le; 8,000</td><td>~256&nbsp;KB</td></tr>
<tr><td>N4</td><td>&le; 160,000+</td><td>~5&nbsp;MB</td></tr>
</table>
<p>Because N1 includes directs and CDN contacts (not just the 20 mesh slots), per-node branching runs above 20 and real N4 lists can exceed the pure-mesh bound &mdash; the "160k+" is a planning floor, not a ceiling. With reporter tags and SQLite index overhead, expect low tens of MB worst-case on disk. That is acceptable on desktop; if list sizes blow past the budget in practice, the depth default shrinks (&lt;20 peers @ 3 bounces was the explicit fallback in the design ruling).</p>
<h3>Device-tiered depth</h3>
<p>Depth is a per-device choice, not a protocol constant. Desktop and anchor nodes hold the full N1&ndash;N4. <strong>Mobile holds 3 bounces</strong> <span class="badge badge-complete">Implemented</span>: it drops the terminal pool on receipt and advertises <code>depth = 3</code> in its announce, so senders skip <em>building</em> the terminal slice for it &mdash; the single biggest bandwidth saving on a cellular link. Mobile still announces its own N3, so it remains a full participant in the network's depth; it only declines to store the terminal layer.</p>
<p><strong>Bloom-compressed N4 <span class="badge badge-planned">Planned</span></strong> was the alternative (~10 bits/entry at 1% false-positive &rarr; ~200&nbsp;KB for 160k entries) and is <em>not</em> implemented. A Bloom N4 would answer the only question N4 exists for &mdash; "can this peer help me reach ID X?" &mdash; without supporting enumeration, which N4 never needs since it is never announced. Dropping is the simpler of the two and is what ships.</p>
<h3>Indexed access</h3>
<p>A node has <strong>indexed access</strong> to a target if the target appears anywhere in its N1&ndash;N4: the target is then resolvable by reporter-chain queries alone, with no worm search or relay introduction. The update-cadence system (see <a href="#keep-alive">Update Cadence &amp; Keep-Alive</a>) uses indexed access to decide whether extra session connections are needed for social and file operations. (This replaces the old "&lt;N4 access" term, which meant "within 3 hops" back when N4 didn't exist as a layer.)</p>
<h3>What is NEVER shared</h3>
<ul style="padding-left: 1.25rem; margin: 0.5rem 0; color: var(--text-muted);">
<li>Addresses for non-anchor entries (anchors' addresses ride the pools by design &mdash; they are self-declared reachable infrastructure; every other ID resolves on-demand via chain queries. Social-routing payloads are the deliberate, consented exception &mdash; see <a href="#social-routing">Social Routing</a> and <a href="#privacy">Graph Privacy</a>)</li>
<li>N4 entries (terminal &mdash; held for search, never re-announced)</li>
<li>Duplication counts (topology leak)</li>
<li>Why we know an ID &mdash; mesh, social, and CDN sources are merged in the announcement</li>
</ul>
<h3>Address resolution cascade (<code>connect_by_node_id</code>) <span class="badge badge-complete">Implemented</span></h3>
<table>
<tr><th>Step</th><th>Method</th><th>Timeout</th><th>Source</th></tr>
<tr><td>0</td><td>Social route cache</td><td>&mdash;</td><td><code>social_routes</code> cached addresses, then known-peer referrals</td></tr>
<tr><td>1</td><td>Peers table</td><td>&mdash;</td><td>Stored address from previous connection</td></tr>
<tr><td>2&ndash;3</td><td>N2/N3 reporter chain</td><td>varies</td><td>Ask the peer who reported the target; N3 chains through the reporter's reporter</td></tr>
<tr><td>4</td><td>Worm search</td><td>bounded</td><td>Point query beyond the index (see <a href="#worm">Worm Search</a>)</td></tr>
<tr><td>5</td><td>Relay introduction</td><td>15s</td><td>Hole punch coordinated via an intermediary (see <a href="#relay">Relay &amp; NAT Traversal</a>)</td></tr>
<tr><td>6</td><td>Session relay</td><td>&mdash;</td><td><strong>Opt-in only, default OFF</strong> on both the serving and using side (see <a href="#relay">Relay &amp; NAT Traversal</a>)</td></tr>
</table>
<p>Peers that fail all direct steps are marked likely-unreachable, so later attempts skip straight to introduction rather than burning timeouts on dead addresses &mdash; but only when a reporter was actually available to ask; failing for want of a connected reporter says nothing about the candidate and must not suppress it. Resolution and growth-candidate scoring now share one horizon (bounces 2&ndash;4), and depth is a <em>graded</em> penalty in the scorer so a bounce-4 sighting can never outrank a bounce-2 one. <strong>The third reporter-chain link &mdash; resolving an N4 hit through the reporter's reporter &mdash; has not landed</strong> <span class="badge badge-planned">Planned</span>: today an N4 entry is resolved by asking its direct reporter, exactly like N2 and N3.</p>
</section>
<!-- Three-Layer Architecture -->
<section id="three-layers">
<h2>7. Three-Layer Architecture (Mesh / Social / File) <span class="badge badge-rework">Rework</span></h2>
<p>The network operates across three layers, each with its own connections, routing, and purpose. The division of labor is: <strong>mesh is width, CDN is depth</strong>. The mesh gives every node a diverse random slice of the network &mdash; enough width that searches terminate and the uniques index covers the graph. The CDN gives content a home and every lookup a concrete target &mdash; updates, messages, and FoF-adjacent content discovery all resolve to "find a holder, fetch from it."</p>
<table>
<tr><th>Layer</th><th>Job</th><th>Connections</th><th>What travels</th></tr>
<tr><td><strong>Mesh</strong></td><td>Width: diverse connectivity, N1&ndash;N4 uniques index, searches, referrals</td><td>~20 mesh slots + 4&ndash;10 temp referral slots (see <a href="#connections">Connections</a>)</td><td>Uniques announcements, worm queries, introduction traffic</td></tr>
<tr><td><strong>Social</strong></td><td>Follows, audience, DMs &mdash; the human relationships</td><td>Social routes + on-demand session connections</td><td>Address updates, check-ins, DM delivery</td></tr>
<tr><td><strong>File</strong></td><td>Depth: content storage and distribution</td><td>Flat <code>file_holders</code>, cap 5 per file (see <a href="#files">Files &amp; Storage</a>) + on-demand fetches</td><td>Posts, blobs, comments, engagement</td></tr>
</table>
<h3>Key principle: the mesh carries the index, the CDN carries the content</h3>
<p>Pulls between mesh peers exchange <strong>uniques lists</strong> &mdash; the search index &mdash; never post bodies or blobs. When the index says an author or file exists, the actual bytes come from a CDN holder via a point fetch. So mesh connections stay cheap and optimizable purely for topology (diversity scoring has no social bias), while content bandwidth concentrates on the flat holder set where replication and eviction manage it.</p>
<div class="note">
<strong>v0.8 change:</strong> replaces the old rule "pull sync never pulls posts from mesh peers." Under uniques pulls, mesh peers are exactly who you pull from &mdash; but what you pull is the index, not content. The old rule's intent (no content flooding over mesh links) survives; the post-carrying pull it was guarding against is retired (see <a href="#sync">Sync Protocol</a>).
</div>
<h3>Cross-layer benefits</h3>
<p>Each layer feeds the others through the uniques index. Social directs and CDN file contacts enter our N1 alongside mesh peers, so a friendship or a file transfer improves everyone's reach &mdash; a follow can provide the N2 entry that helps a stranger's chain query terminate, and a mesh peer can provide the reporter chain that reconnects two friends after an address change. Because sources are merged before announcing, this sharing costs no relationship privacy.</p>
</section>
<!-- Social Routing -->
<section id="social-routing">
<h2>8. Social Routing <span class="badge badge-complete">Implemented</span></h2>
<p>Caches addresses for follows and audience members, separate from mesh connections. Social contacts are consented relationships, so &mdash; unlike the uniques exchange &mdash; these payloads <strong>do</strong> carry addresses: that is the point of the layer (see <a href="#privacy">Graph Privacy</a> for the exact scoping). Every social route is also an N1 unique, announced source-merged like any other.</p>
<h3><code>social_routes</code> table</h3>
<table>
<tr><th>Field</th><th>Purpose</th></tr>
<tr><td><code>node_id</code></td><td>The social contact's NodeId</td></tr>
<tr><td><code>addresses</code></td><td>Their known IP addresses</td></tr>
<tr><td><code>peer_addresses</code></td><td>Known-peer referral addresses (PeerWithAddress list) &mdash; peers of theirs we can ask when their own addresses go stale</td></tr>
<tr><td><code>relation</code></td><td>Follow / Audience / Mutual</td></tr>
<tr><td><code>status</code></td><td>Online / Disconnected</td></tr>
<tr><td><code>last_connected_ms</code></td><td>When we last connected</td></tr>
<tr><td><code>last_seen_ms</code></td><td>When we last heard anything about them</td></tr>
<tr><td><code>reach_method</code></td><td>Direct / Relay / Indirect</td></tr>
</table>
<div class="note">
<strong>v0.8 change:</strong> the <code>preferred_tree</code> column and all N+10 semantics are retired with preferred peers (see <a href="#connections">Connections</a>). <code>peer_addresses</code> survives as plain known-peer referrals with no preferred-tier meaning.
</div>
<h3>Wire messages <span class="badge badge-complete">Implemented</span></h3>
<table>
<tr><th>Code</th><th>Name</th><th>Stream</th><th>Purpose</th></tr>
<tr><td><code>0x70</code></td><td>SocialAddressUpdate</td><td>Uni</td><td>Sent when a social contact's address changes or they reconnect</td></tr>
<tr><td><code>0x71</code></td><td>SocialDisconnectNotice</td><td>Uni</td><td>Removed in v0.8 &mdash; zero senders existed; social-route staleness is inferred from checkin timeouts</td></tr>
<tr><td><code>0x72</code></td><td>SocialCheckin</td><td>Bi</td><td>Keepalive with address + known-peer referral updates</td></tr>
</table>
<h3>Reconnect watchers <span class="badge badge-complete">Implemented</span></h3>
<p><code>reconnect_watchers</code> table: when peer A asks about disconnected peer B, A is registered as a watcher. When B reconnects, A gets a <code>SocialAddressUpdate</code> notification and the watch is cleared. Watchers are pruned after 30 days.</p>
<h3>Social route lifecycle</h3>
<ul style="padding-left: 1.25rem; margin: 0.5rem 0; color: var(--text-muted);">
<li><strong>Follow</strong> &rarr; store their addresses + referrals, upgrade to Mutual (if audience)</li>
<li><strong>Unfollow</strong> &rarr; downgrade/remove</li>
<li><strong>Approve audience</strong> &rarr; Mutual/Audience</li>
</ul>
<h3>Role under the v0.8 model</h3>
<p>Social routing is the warm path for the people you actually talk to: step 0 of the address resolution cascade, the delivery route for DMs, and the freshness signal that drives the top tiers of the update cadence (see <a href="#keep-alive">Update Cadence &amp; Keep-Alive</a> &mdash; messaged, followed, and vouched relationships check most often). Content from social contacts still arrives via the CDN like everything else; the social layer only carries the addresses and the relationship state.</p>
</section>
<!-- Relay & NAT Traversal -->
<section id="relay">
<h2>9. Relay &amp; NAT Traversal <span class="badge badge-complete">Implemented</span></h2>
<p>How a node reaches a peer behind NAT: a mutual contact performs <em>introduction</em> (signaling only), then both sides hole punch. Relaying actual session bytes through a third party is a separate, opt-in facility (see below) &mdash; introduction and hole punching are always on.</p>
<h3>Relay selection (<code>find_relays_for</code>) <span class="badge badge-rework">Rework</span></h3>
<p>Find up to 3 relay candidates from knowledge-layer reporters, prioritized:</p>
<ol style="padding-left: 1.25rem; margin: 0.5rem 0; color: var(--text-muted);">
<li><strong>N2 reporters</strong>: Our mesh peers who announced the target among their own uniques. They are connected to the target right now (or were very recently). TTL=0.</li>
<li><strong>N3 reporters</strong>: Peers whose announced knowledge places the target two hops away. TTL=1 &mdash; the relay chains the introduction through its own peer.</li>
<li><strong>Deep uniques reporters</strong>: With N1&ndash;N4 knowledge (see <a href="#layers">Network Knowledge</a>), the uniques index names which mesh peer "can help reach" the target even when the target sits three or four bounces out. The introduction chains along the announce path, decrementing TTL at each hop (max TTL=2).</li>
</ol>
<div class="note">
<strong>v0.8 change</strong>: the two <em>preferred-tree</em> candidate tiers (intersecting the target's ~100-node <code>preferred_tree</code> with our connections) are removed along with preferred peers. <strong>Done (Iteration B)</strong>: the tiers were deleted from <code>find_relays_for</code> (<code>connection.rs</code>); code now keeps only knowledge-layer reporters.
</div>
<h3>RelayIntroduce flow (<code>0xB0</code>/<code>0xB1</code>)</h3>
<ol style="padding-left: 1.25rem; margin: 0.5rem 0; color: var(--text-muted);">
<li><strong>Requester</strong> &rarr; opens bi-stream to relay, sends <code>RelayIntroduce { target, requester, requester_addresses, ttl }</code></li>
<li><strong>Relay</strong> handles three cases:
<ul style="margin-top: 0.3rem;">
<li><strong>We ARE the target</strong>: Return our addresses, spawn hole punch to requester</li>
<li><strong>Target is our mesh or session peer</strong>: Forward request to target on new bi-stream, relay response back. Inject observed public addresses for both parties (session peers carry <code>remote_addr</code> from their inbound connection).</li>
<li><strong>TTL > 0 and target in our N2</strong>: Forward to the reporter with TTL-1 (chain forwarding, max TTL=2)</li>
</ul>
</li>
<li><strong>Requester</strong> receives <code>RelayIntroduceResult { target_addresses, relay_available }</code>, then:
<ul style="margin-top: 0.3rem;">
<li><code>hole_punch_parallel()</code>: Try all returned addresses in parallel, retry every 2s, 30s total timeout</li>
<li>If hole punch fails, <code>relay_available</code> is true, <strong>and</strong> the local user has enabled session relay: open <code>SessionRelay</code> (<code>0xB2</code>) pipe through the intermediary</li>
</ul>
</li>
</ol>
<p>Anchors also perform introduction as part of connection convection: when a convection caller and a referred peer are both live on the anchor, the anchor coordinates <code>RelayIntroduce</code> between them (see <a href="#anchors">Anchors</a>).</p>
<h3>Session relay (relay pipes)</h3>
<p>Intermediary splices bi-streams between requester and target. Desktop: max 10 concurrent pipes. Mobile: max 2. Each pipe has a 50MB byte cap and 2-min idle timeout.</p>
<div class="note">
<strong>Session relay is OPT-IN ONLY and DISABLED BY DEFAULT</strong> &mdash; including for anchor-mode nodes (servers are most likely to pay for bandwidth). Gated by the <code>relay.session_relay_enabled</code> setting on both <em>serving</em> (<code>can_accept_relay_pipe</code>) and <em>using</em> (the auto-fallback after a failed hole punch). Settings UI exposes the toggle. Hole-punch-failure never silently routes a peer-to-peer session through an unrelated third party's bandwidth.
</div>
<p style="color: var(--text-muted); font-size: 0.85rem;">This rule covers <em>only</em> full byte-piping. Small relay-style signaling/discovery &mdash; <code>RelayIntroduce</code> for hole-punch coordination, <code>worm_lookup</code> multi-hop search, uniques-list announce exchange &mdash; remains always-on; that's not session relay. The anchor's HTTP proxy path (anchor fetches a post via QUIC and serves it back over HTTP) is also not session relay &mdash; it's the anchor doing its own QUIC fetch on the browser's behalf.</p>
<h3>Deduplication &amp; cooldowns</h3>
<table>
<tr><th>Mechanism</th><th>Window</th><th>Purpose</th></tr>
<tr><td><code>seen_intros</code></td><td>30s</td><td>Prevents forwarding loops</td></tr>
<tr><td><code>relay_cooldowns</code></td><td>5 min per target</td><td>Prevents relay spamming</td></tr>
</table>
<h3>Hole punch mechanics</h3>
<p>Both sides filter self-reported addresses to publicly-routable only (no Docker bridge, VPN, or LAN IPs) and prepend the port-mapped external address if available. The relay injects each party's observed public address (from the QUIC connection) at the front of the list. All paths use <code>hole_punch_parallel()</code>: parse returned addresses into QUIC <code>EndpointAddr</code>, spawn parallel connect attempts to every address simultaneously. Each attempt: 2s timeout, retried until 30s total deadline. First successful connection wins.</p>
<h3>NAT type detection <span class="badge badge-complete">Implemented</span> (interim: public STUN servers)</h3>
<p>On startup, each node classifies its NAT type as one of four categories:</p>
<ul style="padding-left: 1.25rem; margin: 0.5rem 0; color: var(--text-muted);">
<li><strong>Public</strong> &mdash; observed address matches local, or port-mapped. Directly reachable.</li>
<li><strong>Easy</strong> &mdash; same mapped port from multiple probes (endpoint-independent mapping / cone NAT). Hole punch will likely succeed.</li>
<li><strong>Hard</strong> &mdash; different mapped ports per destination (symmetric / address-dependent mapping). Port is unpredictable.</li>
<li><strong>Unknown</strong> &mdash; detection failed or not yet run.</li>
</ul>
<h4>Current implementation (interim)</h4>
<p>Raw STUN Binding Requests (20 bytes, no crate dependency) sent to <code>stun.l.google.com:19302</code> and <code>stun.cloudflare.com:3478</code> from a single UDP socket. XOR-MAPPED-ADDRESS parsed from each response (IPv4 + IPv6 supported). Comparison: same mapped port = Easy, different = Hard, matches local = Public. 3s timeout per server. Port-mapping success overrides to Public. Anchors skip probing entirely (already Public).</p>
<h4>Target design (multi-anchor STUN) <span class="badge badge-planned">Planned</span></h4>
<p>When the network has enough anchors, replace public STUN servers with anchor-reported <code>your_observed_addr</code> from InitialExchange. Connecting to <strong>two or more anchors</strong> at different public IPs provides the same classification without external dependencies. Anchor convection makes this cheap: the convection trigger already contacts anchors periodically.</p>
<h4>NAT type sharing</h4>
<p>NAT type is included as a string field (<code>"public"</code>/<code>"easy"</code>/<code>"hard"</code>/<code>"unknown"</code>) in <code>InitialExchangePayload</code>. Stored per-peer in the <code>peers</code> table (<code>nat_type TEXT</code> column). Available for hole punch decisions before any connection attempt.</p>
<h4>Hole punch strategy</h4>
<table>
<tr><th>Peer A</th><th>Peer B</th><th>Strategy</th></tr>
<tr><td>Public / Easy</td><td>Any</td><td>Hole punch (likely success)</td></tr>
<tr><td>Hard NAT</td><td>Easy NAT</td><td>Hole punch (B's port is predictable)</td></tr>
<tr><td>Hard NAT</td><td>Hard NAT</td><td><strong>Port scanning</strong> &mdash; <code>hole_punch_with_scanning()</code> tries standard punch first, then escalates to tiered port scanning (&plusmn;500, &plusmn;2000, full ephemeral range). Currently disabled &mdash; see below.</td></tr>
</table>
<p>All hole punch paths use <code>hole_punch_with_scanning()</code>. NAT profiles (NatMapping + NatFiltering) from InitialExchange determine whether scanning is attempted. Behavioral inference updates filtering classification from connection outcomes.</p>
<h3>Advanced NAT traversal (EDM port scanner) <span class="badge badge-rework">Rework</span></h3>
<div class="note">
<strong>Disabled since v0.7.3.</strong> <code>hole_punch_with_scanning()</code> currently does only Step 1 (quick punch to the anchor-observed address) and Step 2 (parallel punch to all known addresses over a 30s window). No port scan.
<p style="margin: 0.5rem 0 0 0;"><strong>Why:</strong> iroh's <code>Endpoint</code> accumulates every <code>endpoint.connect()</code> target into a per-endpoint paths set and probes them all in the background under QUIC NAT-traversal. A 100-probes/sec / 5-min scan inserted ~30,000 paths; iroh then probed all of them. Observed at 22MB/s outbound from a single client &mdash; DoS-grade.</p>
<p style="margin: 0.5rem 0 0 0;"><strong>Refactor target:</strong> replace per-probe <code>endpoint.connect()</code> with raw <code>socket.send_to()</code> on the endpoint's bound UDP socket. The probe still opens a NAT mapping on our side; we just don't ask iroh to manage the path. The original scanner body is preserved as <code>edm_port_scan_disabled_v0_7_3</code> in <code>connection.rs</code>, including <code>PortWalkIter</code>, <code>scanner_semaphore</code>, role-based scanner/puncher split, and the <code>tokio::select!</code> orchestration &mdash; refactor against that.</p>
<p style="margin: 0.5rem 0 0 0;">The description below documents the <em>intended design</em> the refactor will deliver against.</p>
</div>
<p>NAT "hardness" has two independent dimensions:</p>
<ul style="padding-left: 1.25rem; margin: 0.5rem 0; color: var(--text-muted);">
<li><strong>Mapping</strong>: Endpoint-Independent Mapping (EIM / "easy") uses the same external port for all destinations. Endpoint-Dependent Mapping (EDM / "hard") assigns a different port per destination.</li>
<li><strong>Filtering</strong>: Address-restricted (Open) accepts from any port on an IP the host has sent to. Port-restricted accepts only from the exact IP:port the host has sent to.</li>
</ul>
<p>STUN probing at startup classifies mapping (EIM/EDM). Filtering is determined reliably via the anchor filter probe.</p>
<h4>NAT filter probe (0xC6/0xC7)</h4>
<p>At startup, after first anchor contact, each node with Unknown filtering sends a <code>NatFilterProbe</code> bi-stream request to its anchor. The anchor creates a temporary QUIC endpoint on a random port and attempts to connect to the node&rsquo;s observed address (2s timeout). If the connection succeeds, the node is <strong>Open</strong> (address-restricted or better &mdash; accepts packets from any port on the anchor&rsquo;s IP). If it times out, the node is <strong>PortRestricted</strong>.</p>
<p>The result feeds into all subsequent InitialExchange payloads, so peers know each other&rsquo;s exact filtering type.</p>
<p><strong>Note:</strong> &ldquo;Public&rdquo; NAT type does not automatically mean Open filtering. A node may be public on IPv6 but NATed on IPv4. The filter probe tests actual reachability from a different port, regardless of self-declared NAT type. Startup logs report <code>public (v4 only)</code>, <code>public (v6 only)</code>, or <code>public (v4+v6)</code>.</p>
<h4>NAT combination matrix</h4>
<table>
<tr><th>Side A</th><th>Side B</th><th>Result</th></tr>
<tr><td>addr-restricted, EIM</td><td>addr-restricted, EDM</td><td>Basic hole punch</td></tr>
<tr><td>port-restricted, EIM</td><td>addr-restricted, EDM</td><td>A scans to find+open port; B punches A&rsquo;s stable port regularly</td></tr>
<tr><td>addr-restricted, EDM</td><td>port-restricted, EDM</td><td>B scans to find+open port; A waits then responds</td></tr>
<tr><td>port-restricted, EDM</td><td>port-restricted, EDM</td><td>Both scan+punch alternately</td></tr>
<tr><td>addr-restricted, EIM</td><td>addr-restricted, EIM</td><td>Basic hole punch</td></tr>
<tr><td>port-restricted, EIM</td><td>addr-restricted, EIM</td><td>Basic hole punch</td></tr>
<tr><td>addr-restricted, EDM</td><td>port-restricted, EIM</td><td>B scans to find+open port; A punches B&rsquo;s stable port regularly</td></tr>
<tr><td>port-restricted, EDM</td><td>port-restricted, EIM</td><td>B scans to find+open port; A punches B&rsquo;s stable port regularly</td></tr>
</table>
<p>Key insight: if both sides have <strong>Open</strong> (address-restricted) filtering, scanning is never needed &mdash; <code>should_try_scanning()</code> returns false and basic hole punch handles it.</p>
<h4>Role-based scanning protocol</h4>
<p>Each side independently determines its role based on its own NAT profile:</p>
<ul style="padding-left: 1.25rem; margin: 0.5rem 0; color: var(--text-muted);">
<li><strong>EIM (stable port) &rarr; Puncher</strong>: Punch peer&rsquo;s anchor-observed address every 2s. Our port is stable &mdash; the peer&rsquo;s scanner will find us.</li>
<li><strong>EDM or Unknown &rarr; Scanner+Puncher</strong>: Walk outward from peer&rsquo;s anchor-observed base port at ~100 ports/sec (base, base+1, base-1, base+2, base-2, ...). Each probe opens a firewall entry on our NAT. Also punch every 2s to check if peer has opened their port for us.</li>
</ul>
<p>The scanner opens ports on its own firewall. The other side&rsquo;s periodic punch (one every 2s to the scanner&rsquo;s observed address) checks if the scanner has opened a port matching the puncher&rsquo;s actual port. For both-EDM pairs, both sides scan and punch simultaneously.</p>
<h4>Scan parameters</h4>
<ul style="padding-left: 1.25rem; margin: 0.5rem 0; color: var(--text-muted);">
<li><strong>Rate</strong>: ~100 ports/sec (one probe every 10ms)</li>
<li><strong>In-flight</strong>: ~20 concurrent (100/sec &times; 200ms connect timeout)</li>
<li><strong>Direction</strong>: Outward walk from anchor-observed base port</li>
<li><strong>Target address</strong>: Anchor-observed (relay-injected) address only &mdash; not VPN/cellular/LAN addresses</li>
<li><strong>Max duration</strong>: 5 minutes (covers full 65K port space at ~100/sec in ~11 minutes; &plusmn;2000 range covered in first 40 seconds)</li>
<li><strong>Task management</strong>: JoinSet with abort_all() on success or exhaustion &mdash; no orphaned tasks</li>
<li><strong>Punch interval</strong>: Every 2s to peer&rsquo;s anchor-observed address</li>
</ul>
<h4>Why 5-minute scan duration is acceptable</h4>
<p>The cost is time, not resources (~20 in-flight at any time, ~100 probes/sec). For connections that would otherwise be <em>impossible</em> (both EDM + port-restricted), accepting a longer setup time is far better than giving up entirely. Most successful connections resolve within the first 40 seconds (&plusmn;2000 port range).</p>
<div class="note">
<strong>Design principle</strong>: This protocol eliminates the need for full relay in virtually all NAT scenarios. Session relay remains <strong>opt-in only</strong> &mdash; it is never used as an automatic fallback. The scanning approach respects the user&rsquo;s intent that peers communicate directly whenever physically possible.
</div>
</section>
<!-- Port Mapping (UPnP-IGD + NAT-PMP + PCP) -->
<section id="upnp">
<h2>10. Port Mapping &mdash; UPnP-IGD + NAT-PMP + PCP <span class="badge badge-complete">Implemented</span></h2>
<h3>Purpose</h3>
<p>Asks the local gateway router to forward an external port to this node's local QUIC port. A successful mapping makes the node <strong>directly reachable from the internet</strong> without hole punching &mdash; any peer with the external address can connect immediately. Three protocols are attempted in parallel; the first router-response wins.</p>
<h3>Protocols</h3>
<ul style="padding-left: 1.25rem; margin: 0.5rem 0; color: var(--text-muted);">
<li><strong>UPnP-IGD</strong> &mdash; long-standing consumer-router default. Discovery via SSDP multicast on 239.255.255.250:1900. Behavior varies; many routers ship with UPnP disabled by default.</li>
<li><strong>NAT-PMP</strong> (RFC 6886) &mdash; Apple lineage; widespread on routers that ever shipped Bonjour. Unicast to the gateway on UDP/5351.</li>
<li><strong>PCP</strong> (RFC 6887) &mdash; modern IETF-track successor to NAT-PMP. Unicast on UDP/5351. Supports both IPv4 NAT mapping and IPv6 firewall pinholes. Works on iOS without the multicast networking entitlement.</li>
</ul>
<p>Implementation uses the <code>portmapper</code> crate (also used by iroh internally), wrapped in the <code>upnp.rs</code> module (named for historical reasons; it covers all three protocols); <code>network.rs</code> holds the resulting mapping handle alongside the iroh Endpoint &mdash; port mapping is an Endpoint concern, not a connection concern.</p>
<h3>Startup flow</h3>
<pre><code>bind Endpoint &rarr; spawn portmapper Client (UDP) &rarr; wait up to 3s for first protocol response &rarr; bootstrap (TCP mapping fires in parallel for HTTP serving)</code></pre>
<ol style="padding-left: 1.25rem; margin: 0.5rem 0; color: var(--text-muted);">
<li><strong>Probe all three protocols in parallel</strong>: portmapper's background service fires UPnP-IGD discovery + NAT-PMP unicast + PCP unicast concurrently. First success wins; failures from the others are absorbed silently.</li>
<li><strong>UDP mapping for QUIC</strong>: maps the local QUIC port to an external port. Required for direct inbound. Address feeds self-identification, InitialExchange, anchor candidacy, and peer address advertisements.</li>
<li><strong>TCP mapping for HTTP</strong>: separate parallel attempt for HTTP serving (see <a href="#http-delivery">HTTP Content Delivery</a>). Independent of UDP &mdash; either can succeed alone. Phones with permissive NAT can serve HTTP directly to browser fetches.</li>
<li><strong>Per-platform behavior</strong>: All three protocols on desktop. On Android, a WiFi/Ethernet gate skips probing on cellular (no UPnP/PCP gateway exposed by carriers) and a <code>WifiManager.MulticastLock</code> is held for the lifetime of the mapping so UPnP-IGD's SSDP responses actually arrive. On iOS, PCP and NAT-PMP work without the multicast entitlement; UPnP-IGD silently fails until the entitlement is granted.</li>
</ol>
<h3>Auto-renewal</h3>
<p>The <code>portmapper::Client</code> renews leases internally in a background task. No external renewal cycle to schedule. Dropping the <code>PortMapping</code> handle aborts the renewal task and releases the mapping.</p>
<h3>Bidirectional anchor reachability watcher</h3>
<p>A startup-spawned task watches the UDP mapping's reactive external-address channel:</p>
<ul style="padding-left: 1.25rem; margin: 0.5rem 0; color: var(--text-muted);">
<li><strong>Mapping lost for &gt;5 min</strong> &rarr; clear <code>is_anchor</code>. The node stops advertising itself as an anchor at a now-stale external address.</li>
<li><strong>Mapping restored</strong> (None &rarr; Some) &rarr; re-evaluate auto-anchor. On non-mobile devices the anchor flag is set back on so the node re-joins the anchor set without a restart.</li>
</ul>
<p>Network roams between UPnP-capable WiFi networks self-heal. Mobile devices never auto-anchor regardless &mdash; cellular IPs look public but sit behind CGNAT.</p>
<h3>Port mapping drives anchor candidacy</h3>
<p>A node with a successful mapping is directly reachable from the internet &mdash; which is the only thing that makes an anchor an anchor. Anchor candidacy under v0.8 is exactly this: <strong>stable direct reachability (the watcher above) + opt-in</strong>, not a peer-count threshold. When the mapping succeeds on an opted-in non-mobile node, <code>is_anchor</code> is set and the node begins offering the convection service (see <a href="#anchors">Anchors</a>); when the mapping is lost, it reverts to non-anchor and peers prune the stale entry via failed-probe pruning. Any desktop on a home network with a mapping-capable router becomes a potential entry point for the network, with no manual server deployment.</p>
<h3>Shutdown</h3>
<p>Explicitly release the mapping on clean shutdown. Routers have finite mapping tables &mdash; releasing is good citizenship.</p>
<h3>Integration with existing address logic</h3>
<p>The mapped external address is treated the same as any other address the node knows about. It feeds into:</p>
<ul style="padding-left: 1.25rem; margin: 0.5rem 0; color: var(--text-muted);">
<li><strong>Self-identification</strong>: Included when identifying ourselves to peers so they store a routable address</li>
<li><strong>InitialExchange</strong>: Advertised to new connections</li>
<li><strong>Anchor convection</strong>: The address handed to convection callers as a referral (see <a href="#anchors">Anchors</a>)</li>
<li><strong>Social routing</strong>: Available in the social route address cache for follows/audience</li>
<li><strong>Relay introduction results</strong>: Returned alongside hole-punch candidate addresses</li>
<li><strong>HTTP serving eligibility</strong>: A node only serves browser traffic (or lists itself as a redirect target) if it has confirmed TCP reachability &mdash; either via a TCP port mapping or a known public IPv6 address (see <a href="#http-delivery">HTTP Content Delivery</a>)</li>
</ul>
<h3>Why this matters for mobile</h3>
<p>Mobile devices on cellular networks cannot use UPnP (carrier NAT doesn't expose it). However, if the <strong>peers they're trying to reach</strong> (especially desktop nodes and anchors) have port mappings, those peers become directly reachable from the phone without hole punching. The phone doesn't need a mapping &mdash; the other side does.</p>
<h3>Honest limitations</h3>
<table>
<tr><th>Limitation</th><th>Impact</th></tr>
<tr><td>UPnP disabled on router</td><td>Some ISPs ship routers with UPnP off. Mapping silently fails, fallback to hole punch.</td></tr>
<tr><td>Double NAT</td><td>ISP modem + user router: mapping reaches inner router but not outer. Partial help at best.</td></tr>
<tr><td>Cellular networks</td><td>No UPnP at all. This is purely a desktop/home-network feature.</td></tr>
<tr><td>Carrier-grade NAT (CGNAT)</td><td>ISP shares one public IP across many customers. Mapping reaches the ISP's NAT, not the internet. Same as double NAT.</td></tr>
</table>
<div class="note">
<strong>Design principle</strong>: Port mapping is a best-effort enhancement that improves direct connection reliability for the common case. It is not a dependency. The hole punch fallback chain already handles all failure cases &mdash; mapping just reduces how often you fall back to it.
</div>
</section>
<!-- LAN Discovery -->
<section id="lan">
<h2>11. LAN Discovery <span class="badge badge-planned">Planned</span> (passive lookup <span class="badge badge-complete">Implemented</span>)</h2>
<h3>What exists today: passive mDNS address lookup</h3>
<p>iroh's built-in <code>MdnsAddressLookupBuilder</code> is registered on the endpoint (service name <code>"irohv1"</code>, backed by the <code>swarm-discovery</code> crate). It resolves addresses <em>passively</em>: when this node already knows a peer's NodeId and tries to connect, mDNS supplies the peer's LAN address if the peer is on the same network segment, and the connection rides the local link. There is no subscribe loop, no proactive session establishment, and no LAN-specific treatment beyond address resolution.</p>
<div class="note">
<strong>v0.8 change</strong>: earlier revisions of this document badged the full active flow as complete; an audit found only the passive lookup exists. The active design below is kept as a low-priority target.
</div>
<h3>Active discovery flow</h3>
<ol style="padding-left: 1.25rem; margin: 0.5rem 0; color: var(--text-muted);">
<li><strong>Hold the mDNS handle</strong>: Build <code>MdnsAddressLookup</code> explicitly (not via the endpoint builder) so we retain a clone for subscribing.</li>
<li><strong>Spawn a LAN scan loop</strong>: Call <code>mdns.subscribe().await</code> to get a stream of <code>DiscoveryEvent::Discovered</code> and <code>DiscoveryEvent::Expired</code> events.</li>
<li><strong>On discovery</strong>: Extract NodeId + LAN addresses from the event. If not already connected, initiate a direct connection + initial exchange. Register as a <strong>LAN session</strong> (its own planned session class &mdash; see below).</li>
<li><strong>On expiry</strong>: Clean up the LAN session. Peer left the network or powered off.</li>
</ol>
<h3>LAN sessions</h3>
<p>LAN peers are special: zero-cost bandwidth, sub-millisecond latency, and very likely someone you know (same household/office). They deserve their own treatment beyond regular mesh or session slots:</p>
<ul style="padding-left: 1.25rem; margin: 0.5rem 0; color: var(--text-muted);">
<li><strong>Automatic keep-alive</strong>: LAN sessions stay open as long as the peer is on the network (mDNS heartbeat). No idle timeout. Not counted against session slot limits. This is a deliberate, explicitly-scoped exception to the no-standing-sessions rule (see <a href="#keep-alive">Update Cadence &amp; Keep-Alive</a>), justified by zero-cost local bandwidth.</li>
<li><strong>Sync priority</strong>: Update checks and uniques exchanges go to LAN peers first &mdash; instant delivery over the local link.</li>
<li><strong>Local relay</strong>: relaying traffic between LAN peers costs nothing, so limits on LAN-local relaying can be much more generous than over the internet. Relaying a LAN peer <em>out</em> to the wider internet &mdash; e.g., a phone behind carrier NAT riding the desktop's port-mapped connection &mdash; is <strong>session relay</strong>: it consumes the serving device's WAN bandwidth and remains gated by <code>relay.session_relay_enabled</code>, opt-in and OFF by default (see <a href="#relay">Relay &amp; NAT Traversal</a>). A same-owner linked-device carve-out may be proposed later, but only as an explicit opt-in extension, never as automatic behavior.</li>
<li><strong>Blob transfer</strong>: Large blob transfers between LAN peers are essentially free. Prefer LAN peers as blob sources when available.</li>
</ul>
<h3>Design rationale</h3>
<p>Today, two devices on the same WiFi network only connect over the LAN if one already knows the other's NodeId. That's a narrow win &mdash; they're on the same network segment and should find each other unaided. Active LAN discovery turns mDNS from a passive address resolver into an active peer source, exploiting the fact that local bandwidth is essentially unlimited.</p>
<p>The LAN-session pattern means a household with one well-connected desktop and several phones creates its own mini-mesh: the desktop provides anchor-like connectivity (routing the phones' traffic through it requires the owner's session-relay opt-in), and everyone syncs instantly over the LAN even when the internet connection drops.</p>
<div class="note">
<strong>Implementation note</strong>: iroh's <code>MdnsAddressLookup::subscribe()</code> returns a <code>Stream&lt;DiscoveryEvent&gt;</code>. The <code>DiscoveryEvent::Discovered</code> variant includes <code>EndpointInfo</code> with NodeId + IP addresses. Custom <code>user_data</code> can be set via <code>endpoint.set_user_data_for_address_lookup()</code> to embed app-specific metadata (e.g., display name) in the mDNS TXT record. Low priority relative to the v0.8 mesh work.
</div>
</section>
<!-- Worm Search -->
<section id="worm">
<h2>12. Worm Search <span class="badge badge-complete">Implemented</span></h2>
<p>The network's <strong>point-query</strong> mechanism: given a specific NodeId, post ID, or blob ID, find someone who has it. Used at step 4 of <code>connect_by_node_id</code>, after direct-address and N2/N3 resolution fail. Under v0.8 it is the fallback <em>behind</em> the uniques index &mdash; deep N1&ndash;N4 knowledge answers most "who can reach X" questions locally, and worm search covers the remainder.</p>
<div class="note">
<strong>No keyword search &mdash; by design.</strong> Worm search takes exact IDs only. A global keyword flood-search was considered and rejected for v0.8: in a 20-wide mesh it degenerates into hop-TTL flooding whose abuse and jamming limits would destroy its value. Person-discovery is handled by the opt-in registry instead (see <a href="#discovery">Discovery &amp; First Contact</a>).
</div>
<h3>Algorithm</h3>
<ol style="padding-left: 1.25rem; margin: 0.5rem 0; color: var(--text-muted);">
<li><strong>Build needles</strong>: target NodeId + up to 10 of the target's recent peers (from their stored profile) &mdash; 11 needles total. Finding anyone who can reach <em>any</em> needle gives a path to the target.</li>
<li><strong>Local check</strong>: Search own connections + N2/N3 for any needle. Also check local storage, known <code>file_holders</code>, and the blob store for any requested post/blob content.</li>
<li><strong>Burst</strong> (500ms timeout): Send <code>WormQuery{ttl=0}</code> (<code>0x60</code>) to all mesh peers in parallel. Each peer runs the same local check against its own knowledge.</li>
<li><strong>Nova</strong> (1.5s timeout): Each burst response includes a random referral to one of the responder's other peers (historically called a "wide referral," after the retired Wide slot tier). Connect to those referrals and send <code>WormQuery{ttl=1}</code>; the referred peer fans out to its own mesh with ttl=0. Expansion is one hop at a time rather than a flood, keeping network pressure low.</li>
<li><strong>Total timeout</strong>: 3 seconds for the entire search.</li>
</ol>
<p>With a ~20-peer mesh the raw two-hop fan-out is a few hundred nodes &mdash; smaller than the old 101-mesh's reach &mdash; but under the v0.8 knowledge model <span class="badge badge-rework">Rework</span> each queried peer will answer from much deeper knowledge: its N1&ndash;N4 uniques (tens of thousands of IDs at 4 bounces) plus its <code>file_holders</code> map. (Today peers answer from N2/N3 share lists.) Coverage shifts from "how many nodes did the query touch" to "how much does each touched node know".</p>
<h3>Content search</h3>
<p><code>WormQuery</code> carries optional <code>post_id</code> and <code>blob_id</code> fields, enabling unified search for nodes, posts, and blobs in a single query. Each peer checks:</p>
<ul style="padding-left: 1.25rem; margin: 0.5rem 0; color: var(--text-muted);">
<li><strong>Posts</strong>: local storage (direct match), then the flat <code>file_holders</code> map (up to 5 known holders per post &mdash; see <a href="#content">Content Propagation</a>)</li>
<li><strong>Blobs</strong>: local blob store, then CDN post ownership (<code>get_blob_post_id</code> &rarr; <code>file_holders</code>)</li>
</ul>
<p><code>WormResponse</code> carries <code>post_holder</code> and <code>blob_holder</code> fields alongside the node search results. A content hit (post or blob holder found) is treated as a successful response even without a node match.</p>
<h3>PostFetch (<code>0xD4</code>/<code>0xD5</code>)</h3>
<p>Lightweight single-post retrieval after worm search identifies a holder. Opens a bi-stream to the holder and requests one post by ID. Much lighter than a full pull exchange &mdash; no filtering, no batch processing, just the target post.</p>
<h3>Dedup &amp; cooldown</h3>
<table>
<tr><th>Mechanism</th><th>Window</th><th>Purpose</th></tr>
<tr><td><code>seen_worms</code></td><td>10s</td><td>Prevents loops during fan-out</td></tr>
<tr><td>Miss cooldown</td><td>5 min (in DB)</td><td>Prevents repeated searches for unreachable targets</td></tr>
</table>
</section>
<!-- Update Cadence & Keep-Alive -->
<section id="keep-alive">
<h2>13. Update Cadence &amp; Keep-Alive <span class="badge badge-rework">Rework</span></h2>
<h3>Purpose</h3>
<p>One scheduled system drives all recurring traffic: content freshness checks, uniques-list refresh, and connection keep-alive. Every node runs a check cycle whose per-item frequency <strong>descends from minutes to days</strong>, computed from two axes:</p>
<ul style="padding-left: 1.25rem; margin: 0.5rem 0; color: var(--text-muted);">
<li><strong>Freshness</strong> &mdash; how recently the content saw a comment, update, or engagement. Fresh content is checked often; stale content decays toward daily checks.</li>
<li><strong>Relationship tier</strong> &mdash; how much the local user cares, in descending priority: actively searching/engaging right now &rarr; own content (posts of ours held elsewhere) &rarr; messaged &rarr; followed &rarr; pinned &rarr; vouched &rarr; vouched-by &rarr; reacted-to. Untiered content sits at the floor cadence.</li>
</ul>
<p>The effective interval is the product of both axes: a followed author's post commented on an hour ago might check every few minutes; a reacted-to post untouched for a month checks daily.</p>
<h3>What rides the cycle</h3>
<ul style="padding-left: 1.25rem; margin: 0.5rem 0; color: var(--text-muted);">
<li><strong>Content update checks</strong>: fetch new comments/edits for held posts from their holders (CDN-level, no mesh involvement).</li>
<li><strong>Uniques-list refresh</strong>: the uniques announce (two pools: forwardable N0&ndash;N2 + terminal N3 &mdash; see <a href="#layers">Network Knowledge</a>) with mesh peers piggybacks on the same scheduler &mdash; it doubles as the mesh keep-alive.</li>
<li><strong>Replication traffic</strong>: holder-count maintenance and the author-concealment push (see <a href="#content">Content Propagation</a>) are deliberately scheduled through the same shaped cycle.</li>
</ul>
<h3>Same-author dedup</h3>
<p>A successful check against one of an author's posts is evidence about all of them: their other posts' checks are deprioritized (pushed later in the cycle) rather than fired back-to-back. This is the main lever for finding the lowest viable cadence &mdash; per-author, not per-post, contact frequency.</p>
<h3>Jitter as traffic shaping</h3>
<p>Every interval carries random jitter, and checks are spread rather than batched at tier boundaries. The goal is that a node's outbound request stream looks like <strong>uniform network overhead</strong>: an observer watching traffic timing should not be able to infer which authors the user cares about, which posts are theirs, or where content originated. Request-timing correlation is the primary deanonymizer in a P2P network; the cadence system is the defense (adversary model in <a href="#privacy">Graph Privacy</a>).</p>
<h3>Mobile</h3>
<p>Mobile stretches all cadence bands and batches wakeups &mdash; the radio is the battery cost, not the bytes. Background-restricted platforms run the cycle only while the foreground service is alive.</p>
<div class="note">
<strong>v0.8 change</strong>: this replaces the previously planned "keep-alive session pool" (a 60s connectivity check holding up to 50% of session capacity open to social/file contacts). Deep N1&ndash;N4 knowledge plus CDN holder search make standing reachability sessions unnecessary &mdash; sessions open on demand and close when idle; only the shaped check cycle recurs.
</div>
<h3>Implementation status</h3>
<p>The seed exists: a freshness-tiered engagement check in storage (<code>get_posts_due_for_engagement_check</code>) schedules per-post checks at 5&nbsp;min / 1&nbsp;h / 4&nbsp;h / 24&nbsp;h keyed on last-engagement age. The relationship-tier axis, same-author dedup, jittered shaping, and the uniques-refresh piggyback are not built &mdash; the current code is the freshness axis only.</p>
</section>
<!-- Content Propagation -->
<section id="content">
<h2>14. Content Propagation</h2>
<h3>Intent</h3>
<p><strong>"Attention creates propagation"</strong>: when you view something, you cache it. The cache is optionally offered for serving. Hot content spreads naturally through demand. Cold content decays unless intentionally hosted.</p>
<p>Content travels through the network exclusively via the CDN: the mesh provides <em>width</em> (who exists, who can reach whom &mdash; the N1&ndash;N4 uniques index), the CDN provides <em>depth</em> (the actual posts, blobs, comments, and updates). A pull tells you <em>who</em> to talk to; the CDN holder model is <em>how</em> the bytes move.</p>
<div class="note">
<strong>v0.8 change:</strong> the flat holder model replaces the CDN hosting tree (1 upstream + 100 downstream per blob, relay-down-tree ManifestPush, BlobDeleteNotice healing). There are no upstream/downstream relationships anywhere &mdash; every holder is a peer of equal standing.
</div>
<h3>The flat holder model <span class="badge badge-complete">Implemented</span></h3>
<p>Every file (blob CID) has a single flat set of known holders in the <code>file_holders</code> table, <strong>capped at 5 per file with LRU eviction</strong> &mdash; the 5 most recently interacted-with holders survive, older entries are dropped. A holder entry records the peer's NodeId, last-known addresses, last interaction time, and a direction flag:</p>
<table>
<tr><th>Direction</th><th>Meaning</th></tr>
<tr><td><code>sent</code></td><td>We served this file to the peer &mdash; they likely hold it now</td></tr>
<tr><td><code>received</code></td><td>We fetched this file from the peer &mdash; they definitely held it</td></tr>
<tr><td><code>both</code></td><td>Traffic has flowed both ways (entry promoted on conflict)</td></tr>
</table>
<p>Holder knowledge accretes as a side effect of every transfer: serving a blob registers the requester, fetching a blob registers the source, replication acceptance registers both ends, and engagement propagation refreshes entries. No registration protocol exists &mdash; the table is a passive record of observed reality, which is why the LRU cap is safe: stale entries age out under real traffic.</p>
<h3>Holder redirect at capacity <span class="badge badge-complete">Implemented</span></h3>
<p>When a node serving a <code>BlobRequest</code> already knows 5 holders for the file, it still serves the bytes but declines to register the requester (<code>cdn_registered: false</code>) and instead returns its holder list as <strong>redirect candidates</strong>. The requester records those peers as additional sources for future lookups. This spreads lookup load across the holder set instead of concentrating it on whichever node happens to be well-known.</p>
<h3>Manifest freshness <span class="badge badge-complete">Implemented</span></h3>
<ul style="padding-left: 1.25rem; margin: 0.5rem 0; color: var(--text-muted);">
<li><strong>ManifestPush</strong> (<code>0x94</code>): when an author posts again, updated AuthorManifests for their blobs are pushed to the file's known holders; a receiving holder re-pushes newly-stored manifests to its own connected holders of the same CID, excluding the sender. No tree structure &mdash; propagation follows the flat holder set.</li>
<li><strong>ManifestRefreshRequest/Response</strong> (<code>0x92</code>/<code>0x93</code>): pull-side counterpart &mdash; "has this manifest changed since <code>updated_at</code>?" Used by the update-cadence cycle (see <a href="#keep-alive">Update Cadence &amp; Keep-Alive</a>) to pick a refresh source from <code>file_holders</code>.</li>
<li><strong>BlobHeaderDiff/BlobHeaderRequest</strong> (<code>0xD0</code>/<code>0xD1</code>): engagement state (comments, reactions, encrypted slots, FoF ops) travels as header diffs between holders; a header can be fetched without retransferring blob data.</li>
</ul>
<h3>Passive discovery via manifest neighborhoods</h3>
<p>Every AuthorManifest carries a neighborhood of the author's surrounding PostIds. Holding any file by author X therefore passively reveals some of X's other recent posts &mdash; hosts of old content are pulled toward new content by the same author. Attention creates propagation. In v0.8 the neighborhood is deliberately small: it is capped to the author's <strong>concealment budget of 2&ndash;5 publicly revealed posts</strong> (below), not a full recent-history window.</p>
<h3>Active replication cycle <span class="badge badge-rework">Rework</span></h3>
<p>Authors own durability for their own recent content. Every 10 minutes, a node checks its own posts younger than 72 hours; any with <strong>fewer than 2 known holders</strong> are offered to connected peers via <code>ReplicationRequest</code>. Target selection ranks peers by device role (Available desktops &gt; Persistent anchors &gt; Intermittent phones) plus advertised cache pressure. Accepting peers store the blob and both ends record each other in <code>file_holders</code>.</p>
<div class="note">
<strong>Known bug (fix required):</strong> the cycle queries "own posts" by <em>network</em> NodeId, but posts are authored under <em>posting</em> IDs (split in v0.6.1) &mdash; the under-replicated set is always empty and the cycle is currently inert. The machinery is correct; the author lookup must move to posting identities.
</div>
<h3>Author concealment &amp; traffic shaping <span class="badge badge-planned">Planned</span></h3>
<p>The replication cycle is also the privacy mechanism. An author's node publicly holds and reveals only <strong>2&ndash;5 of its own posts</strong> at any time; the surplus is pushed out to holder sets on other nodes via replication requests, so the full catalog is served from behind public mesh IDs rather than from the author's device. Combined with the jittered update cadence, the goal is that <strong>update and replication traffic is indistinguishable from uniform network overhead</strong> &mdash; an observer watching traffic cannot tell where content originates or which node is the author. The adversary model is detailed in <a href="#privacy">Network Graph Privacy</a>.</p>
<h3>Deletion</h3>
<p>There is no delete notification message. Deletes are <strong>signed control posts</strong> that propagate through the same manifest/pull machinery as any other post; holders that process one drop the content and its holder entries, and everyone else's copy ages out via LRU eviction. Details in <a href="#deletes">Delete Propagation &amp; Expiry</a>.</p>
</section>
<!-- Files & Storage -->
<section id="files">
<h2>15. Files &amp; Storage</h2>
<h3>Blob storage <span class="badge badge-complete">Implemented</span></h3>
<table>
<tr><th>Property</th><th>Value</th></tr>
<tr><td>CID format</td><td>BLAKE3 hash of blob data (32 bytes, hex-encoded)</td></tr>
<tr><td>Filesystem path</td><td><code>{data_dir}/blobs/{hex[0..2]}/{hex}</code> (256 shards)</td></tr>
<tr><td>Metadata table</td><td><code>blobs</code> (cid, post_id, author, size_bytes, mime_type, created_at, stored_at, last_accessed_at, pinned)</td></tr>
<tr><td>Max blob size</td><td>10 MB</td></tr>
<tr><td>Max attachments per post</td><td>4</td></tr>
<tr><td>Delivery budget</td><td>Per-hour outbound serving cap set by device role; auto-resets hourly. Requests beyond budget answered <code>found: false</code>.</td></tr>
</table>
<h3>Blob content immutability</h3>
<p>Blob data is BLAKE3-addressed &mdash; the CID <em>is</em> the hash of the content. This means blob content is <strong>immutable by definition</strong>. Any mutable metadata (engagement, holder knowledge, signatures) MUST be stored separately in a <strong>BlobHeader</strong>. Inline mutable headers are architecturally incompatible with content addressing.</p>
<h3>BlobHeader <span class="badge badge-complete">Implemented</span></h3>
<p>Mutable structure stored and transmitted separately from blob data. Carries engagement state and encrypted slots for private posts; mutations travel as <code>BlobHeaderDiff</code> ops between holders.</p>
<pre><code>BlobHeader {
post_id, // PostId this header belongs to
author, // Author posting ID
reactions, // Vec of public reactions (emoji + reactor + timestamp)
comments, // Vec of comments (text + author + timestamp + signature)
policy, // Author-controlled CommentPolicy (incl. FriendsOfFriends)
updated_at, // Timestamp of last header update
thread_splits, // Linked thread posts when comments exceed 16KB
receipt_slots, // Encrypted delivery/read/react receipt slots (private posts)
comment_slots, // Encrypted comment slots (private posts)
prior_author, // Original author before cross-identity post merge (optional)
}</code></pre>
<p>Header diffs also carry the FoF lifecycle ops (<code>FoFRevocation</code>, <code>FoFAccessGrant</code>, <code>FoFKeyBurn</code> &mdash; see <a href="#fof">Friend-of-Friend Visibility</a>) and, in the v0.8 target, each comment carries its <strong>expiry timestamp</strong> fixed at creation (see <a href="#deletes">Delete Propagation &amp; Expiry</a>).</p>
<h3>Blob transfer flow (<code>0x90</code>/<code>0x91</code>) <span class="badge badge-complete">Implemented</span></h3>
<ol style="padding-left: 1.25rem; margin: 0.5rem 0; color: var(--text-muted);">
<li>Requester sends <code>BlobRequest { cid, requester_addresses }</code></li>
<li>Host checks local BlobStore and delivery budget:
<ul style="margin-top: 0.3rem;">
<li><strong>Has blob, budget OK</strong>: Return base64-encoded data + CDN manifest (with current holder count). If the host knows fewer than 5 holders, register the requester (<code>cdn_registered: true</code>); otherwise return the known holders as <code>cdn_redirect_peers</code>.</li>
<li><strong>No blob / budget exhausted</strong>: Return <code>found: false</code></li>
</ul>
</li>
<li>Requester verifies CID, stores blob locally, records the host in <code>file_holders</code> (direction <code>received</code>), and stores any redirect peers as additional holder knowledge.</li>
</ol>
<h3>Manifests <span class="badge badge-rework">Rework</span></h3>
<ul style="padding-left: 1.25rem; margin: 0.5rem 0; color: var(--text-muted);">
<li><strong>AuthorManifest</strong>: ed25519-signed by the post author (posting key). Contains the anchor post, creation/update timestamps, and the author's post neighborhood. In v0.8 the neighborhood is capped to the <strong>2&ndash;5 post concealment budget</strong> and the manifest carries <strong>no device addresses</strong> &mdash; readers reach the author's content through <code>file_holders</code> search, never through addresses tied to the posting identity.</li>
<li><strong>CdnManifest</strong>: AuthorManifest + serving-host metadata. The v0.8 wire format drops the legacy tree fields (source, source addresses, downstream count) as part of the clean protocol break (see <a href="#sync">Sync Protocol</a>).</li>
</ul>
<div class="note">
<strong>v0.8 change (privacy fix):</strong> today's AuthorManifest ships the device's real addresses under a posting-identity author and a 10+10 post neighborhood. Both violate the v0.8 privacy rulings: posting identities must be location-anonymous, and the neighborhood must not reveal more than the concealment budget. This fix is a prerequisite for the discovery registry (<a href="#discovery">Discovery &amp; First Contact</a>).
</div>
<h3>Blob eviction <span class="badge badge-complete">Implemented</span></h3>
<pre><code>priority = pin_boost + share_boost + (relationship &times; heart_recency &times; freshness / (peer_copies + 1))</code></pre>
<table>
<tr><th>Factor</th><th>Calculation</th></tr>
<tr><td><code>pin_boost</code></td><td>1000.0 if pinned, else 0.0. Own blobs auto-pinned.</td></tr>
<tr><td><code>share_boost</code></td><td>100.0 at 3+ known holders; <code>50.0 &times; n / 3</code> for 1&ndash;2 holders; 0 otherwise. Holder count comes from <code>file_holders</code> (max 5) &mdash; content with a healthy holder set is being actively served and stays cached longer.</td></tr>
<tr><td><code>relationship</code></td><td>5.0 (own content), 2.0 (followed author), 0.1 (stranger). <em>The own-content tier currently compares against the network ID and never matches post-split; own blobs stay protected via auto-pin. Fix rides the ID-class sweep (see <a href="#identity-architecture">Identity Architecture</a>).</em></td></tr>
<tr><td><code>heart_recency</code></td><td>Linear decay over 30 days since last access: <code>max(0, 1 - age/30d)</code></td></tr>
<tr><td><code>freshness</code></td><td><code>1 / (1 + post_age_days)</code></td></tr>
<tr><td><code>peer_copies</code></td><td>Known replica count from <code>post_replicas</code>, counted only if confirmed within the staleness window</td></tr>
</table>
<p>Eviction is also the passive half of delete propagation: unreferenced blobs (orphaned by a delete control post that hasn't reached this node) simply score low and age out.</p>
<h3>Pin modes <span class="badge badge-planned">Planned</span></h3>
<p>The CDN is delivery infrastructure, not storage. Authors own durability. Pinning extends content in the local delivery pool &mdash; it is not a network obligation.</p>
<table>
<tr><th>Concept</th><th>Status</th></tr>
<tr><td>Anchor pin vs Fork pin</td><td>Not started. Anchor pin = host the original (author retains control). Fork pin = independent copy (you become key owner).</td></tr>
<tr><td>Personal vault</td><td>Not started. Private durability for saved/pinned items.</td></tr>
</table>
</section>
<!-- Erasure-Coded CDN Replication (Future) -->
<section id="erasure-cdn">
<h2>16. Erasure-Coded CDN Replication <span class="badge badge-planned">Planned</span></h2>
<h3>Problem</h3>
<p>Full-copy replication works well when the replicating node chose to pull the content &mdash; a follow relationship or explicit action establishes user consent. But the <code>ReplicationRequest</code> (0xE1) protocol also pushes content to infrastructure nodes that never chose to host it. A node holding a full copy of content it never reviewed faces potential liability for that content.</p>
<p>Encryption does not solve this for public posts: the content is plaintext by definition. A different mechanism is needed that makes it <strong>technically impossible</strong> for a CDN node to possess reconstructable content.</p>
<h3>Approach: sub-threshold erasure shards</h3>
<p>Instead of replicating full blobs, public post auto-replication distributes erasure-coded shards using a <strong>3-of-10</strong> scheme (k=3, n=10). Each shard contains 1/3 of the data. Reconstruction requires cooperation from any 3 of the 10 shard holders. A single shard is mathematically meaningless noise &mdash; not encrypted content where the full payload exists behind a key, but genuinely incomplete data that cannot be reconstructed alone.</p>
<h3>Where sharding applies</h3>
<p>The existing storage tiers each have their own liability story. Sharding only fills the gap for public auto-replication:</p>
<table>
<tr><th>Tier</th><th>Storage</th><th>Defense</th></tr>
<tr><td>Author&rsquo;s node</td><td>Full copy</td><td>Publisher responsibility (content originator)</td></tr>
<tr><td>Pulled content (follows)</td><td>Full copy</td><td>User consent &mdash; explicit follow relationship</td></tr>
<tr><td>Private auto-replication</td><td>Encrypted (CEK envelope, <a href="#encryption">Encryption Architecture</a>)</td><td>Replicating nodes are provably not keyring recipients &mdash; existing encryption architecture handles this</td></tr>
<tr><td>Public auto-replication</td><td><strong>Erasure-coded shards</strong></td><td>Sub-threshold shard &mdash; reconstruction impossible from any single holder</td></tr>
</table>
<h3>Composition with the flat holder model</h3>
<p>Each shard is content-addressed like any other blob and tracked through the same <code>file_holders</code> machinery, keyed on the shard's own CID. The 5-holder LRU cap applies <em>per shard</em>, so the 10 slots of a 3-of-10 chain are 10 independent holder sets &mdash; the cap and the slot count do not interact. Eviction scoring, holder redirect, and manifest refresh work unchanged; sharding is an additional layer on the auto-replication path only, and full copies continue to flow through the flat model untouched.</p>
<h3>Shard assignment</h3>
<p>Slot assignment is deterministic from the PostId via DHT-style hashing, carried in the existing BlobHeader metadata &mdash; no additional discovery round required. Each node enforces <strong>single-slot acceptance</strong>: it only accepts shard push offers for its assigned slot, rejecting others. This prevents a bad actor from accumulating multiple shards toward the reconstruction threshold. Slot assignment is acceptance policy, not exclusivity &mdash; transient duplicate holders for the same slot are harmless and add redundancy.</p>
<h3>Health monitoring</h3>
<ul>
<li><strong>&ge;5 live slots:</strong> healthy, no action</li>
<li><strong>4 live slots:</strong> trigger background re-replication, targeting the longest-dark slot first</li>
<li><strong>&lt;3 live slots:</strong> content at risk (requires catastrophic loss of 8+ nodes simultaneously)</li>
</ul>
<h3>Interaction with full copies</h3>
<p>As content gains followers, the follow graph naturally absorbs redundancy through full-copy pulls. The shard layer can back off:</p>
<ul>
<li><strong>2+ known full copies:</strong> equivalent to &ge;4 live shards &rarr; shard chain deprioritizes, may decay</li>
<li><strong>1 full copy:</strong> shard chain reformation trigger</li>
<li><strong>0 full copies:</strong> shard chain is sole redundancy, maintain aggressively</li>
</ul>
<p>This means popular content automatically shifts from CDN shard infrastructure to the social follow graph. The shard layer only works hard for content nobody has explicitly chosen to keep &mdash; exactly the content with the highest liability exposure.</p>
<h3>Re-replication</h3>
<p>When a slot goes dark, a new shard holder is assigned via DHT. The new holder determines which chunks belong to its slot and <strong>pulls only those chunks</strong> from the live shard holders that have them. No shard holder ever reconstructs the full content &mdash; each node only ever possesses its own slot&rsquo;s chunks. The pulling node identifies what it needs, requests those specific chunks, and aggressively refuses anything outside its assigned slot. The author&rsquo;s node can go offline permanently once shard replication is established.</p>
<h3>Replication window</h3>
<p>Active shard push replication applies the same 72-hour window as full-copy replication: only posts less than 72 hours old are actively pushed to shard holders. Beyond 72 hours, the shard chain relies on natural decay and pull-based replication only.</p>
<p><strong>Exception &mdash; share link re-promotion:</strong> When a share link is added to a post&rsquo;s BlobHeader, the 72-hour active replication window resets from that event. This ensures that content being actively shared gets CDN-prioritized delivery regardless of original post age. The re-promotion window is 72 hours from the share link addition, not from the original post timestamp.</p>
<h3>Implementation path</h3>
<p>Extends the existing <code>ReplicationRequest</code>/<code>ReplicationResponse</code> (0xE1/0xE2) protocol. Shard slot metadata fits in the existing BlobHeader. Flat holder tracking and eviction scoring continue to work unchanged for full copies. Shard traffic also serves the traffic-shaping goal: shard pushes are one more class of replication traffic that makes origin-tracing harder.</p>
</section>
<section id="sync">
<h2>17. Sync Protocol <span class="badge badge-rework">Rework</span></h2>
<div class="note">
<strong>v0.8 change: clean protocol break.</strong> v0.8 has <strong>no wire compatibility with v0.7.x or earlier</strong>. All v0.6.x receive-only compat handlers (<code>DeleteRecord</code> 0x51, <code>VisibilityUpdate</code> 0x52), the dual pull-matching path (<code>have_post_ids</code> fallback), and serde-default field shims are removed rather than maintained. There is no ALPN negotiation and never was &mdash; a node speaks exactly one protocol version.
</div>
<h3>Wire format <span class="badge badge-complete">Implemented</span></h3>
<pre><code>[1 byte: MessageType] [4 bytes: length (big-endian)] [length bytes: JSON payload]</code></pre>
<p>Max payload: <strong>64 MB</strong>. ALPN: <code>itsgoin/4</code>. Every message travels on its own QUIC stream &mdash; uni-directional for fire-and-forget pushes, bi-directional for request/response pairs. Unknown message-type bytes fail the stream; unknown <em>fields</em> inside known payloads are ignored by serde.</p>
<div class="note">
<strong>Done (Iteration B)</strong>: the v0.8 break bumped the ALPN to <code>itsgoin/4</code>, so pre-v0.8 nodes are refused cleanly at the QUIC handshake rather than failing mid-conversation. The constant is now named plain <code>ALPN</code> (the versioned name <code>ALPN_V2</code> had rotted against its value).
</div>
<h3>Message types (40 total; rows marked &ldquo;Removed in v0.8&rdquo; are kept for history) <span class="badge badge-complete">Implemented</span></h3>
<table>
<tr><th>Hex</th><th>Name</th><th>Stream</th><th>Purpose</th></tr>
<tr><td><code>0x01</code></td><td>NodeListUpdate</td><td>Uni</td><td>Incremental knowledge-layer diff broadcast</td></tr>
<tr><td><code>0x02</code></td><td>InitialExchange</td><td>Bi</td><td>Full state exchange on connect: N1/N2 IDs, profile, post IDs, peer addresses, NAT observations, device role, anchor address</td></tr>
<tr><td><code>0x03</code></td><td>AddressRequest</td><td>Bi</td><td>Resolve NodeId &rarr; address via tagged reporter</td></tr>
<tr><td><code>0x04</code></td><td>AddressResponse</td><td>Bi</td><td>Address resolution reply</td></tr>
<tr><td><code>0x05</code></td><td>RefuseRedirect</td><td>Uni</td><td>Refuse mesh + suggest alternative</td></tr>
<tr><td><code>0x40</code></td><td>PullSyncRequest</td><td>Bi</td><td>Pull exchange (v0.8: uniques-index exchange &mdash; see below)</td></tr>
<tr><td><code>0x41</code></td><td>PullSyncResponse</td><td>Bi</td><td>Pull exchange reply</td></tr>
<tr><td><code>0x50</code></td><td>ProfileUpdate</td><td>Uni</td><td>Push profile changes</td></tr>
<tr><td><code>0x51</code></td><td>DeleteRecord</td><td>Uni</td><td>Receive-only compat; deletes are signed control posts (<a href="#deletes">Delete Propagation</a>). Removed in v0.8.</td></tr>
<tr><td><code>0x52</code></td><td>VisibilityUpdate</td><td>Uni</td><td>Receive-only compat; visibility re-wraps propagate via control posts. Removed in v0.8.</td></tr>
<tr><td><code>0x60</code></td><td>WormQuery</td><td>Bi</td><td>Point query for nodes, posts, or blobs beyond local knowledge (<a href="#worm">worm search</a>)</td></tr>
<tr><td><code>0x61</code></td><td>WormResponse</td><td>Bi</td><td>Worm search reply (node + post_holder + blob_holder)</td></tr>
<tr><td><code>0x70</code></td><td>SocialAddressUpdate</td><td>Uni</td><td>Social contact address changed</td></tr>
<tr><td><code>0x71</code></td><td>SocialDisconnectNotice</td><td>Uni</td><td>Social contact disconnected. Removed in v0.8 &mdash; zero senders existed; checkin timeouts carry the signal.</td></tr>
<tr><td><code>0x72</code></td><td>SocialCheckin</td><td>Bi</td><td>Keepalive + address + peer-address update</td></tr>
<tr><td><code>0x90</code></td><td>BlobRequest</td><td>Bi</td><td>Fetch blob by CID</td></tr>
<tr><td><code>0x91</code></td><td>BlobResponse</td><td>Bi</td><td>Blob data + CDN manifest + file header</td></tr>
<tr><td><code>0x92</code></td><td>ManifestRefreshRequest</td><td>Bi</td><td>Check manifest freshness</td></tr>
<tr><td><code>0x93</code></td><td>ManifestRefreshResponse</td><td>Bi</td><td>Updated manifest reply</td></tr>
<tr><td><code>0x94</code></td><td>ManifestPush</td><td>Uni</td><td>Push updated manifests to known file holders</td></tr>
<tr><td><code>0xA1</code></td><td>GroupKeyRequest</td><td>Bi</td><td>Orphaned: no live sender, handler has an ID-class bug. Removed in v0.8 &mdash; group seeds travel as encrypted posts (<a href="#encryption">Encryption</a>).</td></tr>
<tr><td><code>0xA2</code></td><td>GroupKeyResponse</td><td>Bi</td><td>Orphaned reply pair of 0xA1. Removed in v0.8.</td></tr>
<tr><td><code>0xB0</code></td><td>RelayIntroduce</td><td>Bi</td><td>Hole-punch introduction via mutual peer (always-on signaling, not session relay)</td></tr>
<tr><td><code>0xB1</code></td><td>RelayIntroduceResult</td><td>Bi</td><td>Introduction result with addresses</td></tr>
<tr><td><code>0xB2</code></td><td>SessionRelay</td><td>Bi</td><td>Splice bi-streams (opt-in; <code>relay.session_relay_enabled</code> OFF by default on every role, including anchors &mdash; see <a href="#relay">Relay &amp; NAT Traversal</a>)</td></tr>
<tr><td><code>0xB3</code></td><td>MeshPrefer</td><td>Bi</td><td>Preferred-peer negotiation. Removed in v0.8 &mdash; the preferred tier is eliminated.</td></tr>
<tr><td><code>0xB4</code></td><td>CircleProfileUpdate</td><td>Uni</td><td>Encrypted circle profile variant</td></tr>
<tr><td><code>0xC0</code></td><td>AnchorRegister</td><td>Uni</td><td>Register with anchor. Still live; retires when anchor convection replaces the register loop (Roadmap item 5, <a href="#anchors">Anchors</a>).</td></tr>
<tr><td><code>0xC1</code></td><td>AnchorReferralRequest</td><td>Bi</td><td>Request peer referrals (v0.8: the convection call)</td></tr>
<tr><td><code>0xC2</code></td><td>AnchorReferralResponse</td><td>Bi</td><td>Referral list reply</td></tr>
<tr><td><code>0xC3</code></td><td>AnchorProbeRequest</td><td>Bi</td><td>A &rarr; B &rarr; C: test cold reachability of address</td></tr>
<tr><td><code>0xC4</code></td><td>AnchorProbeResult</td><td>Bi</td><td>C &rarr; A (success) or C &rarr; B &rarr; A (failure)</td></tr>
<tr><td><code>0xC5</code></td><td>PortScanHeartbeat</td><td>Uni</td><td>Dormant: part of the disabled EDM scanner; no live senders or handlers. Fate rides the raw-UDP scanner refactor.</td></tr>
<tr><td><code>0xC6</code></td><td>NatFilterProbe</td><td>Bi</td><td>NAT filtering classification probe via third party</td></tr>
<tr><td><code>0xC7</code></td><td>NatFilterProbeResult</td><td>Bi</td><td>Filtering probe result</td></tr>
<tr><td><code>0xD0</code></td><td>BlobHeaderDiff</td><td>Uni</td><td>Incremental engagement update (reactions, comments, policy, slots, FoF ops)</td></tr>
<tr><td><code>0xD1</code></td><td>BlobHeaderRequest</td><td>Bi</td><td>Request full engagement header for a post</td></tr>
<tr><td><code>0xD2</code></td><td>BlobHeaderResponse</td><td>Bi</td><td>Full engagement header response (JSON, only if newer)</td></tr>
<tr><td><code>0xD3</code></td><td>PostDownstreamRegister</td><td>Uni</td><td>Holder registration: &ldquo;I now hold this post&rdquo; &rarr; sender's <code>file_holders</code> (name is historical; no tree exists)</td></tr>
<tr><td><code>0xD4</code></td><td>PostFetchRequest</td><td>Bi</td><td>Request a single post by ID from a known holder</td></tr>
<tr><td><code>0xD5</code></td><td>PostFetchResponse</td><td>Bi</td><td>Single post response (SyncPost or not-found)</td></tr>
<tr><td><code>0xD6</code></td><td>TcpPunchRequest</td><td>Bi</td><td>Ask holder to punch TCP toward browser IP</td></tr>
<tr><td><code>0xD7</code></td><td>TcpPunchResult</td><td>Bi</td><td>Punch result + HTTP address for redirect</td></tr>
<tr><td><code>0xE0</code></td><td>MeshKeepalive</td><td>Uni</td><td>30s connection heartbeat</td></tr>
<tr><td><code>0xE1</code></td><td>ReplicationRequest</td><td>Bi</td><td>Request peer to cache specific posts (<a href="#content">Content Propagation</a>)</td></tr>
<tr><td><code>0xE2</code></td><td>ReplicationResponse</td><td>Bi</td><td>Accept/reject replication request</td></tr>
</table>
<p style="color: var(--text-muted);">Retired type bytes, never reused: <code>0x42</code>&ndash;<code>0x45</code> (PostNotification/PostPush/AudienceRequest/AudienceResponse, v0.6.2), <code>0x95</code> (BlobDeleteNotice &mdash; holders evict via LRU, no notice needed), <code>0xA0</code> (GroupKeyDistribute &mdash; group seeds travel as encrypted posts).</p>
<h3>Pull = uniques-index exchange <span class="badge badge-complete">Implemented</span></h3>
<p>In v0.8 a pull is <strong>not</strong> a post transfer. A pull exchanges <strong>uniques lists</strong>: the set of unique IDs each side can help the other reach &mdash; mesh peers, direct contacts, CDN file authors, and CDN file peers, including throwaway/anonymous IDs (deliberate noise that protects real identities). The semantics are &ldquo;if you want one of these IDs, talk to me and I'll help you find their CDN holders to connect to and search.&rdquo; The union of these lists across the mesh forms a <strong>distributed search index</strong> &mdash; the same index that powers person-discovery for the <a href="#discovery">registry</a>. What a node knows and announces at each depth (N1&ndash;N4, termination rules, dedup, size budgets) is specified in <a href="#layers">Network Knowledge</a>; this section covers only the wire exchange.</p>
<p>Content itself never rides the pull path: posts and blobs travel through the CDN (<code>PostFetchRequest</code>/<code>BlobRequest</code> against holders resolved through the index &mdash; see <a href="#content">Content Propagation</a>). The old &ldquo;never pull from mesh peers&rdquo; principle dissolves cleanly: the mesh exchanges the <em>index</em>, the CDN carries the <em>content</em>.</p>
<p>The pull opcodes (<code>0x40</code>/<code>0x41</code>) now carry <strong>only</strong> the index &mdash; that is what makes "a pull is not a post transfer" literally true of them. Post bodies still move between mesh peers over a separate, explicitly <strong>transitional</strong> content-sync pair (<code>0x46 ContentSyncRequest</code> / <code>0x47 ContentSyncResponse</code>) <span class="badge badge-rework">Rework</span>, which exists until the CDN cadence work in Iteration D can carry everything. Read caps differ accordingly: the uniques opcodes read against a 2&nbsp;MB ceiling, not the 64&nbsp;MB file-transfer cap.</p>
<div class="note">
<strong>What it replaces</strong>: today <code>PullSyncRequest</code> (0x40) carries a merged query list plus per-author <code>since_ms</code> timestamps (Self Last Encounter), and the responder returns posts newer than each timestamp after <code>should_send_post()</code> visibility filtering, plus visibility updates for own posts the requester already holds. The cycle ticks every 60s: a full pull from all connected peers on the first tick, then pulls only for authors stale beyond 4 hours. This per-author post-pull dies with v0.8; its staleness-tiering idea survives inside the <a href="#keep-alive">update cadence system</a>.
</div>
<h3>Recipient matching (merged pull) <span class="badge badge-complete">Implemented</span></h3>
<p>The query list a node sends with a pull merges its public follows with <strong>every posting identity it holds</strong>, so DMs addressed to any of its personas match. The responder's filter, <code>should_send_post()</code>, sends a post when:</p>
<ol style="padding-left: 1.25rem; margin: 0.5rem 0; color: var(--text-muted);">
<li>Author is the requester &rarr; always send (own posts relayed back)</li>
<li>Public or <code>FoFClosed</code> + author in the query list &rarr; send (FoFClosed carries no recipient IDs on the wire; it propagates by author like public content)</li>
<li><code>Encrypted</code> + any wrapped-key recipient in the query list &rarr; send</li>
<li><code>GroupEncrypted</code> + any known group member in the query list &rarr; send</li>
<li>Otherwise &rarr; skip</li>
</ol>
<p>The network NodeId is never an author or recipient (persona split), so it never appears in query lists. This matching machinery survives the v0.8 pull redefinition as the visibility filter on CDN content fetches.</p>
<h3>Manifest machinery <span class="badge badge-complete">Implemented</span></h3>
<p>Each post's <code>AuthorManifest</code> carries the author, creation/update timestamps, a post neighborhood (up to 10 previous + 10 following <code>ManifestEntry</code> items), and an ed25519 signature over the canonical digest. It travels wrapped in a <code>CdnManifest</code> alongside blob responses and via <code>ManifestPush</code> (0x94). On receipt:</p>
<ul style="padding-left: 1.25rem; margin: 0.5rem 0; color: var(--text-muted);">
<li><strong>Signature verified</strong>; unsigned or forged manifests dropped</li>
<li><strong>Newer-wins</strong>: stored only if <code>updated_at</code> beats the local copy</li>
<li><strong>Holder tracking</strong>: the pusher is recorded in <code>file_holders</code> (they evidently hold the file)</li>
<li><strong>Relay</strong>: the manifest is re-pushed to connected file holders of the same CID, excluding the sender</li>
<li><strong>Post discovery</strong>: neighborhood post IDs from followed authors that are missing locally are fetched via <code>PostFetch</code>, capped at 10 per push to avoid storms</li>
</ul>
<p><code>ManifestRefreshRequest</code>/<code>Response</code> (0x92/0x93) let any holder check manifest freshness for a CID and receive the newer copy. This is how a post's neighborhood keeps growing on CDN copies after the author publishes newer posts.</p>
<div class="note">
<strong>v0.8 change</strong>: manifests lose all device-address fields. <code>AuthorManifest.author_addresses</code> and the <code>CdnManifest</code> host metadata (<code>host_addresses</code>, <code>source</code>, <code>source_addresses</code>, <code>downstream_count</code>) link a posting identity to device locations &mdash; a privacy bug &mdash; and the tree fields describe a topology that no longer exists. Holder resolution goes through the uniques index + <code>file_holders</code> instead. The neighborhood is also capped to the 2&ndash;5 post concealment budget (see <a href="#files">Files &amp; Storage</a>).
</div>
<h3>Engagement propagation via file holders <span class="badge badge-complete">Implemented</span></h3>
<p>Reactions, comments, policy changes, slot writes, and FoF ops propagate as <code>BlobHeaderDiff</code> (0xD0) batches through the <strong>flat holder set</strong> &mdash; there is no tree:</p>
<ul style="padding-left: 1.25rem; margin: 0.5rem 0; color: var(--text-muted);">
<li><strong>Push</strong>: on receipt of a diff, the sender is recorded as a holder, ops are verified and applied, the header JSON is rebuilt from the authoritative tables, and the diff is re-propagated to all known <code>file_holders</code> of the post (flat set, max 5), excluding the sender.</li>
<li><strong>Holder registration</strong>: nodes that receive a post via pull or manifest discovery send <code>PostDownstreamRegister</code> (0xD3) to the sender, which simply records them in the sender's <code>file_holders</code> &mdash; ensuring diffs flow both ways.</li>
<li><strong>Pull (safety net)</strong>: tiered polling by engagement freshness &mdash; 5&nbsp;min (&lt;72h), 1&nbsp;hr (3&ndash;14d), 4&nbsp;hr (14&ndash;30d), 24&nbsp;hr (older) &mdash; via a single SQL pass over <code>last_engagement_ms</code>/<code>last_check_ms</code>. <code>BlobHeaderRequest</code> (0xD1) carries the local header timestamp; peers respond with the full header only if theirs is newer. In v0.8 this cadence folds into the <a href="#keep-alive">update cadence system</a> with jitter as traffic shaping.</li>
<li><strong>Tombstones</strong>: deleted reactions and comments keep a <code>deleted_at</code> timestamp instead of being hard-deleted, and propagate through header rebuilds &mdash; preventing re-introduction by peers that missed the deletion.</li>
</ul>
<table>
<tr><th>Diff op</th><th>Purpose</th></tr>
<tr><td><code>AddReaction</code> / <code>RemoveReaction</code></td><td>Signed reaction add; removal by reactor or post author</td></tr>
<tr><td><code>AddComment</code> / <code>EditComment</code> / <code>DeleteComment</code></td><td>Signed comment lifecycle, gated by <code>CommentPolicy</code></td></tr>
<tr><td><code>SetPolicy</code></td><td>Author-controlled comment/react permissions + blocklist</td></tr>
<tr><td><code>ThreadSplit</code></td><td>Register a comment-overflow post</td></tr>
<tr><td><code>WriteReceiptSlot</code> / <code>WriteCommentSlot</code> / <code>AddCommentSlots</code></td><td>Encrypted slot writes (below)</td></tr>
<tr><td><code>FoFRevocation</code> / <code>FoFAccessGrant</code> / <code>FoFKeyBurn</code></td><td>FoF key lifecycle ops, author-signed (<a href="#fof">Friend-of-Friend Visibility</a>)</td></tr>
<tr><td><code>Unknown</code></td><td>serde catch-all: ops from newer versions are silently skipped</td></tr>
</table>
<h3>Engagement security <span class="badge badge-complete">Implemented</span></h3>
<p>Engagement operations are cryptographically verified on receipt to prevent forgery and unauthorized modification:</p>
<ul style="padding-left: 1.25rem; margin: 0.5rem 0; color: var(--text-muted);">
<li><strong>Reaction signatures</strong>: each reaction carries an ed25519 signature over <code>BLAKE3(reactor || post_id || emoji || timestamp_ms)</code>, verified before storing.</li>
<li><strong>Comment signatures</strong>: <code>verify_comment_signature()</code> runs on every comment received via <code>BlobHeaderDiff</code>; forged comments are rejected. FoF-gated comments additionally pass the CDN-level <code>pub_post_set</code> gate (<a href="#fof">FoF</a>).</li>
<li><strong>Reaction removal</strong>: only the original reactor or the post author can remove a reaction; the QUIC-authenticated sender is checked against both.</li>
<li><strong>Comment edit/delete</strong>: only the comment author can edit; comment author or post author can delete. Sender identity comes from QUIC transport authentication (iroh ed25519).</li>
<li><strong>Header author</strong>: rebuilt headers take the author from the stored post, never from the payload &mdash; relay nodes cannot misattribute.</li>
<li><strong>Source of truth</strong>: the <code>reactions</code> and <code>comments</code> tables are authoritative; <code>blob_headers</code> JSON is a derived snapshot rebuilt after each engagement op.</li>
</ul>
<div class="note">
<strong>v0.8 change</strong>: the empty-signature acceptance path for reactions (a v0.6.x <code>#[serde(default)]</code> compat shim) is removed with the clean break &mdash; signatures become mandatory.
</div>
<h3>Encrypted receipt &amp; comment slots <span class="badge badge-complete">Implemented</span></h3>
<p>Private posts (Friends, Circle, Direct) carry encrypted slots in their BlobHeader for delivery receipts, read receipts, reactions, and private comments. The CDN propagates these as opaque bytes &mdash; only participants with the post's CEK can decrypt them. (<code>FoFClosed</code> posts skip this path; their wrap-slot mechanism covers both reads and comments.)</p>
<ul style="padding-left: 1.25rem; margin: 0.5rem 0; color: var(--text-muted);">
<li><strong>Pre-filled noise</strong>: all slots are filled with random bytes at post creation; writing replaces noise with same-size ciphertext, so writes are indistinguishable from creation to observers.</li>
<li><strong>Slot key</strong>: <code>slot_key = BLAKE3_derive_key("itsgoin/slot/v1", CEK)</code> &mdash; only participants who can decrypt the post can read or write slots.</li>
<li><strong>Receipt slots</strong> (64 bytes each): one per participant, index = position in the NodeId-sorted participant list. Decrypted: <code>[1B state][8B timestamp LE][23B emoji/padding]</code>; states empty/delivered/seen/reacted. The author may pre-feed their own slot with a reaction at creation.</li>
<li><strong>Comment slots</strong> (256 bytes each): <code>ceil(participants / 3)</code> initial slots. Decrypted: <code>[32B author][8B timestamp LE][216B UTF-8 + padding]</code>. First-available selection; when exhausted, any participant appends new noise-filled slots via <code>AddCommentSlots</code>.</li>
<li><strong>CDN-safe</strong>: relay nodes store and forward slot bytes without decryption; slots travel inside <code>BlobHeaderDiff</code>, no dedicated messages.</li>
</ul>
<p>DM conversations render these as delivery indicators: sent &rarr; delivered &rarr; seen checkmarks, emoji on react. Opening a conversation auto-marks incoming messages as seen.</p>
<h3>Device roles &amp; bandwidth budgets <span class="badge badge-complete">Implemented</span></h3>
<p>Each node advertises its device role in <code>InitialExchange</code>, which sets hourly budgets for replication (pulling posts to cache) and delivery (serving requests from peers):</p>
<table>
<tr><th>Role</th><th>Replication / hour</th><th>Delivery / hour</th></tr>
<tr><td><strong>Intermittent</strong> (phones)</td><td>100 MB</td><td>1 GB</td></tr>
<tr><td><strong>Available</strong> (desktops)</td><td>200 MB</td><td>2 GB</td></tr>
<tr><td><strong>Persistent</strong> (anchors)</td><td>200 MB</td><td>1 GB</td></tr>
</table>
<ul style="padding-left: 1.25rem; margin: 0.5rem 0; color: var(--text-muted);">
<li>Budgets auto-reset every hour; anchors reserve delivery headroom for browser-facing HTTP serving</li>
<li>Role is self-declared from device type; peers respect advertised budgets when selecting replication targets (<a href="#content">Content Propagation</a>)</li>
</ul>
<h3>Connection rate limiting <span class="badge badge-complete">Implemented</span></h3>
<p>Incoming QUIC connections that fail authentication are rate-limited per source IP to prevent CPU exhaustion from rogue or stale nodes:</p>
<ul style="padding-left: 1.25rem; margin: 0.5rem 0; color: var(--text-muted);">
<li><strong>First 3 failures</strong>: logged normally, connection attempts proceed</li>
<li><strong>4+ failures</strong>: silently dropped with exponential backoff (2<sup>n-3</sup> seconds, capped at ~256s)</li>
<li><strong>Successful connection</strong>: clears the failure count for that IP</li>
<li><strong>Cleanup</strong>: stale entries pruned after 5 minutes of inactivity</li>
</ul>
<h3>Knowledge freshness <span class="badge badge-complete">Implemented</span></h3>
<p>Gossiped knowledge decays unless refreshed. Today's timers, which carry forward under the N1&ndash;N4 uniques model (<a href="#layers">Network Knowledge</a>):</p>
<ul style="padding-left: 1.25rem; margin: 0.5rem 0; color: var(--text-muted);">
<li><strong>TTL</strong>: N2/N3 entries expire after <strong>5 hours</strong> (pruned during the rebalance cycle)</li>
<li><strong>Full state broadcast</strong>: every <strong>4 hours</strong>, nodes re-broadcast complete state (not just diffs) to catch missed incrementals</li>
<li><strong>Disconnect cleanup</strong>: when a mesh peer disconnects, everything they reported is immediately removed (<code>clear_peer_n2</code>/<code>clear_peer_n3</code>)</li>
<li><strong>Startup sweep</strong>: on boot, all gossiped entries and mesh-peer rows are cleared &mdash; stale from the previous session, rebuilt as connections establish</li>
</ul>
<h3>Bootstrap isolation recovery <span class="badge badge-complete">Implemented</span></h3>
<p>Prevents network segments from becoming permanently isolated from the main network:</p>
<ul style="padding-left: 1.25rem; margin: 0.5rem 0; color: var(--text-muted);">
<li><strong>24-hour check</strong>: starting 24 hours after boot, nodes verify the bootstrap anchor is within their knowledge reach</li>
<li><strong>If absent</strong>: reconnect to bootstrap, request referrals, connect to referred peers</li>
<li><strong>Sticky N1</strong>: the bootstrap joins a sticky N1 set for 24 hours, so mesh peers learn about it via routing diffs and can independently bridge back</li>
<li><strong>Self-limiting</strong>: once the bootstrap is within reach, the check passes silently; goes quiet as the network grows</li>
</ul>
<h3>Schema versioning <span class="badge badge-complete">Implemented</span></h3>
<p>SQLite databases track their schema version via <code>PRAGMA user_version</code>. On startup:</p>
<ul style="padding-left: 1.25rem; margin: 0.5rem 0; color: var(--text-muted);">
<li>If the database version is older than <code>MIN_MIGRATABLE_VERSION</code>, the database is reset (preserving identity key)</li>
<li>If older than the current version, data migrations run once and the version is bumped</li>
<li>Schema-level changes are handled by <code>init_tables()</code> (<code>CREATE TABLE IF NOT EXISTS</code>) and <code>migrate()</code> (column-level <code>ALTER TABLE</code> checks)</li>
</ul>
<h3>IPv6 HTTP address advertisement <span class="badge badge-complete">Implemented</span></h3>
<p>Nodes with public IPv6 addresses advertise their actual routable address (from <code>endpoint.addr().ip_addrs()</code>) paired with their bound port, rather than the bind address (<code>0.0.0.0</code>). This enables direct browser-to-node HTTP serving for share links. Unroutable addresses (<code>0.0.0.0</code>, <code>127.x</code>) are filtered out in the tiered web-serving redirect path (<a href="#http-delivery">HTTP Delivery</a>).</p>
</section>
<!-- Encryption -->
<section id="encryption">
<h2>18. Encryption</h2>
<h3>Envelope encryption (1-layer) <span class="badge badge-complete">Implemented</span></h3>
<ol style="padding-left: 1.25rem; margin: 0.5rem 0; color: var(--text-muted);">
<li>Generate random 32-byte CEK (Content Encryption Key)</li>
<li>Encrypt content: <code>ChaCha20-Poly1305(plaintext, CEK, random_nonce)</code></li>
<li>Store as: <code>base64(nonce[12] || ciphertext || tag[16])</code></li>
<li>For each recipient (including self):
<ul style="margin-top: 0.3rem;">
<li>X25519 DH: <code>our_ed25519_private (as X25519) * their_ed25519_public (as montgomery)</code></li>
<li>Derive wrapping key: <code>BLAKE3_derive_key("itsgoin/cek-wrap/v1", shared_secret)</code></li>
<li>Wrap CEK: <code>ChaCha20-Poly1305(CEK, wrapping_key, random_nonce)</code> &rarr; 60 bytes per recipient</li>
</ul>
</li>
</ol>
<p>Recipients are <strong>posting</strong> identities (personas), never network identities. The wrapping key derivation runs against the recipient's posting pubkey; the sender wraps with the authoring persona's posting secret.</p>
<h3>Visibility variants</h3>
<table>
<tr><th>Variant</th><th>Overhead</th><th>Audience limit</th></tr>
<tr><td><code>Public</code></td><td>None</td><td>Unlimited</td></tr>
<tr><td><code>Encrypted { recipients }</code></td><td>~60 bytes per recipient</td><td>~500 (design target; not enforced in code)</td></tr>
<tr><td><code>GroupEncrypted { group_id, epoch, wrapped_cek }</code></td><td>~100 bytes total</td><td>Unlimited (one CEK wrap for the group)</td></tr>
<tr><td><code>FoFClosed</code></td><td>98-byte wrap slot + 32-byte <code>pub_post_set</code> entry per admitted V_x, bucket-padded</td><td>Bucketed (8/16/32/64/128/256, then +128 steps)</td></tr>
</table>
<h3>PostId integrity</h3>
<p><code>PostId = BLAKE3(Post)</code> covers the ciphertext, NOT the recipient list. Visibility is separate metadata. This means visibility can be updated (re-wrapped) without changing the PostId.</p>
<h3>Group keys (circles) <span class="badge badge-complete">Implemented</span></h3>
<ul style="padding-left: 1.25rem; margin: 0.5rem 0; color: var(--text-muted);">
<li>Each circle gets its own ed25519 keypair</li>
<li><code>group_id = BLAKE3(initial_public_key)</code> &mdash; permanent identifier</li>
<li>Group seed wrapped per-member via X25519 DH (KDF domain: <code>"itsgoin/group-key-wrap/v1"</code>)</li>
<li><strong>Epoch rotation</strong>: On member removal, generate new keypair, increment epoch, re-wrap for remaining members</li>
<li><strong>Distribution</strong>: the group seed travels as a normal <em>encrypted post</em> (<code>VisibilityIntent::GroupKeyDistribute</code>) authored by the admin's persona, with each member's posting ID as a recipient. Members receive it via the ordinary CDN / pull paths and unwrap with their posting secret. No dedicated wire push exists &mdash; there is no wire-level signal that one endpoint is coordinating group membership with another.</li>
</ul>
<div class="note">
<strong>v0.8 change</strong>: the orphaned <code>GroupKeyRequest/Response</code> (<code>0xA1</code>/<code>0xA2</code>) pair is deleted &mdash; its handler compares network IDs against posting-ID member lists and can never grant (ID-class bug family), and no sender was ever wired. Post-based distribution is the only path.
</div>
<h3>Three-tier access revocation</h3>
<p>Three levels of revocation, chosen based on threat level:</p>
<div class="card">
<h3>Tier 1: Remove Going Forward (default) <span class="badge badge-complete">Implemented</span></h3>
<p>Revoked member is excluded from future posts automatically. They retain access to anything they already received. This is the default behavior when removing a circle member &mdash; no special action needed.</p>
<p><strong>When to use</strong>: Normal membership changes. Someone leaves a group, you unfollow someone. The common case.</p>
<p><strong>Cost</strong>: Zero. Just stop including them in future recipient lists.</p>
</div>
<div class="card">
<h3>Tier 2: Rewrap Old Posts (cleanup) <span class="badge badge-rework">Rework</span></h3>
<p>Same CEK, re-wrap for remaining recipients only. The revoked member can no longer unwrap the CEK even if they later obtain the ciphertext. The updated visibility propagates as a <em>signed control post</em> authored by the post's persona &mdash; peers verify the control post's signature against the target's author before applying, so only the author can rewrite visibility.</p>
<p><strong>When to use</strong>: Revoked member never synced the post (common with pull-based sync &mdash; encrypted posts only sent to recipients). You want to clean up access lists.</p>
<p><strong>Cost</strong>: One WrappedKey operation per remaining recipient, no content re-encryption.</p>
<p><strong>Required fix</strong>: the entry-point guard compares <code>post.author</code> (a posting ID) against the node's <em>network</em> ID &mdash; always different since the identity split &mdash; so every revoke currently bails before doing anything, and the rewrap is keyed with the wrong identity pair. Same ID-class bug family as the group-key handler. The control-post propagation machinery underneath is correct.</p>
</div>
<div class="card">
<h3>Tier 3: Delete &amp; Re-encrypt (nuclear) <span class="badge badge-rework">Rework</span></h3>
<p>Generate new CEK, re-encrypt content, wrap new CEK for remaining recipients, issue a control-post delete for the old post ID (see <a href="#deletes">Delete Propagation</a>), repost with new content but same logical identity. Well-behaved nodes honor the delete.</p>
<p><strong>When to use</strong>: Revoked member already has the ciphertext and could unwrap the old CEK. Only for content that poses an actual danger/risk if the revoked member retains access. <strong>Recommended against</strong> in most cases.</p>
<p><strong>Cost</strong>: Full re-encryption + delete propagation + new post propagation. Heavy.</p>
<p><strong>Required fix</strong>: shares the Tier-2 entry-point author-check bug.</p>
</div>
<div class="note">
<strong>Trust model</strong>: The app honors delete requests from content authors by default. 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.
</div>
<h3>Private profiles <span class="badge badge-complete">Implemented</span></h3>
<p>Different profile versions per circle, encrypted with the circle/group key. A peer sees the profile version for the most-privileged circle they belong to. <code>CircleProfileUpdate</code> (<code>0xB4</code>) wire message. Public profiles can be hidden (<code>public_visible=false</code> strips display_name/bio).</p>
</section>
<!-- Friend-of-Friend Visibility -->
<section id="fof">
<h2>19. Friend-of-Friend Visibility <span class="badge badge-complete">Implemented</span></h2>
<div class="note">
<strong>Distinct from directory vouches.</strong> The "FoF vouch" described here is a <em>cryptographic</em> primitive for post readership and comment gating (per-persona symmetric key <code>V_me</code>). It is unrelated to the <em>directory vouch</em> system in the <a href="#directory">Directory Trust Layer</a>, which governs discovery-layer trust and bot-ring resistance. The two share vocabulary but operate at different layers.
</div>
<h3>The problem</h3>
<p>Existing visibility variants gate by explicit recipient lists (<code>Encrypted{recipients}</code>) or named-circle membership (<code>GroupEncrypted</code>). Neither expresses "people who are reachable through my social graph" without leaking the graph itself. FoF visibility fills that gap: posts whose readership emerges from <em>cryptographic reachability</em> through a unilateral vouch graph, with no recipient IDs on the wire and no centrally-computed membership lists.</p>
<h3>User-facing model</h3>
<p>Authors pick one of four visibility levels at compose time:</p>
<table>
<tr><th>Level</th><th>Reaches</th></tr>
<tr><td><strong>Public</strong></td><td>All readers (unchanged)</td></tr>
<tr><td><strong>Friends-only</strong></td><td>Personas you have vouched for</td></tr>
<tr><td><strong>Friends-of-Friends</strong></td><td>Your vouchees + every vouchee of anyone who vouched for you (emergent FoF)</td></tr>
<tr><td><strong>Custom</strong> <span class="badge badge-planned">Planned</span></td><td>Author-selected subset of held vouch keys</td></tr>
</table>
<h3>Core primitives</h3>
<ul style="padding-left: 1.25rem; margin: 0.5rem 0; color: var(--text-muted);">
<li><strong><code>V_me</code></strong>: a 32-byte symmetric key owned by each persona. Distributed to everyone the persona vouches for. Anyone holding <code>V_alice</code> can decrypt wrap slots Alice sealed under it.</li>
<li><strong>Keyring</strong>: per-persona, holds the persona's own <code>V_me</code> plus every <code>V_x</code> received from vouchers. The union of these is what makes FoF reach emergent: an author wraps a post slot under every <code>V_x</code> they hold, and any reader whose keyring intersects with that set can decrypt.</li>
<li><strong>Wrap slot</strong>: an anonymous AEAD ciphertext in the post header, sealed under one <code>V_x</code>. Dual-derived: a 48-byte <em>read</em> part carries the post's CEK, a 48-byte <em>sign</em> part carries a per-slot signing seed. With the 2-byte prefilter tag, each slot is 98 bytes on the wire. No recipient ID visible.</li>
<li><strong><code>slot_binder_nonce</code></strong>: a random 32-byte value in the post header that all slot derivations are bound to. It plays the role "post_id in the KDF info" would &mdash; but <code>PostId = BLAKE3(post)</code> depends on the wrap slots themselves, so keying on the PostId would be circular.</li>
<li><strong>Prefilter tag</strong>: 2 bytes, <code>HMAC(V_x, slot_binder_nonce)[:2]</code>, on each slot. Readers precompute tags for keys in their keyring and skip non-matching slots, cutting trial-decrypt cost ~65,000&times; per post.</li>
<li><strong><code>pub_post_set</code></strong>: list of all admitted signing pubkeys for a post's FoF set. Inline in the post header, randomly ordered. Allows CDN-level verification of comment signatures without revealing membership identities.</li>
</ul>
<h3>Distribution: vouches ride bio posts</h3>
<p>Vouches are NOT delivered via DM. Instead, the voucher publishes anonymous sealed wrappers (one per recipient) inside their bio post. The sealing is an <strong>HPKE-style construction</strong> &mdash; per-batch ephemeral X25519 keypair, ECDH against the recipient's persona key, BLAKE3-derived wrapping key and nonce (domain <code>itsgoin/vouch-grant/v1</code>, bound to the bio post's ID), ChaCha20-Poly1305 seal. It is <em>not</em> RFC 9180 HPKE, but provides the same <strong>recipient anonymity</strong> property: wrappers carry no recipient identifier, the KDF context is recipient-free, and every wrapper in a batch shares one ephemeral pubkey. Each wrapper is 48&nbsp;bytes (32-byte sealed <code>V_me</code> + 16-byte AEAD tag).</p>
<p>Readers auto-scan bio posts of accounts they follow, trial-decrypting each wrapper against each of their personas. Cost is ~60&micro;s per wrapper per persona on mobile &mdash; a 200-wrapper bio scanned against 3 personas is ~36&nbsp;ms. The bio's <code>VouchGrantBatch</code> is padded with dummy wrappers and shuffled on every publish so observers see neither vouch-set size nor change-targets.</p>
<h3>Two modes</h3>
<div class="card">
<h3>Mode 2: Public body, FoF-gated comments</h3>
<p>Body is plaintext in the CDN (indexable, cacheable, shardable &mdash; unchanged from existing public-post path). Comments are encrypted under a CEK derived from the wrap-slot CEK, so only FoF members can read them. Non-FoF observers see only ciphertext + signature fields. The compose UI exposes this via <code>CommentPermission::FriendsOfFriends</code>, carried in the post's <code>CommentPolicy</code>.</p>
</div>
<div class="card">
<h3>Mode 1: <code>FoFClosed</code> (body + comments encrypted)</h3>
<p><code>PostVisibility::FoFClosed</code> variant. Body is encrypted under CEK (same wrap-slot mechanism as comments). Both readership and comment authority emerge from keyring intersection with the post's <code>wrap_slots</code>.</p>
</div>
<h3>CDN-level comment verification</h3>
<p>Each wrap slot is dual-derived: one half yields the shared CEK (read capability), the other yields a per-V_x Ed25519 <em>signing</em> keypair (<code>priv_x</code> sealed inside the slot; the corresponding <code>pub_x</code> published in the post's <code>pub_post_set</code>). Comments declare which <code>pub_x</code> signed them and carry the signature.</p>
<p>Propagation nodes verify three things before forwarding a comment:</p>
<ol style="padding-left: 1.25rem; margin: 0.5rem 0; color: var(--text-muted);">
<li><code>pub_x_index</code> points to a valid entry in <code>pub_post_set</code>.</li>
<li>That entry is not in the post's <code>revocation_list</code>.</li>
<li>The comment's <code>group_sig</code> validates against that <code>pub_x</code>.</li>
</ol>
<p>Any failure &rarr; drop, do not forward. This kills the bandwidth-amplification attack that a single admitted-but-malicious FoF member could otherwise mount: their forgeries cannot pass the propagation gate.</p>
<div class="note">
The first-contact mechanism reuses this exact gate: a bio post that opts into contact publishes a <em>greeting-slot</em> key in its <code>pub_post_set</code>, so stranger greetings pass CDN comment verification like any FoF comment. See <a href="#discovery">Discovery &amp; First Contact</a>.
</div>
<h3>Privacy properties</h3>
<ul style="padding-left: 1.25rem; margin: 0.5rem 0; color: var(--text-muted);">
<li><strong>Unilateral</strong>: vouching is a one-way act, no handshake. The FoF graph forms without bilateral negotiation.</li>
<li><strong>Graph-private</strong>: wrap slots carry no recipient IDs. Observers cannot enumerate who can read a post.</li>
<li><strong>Bucketed padding</strong>: slot count and body size are deterministically padded to fixed buckets (power-of-2 up to 256, then +128 steps for slots; same shape up to 256&nbsp;KB then +256&nbsp;KB steps for bodies). Observers learn the bucket, not the position within it.</li>
<li><strong>Recipient anonymity on vouch distribution</strong>: the sealed wrappers' key privacy ensures bio-post wrappers do not reveal recipients.</li>
<li><strong>Per-post chain pseudonym</strong> (accepted tradeoff): the <code>pub_x_index</code> in a comment lets observers correlate "these N comments came through the same chain" within a single post. Cross-post correlation is broken because keys regenerate per post.</li>
</ul>
<h3>Revocation &amp; key lifecycle</h3>
<p>Three complementary mechanisms:</p>
<div class="card">
<h3>Per-post comment revocation (default)</h3>
<p>The author signs a <code>RevocationEntry</code> for a specific <code>pub_x</code> on a specific post. Propagation nodes <strong>delete</strong> locally-stored comments by that signer, remove the entry from <code>pub_post_set</code>, append to <code>revocation_list</code>, and forward the diff. Retroactive: the mesh self-cleans as the diff sweeps through. The same entry type is the off-switch for greeting slots (<a href="#discovery">Discovery &amp; First Contact</a>).</p>
</div>
<div class="card">
<h3>Persona-wide <code>V_me</code> rotation</h3>
<p>To remove a vouchee, the persona generates <code>V_me_new</code> and issues it to every non-revoked vouchee via the next bio-post batch. Revoked vouchees retain <code>V_me_old</code>. Old posts (sealed under <code>V_me_old</code>) stay readable by anyone who holds the old key &mdash; <strong>grandfathered by default</strong>. The CDN does not auto-cascade revocations.</p>
<p>Receivers append new <code>V_me</code> values to their keyring (chain), so newly-issued keys do not invalidate prior ones &mdash; the receiver keeps holding the old key for reading historical content.</p>
</div>
<div class="card">
<h3>Opt-in cascade + key burn (advanced)</h3>
<p>If the author wants to cut off comment authority on old posts after a rotation, they cascade by publishing per-<code>pub_x</code> revocations on each affected post. Local-only <code>own_post_slot_provenance</code> table maps "which pub_x was sealed under which V_me" so the author can target precisely.</p>
<p>For the rare case of a leaked <code>V_me</code>, an optional <code>KeyBurnDiff</code> primitive swaps the V_old wrap slot for a V_new wrap slot in-place on a specific post. Scrubs the leaked key from the CDN copy of old posts. Body CEK unchanged.</p>
</div>
<h3>Performance budget</h3>
<p>At realistic scale (~500 vouchees, ~500 wrap slots per post), reader-side decryption uses an unlock cache: the first time persona <code>P</code> decrypts a post from author <code>A</code> via key <code>V_x</code>, the <code>(P, V_x)</code> tuple is cached. Subsequent posts from <code>A</code> try that key first &mdash; one HMAC + one AEAD attempt in the hot path. Full scan only on cache miss; newly-received <code>V_x</code> triggers a retry sweep over the unreadable-posts table.</p>
<h3>Post-quantum readiness</h3>
<p>Body encryption, wrap slots, and HKDF/HMAC are all symmetric &mdash; PQ-safe. Comment signing uses Ed25519 today; the spec shape is algorithm-agnostic so ML-DSA-65 (~2&nbsp;KB pubkey, ~3.3&nbsp;KB signature) can substitute, optionally with a Merkle-commit variant on <code>pub_post_set</code> to keep header size bounded.</p>
<h3>Implementation</h3>
<p>Full crypto-level byte layouts, data models, wire-format additions, ship criteria, and integration tests are specified in <code>docs/fof-spec/</code>. All five layers shipped in v0.7.0:</p>
<table>
<tr><th>Layer</th><th>Scope</th><th>Status</th></tr>
<tr><td>1</td><td>Vouch primitive (V_x keys, keyring, bio-post sealed wrappers, scan policy)</td><td><span class="badge badge-complete">Implemented</span></td></tr>
<tr><td>2</td><td>Mode 2: public posts with FoF-gated comments, CDN-level verification</td><td><span class="badge badge-complete">Implemented</span></td></tr>
<tr><td>3</td><td>Mode 1: <code>FoFClosed</code> body + wrap slots + anonymous prefilter</td><td><span class="badge badge-complete">Implemented</span></td></tr>
<tr><td>4</td><td>Rotation, revocation, key lifecycle (grandfather + cascade + key-burn)</td><td><span class="badge badge-complete">Implemented</span></td></tr>
<tr><td>5</td><td>Unlock cache + prefilter optimization (perf-critical at scale)</td><td><span class="badge badge-complete">Implemented</span></td></tr>
</table>
</section>
<!-- Delete Propagation & Comment Expiry -->
<section id="deletes">
<h2>20. Delete Propagation &amp; Comment Expiry</h2>
<h3>Deletes are signed control posts <span class="badge badge-complete">Implemented</span></h3>
<p>A delete is a <em>control post</em>: a small public post with <code>VisibilityIntent::Control</code>, signed by the target's authoring persona, naming the PostId to delete. It propagates exactly like any other post &mdash; it enters the author's neighbor manifests (<code>following_posts</code> lists on their other posts) and rides manifest diffs out to <code>file_holders</code>; anyone following any of the author's posts picks it up via the normal CDN / pull paths.</p>
<p>On receipt, a node verifies the control post's signature against the target post's author before applying. Application is atomic with the control-post insert: record in <code>deleted_posts</code> (INSERT OR IGNORE), then DELETE the target from <code>posts</code>. The <code>deleted_posts</code> table also guards against re-ingesting the deleted post from a stale holder later.</p>
<div class="note">
<strong>v0.8 change</strong>: the direct-push delete paths are gone entirely &mdash; <code>DeleteRecord</code> (<code>0x51</code>) uni-stream push and <code>BlobDeleteNotice</code> (<code>0x95</code>) were retired in v0.6.2, and the receive-only <code>0x51</code>/<code>0x52</code> compat handlers die with the v0.8 clean protocol break. Control posts via manifests are the only delete path.
</div>
<h3>Blob cleanup on delete <span class="badge badge-complete">Implemented</span></h3>
<ol style="padding-left: 1.25rem; margin: 0.5rem 0; color: var(--text-muted);">
<li>Locally: blob metadata and CDN records for the post's blobs are removed, blob files deleted from disk.</li>
<li>Remotely: no notice is sent. Copies held by other <code>file_holders</code> become <em>orphans</em> &mdash; nothing references them, nothing re-shares them &mdash; and are evicted naturally by the CDN's LRU eviction cycle (see <a href="#content">Content Propagation</a>).</li>
</ol>
<h3>Comment expiry: rand(30&ndash;365 days) TTL <span class="badge badge-planned">Planned</span></h3>
<p>Every comment gets an expiry timestamp at creation: <code>created_at + rand(30&ndash;365 days)</code>. The expiry is <strong>fixed at creation, signed, and carried with the comment</strong> &mdash; it is part of the comment's identity, not a per-holder policy. Every holder in the network expires the comment at (near) the same moment.</p>
<ul style="padding-left: 1.25rem; margin: 0.5rem 0; color: var(--text-muted);">
<li><strong>Why carried, not local</strong>: if each holder chose its own retention, staggered disappearance would fingerprint individual holders and their policies. A carried timestamp makes expiry a network-wide event that reveals nothing about who holds what.</li>
<li><strong>Why random</strong>: a fixed TTL would let observers date a comment's creation from its expiry. A 30&ndash;365-day uniform draw breaks that inference.</li>
<li><strong>Identity hygiene</strong>: throwaway persona IDs used for greeting comments (<a href="#discovery">Discovery &amp; First Contact</a>) exist in the network only through their comments. When the comments expire, the IDs vanish from uniques lists entirely &mdash; the network forgets them by construction.</li>
<li><strong>No silent extension</strong>: the TTL cannot be edited &mdash; re-posting the content is a new comment with a new ID and a fresh draw.</li>
</ul>
</section>
<!-- Social Graph Privacy -->
<section id="privacy">
<h2>21. Social Graph Privacy &amp; Traffic Shaping</h2>
<h3>What is never shared</h3>
<ul style="padding-left: 1.25rem; margin: 0.5rem 0; color: var(--text-muted);">
<li><strong>Follows</strong> are never shared in gossip or profiles. <span class="badge badge-complete">Implemented</span></li>
<li><strong>Uniques announcements carry bare IDs &mdash; except anchors.</strong> The uniques announce (see <a href="#layers">Network Knowledge</a>) ships NodeIds without addresses; the sole exception is anchor-flagged entries, whose addresses ride the pools by design (self-declared reachable infrastructure &mdash; publishing them links nothing to any person). Node-class and author-class IDs travel in separate packed arrays, and the anchor array is the only address-bearing field, so the exception is structural rather than conventional. <span class="badge badge-complete">Implemented</span></li>
<li><strong>N4 is never re-announced</strong> &mdash; knowledge terminates at the fourth bounce, so an observer M hops away cannot enumerate your neighborhood through transitive gossip. The announce builder simply never reads bounce-4 rows, and third-party anchor reports are never promoted into our own bounce-1 slice &mdash; that promotion would have reset the horizon of every address-bearing entry on every hop, forever. <span class="badge badge-complete">Implemented</span></li>
<li><strong>Posting identities never map to device addresses on the wire.</strong> Addresses attach to <em>network</em> identities only; the persona split (see <a href="#identity-architecture">Identity Architecture</a>) keeps the two namespaces unlinkable. <span class="badge badge-complete">Implemented</span> &mdash; <code>AuthorManifest.author_addresses</code> is gone (Iteration A). The same invariant is enforced a second time in the uniques announce (Iteration C): the wire separates node-class from author-class IDs into distinct arrays so an address can never attach to a persona, and our own personas &mdash; every one of them, plus the author of any comment we wrote locally, which covers per-greeting throwaway IDs &mdash; are excluded from the uniques we announce, because our node ID and anchor address ride the same payload. Our personas stay discoverable through other holders of our content; we must simply never be the reporter. See <a href="#layers">Network Knowledge</a>.</li>
</ul>
<h3>Where addresses do travel, and why</h3>
<p>Routing needs somewhere to connect. Addresses appear in exactly these payload classes, all scoped to <em>network</em> identities:</p>
<ul style="padding-left: 1.25rem; margin: 0.5rem 0; color: var(--text-muted);">
<li><strong>Social-routing peer addresses</strong>: the initial exchange includes addresses of our <em>currently connected mesh peers</em>. These are network endpoints an observer at that vantage could already see us talking to &mdash; sharing them reveals mesh topology (public by design), not social links.</li>
<li><strong>Point lookups</strong>: worm address-requests and lookup responses return addresses for a specific queried network ID.</li>
<li><strong>Connection convection referrals</strong>: anchors hand out addresses of recent callers, and <code>RelayIntroduce</code> coordination carries both ends' addresses for hole-punching (see <a href="#anchors">Anchors</a>).</li>
</ul>
<h3>Uniques lists as cover traffic</h3>
<p>The N1 uniques list deliberately merges mesh peers, social contacts, CDN file authors, CDN file peers, and throwaway/anonymous IDs into one flat set. An observer diffing your announcements over time is looking at a list dominated by high-churn CDN-derived entries and deliberate throwaway-ID noise; the stable social core does not stand out the way it did when the share list was just mesh-plus-contacts. Throwaway IDs additionally age out of the network via comment TTL (<a href="#deletes">above</a>), so the noise floor is self-renewing rather than monotonically accumulating. Our own personas and our own greeting throwaways are the deliberate exception &mdash; they are excluded, because we would be the reporter. <span class="badge badge-complete">Implemented</span></p>
<h3>The timing adversary <span class="badge badge-planned">Planned</span></h3>
<p>With content encrypted and recipient lists off the wire, the remaining deanonymizer is <strong>request-timing correlation</strong>: an observer who can watch traffic sees <em>who fetches what, when</em>. If your device checks an author's CDN seconds after every new post, you are that author's reader; if fresh content always radiates outward from one node first, that node is the author. The v0.8 defenses are behavioral, not cryptographic:</p>
<ul style="padding-left: 1.25rem; margin: 0.5rem 0; color: var(--text-muted);">
<li><strong>Jittered, tiered update cadence</strong>: CDN update checks run on a descending-frequency schedule (minutes &rarr; days) keyed on freshness &times; relationship tier, with jitter on every timer (see <a href="#keep-alive">Update Cadence</a>). No check fires <em>because</em> a post appeared; checks fire because a schedule slot came up.</li>
<li><strong>Replication shaped as overhead</strong>: replication pushes are paced and jittered so an author seeding their own content is indistinguishable from a holder performing routine redundancy maintenance. The goal: update + replication traffic reads as uniform network overhead, with no observable "origin burst".</li>
<li><strong>2&ndash;5 visible posts per author</strong>: the public CDN surface reveals at most 2&ndash;5 posts per author at any time; the surplus is pushed out to holders via replication requests and reached through the uniques index. This caps what enumeration of any single node's public holdings discloses about any author's corpus &mdash; and hides the author's own node behind ordinary public mesh IDs.</li>
<li><strong>Comment TTL</strong>: long-lived comment trails are the easiest correlation anchors across time; the 30&ndash;365-day expiry bounds how long any trail exists.</li>
</ul>
<div class="note">
<strong>Honest scope</strong>: this defeats passive observers correlating timing and public holdings. A global adversary who can watch every link, or an adversary who compromises your own device, is out of scope &mdash; as in every friend-to-friend design.
</div>
</section>
<section id="multidevice">
<h2>22. Identity Management</h2>
<h3>Multi-identity per device <span class="badge badge-complete">Implemented</span></h3>
<p>A single device can hold <strong>multiple identities</strong>, each with its own ed25519 keypair, database, blob store, follows, and posts. One identity is active at a time &mdash; switching performs a hot-swap (Node teardown + rebuild, ~3-5 seconds).</p>
<ul style="padding-left: 1.25rem; margin: 0.5rem 0; color: var(--text-muted);">
<li><strong>Directory structure</strong>: <code>itsgoin-data/identities/{node_id_hex}/</code> &mdash; each identity gets its own subdirectory with <code>identity.key</code>, <code>itsgoin.db</code>, <code>blobs/</code>, and <code>meta.json</code></li>
<li><strong>Create, import, switch, delete</strong> via Settings UI</li>
<li><strong>Key permissions</strong>: <code>identity.key</code> files written with 0600 permissions (Unix)</li>
</ul>
<h3>Multi-device: posting-key bundles <span class="badge badge-complete">Implemented</span></h3>
<p>Devices are linked by sharing <strong>posting keys</strong>, never network keys. Each device keeps its own network key (its own QUIC NodeId); what travels between your devices is the export bundle &mdash; a ZIP containing the identity key, all posting identities, posts, blobs, follows, and settings. Transfer happens out-of-band between your own devices; the network sees no linking message.</p>
<ul style="padding-left: 1.25rem; margin: 0.5rem 0; color: var(--text-muted);">
<li><strong>Export</strong>: Settings &rarr; Export produces the ZIP bundle. All posting keys held by the identity are included (<code>posting_identities.json</code>), so every persona restores on the target device.</li>
<li><strong>Import as personas</strong> (the multi-device path): the bundle&rsquo;s posting keys are added to the current identity&rsquo;s persona set and its posts are inserted <em>as-authored</em> &mdash; original PostIds, authors, and signatures intact. Content encrypted to any imported key becomes decryptable because the device now holds those secrets. Idempotent; duplicates are skipped.</li>
<li><strong>Import as new identity</strong>: alternatively, the bundle can be imported as a separate identity on the device (creates the identity subdirectory from the bundle&rsquo;s key; data restores on first switch).</li>
<li><strong>QR / file-share linking flows</strong> for pairing a new device without manual file transfer <span class="badge badge-planned">Planned</span></li>
</ul>
<div class="note">
<strong>Raw key import restores the network key only.</strong> Pasting a bare <code>identity.key</code> hex string creates an identity around that network key &mdash; it carries <em>no</em> posting identities, posts, or follows. To move personas between devices, use the ZIP bundle.
</div>
<div class="note">
<strong>v0.8 change:</strong> the earlier &ldquo;same identity key on all devices&rdquo; multi-device design is deleted &mdash; it predates the network/posting key split and contradicted it. Devices never share a network key; see <a href="#identity-architecture">Identity Architecture</a>.
</div>
<h3>Post import &amp; merge <span class="badge badge-complete">Implemented</span></h3>
<p>Two ways to bring another identity&rsquo;s posts into the current one, both wired into the import wizard:</p>
<ul style="padding-left: 1.25rem; margin: 0.5rem 0; color: var(--text-muted);">
<li><strong>Import public posts</strong>: public posts from the bundle are re-created under the current identity with new PostIds.</li>
<li><strong>Merge with key</strong>: supply the original identity&rsquo;s key; encrypted posts are decrypted with it, then re-created under the current identity. Posts the original identity could not read are skipped. Original timestamps are preserved and the prior author is recorded in the BlobHeader for provenance.</li>
</ul>
</section>
<section id="phase2">
<h2>23. Reciprocity (Reconsidered) <span class="badge badge-planned">Planned</span></h2>
<p>The original Phase 2 design centered on hosting quotas (3x rule), chunk audits, and tit-for-tat QoS. On reflection, the attention-driven delivery model makes quota enforcement unnecessary. The CDN is a delivery amplifier, not a storage system &mdash; hot content propagates through demand, cold content decays. Authors are responsible for their own content durability.</p>
<p>Tit-for-tat QoS solves the wrong problem: it optimizes for fairness in a storage-obligation model that no longer exists. What matters is that the delivery network functions efficiently, which it does through natural attention dynamics.</p>
<p>If reciprocity mechanisms are needed at scale, they should address <strong>delivery quality</strong> (bandwidth, latency, uptime) rather than storage quotas. This remains an open design area.</p>
</section>
<section id="identity-architecture">
<h2>24. Identity Architecture</h2>
<p>ItsGoin separates <strong>network identity</strong> (per-device routing/connection key) from <strong>posting identity</strong> (the face/persona authoring content). This is the architectural foundation for multi-device, multi-persona, and DM-level traffic-graph privacy &mdash; and it fully survives into v0.8: the uniques index treats posting IDs (including throwaway ones) as first-class searchable entries, and the registry + greeting-comment first-contact design (<a href="#discovery">Discovery &amp; First Contact</a>) builds directly on the split.</p>
<h3>Two layers of identity <span class="badge badge-complete">Implemented</span></h3>
<p>Each device has ONE network key &mdash; used for QUIC connections, endpoint ID, mesh routing. It&rsquo;s never linked on the wire to any posting key.</p>
<p>Each user can hold MANY posting keys simultaneously &mdash; no &ldquo;active&rdquo; persona, no switching. Each posting key is a persona (Public, Private, Work, Family, etc). Posts are signed with the posting key chosen at compose time. The two key layers are <strong>always distinct</strong>: fresh installs generate an independent default posting identity, and the v0.6.1 migration rotates the network key of any install whose default posting key still equaled it. No node&rsquo;s network key ever doubles as a posting key.</p>
<p><strong>Privacy invariant:</strong> peers cannot determine which network IDs belong to a given posting ID, which posting IDs belong to the same network ID, or which posting IDs belong to the same user. These associations are private to the device owner.</p>
<div class="note">
<strong>Known violation &mdash; manifest addresses <span class="badge badge-rework">Rework</span>:</strong> today&rsquo;s AuthorManifest ships the device&rsquo;s real addresses under a posting-identity author, linking persona to location on the wire. The v0.8 target removes device addresses from manifests entirely (holders are found via the CDN holder search, not via addresses embedded in author-signed data). This fix must land before or with the registry &mdash; location-anonymous discovery depends on it. Details in <a href="#files">Files &amp; Storage</a>.
</div>
<h3>Persona types <span class="badge badge-complete">Implemented</span></h3>
<ul>
<li><strong>Public posting IDs</strong> &mdash; main persona(s), openly associated with &ldquo;you&rdquo;</li>
<li><strong>Private posting IDs</strong> &mdash; smaller-context personas for close contacts or specific groups</li>
<li><strong>Contextual posting IDs</strong> &mdash; per-relationship or per-group; user creates one per context and sticks with it</li>
<li><strong>Throwaway posting IDs</strong> &mdash; single-purpose IDs (e.g., the outer signer of a greeting comment). Deliberately kept in uniques lists as network noise; retired naturally by comment TTL expiry (see <a href="#deletes">Deletes &amp; Expiry</a>)</li>
</ul>
<h3>Required fixes: network-ID/posting-ID confusion <span class="badge badge-rework">Rework</span></h3>
<p>The key split left four code paths comparing the device&rsquo;s <em>network</em> NodeId where <em>posting</em> IDs now live. Since the two are always distinct, each path silently fails. All four are required v0.8 fixes (first item on the roadmap):</p>
<ul style="padding-left: 1.25rem; margin: 0.5rem 0; color: var(--text-muted);">
<li><strong>Visibility revocation</strong>: the author check compares <code>post.author</code> (posting ID) against the network ID, so every revoke bails with &ldquo;not the author&rdquo;; the rewrap path also pairs the default posting secret with the network ID.</li>
<li><strong>Group key granting</strong>: the GroupKeyRequest handler checks the requester&rsquo;s network ID against a posting-ID member list and wraps with the network key &mdash; it can never grant. (No send side exists; the message pair is retired in the v0.8 protocol.)</li>
<li><strong>Replication cycle</strong>: under-replicated own posts are queried by network ID, so the set is always empty and the cycle is inert.</li>
<li><strong>Blob-eviction relationship tier</strong>: eviction scoring compares <code>candidate.author</code> (a posting ID) against the network ID, so the 5.0 own-content tier never matches. Masked in practice because own blobs are auto-pinned (pin boost 1000), but the tier is inert.</li>
</ul>
<p>The fix class is uniform: every <code>self.node_id</code> comparison or query against post/persona authors must use posting identities (all of them, not just the default). A full sweep for further instances is part of the fix.</p>
<h3>Multi-device is a special case <span class="badge badge-complete">Implemented</span></h3>
<p>&ldquo;Two devices holding the same posting key&rdquo; is a trivial case of the multi-key model. Link happens out-of-band between the user&rsquo;s own devices via the export/import bundle (<a href="#multidevice">Identity Management</a>) &mdash; the export includes all posting identities so they restore on the second device, which keeps its own network key. The network sees no cross-device message announcing the relationship; each device pulls content for its posting IDs via the normal CDN. The fact that two network nodes hold the same posting key is only discoverable if an observer has private knowledge (which they shouldn&rsquo;t).</p>
<p>In-app QR / file-share flows for easy linking between an existing device and a new one are not wired yet &mdash; today it&rsquo;s manual export-then-import.</p>
<h3>Ephemeral rotating IDs for DM threads <span class="badge badge-planned">Planned</span></h3>
<p>The intent: DM threads and group messages use per-thread unique posting IDs that rotate per message. Each encrypted message would include a handshake field &mdash; the next posting ID to use. Observer sees a stream of distinct posting IDs with no cryptographic tie between them, defeating thread-level traffic correlation. Desync recovery via a sliding window of the last N accepted IDs. Message history kept in a local encrypted-to-self archive that replicates across linked devices via self-follow.</p>
<p>v0.8 removes most of the old blockers. The uniques index deliberately carries throwaway/anonymous posting IDs (they double as network noise), so an ephemeral author is findable through the same distributed search as any other ID; comment TTL expiry retires spent IDs without leaving a permanent trail; and the greeting-comment machinery provides the cold-contact channel. What still needs a crisp design is <em>freshness</em>: how a just-generated posting ID becomes reachable fast enough to receive a reply before the next rotation.</p>
<h3>CDN: per-file holder sets <span class="badge badge-complete">Implemented</span></h3>
<p>Each file (post, blob, manifest) has its own flat holder set &mdash; up to <strong>5 most-recent peers, LRU-capped</strong>. Once a file already has 5 holders, further requesters are redirected to existing holders instead of being registered. Eviction scoring is relationship-tiered (own content &gt; followed &gt; other). There is no tree rooted at the author and no upstream/downstream structure anywhere.</p>
<p>When a new post is created, the creator&rsquo;s updated AuthorManifests (whose neighborhoods now reference the new post) are pushed to each prior post&rsquo;s holder set via <code>ManifestPush</code>; recipients see the reference and fetch the new post via <code>PostFetch</code>. Notifications thus route via network-ID peers who happen to hold related files &mdash; content always arrives via pull, never pushed directly. Full mechanics in <a href="#files">Files &amp; Storage</a> and <a href="#sync">Sync Protocol</a>.</p>
<h3>DM privacy model <span class="badge badge-complete">Implemented</span></h3>
<p>Three mechanisms eliminate the &ldquo;A messaged B&rdquo; traffic signal:</p>
<ol>
<li><strong>CDN-only propagation.</strong> There is no direct push for encrypted posts. All encrypted posts propagate via the file-holder CDN, indistinguishable on the wire from any other encrypted content.</li>
<li><strong>Merged pull + recipient-match.</strong> Content queries are a uniform list of IDs; a serving peer returns posts matching <code>author &isin; query_list</code> OR <code>wrapped_key.recipient &isin; query_list</code>. The client always includes every one of its own posting identities alongside follows, so there is no distinguishable &ldquo;searching for DMs&rdquo; traffic fingerprint. This matching machinery survives the v0.8 pull redefinition &mdash; it runs at the CDN content-fetch step (see <a href="#sync">Sync Protocol</a>).</li>
<li><strong>Greeting comments for cold contact <span class="badge badge-planned">Planned</span>.</strong> First contact from outside the graph arrives as an HPKE-sealed comment on a bio post, signed by a throwaway outer ID &mdash; see <a href="#discovery">Discovery &amp; First Contact</a>.</li>
</ol>
<h3>What the user sees</h3>
<ul>
<li>One merged incoming feed (all content to all personas), with filter-by-persona pills <span class="badge badge-complete">Implemented</span></li>
<li>Per-post &ldquo;(you) as &lt;persona&gt;&rdquo; label on own posts authored by a non-default persona <span class="badge badge-complete">Implemented</span></li>
<li>Persona picker at post-creation time, auto-hidden with a single persona <span class="badge badge-complete">Implemented</span></li>
<li>Settings &rarr; Personas to create, delete, and set default <span class="badge badge-complete">Implemented</span></li>
<li>Reply/comment default persona = whichever key decrypted the post <span class="badge badge-planned">Planned</span></li>
<li>Contextual compose defaults (e.g., posting to a circle auto-picks that circle&rsquo;s last-used persona) <span class="badge badge-planned">Planned</span></li>
<li>DMs to different personas as distinct inbox threads <span class="badge badge-planned">Planned</span></li>
</ul>
<h3>Key/collision safety</h3>
<p>Posting keys and network keys are ed25519 seeds (256 bits of entropy). Birthday paradox reaches 50% collision at ~2<sup>128</sup> keys generated &mdash; not a concern even at aggressive rotation rates across a global userbase. The operational risk is weak RNG during key generation; we rely on the platform CSPRNG everywhere.</p>
<h3>Status</h3>
<p>The persona split is fully implemented: key-layer plumbing, multi-persona storage and UX, CDN-only encrypted propagation, and merged-pull recipient matching are all live. Ephemeral rotating DM IDs remain planned. The network-ID/posting-ID bug family above is the one required rework before v0.8 features (registry, concealment budget) can sit on this foundation.</p>
</section>
<!-- HTTP Post Delivery -->
<section id="http-delivery">
<h2>25. HTTP Post Delivery <span class="badge badge-complete">Implemented</span></h2>
<h3>Intent</h3>
<p>Every ItsGoin node that is publicly reachable can serve its cached public posts directly to browsers over HTTP &mdash; no extra infrastructure, no additional dependencies, no new binary. The same port number used for QUIC (UDP) carries a TCP listener for a minimal raw HTTP/1.1 handler baked into the binary. This makes every publicly-reachable node a browser-accessible content endpoint, enabling share links that deliver content peer-to-browser without routing post bytes through itsgoin.net.</p>
<h3>Dual listener architecture</h3>
<pre><code>&lt;port&gt;/UDP &rarr; QUIC (app protocol)
&lt;port&gt;/TCP &rarr; HTTP/1.1 (read-only, two routes)</code></pre>
<p>Both listeners bind the same port number. The OS routes UDP and TCP to separate sockets &mdash; no conflict, no protocol ambiguity. The handler is raw <code>tokio::net::TcpListener</code>, no HTTP crate, zero new dependencies.</p>
<h3>Routes</h3>
<table>
<tr><th>Route</th><th>Serves</th></tr>
<tr><td><code>GET /p/&lt;postid_hex&gt;</code></td><td>Post rendered as a minimal HTML page (static compiled-in footer, no template engine)</td></tr>
<tr><td><code>GET /b/&lt;blob_hex&gt;</code></td><td>Raw attachment bytes (images/video referenced from the post page via <code>/b/</code> URLs)</td></tr>
</table>
<ul style="padding-left: 1.25rem; margin: 0.5rem 0; color: var(--text-muted);">
<li>Both hex arguments must be exactly 64 <em>lowercase</em> hex characters (BLAKE3 hash). Anything else &mdash; any other path, method, or malformed request &mdash; is a hard close with no response. Do not be helpful to malformed requests.</li>
<li>Posts must be <code>PostVisibility::Public</code>. Blobs are served only if they are an attachment of a public post. Encrypted content is never served over HTTP regardless of what the node holds.</li>
<li>Connections are <strong>keep-alive</strong>: one connection serves sequential requests (the post page, then its attachments) inside its slot.</li>
</ul>
<h3>Connection budget</h3>
<p>The server runs a two-tier slot budget instead of a flat connection cap:</p>
<table>
<tr><th>Budget</th><th>Value</th><th>Purpose</th></tr>
<tr><td>Content slots</td><td>5</td><td>Full service: post pages + blob bytes</td></tr>
<tr><td>Redirect slots</td><td>15</td><td>302-only service when content slots are full</td></tr>
<tr><td>Per-IP cap</td><td>1</td><td>One connection per client IP across both tiers</td></tr>
<tr><td>Header timeout</td><td>5&nbsp;s</td><td>Slow-request defense; exceeded &rarr; hard close</td></tr>
</table>
<p>A new connection tries to acquire a content slot first; if those are full it gets a redirect slot, where post requests are answered with a 302 to another holder (below) and blob requests are hard-closed. Over-budget connections are closed immediately &mdash; no queue, no wait.</p>
<h3>Security constraints</h3>
<table>
<tr><th>Concern</th><th>Mitigation</th></tr>
<tr><td>Connection exhaustion</td><td>5+15 slot budget, 1 per IP, immediate close over budget</td></tr>
<tr><td>Slow HTTP attacks</td><td>5-second read timeout for request headers</td></tr>
<tr><td>Content enumeration</td><td>Identical response (hard close) for &ldquo;not found&rdquo; and &ldquo;not public.&rdquo; No timing oracle, no distinguishable error codes.</td></tr>
<tr><td>Malformed requests</td><td>Hard close only. No error response, not even a 400.</td></tr>
<tr><td>Encrypted content</td><td>Never served. Public-visibility check is mandatory on both routes.</td></tr>
</table>
<h3>Which nodes serve HTTP</h3>
<p>A node starts the HTTP listener only if it is publicly TCP-reachable, meaning any of:</p>
<ul style="padding-left: 1.25rem; margin: 0.5rem 0; color: var(--text-muted);">
<li><strong>Active TCP port mapping</strong> via UPnP-IGD / NAT-PMP / PCP (see <a href="#upnp">Port Mapping</a>). Since v0.7.2 this includes mobile: phones on WiFi/Ethernet behind a permissive NAT serve HTTP too.</li>
<li><strong>Public IPv6 address</strong> &mdash; serves directly (desktop, anchor, or mobile).</li>
<li><strong>Explicit public bind</strong> (servers started with <code>--bind</code>).</li>
</ul>
<p>Nodes behind a NAT that refuses all three mapping protocols cannot serve HTTP but can still appear as holders for app-protocol delivery; the share-link tiers (see <a href="#share-links">Share Links</a>) route around them. A serving node advertises its HTTP capability and address to peers so they can be selected as redirect targets. This is the same direct-reachability signal that drives anchor candidacy (see <a href="#anchors">Anchors &amp; Connection Convection</a>) &mdash; in practice the HTTP-serving population and the anchor-candidate population are the same nodes.</p>
<h3>302 load shedding via file holders</h3>
<p>A redirect-slot request is answered from the flat holder model:</p>
<ol style="padding-left: 1.25rem; margin: 0.5rem 0; color: var(--text-muted);">
<li>Verify the post exists locally and is public (else hard close).</li>
<li>Query <code>file_holders</code> for the post (flat set, cap 5 &mdash; see <a href="#content">Content Propagation</a>).</li>
<li>Filter each holder's known addresses to publicly-routable ones.</li>
<li>TCP-probe candidates with a 200&nbsp;ms timeout; <code>302 &rarr; http://&lt;addr&gt;:&lt;port&gt;/p/&lt;postid&gt;</code> to the first live one.</li>
<li>All dead &rarr; hard close.</li>
</ol>
<p>The receiving node applies the same logic recursively if its own content slots are full. Load spreads across whichever holders are publicly reachable, mirroring the attention-driven holder model at the HTTP layer.</p>
<div class="note">
<strong>v0.8 change:</strong> redirect candidates come from the flat <code>file_holders</code> set; the old <code>post_downstream</code> tree (and the &ldquo;CDN tree&rdquo; framing of HTTP load shedding) is gone.
</div>
</section>
<!-- Share Links -->
<section id="share-links">
<h2>26. Share Links <span class="badge badge-complete">Implemented</span></h2>
<h3>Intent</h3>
<p>Every public post can be shared as a URL that works for both app users and browser users:</p>
<ul style="padding-left: 1.25rem; margin: 0.5rem 0; color: var(--text-muted);">
<li><strong>App installed</strong>: OS intercepts the URL via Universal Links (iOS) / App Links (Android) before the browser loads. App opens directly to the post, fetched via QUIC. Zero browser involvement. (Planned &mdash; see below.)</li>
<li><strong>No app</strong>: Browser loads itsgoin.net, which finds the post on the network and delivers it through the tiered strategy below. The share link becomes a product demo and install opportunity.</li>
</ul>
<h3>URL format <span class="badge badge-complete">Implemented</span></h3>
<pre><code>https://itsgoin.net/p/&lt;postid_hex&gt;</code></pre>
<ul style="padding-left: 1.25rem; margin: 0.5rem 0; color: var(--text-muted);">
<li><code>postid_hex</code>: 64 hex characters (BLAKE3 post hash). That is the whole payload &mdash; no author ID, no host hints, no binary encoding. The anchor resolves holders itself.</li>
<li>Older <code>/p/&lt;postid&gt;/&lt;author&gt;</code> URLs (pre-v0.7.2) still parse; the trailing author hex is used as an extra holder hint for the search rather than encoded into new links.</li>
<li>Only public posts generate share links.</li>
</ul>
<div class="note">
<strong>v0.8 change:</strong> the former Phase&nbsp;2/3 roadmap (holder-hint URLs, base64url hostlist encoding) is dropped. The bare postid is the final format &mdash; holder resolution is the anchor's job, and embedding device addresses in share links would undercut the location-anonymity goals of the v0.8 privacy model.
</div>
<h3>Tiered web serving <span class="badge badge-complete">Implemented</span></h3>
<p>When a browser visits a share link, the itsgoin.net web handler attempts three tiers:</p>
<ol style="padding-left: 1.25rem; margin: 0.5rem 0; color: var(--text-muted);">
<li><strong>302 redirect</strong>: holders advertising HTTP capability with a known public address (and still connected or in session) &mdash; redirect the browser straight to that node's HTTP endpoint. Zero post bytes flow through itsgoin.net.</li>
<li><strong>TCP punch</strong>: holders behind endpoint-independent (EIM) NAT that are connected but not publicly reachable &mdash; the anchor sends <code>TcpPunchRequest</code> (<code>0xD6</code>) asking the holder to punch TCP toward the browser's IP; on success <code>TcpPunchResult</code> (<code>0xD7</code>) returns the HTTP address for a redirect.</li>
<li><strong>QUIC proxy</strong>: serve from local storage if the post is already held; otherwise a worm point-query (see <a href="#worm">Worm Search</a>) finds a holder and <code>PostFetchRequest/Response</code> (<code>0xD4</code>/<code>0xD5</code>) pulls the post (15&nbsp;s budget), which is rendered as HTML and served directly. Posts fetched this way are cached, not permanently hosted &mdash; the proxy is a convenience service, not a data store.</li>
</ol>
<p>If all three tiers fail, the browser gets the unavailable page below.</p>
<h3>itsgoin.net node</h3>
<p>itsgoin.net runs a permanent, well-connected ItsGoin node with the web handler enabled. It is an anchor in the v0.8 sense &mdash; a directly-reachable, opted-in node serving connection convection to joining peers (see <a href="#anchors">Anchors &amp; Connection Convection</a>) &mdash; and its mesh position plus uniques knowledge is what makes share-link holder resolution likely to succeed even for content it has never synced. Scalable via additional instances (<code>1.itsgoin.net</code>, <code>2.itsgoin.net</code>, &hellip;), each with its own mesh connections increasing total search coverage.</p>
<h3>Unavailable page</h3>
<pre><code>&#x2B21; This content isn't currently reachable.
It may be available again when someone
who has it comes back online.
[ Install ItsGoin to find it when it resurfaces ]</code></pre>
<p>This is not a 404. It communicates the honest model: content lives on devices, not servers. Cold content decays. The install CTA is the honest answer to &ldquo;how do I get this.&rdquo;</p>
<h3>Universal Links / App Links <span class="badge badge-planned">Planned</span></h3>
<p>Same URL &mdash; <code>itsgoin.net/p/...</code> &mdash; intercepts to the native app for users who have ItsGoin installed. No separate URL scheme, no <code>app://</code> links. Requires two static JSON files on itsgoin.net:</p>
<pre><code>/.well-known/apple-app-site-association (iOS Universal Links)
/.well-known/assetlinks.json (Android App Links)</code></pre>
<p>App-side: register the URL pattern in Tauri config; on receipt, parse the postid and fetch via QUIC (render immediately if already in local SQLite). Universal Links intercept before the browser loads, so itsgoin.net sees zero traffic for app users. Until this ships, share links always traverse itsgoin.net's tiered serving even on app-installed devices.</p>
<p><strong>iOS caveat</strong>: Universal Links require the app to have been opened manually once before interception activates. A first-time tap goes to the browser fallback &mdash; which is the full product-demo page, the right outcome for a first-time user anyway.</p>
<h3>QR codes</h3>
<p>Share links are valid QR payloads as-is: the full URL is under 90 characters, comfortably scannable at low error correction. A phone camera sees an itsgoin.net URL and offers to open it in the app (once link interception ships) or the browser. No custom QR scheme.</p>
<h3>Chrome HTTPS (October 2026)</h3>
<p>Chrome 154 enables &ldquo;Always Use Secure Connections&rdquo; by default, warning before HTTP sites. This does not affect the architecture: itsgoin.net is HTTPS (Universal Links work normally); the 302 redirect is a header only, taking the browser off the HTTPS page before any content loads; and node HTTP endpoints are raw IP:port addresses, which Chrome exempts from the public-site warning requirement. No changes needed before or after the rollout.</p>
</section>
<!-- Discovery & First Contact -->
<section id="discovery">
<h2>27. Discovery &amp; First Contact <span class="badge badge-planned">Planned</span></h2>
<h3>The problem</h3>
<p>The mesh-CDN architecture deliberately has no global feed and no keyword flood-search (see <a href="#worm">Worm Search</a> for why flooding is rejected). That leaves two gaps: <strong>discovery</strong> &mdash; how do you find a person you don't already have a graph path to? &mdash; and <strong>first contact</strong> &mdash; how does a stranger send you an initial message when every messaging rail (DMs, FoF comments) requires an existing key relationship? This section is the designed answer: an opt-in <strong>registry</strong> for discovery, and <strong>greeting comments</strong> for first contact. Both compose with machinery that already exists (bio posts, CDN comment verification, HPKE-sealed wrappers, RevocationEntry, comment TTL).</p>
<h3>The registry: opt-in discoverable bio posts</h3>
<p>Registration is a flag on a public bio post: <em>&ldquo;this persona wants to be findable.&rdquo;</em> A discoverable bio post links a <strong>public persona</strong> (display name, bio text, avatar) to a <strong>public author ID</strong> (a posting identity) &mdash; and nothing else. There is no directory server; the registry <em>is</em> the set of discoverable-flagged bio posts, replicated as ordinary CDN content.</p>
<ul style="padding-left: 1.25rem; margin: 0.5rem 0; color: var(--text-muted);">
<li><strong>Location-anonymous by construction</strong>: a registry entry maps name &rarr; author ID only. Resolving the author ID to actual content goes through normal CDN holder search &mdash; never through an address in the entry. This depends on the AuthorManifest privacy fix (manifests must not carry device addresses &mdash; see <a href="#files">Files &amp; Storage</a>); the fix must land before or with the registry.</li>
<li><strong>Replication set</strong>: flagging a bio discoverable is explicit consent to wide replication. Discoverable bios form their own replication set that opted-in holders spread beyond the 2&ndash;5-post concealment budget that applies to ordinary content &mdash; registration trades concealment for reach, deliberately and per-persona.</li>
<li><strong>Search</strong>: a searching node fetches the registry post's comment chain (below) via an ordinary pull, then queries locally. No flood queries; search cost lands on the searcher, not the network.</li>
<li><strong>Consent gradient</strong>: registered personas are searchable by anyone. Unregistered personas are reachable only through the graph &mdash; someone must encounter their author ID via the uniques index (see <a href="#layers">Network Knowledge</a>), a comment thread, or a vouch. Nothing about an unregistered persona is indexed.</li>
</ul>
<p>The v0.6.2 Discover tab &mdash; a local list of named, public-visible profiles already held in storage &mdash; is the UI seed for this: the registry generalizes it from &ldquo;profiles I happen to hold&rdquo; to a replicated, searchable set.</p>
<h3>The registry post: rendezvous &amp; enumeration <span class="badge badge-planned">Planned &mdash; tester-critical</span></h3>
<p>Discoverable bios alone leave a gap: how does a searcher <em>enumerate</em> them? The answer is a well-known <strong>&ldquo;registrations here&rdquo; post</strong> whose comment chain is the registry index. A registration is a <strong>signed public comment</strong> on that post &mdash; structurally the same open-slot comment as a greeting (see below), aimed at a well-known target instead of a stranger's bio. One implementation serves both features.</p>
<ul style="padding-left: 1.25rem; margin: 0.5rem 0; color: var(--text-muted);">
<li><strong>The post</strong>: published at network genesis by the default anchor's persona; its ID is derived from a well-known seed the app ships with (<code>shard(seed, k)</code> &mdash; shard count starts at 1, and the derivation scheme exists from day one so splitting by <code>hash(persona_id)</code> later is a threshold change, not a migration). It replicates as ordinary CDN content; anchors hold it by durability, not appointment.</li>
<li><strong>The entry</strong>: a registration comment carries {display name, search keywords, persona ID / public author ID} <strong>signed by the registering persona's posting key</strong> &mdash; self-certifying: any holder can verify the entry against the key it names, no authority consulted.</li>
<li><strong>Self-cleaning</strong>: registration comments carry a <strong>fixed 30-day TTL</strong> (unlike ordinary comments' randomized TTL &mdash; that randomness serves anonymity, which a deliberately-public registration doesn't want). Staying listed means re-signing a fresh entry; holders keep <strong>one active entry per persona ID</strong> (newest wins). The chain self-cleans and is never more than 30 days stale.</li>
<li><strong>Delete / modify</strong>: a delete request signed by the same persona key is honored by every holder (verifiable from the entry itself &mdash; the network agreement is checkable, not honor-system). Modify = delete + re-register; no separate primitive needed.</li>
<li><strong>Moderation hook</strong>: the registry post's author retains the standard per-post <code>RevocationEntry</code> power &mdash; manual spam removal for the alpha era, at the acknowledged cost that the author <em>could</em> censor entries. Acceptable while the author is the project's own anchor; the trust layer (see <a href="#directory">Directory Trust Layer</a>) is the eventual replacement.</li>
<li><strong>Flood limits</strong>: registrations fit a single small size bucket and are rate-capped per holder. <em>Proof-of-work was considered and rejected</em>: its cost lands inverted (a phone registrant pays seconds of battery; a botnet rig pays effectively nothing), so it taxes the users we want while barely inconveniencing the adversary we fear. The registry's real defenses are the bounded blast radius and vouch gating below.</li>
</ul>
<div class="note">
<strong>Honest threat model</strong>: personas are deliberately near-free in this system (throwaway IDs are a feature), so no per-key or proof-of-work measure changes the spam economics against a <em>motivated</em> adversary &mdash; and this network must expect motivated adversaries with an interest in making decentralized social feel broken. The alpha registry survives casual spam only. Two things make that acceptable: (1) <strong>bounded blast radius</strong> &mdash; searches are local and the registry is an opt-in convenience layer, so a fully-flooded registry blocks only <em>new stranger discovery</em> while every existing relationship, follow, vouch, and sync continues untouched; griefing spends real resources to inconvenience only people who haven't met anyone yet. (2) <strong>Vouch-gated registration</strong> (see <a href="#directory">Directory Trust Layer</a>) is the durable fix once utilization supports vouch introductions &mdash; the registry post's <em>format</em> survives that transition, only the acceptance rule tightens. Sharding is deliberately deferred to the same moment: by the time volume demands shards, registration demands vouches.
</div>
<h3>Greeting comments: sealed first contact</h3>
<p>First contact rides the comment rails. A bio post that opts into contact includes a <strong>greeting slot</strong> in its <code>pub_post_set</code> (see <a href="#fof">Friend-of-Friend Visibility</a>) whose signing keypair is published &mdash; derivable by anyone from material in the bio post header. A stranger's greeting comment signed with the greeting key passes the CDN-level comment-verification gate that otherwise drops non-member comments, so it propagates like any FoF comment.</p>
<ul style="padding-left: 1.25rem; margin: 0.5rem 0; color: var(--text-muted);">
<li><strong>Sealed content</strong>: the greeting body is HPKE-sealed to the bio author's posting key. Inside: the sender's <em>real</em> persona ID and a return path. Outside: an opaque ciphertext authored by a <strong>throwaway outer ID</strong>. Observers learn that the bio received <em>a</em> greeting, not from whom.</li>
<li><strong>Blends in</strong>: FoF Mode&nbsp;2 already makes encrypted comment blobs commonplace on public posts. Greetings are one more ciphertext among many &mdash; if everyone's anonymous, anonymity doesn't stand out.</li>
<li><strong>Off-switch</strong>: the author revokes the greeting <code>pub_x</code> via the existing per-post <code>RevocationEntry</code> sweep &mdash; propagation nodes delete stored greetings and stop accepting new ones. No new revocation machinery.</li>
<li><strong>Retirement</strong>: comments expire at a random TTL fixed at creation (see <a href="#deletes">Delete Propagation &amp; Comment Expiry</a>), so throwaway greeting IDs eventually vanish from the network &mdash; and from everyone's uniques lists &mdash; entirely.</li>
</ul>
<h3>Spam controls</h3>
<table>
<tr><th>Control</th><th>Mechanism</th></tr>
<tr><td>Active choice</td><td>No greeting slot in the bio &rarr; no greetings possible. The choice is made visibly at first profile publish &mdash; pre-checked YES with opt-out before publishing (alpha default; revisit for public release). Revocable per persona at any time.</td></tr>
<tr><td>Size bucket</td><td>Greetings fit a single small padded bucket &mdash; no attachments, no long-form spam payloads.</td></tr>
<tr><td>Holder-side count caps</td><td>Nodes holding a bio cap how many unexpired greetings they store and forward per bio.</td></tr>
<tr><td>Rate caps</td><td>Holder-side per-bio limits on unexpired greetings stored and forwarded. (Proof-of-work stamps were considered and rejected &mdash; inverted cost: phones pay, botnets don't.)</td></tr>
</table>
<div class="note">
<strong>Known limit</strong>: the greeting slot's <code>pub_x_index</code> is observable, so greetings are identifiable <em>as a class</em> (never by sender). Mitigation: friends can route innocuous chatter through the open greeting key as padding, so greeting traffic on a bio is not automatically stranger traffic.
</div>
<h3>The composed flow</h3>
<div class="card">
<p><strong>Find</strong> a persona via registry search (or encounter their author ID through the graph) &rarr; <strong>greet</strong> with a sealed greeting comment on their bio post &mdash; real identity and a <em>return path</em> ("where I'll watch for a response") inside &rarr; the author unseals it and <strong>replies</strong> with a sealed message to that return path, from their own fresh temp ID. The conversation continues over <strong>rotating temporary IDs</strong> &mdash; each message names the next rendezvous, no two messages share an outer identity, and observers see only never-before-seen IDs dropping ciphertext on unrelated open-slot posts. No vouch is ever required to talk. <strong>Vouching is fully disconnected from messaging</strong>: it is a relationship act that lives in the People tab &mdash; granting FoF readership via the normal bio-post wrapper batch &mdash; and deliberately has no control on any messaging surface, so vouching someone is always a considered decision, never a reflex mid-conversation. Inbox actions: <em>Reply</em> (primary), <em>Dismiss</em>. People you message surface in People for relationship management. Every temp ID expires with its comment's TTL; nothing permanent links the approach, the conversation, or the relationship that followed.</p>
</div>
<div class="note">
<strong>v1 scope</strong>: return paths start as the participants' bio posts; full rendezvous-hopping across arbitrary open-slot posts follows once an open-slot post index exists. The anonymity set for hopping is exactly the set of posts accepting open-slot comments &mdash; made large by the default-checked greeting choice at first publish.
</div>
</section>
<!-- Directory Trust Layer -->
<section id="directory">
<h2>28. Directory Trust Layer (Future) <span class="badge badge-planned">Planned</span></h2>
<div class="card">
<p><em>The trust layer is an opt-in convenience layer for curated discovery and creator protection. It is not node access &mdash; losing trust-layer standing does not disconnect anyone from the network or from their existing connections. This asymmetry is load-bearing: humans with mature relationships shrug off directory loss; bots and content thieves depend on it entirely.</em></p>
</div>
<div class="note">
<strong>Distinct from FoF cryptographic vouches.</strong> The "vouch" described in this section is a <em>directory-layer</em> trust signal governing discovery and bot-ring resistance. It is unrelated to the <em>cryptographic vouch</em> (<code>V_me</code>) in <a href="#fof">Friend-of-Friend Visibility</a>, which gates post readership and commenting via per-persona symmetric keys. The two share vocabulary but operate at different layers.
</div>
<div class="note">
<strong>v0.8 change:</strong> the directory no longer defines its own entry/listing mechanics. The <a href="#discovery">registry</a> is the entry point &mdash; anyone can register a discoverable bio. The trust layer, when built, curates <em>standing</em> over registry entries: vouch-weighted visibility, moderation, and creator protection on top of the flat registry. Everything below is a future layer beyond the v0.8 scope.
</div>
<h3>Scope</h3>
<ul style="padding-left: 1.25rem; margin: 0.5rem 0; color: var(--text-muted);">
<li><strong>Whitelist track</strong> &mdash; vouch-weighted discoverability and graph-scoped visibility over registry entries.</li>
<li><strong>Blacklist track</strong> &mdash; community-flagged accounts and content; voluntary node-level replication refusal.</li>
<li><strong>Out of scope</strong> &mdash; node access, message delivery, post sync, existing follows, and bare registry presence. All continue to work without trust-layer standing.</li>
</ul>
<p>Because trust-layer loss is a low-cost outcome for real humans and a high-cost outcome for bad actors, enforcement thresholds can be deliberately aggressive without meaningful false-positive risk.</p>
<h3>Vouch capacity</h3>
<p>A member's outbound vouch capacity is derived from received vouches:</p>
<ul style="padding-left: 1.25rem; margin: 0.5rem 0; color: var(--text-muted);">
<li>Base rate: <strong>1 outbound vouch per 30 days per received vouch</strong>, up to a hard cap of <strong>5 outbound vouches per 30 days</strong>.</li>
<li>A registered persona with zero received vouches has zero outbound capacity &mdash; presence in the registry grants findability, never vouch power.</li>
<li>Capacity regenerates; unused capacity does not accumulate beyond the monthly window.</li>
</ul>
<p><strong>Trust signals are internal, not purchasable.</strong> Aged off-platform accounts (FB, X, etc.) can be bought cheaply; in-system tenure crossed with graph density cannot. Vouch weight derives from the voucher's in-system graph depth and their history of non-revoked vouches &mdash; not account age.</p>
<h3>Cascade punishment</h3>
<p>A "bad vouch" is a vouch later determined to have been extended to a bot, impersonator, content thief, or other removable actor. Punishment is asymmetric &mdash; mild for humans who miscalled a single vouch, devastating for botnets whose strength <em>is</em> their dense internal vouch graph.</p>
<table>
<tr><th>Offense</th><th>Immediate consequence</th><th>Recovery requirement</th></tr>
<tr><td>1st bad vouch</td><td>All given and received vouches invalidated (member remains listed, but cannot vouch)</td><td>2 <strong>NEW</strong> vouches received before outbound vouching resumes</td></tr>
<tr><td>2nd bad vouch</td><td>Removed from trust-layer visibility</td><td>1 new vouch to relist + 2 additional new vouches before vouching again</td></tr>
<tr><td>3rd bad vouch</td><td>Removed from trust-layer visibility; 1-year outbound vouch freeze</td><td>4 new vouches to relist; no outbound vouching for 12 months regardless of received vouches</td></tr>
</table>
<p><strong>"NEW" is strictly defined</strong>: a voucher who has never previously vouched this member <em>and</em> is not within the first-degree graph of any prior voucher for this member. This blocks ring members from cycling each other through as "recovery" vouchers.</p>
<p><strong>Cascade radius.</strong> Invalidation of received vouches propagates the effective penalty upward: the bad actor's vouchers lose the credibility granted by that downstream relationship. One verified human assertion against a single botnet node can cascade through the entire dense cluster because bots' own topology carries the invalidation. They build the weapon that points back at them.</p>
<h3>Graph-relative visibility</h3>
<p>Trust-layer discovery is not global. A viewer sees vouched entries <strong>N hops</strong> from their own social graph (N tunable; start with N=3). Bots at the fringe of the graph are structurally invisible to most humans. Visibility is further rate-limited (<strong>Y new profiles per viewer per day</strong>, Y tunable) to make harvesting economically unattractive without affecting normal discovery.</p>
<p>This scoping is also why the vouch system's bot-ring problem is bounded: even a successful ring has no harvest value if it cannot be seen by real users.</p>
<h3>Verification (circuit breaker)</h3>
<p>Verification is an emergency override, not a standing credential. In normal operation, the vouch system runs unassisted. When a creator notices their content suppressed or a bot cluster drowning legitimate signals, they invoke verification on their own terms.</p>
<ul style="padding-left: 1.25rem; margin: 0.5rem 0; color: var(--text-muted);">
<li><strong>Trigger</strong>: creator-initiated only. No automatic verification prompts.</li>
<li><strong>Method</strong>: human interaction + fresh content submission (specifics TBD; must resist CAPTCHA-farmed and LLM-automated completion).</li>
<li><strong>Effect</strong>: verified status outweighs normal vouches for the duration it applies. A verified creator's reports of content theft or impersonation trigger cascade invalidation across the offender's vouch graph.</li>
<li><strong>Cost asymmetry</strong>: a botnet must sustain attacks indefinitely; the defender verifies once.</li>
</ul>
<h3>Reporting</h3>
<p>The reporting pipeline is lightweight, member-facing, and feeds a single review queue with multiple severity tracks:</p>
<ul style="padding-left: 1.25rem; margin: 0.5rem 0; color: var(--text-muted);">
<li><strong>Impersonation</strong> &mdash; a member reports an account copying their (or someone they know's) identity. High-confidence signal: verifiable via profile comparison and content signatures.</li>
<li><strong>Bad vouch</strong> &mdash; a member flags an entry they vouched for, or vouches in their extended graph. Triggers cascade punishment on confirmation.</li>
<li><strong>Content theft</strong> &mdash; a creator reports unauthorized reposting. Evidence is the signed original versus the repost.</li>
<li><strong>Automated topology detection</strong> &mdash; graph analysis flags ring structures, abnormal clustering coefficients, and low-bridging clusters. Feeds the same queue as human reports.</li>
</ul>
<p>Reports from verified accounts carry higher weight. A confirmed report against one member in a tight cluster opens review on the whole cluster.</p>
<h3>Blacklist</h3>
<p>Blacklist is a higher-severity tier than trust-layer suspension. Two states:</p>
<ul style="padding-left: 1.25rem; margin: 0.5rem 0; color: var(--text-muted);">
<li><strong>Under review</strong> &mdash; enough independent reports to warrant scrutiny; member still discoverable but flagged.</li>
<li><strong>Confirmed blacklisted</strong> &mdash; entry persists at the identity level. A bad actor creating a new identity starts from zero; the old identity's blacklist entry remains as a reputational cost that outlasts the account.</li>
</ul>
<p>Escalation paths:</p>
<ul style="padding-left: 1.25rem; margin: 0.5rem 0; color: var(--text-muted);">
<li>Confirmed impersonation &rarr; direct blacklist.</li>
<li>Vouch violations &rarr; trust-layer suspension first, blacklist only on repeated/escalated offenses.</li>
<li>Content theft &rarr; voluntary replication refusal (below); blacklist for repeat offenders.</li>
</ul>
<p><strong>The blacklist must be slower and more evidence-bound than suspension.</strong> Suspension costs a bot everything but costs a human almost nothing; blacklist has real network consequences (below) and must not be a censorship weapon.</p>
<h3>Voluntary network compliance</h3>
<p>Nodes may configure policies to <strong>decline replication or delivery of blacklisted content and accounts</strong>. This is opt-in, not forced. The effect is architectural starvation: stolen or abusive content cannot sustain replication when hosts collectively decline to be its infrastructure, while the legitimately signed original continues propagating normally.</p>
<p>This is the key lever for creator protection. ItsGoin does not enforce copyright; it gives creators a network that structurally prefers their signed original over any derivative copy. Combined with embedded ads (below), there is no version of content theft that economically benefits the thief inside ItsGoin.</p>
<h3>Creator-embedded ads</h3>
<p>The platform does not insert or intermediate ads. Creators may embed ads directly in their content or feed, as part of the signed post.</p>
<ul style="padding-left: 1.25rem; margin: 0.5rem 0; color: var(--text-muted);">
<li>Ads inside signed content cannot be stripped without breaking the ed25519 signature &mdash; tampering is detectable.</li>
<li>A repost that strips ads produces an unsigned-or-differently-signed copy, which compliance rules treat as non-compliant.</li>
<li>A repost that preserves ads intact monetizes the original creator automatically.</li>
<li>Ad revenue therefore follows the legitimate copy by construction, not by enforcement.</li>
</ul>
<h3>Repost framework</h3>
<p>A two-track fair-use model. Compliant reposts are unblocked; non-compliant reposts feed the content-theft reporting pipeline.</p>
<table>
<tr><th>Track</th><th>Content limit</th><th>Ads</th><th>Backlink</th><th>Value requirement</th></tr>
<tr><td>Amplification</td><td>Full</td><td>Must preserve</td><td>Required to signed original</td><td>Reach is value; no further justification needed</td></tr>
<tr><td>Discussion / criticism</td><td>&le;1 minute per 4 minutes of original</td><td>Not required</td><td>Required to signed original</td><td>Commentary, review, or response</td></tr>
</table>
<p>Edge cases (what counts as "amplification" vs. "wholesale copy masquerading as amplification") route to human reviewer capacity. The signed original is cryptographic evidence against the repost in any contested case &mdash; reviewers do not need to take anyone's word for what the original contained.</p>
<h3>Philosophical position</h3>
<p>Most platforms have a structural conflict of interest with creators: fakes inflate engagement metrics, thieves generate content volume, both drive ad revenue. ItsGoin's incentives are inverted by design. Fakes degrade the vouch system; thieves attack the network's most valuable users; bots pollute the replication layer the network depends on. Every bad actor makes ItsGoin worse as software, not just ethically. The enforcement mechanisms above are therefore load-bearing, not policy theater.</p>
<h3>Implementation status</h3>
<p><strong>Designed, not implemented &mdash; and deliberately beyond the v0.8 scope.</strong> Requires:</p>
<ul style="padding-left: 1.25rem; margin: 0.5rem 0; color: var(--text-muted);">
<li>Trust-layer storage schema (vouch records, blacklist records, report queue, graph-density caches)</li>
<li>New protocol messages (vouch, revoke, report, verify challenge/response)</li>
<li>Graph-topology analysis job (automated ring/cluster detection)</li>
<li>Client UX for vouching, reporting, verification</li>
<li>Node-side replication policy config (blacklist honoring)</li>
<li>Review-queue tooling for contested cases</li>
</ul>
<p>Recommended staging: minimum viable slice = registry (see <a href="#discovery">Discovery &amp; First Contact</a>) + single-vouch trust entries + impersonation reports &rarr; manual review queue. Defer cascade math, automated topology detection, verification override, and repost compliance until real graph data exists to calibrate thresholds.</p>
<h3>Tunable parameters</h3>
<table>
<tr><th>Constant</th><th>Initial value</th><th>Purpose</th></tr>
<tr><td>DIRECTORY_VOUCH_INTERVAL</td><td>30 days</td><td>Time between outbound vouches per received vouch</td></tr>
<tr><td>DIRECTORY_VOUCH_CAP</td><td>5</td><td>Hard cap on outbound vouches per 30-day window</td></tr>
<tr><td>DIRECTORY_GRAPH_HOPS</td><td>3</td><td>Visibility radius for discovery</td></tr>
<tr><td>DIRECTORY_DISCOVERY_RATE</td><td>~10 / day (provisional)</td><td>New profiles visible per viewer per day; tune against real graph data</td></tr>
<tr><td>RECOVERY_VOUCHES_1ST</td><td>2</td><td>NEW vouches to restore voucher status after 1st bad vouch</td></tr>
<tr><td>RECOVERY_VOUCHES_2ND</td><td>1 + 2</td><td>Relist + additional vouches after 2nd bad vouch</td></tr>
<tr><td>RECOVERY_VOUCHES_3RD</td><td>4</td><td>Relist vouches after 3rd bad vouch</td></tr>
<tr><td>RECOVERY_FREEZE_3RD</td><td>365 days</td><td>Outbound vouch freeze after 3rd bad vouch</td></tr>
<tr><td>REPOST_DISCUSSION_RATIO</td><td>1:4</td><td>Max embed duration relative to original (discussion track)</td></tr>
</table>
</section>
<!-- Appendix A: Timeout & Interval Reference -->
<section id="timeouts">
<h2>Appendix A: Timeout &amp; Interval Reference</h2>
<p style="color: var(--text-muted); font-size: 0.9rem;">Every constant below exists in code today (cited per row in source comments) or comes from a v0.8 ruling (marked <em>Planned</em>). Constants tied to retired subsystems (preferred peers, register loop, keep-alive pool) are gone; the two registration-model referral constants remain, badged <em>Rework</em>, until the convection window replaces them.</p>
<table>
<tr><th>Constant</th><th>Value</th><th>Purpose</th></tr>
<!-- connection.rs:69 MESH_KEEPALIVE_INTERVAL_SECS = 30 -->
<tr><td>MESH_KEEPALIVE_INTERVAL</td><td>30s</td><td>Ping to prevent zombie detection + NAT timeout</td></tr>
<!-- connection.rs:66 ZOMBIE_TIMEOUT_MS = 600_000 -->
<tr><td>ZOMBIE_TIMEOUT</td><td>600s (10 min)</td><td>No stream activity &rarr; dead connection</td></tr>
<!-- connection.rs:40 SESSION_IDLE_TIMEOUT_MS = 300_000; reap at connection.rs:3396 with exemptions connection.rs:3644-3661 -->
<tr><td>SESSION_IDLE_TIMEOUT</td><td>300s (5 min)</td><td>Reap idle sessions (referral-list peers and known anchors exempt)</td></tr>
<!-- network.rs:820 15s QUIC connect timeout -->
<tr><td>QUIC_CONNECT_TIMEOUT</td><td>15s</td><td>Direct connection establishment</td></tr>
<!-- network.rs:1359 from_secs(3) keepalive probe -->
<tr><td>SESSION_KEEPALIVE_PROBE</td><td>3s</td><td>Per-peer keepalive send timeout; failure marks the connection dead</td></tr>
<!-- connection.rs:46-47 HOLE_PUNCH_TIMEOUT_MS = 30_000, HOLE_PUNCH_ATTEMPT_MS = 2_000 -->
<tr><td>HOLE_PUNCH_TIMEOUT</td><td>30s</td><td>Overall hole punch window (parallel over all known addresses)</td></tr>
<tr><td>HOLE_PUNCH_ATTEMPT</td><td>2s</td><td>Per-attempt timeout before retry within the window</td></tr>
<!-- connection.rs:45 RELAY_INTRO_TIMEOUT_MS = 15_000 (const is dead_code; the live value is the inline 15s at network.rs:1910 / node.rs:3747-3773) -->
<tr><td>RELAY_INTRO_TIMEOUT</td><td>15s</td><td>Relay introduction request round-trip</td></tr>
<!-- connection.rs:51 RELAY_PIPE_IDLE_MS = 120_000; connection.rs:49 RELAY_MAX_BYTES = 50 MB -->
<tr><td>RELAY_PIPE_IDLE</td><td>120s (2 min)</td><td>Session-relay pipe idle before close (opt-in serving only)</td></tr>
<tr><td>RELAY_MAX_BYTES</td><td>50 MB</td><td>Max bytes relayed per pipe before close (opt-in serving only)</td></tr>
<!-- connection.rs:42 RELAY_COOLDOWN_MS = 300_000 (const is dead_code; behavior lives via relay_cooldowns storage + inline literal — audit C) -->
<tr><td>RELAY_COOLDOWN</td><td>300s (5 min)</td><td>Per-target relay cooldown after a failed introduction</td></tr>
<!-- connection.rs:43 RELAY_INTRO_DEDUP_EXPIRY_MS = 30_000; cap 500 entries connection.rs:3399-3405 -->
<tr><td>RELAY_INTRO_DEDUP</td><td>30s (cap 500)</td><td>Dedup window for forwarded introductions (<code>seen_intros</code>)</td></tr>
<!-- connection.rs:35-39 WORM_* constants -->
<tr><td>WORM_TOTAL_TIMEOUT</td><td>3s</td><td>Entire worm search</td></tr>
<tr><td>WORM_FAN_OUT_TIMEOUT</td><td>500ms</td><td>Per-peer fan-out query</td></tr>
<tr><td>WORM_BLOOM_TIMEOUT</td><td>1.5s</td><td>Nova round: follow-up queries to burst-response referrals (historically "wide referrals")</td></tr>
<tr><td>WORM_DEDUP</td><td>10s</td><td>In-flight worm dedup</td></tr>
<tr><td>WORM_COOLDOWN</td><td>300s (5 min)</td><td>Miss cooldown before retry</td></tr>
<!-- connection.rs:61 REFERRAL_DISCONNECT_GRACE_MS = 120_000; connection.rs:63 REFERRAL_LIST_CAP = 50 -->
<tr><td>CONVECTION_WINDOW_SIZE</td><td>16</td><td><span class="badge badge-complete">Implemented</span> Rolling caller window on the anchor. Small on purpose: the point is recency, not coverage. (Retired <code>REFERRAL_DISCONNECT_GRACE</code> 120s and <code>REFERRAL_LIST_CAP</code> 50 from the registration model.)</td></tr>
<tr><td>CONVECTION_HANDOUT_CAP</td><td>2</td><td><span class="badge badge-complete">Implemented</span> Hand-outs before a window entry rotates out ("2 before, 2 after")</td></tr>
<tr><td>CONVECTION_REFERRALS</td><td>2</td><td><span class="badge badge-complete">Implemented</span> Referrals returned per served request</td></tr>
<tr><td>CONVECTION_ENTRY_MAX_AGE</td><td>15 min</td><td><span class="badge badge-complete">Implemented</span> Older window entries are not handed out &mdash; a stale address costs the next caller a punch timeout</td></tr>
<tr><td>CONVECTION_SUPPLEMENT_CAP</td><td>2 per 15 min</td><td><span class="badge badge-complete">Implemented</span> Hand-out budget for mesh peers used to supplement a sparse window (address-free, introduction-only)</td></tr>
<tr><td>ANCHOR_REFUSAL_PENALTY</td><td>5 min</td><td><span class="badge badge-complete">Implemented</span> How long a refusing anchor is de-prioritised; the penalty map is swept on every insert</td></tr>
<tr><td>UNIQUES read cap</td><td>2 MB</td><td><span class="badge badge-complete">Implemented</span> Dedicated ceiling for 0x01/0x40/0x41, distinct from the 64 MB file-transfer cap</td></tr>
<tr><td>UNIQUES build / accept caps</td><td>4,000 &amp; 12,000 / 8,000 &amp; 24,000</td><td><span class="badge badge-complete">Implemented</span> Per-slice build caps (shallow, terminal) and per-bounce accept caps (N2&ndash;N3, N4); received slices are truncated, not rejected</td></tr>
<!-- connection.rs:3243 prune_n2_n3(5*60*60*1000); disconnect clear connection.rs:3127-3129; boot sweep node.rs:305-311 -->
<tr><td>KNOWLEDGE_STALE_PRUNE</td><td>Immediate on disconnect + 5h fallback</td><td>Remove reach entries tagged to disconnected reporters; age fallback for stragglers. Boot sweep clears all non-mesh entries.</td></tr>
<!-- connection.rs:55 WATCHER_EXPIRY_MS = 30 days; prune at connection.rs:3247 -->
<tr><td>RECONNECT_WATCHER_EXPIRY</td><td>30 days</td><td>Reconnect-notification watchers pruned after expiry</td></tr>
<!-- network.rs:1463-1475 run_growth_loop blocks on signal channel (no timer); backoff network.rs:1561-1661 -->
<tr><td>GROWTH_LOOP</td><td>Signal-driven (no timer)</td><td>Runs on disconnects, knowledge additions, rebalance backstop; 3-failure backoff, 500ms pause between attempts</td></tr>
<!-- node.rs:4100 start_rebalance_cycle; callers pass 600 (cli/main.rs:219, tauri lib.rs) -->
<tr><td>REBALANCE_CYCLE</td><td>600s (10 min)</td><td>Dead/zombie sweep, knowledge prune, session reap, growth backstop</td></tr>
<!-- node.rs:4060 start_diff_cycle; callers pass 120; full_sync_interval = 4h at node.rs:4062-4080 -->
<tr><td>ROUTING_DIFF_CYCLE</td><td>120s</td><td>Incremental knowledge diff broadcast to connected peers</td></tr>
<tr><td>FULL_STATE_BROADCAST</td><td>4h</td><td>Full knowledge-state broadcast (diff-loss repair)</td></tr>
<!-- node.rs:4151 from_secs(2) debounce in start_recovery_loop -->
<tr><td>RECOVERY_DEBOUNCE</td><td>2s</td><td>Recovery loop debounce after the mesh &lt; 2 signal</td></tr>
<!-- node.rs:4496 add_sticky_n1(…, 24h) -->
<tr><td>STICKY_N1_TTL</td><td>24h</td><td>Bootstrap peer advertised as sticky N1 (isolation recovery)</td></tr>
<!-- node.rs:4011 interval 60s (param ignored); node.rs:4032 get_stale_follows(4h) -->
<tr><td>PULL_CYCLE_TICK</td><td>60s</td><td>Pull cycle tick; first tick full pull. <em>v0.8: replaced by the update-cadence scheduler.</em></td></tr>
<tr><td>SELF_LAST_ENCOUNTER</td><td>4h</td><td>Per-author staleness threshold before a pull fires</td></tr>
<!-- storage.rs:1638-1651 get_posts_due_for_engagement_check (5min/1h/4h/24h keyed on 72h/14d/30d engagement recency) -->
<tr><td>ENGAGEMENT_CHECK_TIERS</td><td>5 min / 1h / 4h / 24h</td><td>Freshness-keyed engagement re-check (active 72h / recent 14d / aging 30d / cold). Seed of the v0.8 update-cadence scheduler.</td></tr>
<!-- node.rs:5905-5998 replication cycle; 600s from cli/main.rs:228; 120s initial delay; 72h window node.rs:5924 -->
<tr><td>REPLICATION_CYCLE</td><td>600s (120s initial delay)</td><td>Own-post replication check: posts &lt;72h old with &lt;2 known holders. <em>Currently inert (author-lookup bug, Roadmap item 1).</em></td></tr>
<!-- upnp.rs:128 timeout(Duration::from_secs(3), watch loop) -->
<tr><td>PORTMAP_DISCOVERY_WAIT</td><td>3s</td><td>Watch-channel wait for first router response across UPnP-IGD / NAT-PMP / PCP (never blocks startup; renewal is internal to <code>portmapper</code>)</td></tr>
<!-- network.rs:302 threshold = from_secs(300) in the reachability watcher -->
<tr><td>ANCHOR_REACHABILITY_LOSS</td><td>300s (5 min)</td><td>Clear <code>is_anchor</code> after sustained port-mapping loss; restored on recovery</td></tr>
<!-- connection.rs:918-920 probe_due: last probe > 30 min -->
<tr><td>ANCHOR_PROBE_INTERVAL</td><td>30 min</td><td>Anchor self-verification re-probe spacing. <em>Rework: today's gate also requires &ge;50 connections + 2h uptime (dying peer-count model); v0.8 re-keys candidacy to the reachability watcher.</em></td></tr>
<!-- node.rs:116-119 BATCH_SIZE=3, BATCH_STAGGER_SECS=2, PER_ANCHOR_TIMEOUT_SECS=10, STALE_THRESHOLD_MS=3 days -->
<tr><td>BOOTSTRAP_BATCH_SIZE</td><td>3</td><td>Concurrent anchor probes in flight</td></tr>
<tr><td>BOOTSTRAP_BATCH_STAGGER</td><td>2s</td><td>Delay between probe batch dispatches</td></tr>
<tr><td>BOOTSTRAP_PER_ANCHOR_TIMEOUT</td><td>10s</td><td>Per-anchor probe timeout during batched bootstrap</td></tr>
<tr><td>ANCHOR_STALE_THRESHOLD</td><td>3 days</td><td>Failed probe to an anchor with <code>last_seen</code> older than this deletes the <code>known_anchors</code> row</td></tr>
<!-- network.rs:2042-2043 NAT filter probe 10s timeout -->
<tr><td>NAT_FILTER_PROBE_TIMEOUT</td><td>10s</td><td>Third-party NAT filtering probe at startup anchor contact</td></tr>
<!-- http.rs:23 HEADER_TIMEOUT_SECS = 5; redirect probe http.rs:405-412 -->
<tr><td>HTTP_HEADER_TIMEOUT</td><td>5s</td><td>HTTP request header read timeout</td></tr>
<tr><td>HTTP_REDIRECT_PROBE</td><td>200ms</td><td>TCP liveness probe of a holder before issuing a 302</td></tr>
<!-- Ruling: project_v08_mesh_redesign.md round 4 — per-disconnect stochastic action replaces threshold+jitter -->
<tr><td>CONVECTION_ACTION</td><td>stochastic, per-disconnect</td><td><span class="badge badge-complete">Implemented</span> On each mesh disconnect: random {nothing | anchor introduction | mesh-peer introduction}; weights adaptive = local anchor-density prior + refusal feedback</td></tr>
<tr><td>CONVECTION_CLASS</td><td>entry / top-up (1 bit)</td><td><span class="badge badge-complete">Implemented</span> Entry (&lt;2 connections) always served; top-up refused cheaply under load &mdash; refusal feeds the adaptive weights</td></tr>
<!-- Ruling: project_v08_mesh_redesign.md — comment TTL rand(30365d) fixed at creation -->
<tr><td>COMMENT_TTL</td><td>rand(30&ndash;365 days)</td><td><span class="badge badge-planned">Planned</span> Comment expiry, fixed at creation and carried with the comment</td></tr>
<!-- Ruling: project_v08_mesh_redesign.md — descending update cadence minutes→days -->
<tr><td>UPDATE_CADENCE</td><td>minutes &rarr; days</td><td><span class="badge badge-rework">Rework</span> Descending-frequency check scale keyed on freshness &times; relationship tier, jittered (seed: ENGAGEMENT_CHECK_TIERS above; see <a href="#keep-alive">Update Cadence &amp; Keep-Alive</a>)</td></tr>
</table>
</section>
<!-- Appendix B: Design Constraints -->
<section id="constraints">
<h2>Appendix B: Design Constraints</h2>
<table>
<tr><th>Constraint</th><th>Value</th><th>Notes</th></tr>
<!-- Target per ruling (~20 mesh); current code Preferred/Local/Wide 10/71/20 desktop, 3/7/5 mobile (types.rs:657-676) -->
<tr><td>Mesh slots</td><td>~20 (Desktop) / 15 (Mobile)</td><td>Single pool, no tiers. <span class="badge badge-rework">Rework</span> &mdash; current code runs 101/15 across a Preferred/Local/Wide split</td></tr>
<!-- Ruling round 3: +4..+10 temp referral slots, no knowledge exchange -->
<tr><td>Temp referral slots</td><td>+10 desktop / +4 mobile, above the cap</td><td><span class="badge badge-complete">Implemented</span> Introduction facilitation only; no uniques or peer-address exchange; graduate or expire (5 min TTL)</td></tr>
<!-- Ruling: 20^4, N4 terminates; mobile 3 bounces / Bloom N4 -->
<tr><td>Knowledge depth</td><td>4 bounces (N4 terminates)</td><td><span class="badge badge-planned">Planned</span> Mobile may hold 3 bounces or Bloom-compress N4</td></tr>
<!-- types.rs:678-683 session_slots 20/5 -->
<tr><td>Session slots</td><td>20 (Desktop) / 5 (Mobile)</td><td>Oldest-idle evicted at capacity</td></tr>
<!-- types.rs:685-690 max_relay_pipes 10/2 -->
<tr><td>Relay pipes</td><td>10 (Desktop) / 2 (Mobile)</td><td>Session relay is opt-in, OFF by default on every role including anchors</td></tr>
<!-- connection.rs:33 MAX_PAYLOAD = 64 MB -->
<tr><td>Max payload (wire)</td><td>64 MB</td><td>Length-prefixed JSON framing</td></tr>
<!-- node.rs:1383 10MB; node.rs:1380 4 attachments -->
<tr><td>Max blob size</td><td>10 MB</td><td>Per attachment</td></tr>
<tr><td>Max attachments per post</td><td>4</td><td></td></tr>
<!-- storage.rs:5675-5709 touch_file_holder LRU cap 5; redirect connection.rs:5668 -->
<tr><td>File holders per content</td><td>5 (LRU)</td><td>Flat <code>file_holders</code> set; registration past 5 answers with a holder redirect</td></tr>
<!-- Ruling: publicly reveal only 25 posts per author -->
<tr><td>Publicly revealed posts per author</td><td>2&ndash;5</td><td><span class="badge badge-planned">Planned</span> Concealment budget; surplus pushed out via replication (see <a href="#privacy">Social Graph Privacy</a>)</td></tr>
<!-- Ruling: comment TTL rand(30365d) fixed at creation -->
<tr><td>Comment TTL</td><td>rand(30&ndash;365 days)</td><td><span class="badge badge-planned">Planned</span> Fixed at creation, carried with the comment; retires throwaway greeting IDs</td></tr>
<!-- crypto.rs:522 per-recipient wrapping; ~500/256KB never enforced (audit B7) -->
<tr><td>Max recipients (per-recipient wrapping)</td><td>~500 / 256 KB</td><td>Design target for WrappedKey lists; not enforced in code</td></tr>
<tr><td>Public post encryption overhead</td><td>Zero</td><td>No WrappedKeys, unlimited audience</td></tr>
<!-- storage.rs:2213-2296 upsert/list (success_count DESC, cap 5); node.rs:240-255 3-day prune -->
<tr><td><code>known_anchors</code> table</td><td>{ node_id, addresses, last_seen, success_count }</td><td>Ranked by <code>success_count</code>, capped at 5; failed probes to rows older than 3 days self-prune</td></tr>
<!-- Ruling D6: zero users, clean break; ALPN protocol.rs:9 -->
<tr><td>Wire compatibility</td><td>None</td><td>v0.8 is a clean protocol break from &le;v0.7.x (zero-users ruling). ALPN bumps <code>itsgoin/3</code> &rarr; <code>itsgoin/4</code> so old nodes are refused at handshake (see <a href="#sync">Sync Protocol</a>)</td></tr>
</table>
</section>
<!-- Appendix C: Scorecard (Appendix E of the previous edition is folded in here) -->
<section id="scorecard">
<h2>Appendix C: Implementation Scorecard</h2>
<p style="color: var(--text-muted); font-size: 0.9rem;">Code status as of v0.7.3, measured against the v0.8 target. <span class="badge badge-complete">Implemented</span> = code matches the target today. <span class="badge badge-rework">Rework</span> = built, but differently; migration required. <span class="badge badge-planned">Planned</span> = not built.</p>
<table class="scorecard">
<tr><th>Area</th><th>Status</th></tr>
<tr><td colspan="2" style="background: var(--bg-muted); font-weight: 600;">Mesh &amp; knowledge</td></tr>
<tr><td>Single ~20-slot mesh pool (tiers eliminated)</td><td><span class="badge badge-complete">Implemented</span> &mdash; Done (Iteration C): one pool, 20 desktop / 15 mobile</td></tr>
<tr><td>Preferred-peer elimination (slots, MeshPrefer, prunes, watchers)</td><td><span class="badge badge-complete">Implemented</span> &mdash; Done (Iteration B, Roadmap item 3)</td></tr>
<tr><td>Temp referral slots (+4..+10, no knowledge exchange)</td><td><span class="badge badge-complete">Implemented</span> &mdash; Done (Iteration C): +10 desktop / +4 mobile, above the cap, never evicting a mesh peer</td></tr>
<tr><td>Uniques announce, payload N1&ndash;N3 (N4 stored but terminates, dedup at store + announce)</td><td><span class="badge badge-complete">Implemented</span> &mdash; Done (Iteration C): two-pool announce, terminal N3, single <code>reachable</code> table, size caps enforced</td></tr>
<tr><td>Pull as uniques-index exchange (distributed search index)</td><td><span class="badge badge-complete">Implemented</span> &mdash; Done (Iteration C); post bodies moved to the transitional 0x46/0x47 content sync</td></tr>
<tr><td>Growth loop (signal-driven, diversity scoring, stochastic anchor path)</td><td><span class="badge badge-complete">Implemented</span> &mdash; Done (Iteration C): whole-pool counting, bounded SQL shortlist, graded depth penalty, per-disconnect stochastic action</td></tr>
<tr><td>Worm search (point queries: posts, blobs, nodes; no keyword flooding)</td><td><span class="badge badge-complete">Implemented</span></td></tr>
<tr><td>Social routing cache + checkins + reconnect watchers</td><td><span class="badge badge-complete">Implemented</span> &mdash; preferred_tree field retires with preferred peers</td></tr>
<tr><td>Update-cadence scheduler (freshness &times; relationship, jitter, dedup)</td><td><span class="badge badge-rework">Rework</span> &mdash; freshness-only engagement tiers exist</td></tr>
<tr><td colspan="2" style="background: var(--bg-muted); font-weight: 600;">Anchors &amp; connectivity</td></tr>
<tr><td>Reachability-based anchor candidacy (portmapper watcher, bidirectional)</td><td><span class="badge badge-complete">Implemented</span></td></tr>
<tr><td>Anchor opt-in gate</td><td><span class="badge badge-planned">Planned</span> &mdash; reachable desktops auto-anchor today</td></tr>
<tr><td>Connection convection (2-before/2-after rolling referrals, per-disconnect stochastic trigger)</td><td><span class="badge badge-complete">Implemented</span> &mdash; Done (Iteration C): rolling window, entry/top-up classes, adaptive weights, 0xC0 register loop retired</td></tr>
<tr><td>Batched bootstrap probes + 3-day stale-anchor self-prune</td><td><span class="badge badge-complete">Implemented</span></td></tr>
<tr><td>Anchor self-verification probe (3rd-party witness)</td><td><span class="badge badge-complete">Implemented</span> &mdash; scheduling re-keys to the watcher in v0.8</td></tr>
<tr><td>Port mapping (UPnP-IGD + NAT-PMP + PCP via <code>portmapper</code>, all platforms)</td><td><span class="badge badge-complete">Implemented</span></td></tr>
<tr><td>Relay introduction + hole punch (single quick + 30s parallel)</td><td><span class="badge badge-complete">Implemented</span></td></tr>
<tr><td>NAT type detection (STUN) + filter probe</td><td><span class="badge badge-complete">Implemented</span></td></tr>
<tr><td>EDM port scanner via raw UDP (bypassing iroh's path store)</td><td><span class="badge badge-planned">Planned</span> &mdash; disabled body preserved as <code>edm_port_scan_disabled_v0_7_3</code></td></tr>
<tr><td>Session relay (opt-in byte pipe, OFF by default, both sides gated)</td><td><span class="badge badge-complete">Implemented</span></td></tr>
<tr><td>Connection rate limiting (per-IP failure backoff)</td><td><span class="badge badge-complete">Implemented</span></td></tr>
<tr><td>LAN discovery &mdash; passive mDNS address lookup</td><td><span class="badge badge-complete">Implemented</span></td></tr>
<tr><td>LAN discovery &mdash; active flow (subscribe, LAN sessions, sync priority)</td><td><span class="badge badge-planned">Planned</span> &mdash; low priority</td></tr>
<tr><td colspan="2" style="background: var(--bg-muted); font-weight: 600;">Identity &amp; personas</td></tr>
<tr><td>Network &harr; posting identity split + v0.6.1 key rotation</td><td><span class="badge badge-complete">Implemented</span></td></tr>
<tr><td>Multi-persona per device (create/switch/import/export)</td><td><span class="badge badge-complete">Implemented</span></td></tr>
<tr><td>Posting-key ZIP bundles: export, import-as-personas, post import &amp; merge</td><td><span class="badge badge-complete">Implemented</span></td></tr>
<tr><td>ID-class bug fixes (revocation, group-key grant, replication lookup, eviction own-content tier)</td><td><span class="badge badge-rework">Rework</span> &mdash; Roadmap item 1</td></tr>
<tr><td>Ephemeral rotating DM identity</td><td><span class="badge badge-planned">Planned</span></td></tr>
<tr><td>Throwaway-ID retirement via comment TTL</td><td><span class="badge badge-planned">Planned</span></td></tr>
<tr><td colspan="2" style="background: var(--bg-muted); font-weight: 600;">Encryption &amp; visibility</td></tr>
<tr><td>Envelope encryption (X25519 + ChaCha20-Poly1305, <code>itsgoin/cek-wrap/v1</code>)</td><td><span class="badge badge-complete">Implemented</span></td></tr>
<tr><td>Group keys as encrypted posts + epoch rotation</td><td><span class="badge badge-complete">Implemented</span></td></tr>
<tr><td>Tier-2/3 revocation (control-post propagation)</td><td><span class="badge badge-rework">Rework</span> &mdash; machinery built; entry-point author-check bug blocks every revoke</td></tr>
<tr><td>Private profiles per circle</td><td><span class="badge badge-complete">Implemented</span></td></tr>
<tr><td>FoF visibility Layers 1&ndash;5 (vouch, gated comments, FoFClosed, rotation/burn, unlock cache)</td><td><span class="badge badge-complete">Implemented</span></td></tr>
<tr><td colspan="2" style="background: var(--bg-muted); font-weight: 600;">Sync &amp; content</td></tr>
<tr><td>Wire protocol (length-prefixed JSON, 64 MB payload)</td><td><span class="badge badge-complete">Implemented</span> &mdash; six types (0x51/0x52/0x71/0xA1/0xA2/0xB3) deleted in the clean-break purge (Iteration B); 0xC0 retired with anchor convection (Iteration C); the uniques opcodes read against a 2 MB cap</td></tr>
<tr><td>Merged pull with recipient matching (all posting identities)</td><td><span class="badge badge-complete">Implemented</span> &mdash; payload semantics change with the uniques redefinition</td></tr>
<tr><td>Manifest machinery (push, refresh, header diffs)</td><td><span class="badge badge-complete">Implemented</span></td></tr>
<tr><td>AuthorManifest privacy (no device addresses, 2&ndash;5 neighborhood)</td><td><span class="badge badge-rework">Rework</span> &mdash; code ships addresses + 10+10 neighborhood</td></tr>
<tr><td>Engagement propagation via flat <code>file_holders</code> + signature verification</td><td><span class="badge badge-complete">Implemented</span></td></tr>
<tr><td>Deletes as signed control posts (+ LRU blob orphaning)</td><td><span class="badge badge-complete">Implemented</span></td></tr>
<tr><td>Comment TTL expiry (rand 30&ndash;365d, fixed at creation)</td><td><span class="badge badge-planned">Planned</span></td></tr>
<tr><td>Author concealment (2&ndash;5 visible posts) + traffic-uniformity shaping</td><td><span class="badge badge-planned">Planned</span></td></tr>
<tr><td>Push notifications, reactions, comments, tombstones, auto-sync on follow</td><td><span class="badge badge-complete">Implemented</span></td></tr>
<tr><td>Audience sharding (large-audience optimization)</td><td><span class="badge badge-planned">Planned</span></td></tr>
<tr><td colspan="2" style="background: var(--bg-muted); font-weight: 600;">Files &amp; CDN</td></tr>
<tr><td>Blob storage (BLAKE3, 256-shard layout) + transfer with delivery budgets</td><td><span class="badge badge-complete">Implemented</span></td></tr>
<tr><td>Flat holder model (cap 5, LRU, redirect at capacity)</td><td><span class="badge badge-complete">Implemented</span></td></tr>
<tr><td>Blob eviction (pin / share-boost / relationship-tier scoring)</td><td><span class="badge badge-complete">Implemented</span></td></tr>
<tr><td>Active replication cycle (10 min, 72h window, &lt;2 holders)</td><td><span class="badge badge-rework">Rework</span> &mdash; cycle ships but is inert (author-lookup bug); becomes the concealment push-out path</td></tr>
<tr><td>Erasure-coded CDN replication (3-of-10 sub-threshold shards)</td><td><span class="badge badge-planned">Planned</span></td></tr>
<tr><td>Pin modes (anchor pin / fork pin / personal vault)</td><td><span class="badge badge-planned">Planned</span></td></tr>
<tr><td colspan="2" style="background: var(--bg-muted); font-weight: 600;">Web, share links &amp; discovery</td></tr>
<tr><td>HTTP post delivery (<code>/p/</code> + <code>/b/</code>, 5+15 slot budget, holder redirect, mobile serving)</td><td><span class="badge badge-complete">Implemented</span></td></tr>
<tr><td>Share link Phase 1 (<code>itsgoin.net/p/&lt;postid&gt;</code>, anchor resolves holders)</td><td><span class="badge badge-complete">Implemented</span></td></tr>
<tr><td>itsgoin.net QUIC proxy + PostFetch + TCP punch</td><td><span class="badge badge-complete">Implemented</span></td></tr>
<tr><td>Universal Links / App Links</td><td><span class="badge badge-planned">Planned</span></td></tr>
<tr><td>Registry (registrations-here post + discoverable bios, location-anonymous)</td><td><span class="badge badge-planned">Planned &mdash; tester-critical, roadmap #2</span></td></tr>
<tr><td>Greeting comments (HPKE-sealed first contact via published slot key)</td><td><span class="badge badge-planned">Planned</span></td></tr>
<tr><td>Directory trust layer (vouch graph, cascade punishment, blacklist)</td><td><span class="badge badge-planned">Planned</span></td></tr>
</table>
</section>
<!-- Appendix D: Roadmap -->
<section id="roadmap">
<h2>Appendix D: Roadmap &mdash; v0.8 Critical Path</h2>
<p>The path from today's code to the v0.8 target, in order. Items 1&ndash;2 clear the ground; 3&ndash;7 are the architecture shift; 8&ndash;10 build on it.</p>
<div class="card">
<h3>1. ID-class bug fixes</h3>
<p>One bug family: the v0.6.1 persona split left code comparing the network NodeId where posting IDs now live. Four known instances: Tier-2/3 visibility revocation (author check bails on every revoke), the GroupKeyRequest handler (checks the wrong ID class, wraps with the wrong key &mdash; can never grant), the replication cycle (queries own posts by network ID, so the under-replicated list is always empty), and the blob-eviction relationship tier (own-content boost compares the network ID; masked by auto-pin). Fix all four, then sweep every <code>self.node_id</code> comparison against post/persona authors for further instances.</p>
</div>
<div class="card">
<h3>2. Registry + greeting comments &mdash; tester-critical</h3>
<p><strong>The app is eminently useful only if people can find each other</strong> &mdash; this is what makes it usable for testers, so it jumps the queue. Ship the &ldquo;registrations here&rdquo; post (open-slot signed registration comments, fixed 30-day TTL, one entry per persona, self-certifying signed deletes, holder-side rate/size caps &mdash; no PoW) and greeting comments (HPKE-sealed first contact via the published greeting-slot key, messaging-first with Reply/Vouch/Dismiss) &mdash; one open-slot comment implementation serves both. <strong>Prerequisite folded in</strong>: remove device addresses from AuthorManifest first (registered author IDs must be location-anonymous). Known-bounded: survives until deliberate bot-flooding; the trust layer (item 10) is the durable fix.</p>
</div>
<div class="card">
<h3>3. Cruft purge</h3>
<p><strong>Done (Iteration B)</strong> &mdash; landed with the <code>itsgoin/4</code> ALPN bump; 0xC0 AnchorRegister deliberately kept until anchor convection (item 5). The zero-users ruling removes all wire-compat obligations. Delete: v0.6.x receive-only compat handlers (0x51/0x52), the sender-less 0x71 SocialDisconnectNotice, and the dual pull-matching path; the preferred-peer subsystem (slots, MeshPrefer negotiation, 7-day prune, 30-day watcher plumbing); dead senders and constants (hostlist encoder, no-op renewal cycle, orphaned GroupKeyRequest/Response); and legacy tree fields in wire structs. Clean protocol break &mdash; bump ALPN to <code>itsgoin/4</code> here.</p>
</div>
<div class="card">
<h3>4. N1&ndash;N4 uniques knowledge layer</h3>
<p><strong>Done (Iteration C)</strong> &mdash; with size caps enforced, receive-side address filtering, and our own personas excluded from our own announce. Bloom-compressed N4 remains unbuilt; mobile drops the terminal pool instead. Narrow the mesh to ~20 slots (single pool), add temp referral slots (+4..+10, no knowledge exchange), and replace N1&ndash;N3 share lists with the two-pool uniques announce: forwardable N0&ndash;N2 + terminal N3, received entries stored shifted one bounce deeper, N4 held but never re-announced. Anchor entries carry addresses (the pools double as the anchor directory); everything else is bare IDs. Redefine pulls as uniques-index exchange. Device-tiered depth and Bloom-compressed N4 for mobile as needed.</p>
</div>
<div class="card">
<h3>5. Anchor convection</h3>
<p><strong>Done (Iteration C)</strong> &mdash; except the opt-in gate, which stays low priority per the ruling. Third-party anchor claims are index rows only; <code>known_anchors</code> and <code>peers.is_anchor</code> are written from proven handshakes. Retire the register loop. Anchor candidacy = reachability watcher + opt-in (opt-in gate is low priority &mdash; IPv6-abundant anchors are unlikely to be overwhelmed); the anchor service becomes a rolling referral window (caller gets 2 recent prior callers, is referred to the next 2), with RelayIntroduce coordination when both ends are live, and entry/top-up request classes (entry always served; top-up refused cheaply under load). Clients act stochastically on each mesh disconnect &mdash; random {do nothing | anchor introduction | mesh-peer introduction} with adaptive weights (anchor-density prior + refusal feedback). Anchors are mined from the uniques pools; <code>known_anchors</code> recedes to a bootstrap cache.</p>
</div>
<div class="card">
<h3>6. Post concealment (2&ndash;5 budget)</h3>
<p>Cap the public post neighborhood to the 2&ndash;5 concealment budget (the AuthorManifest address removal already landed with item 2). Surplus posts are pushed out via replication so update/replication traffic hides content origin.</p>
</div>
<div class="card">
<h3>7. Update-cadence system</h3>
<p>Replace the fixed pull tick with the descending-frequency scheduler: check intervals from minutes to days keyed on content freshness &times; relationship tier, same-author dedup, jittered timing so update and replication traffic reads as uniform network overhead. Uniques-list refresh rides these exchanges. Comment TTL (rand 30&ndash;365d) lands with this work.</p>
</div>
<div class="card">
<h3>8. Raw-UDP EDM refactor</h3>
<p>Revive the disabled port scanner by replacing per-probe <code>endpoint.connect()</code> with raw <code>socket.send_to()</code> on the endpoint's bound UDP socket, so probes never enter iroh's path store (the v0.7.3 DoS-grade outbound cause). The preserved <code>edm_port_scan_disabled_v0_7_3</code> body is the starting point.</p>
</div>
<div class="card">
<h3>9. Erasure-coded CDN replication</h3>
<p>Replace full-copy auto-replication on the public tier with 3-of-10 sub-threshold shards, each shard a content-addressed blob with its own holder set. Removes the liability profile of infrastructure nodes holding reconstructable copies of content they never reviewed.</p>
</div>
<div class="card">
<h3>10. Directory trust layer</h3>
<p>Vouch capacity, cascade punishment, graph-relative visibility, verification circuit-breaker, blacklist &mdash; built on top of the registry as the entry point. Minimum viable slice: single-vouch listing + impersonation reports &rarr; manual review. Defer cascade math until real graph data exists to calibrate thresholds.</p>
</div>
</section>
<!-- Appendix E: File Map (verified against ls crates/core/src + storage.rs, 2026-07-29) -->
<section id="filemap">
<h2>Appendix E: File Map</h2>
<p style="color: var(--text-muted); font-size: 0.9rem;">As of v0.7.3 code. Use this as a navigation map, not an exhaustive line count.</p>
<pre><code>crates/core/
src/
lib.rs — module registration, parse_connect_string, parse_node_id_hex,
DEFAULT_ANCHOR_POSTING_ID
types.rs — Post, PostId, NodeId, PublicProfile, PostVisibility (incl. FoFClosed),
WrappedKey, VisibilityIntent, Circle, DeviceProfile slot tables,
AuthorManifest/CdnManifest, BlobHeader + diff ops, DeviceRole budgets
content.rs — compute_post_id (BLAKE3), verify_post_id
crypto.rs — X25519 conversion, CEK wrap (itsgoin/cek-wrap/v1), group-key wrap,
encrypt/decrypt_post, FoF wrap slots + prefilter, slot keys
blob.rs — BlobStore (256-shard layout), compute_blob_id, verify_blob,
delivery budget tracking
identity.rs — IdentityManager: multi-identity dirs, create/list/switch/delete,
network vs posting identity (v0.6.0 split)
profile.rs — profile posts (signed, content-addressed profile updates)
control.rs — control posts: audience removal, delete control posts,
persona admin ops
fof.rs — Friend-of-Friend visibility: V_x wrap slots, anonymous prefilter,
pub_post_set, CDN-level comment verification,
V_me rotation / cascade-revoke / KeyBurnDiff
group_key_distribution.rs — circle group key distribution as encrypted posts + epoch rotation
activity.rs — in-memory ring buffer (200 events) of node activity
(growth/rebalance/recovery/anchor/connection/relay) for the UI
status panel — NOT engagement storage
announcement.rs — release announcement post handling
storage.rs — SQLite (auto-migration): posts, peers, follows, profiles,
circles/circle_members/circle_profiles, deleted_posts,
post_replicas, peer_neighbors, mesh_peers, reachable_n2/n3,
social_routes, reconnect_watchers, blobs, blob_headers,
cdn_manifests, file_holders (flat CDN, cap 5), post_hosts,
reactions/comments/comment_policies/thread_meta,
seen_engagement/seen_messages, group_keys/group_member_keys/
group_seeds, known_anchors, ignored_peers, relay_cooldowns,
worm_cooldowns, settings, post_recipients, posting_identities,
preferred_peers (dropped in v0.8 Iteration B), + 10 vouch/FoF tables
(vouch_keys_own/received, vouch_bio_scan_cache, own_vouch_targets,
fof_revocations, own_post_slot_provenance, vouch_unlock_cache,
vouch_unreadable_posts, own_fof_post_ceks, fof_key_burns).
post/blob_upstream+downstream tables dropped in v0.6.1-beta.
protocol.rs — MessageType enum, ALPN (itsgoin/4),
length-prefixed JSON framing, InitialExchangePayload
connection.rs — ConnectionManager + ConnHandle/ConnectionActor (actor pattern):
mesh + session slots, initial exchange, N1/N2 diff broadcast,
pull sync, worm search, relay introduction, referral list,
anchor self-probe, engagement diff handling. EDM scanner
preserved as edm_port_scan_disabled_v0_7_3 (#[allow(dead_code)]).
session_relay_enabled gate (opt-in OFF by default).
network.rs — iroh Endpoint, accept loop, connect_to_peer, growth loop,
connect_by_node_id support, mDNS passive address lookup,
bidirectional anchor reachability watcher, per-IP rate limiting
node.rs — Node struct, post CRUD, follow/unfollow, profile/circle CRUD,
encrypted post creation, startup cycles, bootstrap with
probe_anchors_batched + stale-anchor prune, pull/diff/rebalance/
recovery/replication cycles, delete control posts,
connect_by_node_id resolution cascade, blob eviction scoring
web.rs — itsgoin.net web handler: QUIC proxy for share links, tiered
serving (redirect/TCP punch/proxy), on-demand post fetch
http.rs — raw-TCP HTTP listener: /p/ + /b/ routes, 5+15 slot budget,
holder 302 redirect with 200ms probe, render_post_html
upnp.rs — port mapping: UPnP-IGD + NAT-PMP + PCP via portmapper crate;
PortMapping holds Client for service lifetime; watch_external()
stun.rs — STUN probes (NAT type detection)
android_wifi.rs — Android-only JNI bridge: WifiManager.MulticastLock,
WiFi/Ethernet detection, NodeService.stopFromNative
export.rs / import.rs — posting-key ZIP bundles: export, import-as-personas,
import-as-new-identity, post import &amp; merge (merge_with_key)
crates/cli/
src/main.rs — interactive REPL + anchor/daemon mode (--bind, --daemon, --web)
crates/tauri-app/
src/lib.rs — Tauri v2 commands (DTOs + IPC handlers): persona CRUD, identity
import/export, exit_app (Android NodeService stop), session-relay toggle
gen/android/ — Android shell: NodeService.kt foreground service with
companion stopFromNative() static
frontend/
index.html — single-page UI: 5 tabs (Feed / My Posts / People / Messages / Settings),
close-app button with inline SVG power icon, session-relay toggle
app.js — Tauri invoke calls, rendering, identicon generator, circle CRUD
style.css — dark theme, post cards, visibility badges, transitions
website/
design.html — this document (canonical design reference)
download.html — release notes
index.html, tech.html, contribute.html
style.css — shared site styling</code></pre>
</section>
<section>
<h2>License</h2>
<p>ItsGoin is released under the <strong>Apache License, Version 2.0</strong>. You may use, modify, and distribute this software freely under the terms of that license.</p>
<p>This is a gift. Use it well.</p>
</section>
</div>
<div class="container">
<section>
<h2>Contribute</h2>
<p>ItsGoin is open source and built in public. This project was started by a network/tech web dev vibe-coding beyond his skill level with AI assistance. The design is ambitious and the implementation is real &mdash; but more hands make lighter work. If any of this interests you, jump in.</p>
<p style="margin-top: 0.75rem;">
<a href="https://git.itsgoin.net/Sologretto/itsgoin" class="btn btn-primary" style="margin-right: 0.5rem;">Source code on Forgejo</a>
<a href="https://discord.gg/pCjMbY9PmN" class="btn btn-secondary">Join the Discord</a>
</p>
</section>
</div>
<footer>
<p>ItsGoin &mdash; Apache 2.0 License &mdash; <a href="https://itsgoin.com">itsgoin.com</a> &middot; <a href="https://discord.gg/pCjMbY9PmN">Discord</a> &middot; <a href="https://git.itsgoin.net/Sologretto/itsgoin">Source Code</a></p>
</footer>
</body>
</html>