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
This commit is contained in:
Scott Reimers 2026-07-30 13:03:12 -04:00
parent 36e3871c4b
commit e756fabb11
14 changed files with 5503 additions and 2367 deletions

View file

@ -129,11 +129,11 @@
<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 changes to mesh peers (target: uniques-list diffs; today: N1/N2 share-list diffs)</td><td><span class="badge badge-rework">Rework</span></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 mesh slots toward 20 with the most diverse candidates; secondary peer-finding path alongside convection</td><td><span class="badge badge-rework">Rework</span> &mdash; mechanism is live; targets 101 today and only counts local slots</td></tr>
<tr><td>Convection trigger</td><td>Stochastic, per-disconnect</td><td>On each mesh disconnect, 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-planned">Planned</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</td><td><span class="badge badge-rework">Rework</span> &mdash; today's loop still sends <code>AnchorRegister</code></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">
@ -163,30 +163,32 @@
<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 change</strong>: Probe scheduling currently rides the anchor register cycle, which is removed. The target triggers re-probes from the reachability watcher (on mapping acquisition/restoration) and on failed inbound connections.
<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-rework">Rework</span></h3>
<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. Referred peers land in the caller's <a href="#connections">temporary referral slots</a> (above the 20-peer mesh cap, no knowledge exchange) and either graduate into mesh slots or expire.</p>
<p><strong>Trigger &mdash; stochastic, per-disconnect</strong>: each time a mesh peer disconnects, 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>
<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">
Current code implements the older registration model this replaces: an in-memory referral list with tiered use caps (3 uses under 50 entries, fewer as the list grows), a 2-minute disconnect grace, and least-used-first selection. The convection window replaces all of it.
<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-rework">Rework</span></h3>
<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 (<code>node_id</code>, <code>addresses</code>, <code>last_seen</code>, <code>success_count</code> &mdash; a count of successful connects accreted during v0.7.x bootstrap hardening, ranked descending, capped at 5) survives as the <strong>bootstrap cache</strong>. Its careful ranking solved an anchor-scarcity problem that pool-mined abundance retires.</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-planned">Planned</span> &mdash; random anchor-flagged entry, 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>success_count</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>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>
@ -194,7 +196,7 @@
<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-planned">Planned</span>: the per-disconnect stochastic action described above. This is the ONLY ongoing anchor contact &mdash; there is no periodic registration.</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>
@ -203,12 +205,12 @@
</section>
<!-- Connections & Slot Architecture -->
<section id="connections">
<h2>4. Connections &amp; Slot Architecture <span class="badge badge-rework">Rework</span></h2>
<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-planned">Planned</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>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>
@ -217,8 +219,8 @@
<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-rework">Rework</span></td><td>Long-lived routing backbone; every slot equal, filled by growth loop + inbound</td></tr>
<tr><td>Temp referral</td><td>+4&ndash;+10</td><td>+4&ndash;+10</td><td><span class="badge badge-planned">Planned</span></td><td>Introduction facilitation above the cap; no knowledge exchange; graduate or expire</td></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>
@ -230,12 +232,12 @@
</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) goes away with the pool merge; a boolean or small enum distinguishes mesh slots from temp referral slots.</p>
<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-planned">Planned</span></h3>
<p>Nodes hold <strong>+4 to +10 temporary connections above the 20-slot mesh cap</strong>. 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>
<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. Lightweight by construction.</li>
<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>
@ -249,8 +251,8 @@
<li>Full session/ephemeral interaction still works &mdash; messages, probes, routing participation; blacklisted nodes never consume each other's mesh slots</li>
</ul>
<h3><code>--max-mesh &lt;n&gt;</code> CLI flag <span class="badge badge-planned">Planned</span></h3>
<p>Topology control for testing. Forces a node to cap its mesh connections below the 20 default, keeping it permanently in N2 of other nodes. <code>--max-mesh 0</code> = pure N2 participant (free rider &mdash; consumes routing knowledge without carrying mesh load); <code>--max-mesh 20</code> = default full behavior. Reuses <code>RefuseRedirect</code> (<code>0x05</code>) &mdash; no new protocol machinery. <strong>Testing affordance only.</strong></p>
<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);">
@ -277,7 +279,7 @@
<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 change &mdash; stochastic growth</strong> <span class="badge badge-planned">Planned</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>).
<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>
@ -313,7 +315,7 @@
<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-planned">Planned</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</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>
@ -337,18 +339,18 @@
</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 change</strong>: the knowledge-share component becomes 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. <span class="badge badge-rework">Rework</span>
<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 change</strong>: diffs become uniques-announce diffs under the N1&ndash;N4 model, and their cadence folds into the update-cadence system's traffic shaping (see <a href="#keep-alive">Update Cadence &amp; Keep-Alive</a>). <span class="badge badge-rework">Rework</span>
<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-rework">Rework</span></h2>
<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>
@ -365,25 +367,30 @@
<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><code>mesh_peers</code> + <code>social_routes</code> + <code>file_holders</code></td></tr>
<tr><td>N2</td><td>2</td><td>Peers' announced N1, tagged to reporter</td><td>Yes</td><td><code>reachable_n2</code></td></tr>
<tr><td>N3</td><td>3</td><td>Peers' announced N2, tagged to reporter</td><td>Yes</td><td><code>reachable_n3</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_n4</code> <span class="badge badge-planned">Planned</span></td></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>Full exchange on connect</strong>, incremental seq-numbered add/remove diffs afterward (today's <code>NodeListUpdate</code> <code>0x01</code> is the seed; it grows an N3 layer).</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>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.</li>
<li><strong>Disconnect cleanup:</strong> when a peer drops, every entry it reported is removed. Stale entries are pruned after 5 hours regardless.</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>
@ -402,7 +409,8 @@
<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. Mobile may hold only 3 bounces (~8k entries, ~256&nbsp;KB) or keep N4 as a <strong>Bloom filter</strong> (~10 bits/entry at 1% false-positive &rarr; ~200&nbsp;KB for 160k entries). A Bloom N4 answers 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.</p>
<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>
@ -425,7 +433,7 @@
<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. The cascade grows one lookup deeper when N4 lands (a 3-link reporter chain); everything else carries over unchanged.</p>
<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 -->
@ -1059,10 +1067,10 @@
</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-rework">Rework</span></h3>
<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 exact request/response payload shape (announce diffs, Bloom compression for mobile N4) is still open &mdash; see Network Knowledge.</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>
@ -1402,9 +1410,9 @@
<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). <span class="badge badge-rework">Rework</span> (today's N1/N2 share lists are fully address-free; the uniques format adds the anchor exception)</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. <span class="badge badge-planned">Planned</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-rework">Rework</span> &mdash; today's <code>AuthorManifest.author_addresses</code> ships the device's real addresses under a posting-identity author. This is a privacy bug; the v0.8 manifest carries no device addresses, and the fix must land before or with the discovery registry (location-anonymity dependency).</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>
@ -1416,7 +1424,7 @@
</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. <span class="badge badge-planned">Planned</span> (today's N1 share merges only mesh peers + social contacts)</p>
<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>
@ -1892,8 +1900,14 @@
<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>REFERRAL_DISCONNECT_GRACE</td><td>120s (2 min)</td><td><span class="badge badge-rework">Rework</span> Current-code registration model: anchor keeps a disconnected caller referable during this grace; retired by the convection window (see <a href="#anchors">Anchors</a>)</td></tr>
<tr><td>REFERRAL_LIST_CAP</td><td>50</td><td><span class="badge badge-rework">Rework</span> Current-code registration model: soft cap on the anchor's registration list, driving per-entry use tiering; retired by the convection window (replacement window size still open)</td></tr>
<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 -->
@ -1933,8 +1947,8 @@
<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-planned">Planned</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-planned">Planned</span> Entry (&lt;2 connections) always served; top-up refused cheaply under load &mdash; refusal feeds the adaptive weights</td></tr>
<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 -->
@ -1950,7 +1964,7 @@
<!-- 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>+4 to +10 above the cap</td><td><span class="badge badge-planned">Planned</span> Introduction facilitation only; no uniques exchange; graduate or expire</td></tr>
<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 -->
@ -1986,12 +2000,12 @@
<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-rework">Rework</span> &mdash; code runs Local/Wide, 91 desktop / 12 mobile (preferred tier deleted, Iteration B)</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-planned">Planned</span></td></tr>
<tr><td>Uniques announce, payload N1&ndash;N3 (N4 stored but terminates, dedup at store + announce)</td><td><span class="badge badge-rework">Rework</span> &mdash; code exchanges N1&ndash;N3 share lists</td></tr>
<tr><td>Pull as uniques-index exchange (distributed search index)</td><td><span class="badge badge-rework">Rework</span> &mdash; code pulls posts via <code>since_ms</code></td></tr>
<tr><td>Growth loop (signal-driven, diversity scoring, stochastic anchor path)</td><td><span class="badge badge-rework">Rework</span> &mdash; reactive core + scoring shipped; whole-pool counting and the per-disconnect stochastic action are target</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>
@ -1999,7 +2013,7 @@
<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-rework">Rework</span> &mdash; register loop + tiered referral list exist; convection semantics are target</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>
@ -2027,7 +2041,7 @@
<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-rework">Rework</span> &mdash; 40 types today; six (0x51/0x52/0x71/0xA1/0xA2/0xB3) deleted in the clean-break purge (Roadmap item 3); 0xC0 AnchorRegister stays live until anchor convection (Roadmap item 5)</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>
@ -2076,11 +2090,11 @@
</div>
<div class="card">
<h3>4. N1&ndash;N4 uniques knowledge layer</h3>
<p>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>
<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>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>
<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>