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

@ -6,7 +6,7 @@ use tracing::info;
use itsgoin_core::identity::IdentityManager;
use itsgoin_core::node::Node;
use itsgoin_core::types::{NodeId, PeerSlotKind, Post, PostId, PostVisibility, VisibilityIntent};
use itsgoin_core::types::{NodeId, Post, PostId, PostVisibility, VisibilityIntent};
/// The active Node, swappable on identity switch.
type AppNode = Arc<tokio::sync::RwLock<Arc<Node>>>;
@ -1363,7 +1363,7 @@ async fn list_peers(state: State<'_, AppNode>) -> Result<Vec<PeerDto>, String> {
.into_iter()
.map(|(nid, _, _)| nid)
.collect();
let (social_ids, n2_ids, n3_ids) = {
let (social_ids, n2_ids, n3_ids, n4_ids) = {
let storage = node.storage.get().await;
let social: std::collections::HashSet<_> = storage
.list_social_routes()
@ -1372,7 +1372,7 @@ async fn list_peers(state: State<'_, AppNode>) -> Result<Vec<PeerDto>, String> {
.map(|r| r.node_id)
.collect();
let n2: std::collections::HashSet<_> = storage
.build_n2_share()
.list_reach_ids_at(2)
.unwrap_or_default()
.into_iter()
.collect();
@ -1381,7 +1381,12 @@ async fn list_peers(state: State<'_, AppNode>) -> Result<Vec<PeerDto>, String> {
.unwrap_or_default()
.into_iter()
.collect();
(social, n2, n3)
let n4: std::collections::HashSet<_> = storage
.list_distinct_n4()
.unwrap_or_default()
.into_iter()
.collect();
(social, n2, n3, n4)
};
let mut dtos = Vec::with_capacity(records.len());
@ -1406,6 +1411,8 @@ async fn list_peers(state: State<'_, AppNode>) -> Result<Vec<PeerDto>, String> {
"n2"
} else if n3_ids.contains(&rec.node_id) {
"n3"
} else if n4_ids.contains(&rec.node_id) {
"n4"
} else {
"known"
};
@ -2066,12 +2073,12 @@ async fn list_connections(state: State<'_, AppNode>) -> Result<Vec<ConnectionDto
let node = get_node(&state).await;
let conns = node.list_connections().await;
let mut dtos = Vec::with_capacity(conns.len());
for (nid, slot_kind, connected_at) in conns {
for (nid, slot, connected_at) in conns {
let display_name = node.get_display_name(&nid).await.unwrap_or(None);
dtos.push(ConnectionDto {
node_id: hex::encode(nid),
display_name,
slot_kind: format!("{:?}", slot_kind),
slot_kind: slot.to_string(),
connected_at,
});
}
@ -2493,11 +2500,13 @@ async fn sync_from_peer(state: State<'_, AppNode>, node_id_hex: String) -> Resul
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct NetworkSummaryDto {
local_count: usize,
wide_count: usize,
mesh_count: usize,
temp_count: usize,
mesh_cap: usize,
total_connections: usize,
n2_distinct: usize,
n3_distinct: usize,
n4_distinct: usize,
has_public_v6: bool,
has_public_v4: bool,
has_upnp: bool,
@ -2507,26 +2516,24 @@ struct NetworkSummaryDto {
async fn get_network_summary(state: State<'_, AppNode>) -> Result<NetworkSummaryDto, String> {
let node = get_node(&state).await;
let conns = node.list_connections().await;
let mut local = 0usize;
let mut wide = 0usize;
for (_nid, slot_kind, _at) in &conns {
match slot_kind {
PeerSlotKind::Local => local += 1,
PeerSlotKind::Wide => wide += 1,
}
}
let (n2, n3) = {
let mesh = conns.iter().filter(|(_, slot, _)| slot.is_mesh()).count();
let temp = conns.len() - mesh;
let (n2, n3, n4) = {
let storage = node.storage.get().await;
let n2 = storage.count_distinct_n2().unwrap_or(0);
let n3 = storage.count_distinct_n3().unwrap_or(0);
(n2, n3)
(
storage.count_distinct_n2().unwrap_or(0),
storage.count_distinct_n3().unwrap_or(0),
storage.count_distinct_n4().unwrap_or(0),
)
};
Ok(NetworkSummaryDto {
local_count: local,
wide_count: wide,
mesh_count: mesh,
temp_count: temp,
mesh_cap: node.device_profile().mesh_slots(),
total_connections: conns.len(),
n2_distinct: n2,
n3_distinct: n3,
n4_distinct: n4,
has_public_v6: node.network.has_public_v6(),
has_public_v4: node.network.is_anchor(),
has_upnp: node.network.has_upnp(),
@ -2748,15 +2755,14 @@ struct ActivityLogDto {
events: Vec<ActivityEventDto>,
rebalance_last_ms: u64,
rebalance_interval_secs: u64,
anchor_register_last_ms: u64,
anchor_register_interval_secs: u64,
convection_last_ms: u64,
}
#[tauri::command]
async fn get_activity_log(state: State<'_, AppNode>) -> Result<ActivityLogDto, String> {
let node = get_node(&state).await;
let events = node.get_activity_log(200);
let (rebalance_last, anchor_last) = node.timer_state();
let (rebalance_last, convection_last) = node.timer_state();
let dto_events: Vec<ActivityEventDto> = events.into_iter().map(|e| {
ActivityEventDto {
timestamp_ms: e.timestamp_ms,
@ -2770,8 +2776,7 @@ async fn get_activity_log(state: State<'_, AppNode>) -> Result<ActivityLogDto, S
events: dto_events,
rebalance_last_ms: rebalance_last,
rebalance_interval_secs: 600,
anchor_register_last_ms: anchor_last,
anchor_register_interval_secs: 600,
convection_last_ms: convection_last,
})
}
@ -2787,31 +2792,47 @@ async fn trigger_rebalance(state: State<'_, AppNode>) -> Result<String, String>
async fn request_referrals(state: State<'_, AppNode>) -> Result<String, String> {
let node = get_node(&state).await;
let node_id = node.node_id;
// Try known_anchors table first (populated by anchor register cycle),
// fall back to anchor peers from the peers table (is_anchor = true)
let mesh = node.network.conn_handle().mesh_count().await;
// Manual trigger uses the same class rule as the automatic path: below 2
// mesh peers this is an ENTRY request and is always served.
let class = itsgoin_core::protocol::ConvectionClass::for_mesh_count(mesh);
// Pool-mined anchors first (the uniques pools ARE the anchor directory),
// then the known_anchors bootstrap cache, then anchor-flagged peers.
let anchors: Vec<(NodeId, Vec<std::net::SocketAddr>)> = {
let storage = node.storage.get().await;
let known = storage.list_known_anchors().unwrap_or_default();
if !known.is_empty() {
known
} else {
storage.list_anchor_peers().unwrap_or_default()
.into_iter()
.map(|r| (r.node_id, r.addresses))
.collect()
let mut out: Vec<(NodeId, Vec<std::net::SocketAddr>)> = Vec::new();
let mut seen: std::collections::HashSet<NodeId> = std::collections::HashSet::new();
for (nid, addrs) in storage.list_pool_anchors(16).unwrap_or_default() {
let socks: Vec<std::net::SocketAddr> = addrs.iter().filter_map(|a| a.parse().ok()).collect();
if !socks.is_empty() && seen.insert(nid) {
out.push((nid, socks));
}
}
for (nid, addrs) in storage.list_known_anchors().unwrap_or_default() {
if seen.insert(nid) {
out.push((nid, addrs));
}
}
for r in storage.list_anchor_peers().unwrap_or_default() {
if seen.insert(r.node_id) {
out.push((r.node_id, r.addresses));
}
}
out
};
if anchors.is_empty() {
return Ok("No known anchors".to_string());
}
let mut total = 0usize;
let mut reachable = 0usize;
let mut connected = 0usize;
let mut asked = 0usize;
let mut refused = 0usize;
for (anchor_nid, anchor_addrs) in &anchors {
if *anchor_nid == node_id {
continue;
}
// Connect to anchor if not already connected
if !node.network.is_peer_connected(anchor_nid).await {
if !node.network.is_peer_connected_or_session(anchor_nid).await {
let endpoint_id = match itsgoin_core::EndpointId::from_bytes(anchor_nid) {
Ok(eid) => eid,
Err(_) => continue,
@ -2820,40 +2841,26 @@ async fn request_referrals(state: State<'_, AppNode>) -> Result<String, String>
for sa in anchor_addrs {
addr = addr.with_ip_addr(*sa);
}
if let Err(_) = node.network.connect_to_peer(*anchor_nid, addr).await {
if node.network.connect_to_anchor(*anchor_nid, addr).await.is_err() {
continue;
}
}
match node.network.request_anchor_referrals(anchor_nid).await {
Ok(referrals) => {
reachable += 1;
for referral in &referrals {
if referral.node_id == node_id {
continue;
}
if let Some(addr_str) = referral.addresses.first() {
let connect_str = format!(
"{}@{}",
hex::encode(referral.node_id),
addr_str,
);
if let Ok((rid, raddr)) = itsgoin_core::parse_connect_string(&connect_str) {
match node.network.connect_to_peer(rid, raddr).await {
Ok(()) => {}
Err(_) => {
// Direct connect failed (NAT) — try hole punch via anchor
let _ = node.network.connect_via_introduction(rid, *anchor_nid).await;
}
}
total += 1;
}
}
match node.network.request_convection(anchor_nid, class).await {
Ok(response) => {
asked += 1;
if response.refused {
refused += 1;
} else {
connected += node.network.act_on_convection(anchor_nid, &response).await;
}
}
Err(_) => {}
}
}
Ok(format!("Got {} referrals from {} anchors", total, reachable))
Ok(format!(
"Convection: {} new peers from {} anchors ({} refused)",
connected, asked, refused
))
}
#[tauri::command]
@ -3197,13 +3204,13 @@ async fn switch_identity(
// Start background tasks on the new node
new_node.start_accept_loop();
new_node.start_pull_cycle();
new_node.start_sync_cycle();
new_node.start_diff_cycle(120);
new_node.start_rebalance_cycle(600);
new_node.start_growth_loop();
new_node.start_recovery_loop();
new_node.start_social_checkin_cycle(3600);
new_node.start_anchor_register_cycle(600);
new_node.start_convection_loop();
new_node.start_upnp_tcp_renewal_cycle();
new_node.start_http_server();
new_node.start_bootstrap_connectivity_check();
@ -3321,13 +3328,13 @@ async fn clear_duplicate_flag(state: State<'_, AppNode>) -> Result<(), String> {
node.network.duplicate_detected.store(false, std::sync::atomic::Ordering::Relaxed);
// Start the sync tasks that were skipped during bootstrap
node.start_accept_loop();
node.start_pull_cycle();
node.start_sync_cycle();
node.start_diff_cycle(120);
node.start_rebalance_cycle(600);
node.start_growth_loop();
node.start_recovery_loop();
node.start_social_checkin_cycle(3600);
node.start_anchor_register_cycle(600);
node.start_convection_loop();
node.start_upnp_tcp_renewal_cycle();
node.start_http_server();
node.start_bootstrap_connectivity_check();
@ -3569,13 +3576,13 @@ pub fn run() {
// Start all background networking tasks
boot_node.start_accept_loop();
boot_node.start_pull_cycle();
boot_node.start_sync_cycle();
boot_node.start_diff_cycle(120);
boot_node.start_rebalance_cycle(600);
boot_node.start_growth_loop();
boot_node.start_recovery_loop();
boot_node.start_social_checkin_cycle(3600);
boot_node.start_anchor_register_cycle(600);
boot_node.start_convection_loop();
boot_node.start_upnp_tcp_renewal_cycle();
boot_node.start_http_server();
boot_node.start_bootstrap_connectivity_check();