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

@ -230,6 +230,11 @@ async fn main() -> anyhow::Result<()> {
println!(" redundancy Show replica counts for your posts");
println!(" worm <node_id> Worm lookup (find peer beyond 3-hop map)");
println!(" connections Show mesh connections");
println!(" slots Show mesh / temp-referral slot usage");
println!(" uniques [node_id_hex] Uniques index counts, or where an ID is known");
println!(" convection Adaptive convection weights + pool-mined anchors");
println!(" convect <anchor_id> Ask an anchor for peers (convection)");
println!(" uniques-pull Exchange uniques indexes with mesh peers");
println!(" social-routes Show social routing cache");
println!(" name <display_name> Set your display name");
println!(" register <name> [keywords...] Register default persona in the network registry (30d, re-run to renew)");
@ -248,13 +253,22 @@ async fn main() -> anyhow::Result<()> {
// Start background tasks (v2: mesh connections)
let _accept_handle = node.start_accept_loop();
let _pull_handle = node.start_pull_cycle(); // 60s tiered pull cycle
let _sync_handle = node.start_sync_cycle(); // 60s: uniques-index exchange + content sync
let _diff_handle = node.start_diff_cycle(120); // 2 min routing diff
let _rebalance_handle = node.start_rebalance_cycle(600); // 10 min rebalance
let _growth_handle = node.start_growth_loop(); // reactive mesh growth
// Reactive mesh growth. `ITSGOIN_TEST_NO_GROWTH` suppresses it so an
// integration test can hold an exact topology (on loopback the growth loop
// otherwise collapses any chain into a full mesh within seconds, and a
// full mesh has no N2 at all — every peer is a direct). Test gate only.
let _growth_handle = if std::env::var("ITSGOIN_TEST_NO_GROWTH").is_ok() {
eprintln!("[test gate] growth loop disabled (ITSGOIN_TEST_NO_GROWTH)");
None
} else {
Some(node.start_growth_loop())
};
let _recovery_handle = node.start_recovery_loop(); // reactive anchor reconnect on mesh loss
let _checkin_handle = node.start_social_checkin_cycle(3600); // 1 hour social checkin
let _anchor_handle = node.start_anchor_register_cycle(600); // 10 min anchor register
let _convection_handle = node.start_convection_loop(); // stochastic anchor convection + anchor self-probe
let _upnp_tcp_handle = node.start_upnp_tcp_renewal_cycle(); // UPnP TCP lease renewal
let _http_handle = node.start_http_server(); // HTTP post delivery (if publicly reachable)
let _bootstrap_check = node.start_bootstrap_connectivity_check(); // 24h isolation check
@ -858,7 +872,7 @@ async fn main() -> anyhow::Result<()> {
println!("(no mesh connections)");
} else {
println!("Mesh connections ({}):", conns.len());
for (nid, slot_kind, connected_at) in conns {
for (nid, slot, connected_at) in conns {
let name = node.get_display_name(&nid).await.unwrap_or(None);
let id_short = &hex::encode(nid)[..12];
let label = name.map(|n| format!("{} ({})", n, id_short))
@ -870,7 +884,95 @@ async fn main() -> anyhow::Result<()> {
.as_millis() as u64;
(now.saturating_sub(connected_at)) / 1000
};
println!(" {} [{:?}] connected {}s ago", label, slot_kind, duration_secs);
println!(" {} [{}] connected {}s ago", label, slot, duration_secs);
}
}
}
// v0.8 Iteration C observability: slot pool + uniques index.
"slots" => {
let conns = node.list_connections().await;
let mesh = conns.iter().filter(|(_, s, _)| s.is_mesh()).count();
let temp = conns.len() - mesh;
println!("mesh {}/{} temp-referral {}/{}",
mesh, node.device_profile().mesh_slots(),
temp, node.device_profile().temp_referral_slots());
for (nid, slot, _) in conns {
println!(" {} {}", &hex::encode(nid)[..12], slot);
}
}
"uniques" => {
let storage = node.storage.get().await;
if parts.len() > 1 {
match itsgoin_core::parse_node_id_hex(parts[1]) {
Ok(target) => {
let mut any = false;
for bounce in 2u8..=4 {
for reporter in storage.find_reporters_at(&target, bounce).unwrap_or_default() {
println!(" N{} via {}", bounce, &hex::encode(reporter)[..12]);
any = true;
}
}
if !any {
println!("(not in the uniques index)");
}
}
Err(e) => println!("Bad node id: {}", e),
}
} else {
println!("N2 {} N3 {} N4 {} (anchor density {:.2})",
storage.count_distinct_n2().unwrap_or(0),
storage.count_distinct_n3().unwrap_or(0),
storage.count_distinct_n4().unwrap_or(0),
storage.anchor_density().unwrap_or(0.0));
}
}
// The v0.8 "pull": exchange uniques indexes with every mesh peer.
"uniques-pull" => {
let n = node.uniques_pull().await;
println!("uniques-pull: exchanged with {} mesh peers", n);
}
// On-demand convection against a named anchor.
"convect" => {
if parts.len() < 2 {
println!("Usage: convect <anchor_node_id_hex>");
} else {
match itsgoin_core::parse_node_id_hex(parts[1]) {
Ok(anchor) => match node.convection_request(anchor).await {
Ok((_, true, ms)) => println!("convection: refused in {}ms", ms),
Ok((n, false, ms)) => println!("convection: {} new peers in {}ms", n, ms),
Err(e) => println!("convection failed: {}", e),
},
Err(e) => println!("Bad node id: {}", e),
}
}
}
// Convection diagnostics: the adaptive action weights, the
// pool-mined anchor directory, and a manual entry-class request.
"convection" => {
let (density, bias, mesh) = node.network.conn_handle().convection_state().await;
let cap = node.device_profile().mesh_slots();
let fill = if cap == 0 { 1.0 } else { mesh as f64 / cap as f64 };
let w = itsgoin_core::connection::convection_weights(density, bias, fill);
let total = w.nothing + w.anchor + w.mesh;
println!("anchor_density {:.3} anchor_bias {:.2} mesh {}/{}", density, bias, mesh, cap);
println!("weights: nothing {:.3} anchor {:.3} mesh {:.3} (P: {:.0}% / {:.0}% / {:.0}%)",
w.nothing, w.anchor, w.mesh,
100.0 * w.nothing / total, 100.0 * w.anchor / total, 100.0 * w.mesh / total);
let anchors = {
let storage = node.storage.get().await;
storage.list_pool_anchors(16).unwrap_or_default()
};
if anchors.is_empty() {
println!("pool anchors: (none)");
} else {
println!("pool anchors ({}):", anchors.len());
for (nid, addrs) in anchors {
println!(" {} {}", &hex::encode(nid)[..12], addrs.join(","));
}
}
}