diff --git a/Cargo.lock b/Cargo.lock index 416e4a9..ae10be2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2732,7 +2732,7 @@ checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" [[package]] name = "itsgoin-cli" -version = "0.8.0-alpha" +version = "0.7.3" dependencies = [ "anyhow", "hex", @@ -2744,7 +2744,7 @@ dependencies = [ [[package]] name = "itsgoin-core" -version = "0.8.0-alpha" +version = "0.7.3" dependencies = [ "anyhow", "base64 0.22.1", @@ -2769,7 +2769,7 @@ dependencies = [ [[package]] name = "itsgoin-desktop" -version = "0.8.0-alpha" +version = "0.7.3" dependencies = [ "anyhow", "base64 0.22.1", diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 41b245a..2e53c35 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "itsgoin-cli" -version = "0.8.0-alpha" +version = "0.7.3" edition = "2021" [[bin]] diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index e81885b..61c959c 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -27,7 +27,6 @@ async fn main() -> anyhow::Result<()> { let mut web_port: Option = None; let mut print_identity = false; let mut announce = false; - let mut publish_registry = false; let mut ann_category: Option = None; let mut ann_title: Option = None; let mut ann_body: Option = None; @@ -77,7 +76,6 @@ async fn main() -> anyhow::Result<()> { } "--print-identity" => { print_identity = true; i += 1; } "--announce" => { announce = true; i += 1; } - "--publish-registry" => { publish_registry = true; i += 1; } "--ann-category" => { ann_category = args.get(i + 1).cloned(); i += 2; } "--ann-title" => { ann_title = args.get(i + 1).cloned(); i += 2; } "--ann-body" => { ann_body = args.get(i + 1).cloned(); i += 2; } @@ -154,29 +152,6 @@ async fn main() -> anyhow::Result<()> { return Ok(()); } - // One-shot: --publish-registry — genesis publish of the network - // registry post. Refuses unless this node's default posting identity - // is the bootstrap anchor's (debug builds may override for tests via - // ITSGOIN_TEST_ALLOW_REGISTRY_GENESIS=1). Prints the registry post ID - // for verification against the shipped constant. - if publish_registry { - let node = Node::open_with_bind(&data_dir, bind_addr, profile).await?; - match node.publish_registry_genesis().await { - Ok(post_id) => { - println!("registry_post_id: {}", hex::encode(post_id)); - println!( - "shipped_constant: {}", - hex::encode(itsgoin_core::registry::REGISTRY_POST_ID) - ); - } - Err(e) => { - eprintln!("Failed to publish registry genesis: {}", e); - std::process::exit(1); - } - } - return Ok(()); - } - println!("Starting ItsGoin node (data: {}, profile: {:?})...", data_dir, profile); let node = Arc::new(Node::open_with_bind(&data_dir, bind_addr, profile).await?); @@ -187,9 +162,8 @@ async fn main() -> anyhow::Result<()> { let addr = node.endpoint_addr(); let sockets: Vec<_> = addr.ip_addrs().collect(); - // Show our display name if set (profiles are keyed by POSTING identity, - // not the network NodeId). - let my_name = node.get_display_name(&node.default_posting_id).await.unwrap_or(None); + // Show our display name if set + let my_name = node.get_display_name(&node.node_id).await.unwrap_or(None); let name_display = my_name.as_deref().unwrap_or("(not set)"); println!(); @@ -230,21 +204,8 @@ async fn main() -> anyhow::Result<()> { println!(" redundancy Show replica counts for your posts"); println!(" worm 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 Ask an anchor for peers (convection)"); - println!(" uniques-pull Exchange uniques indexes with mesh peers"); println!(" social-routes Show social routing cache"); println!(" name Set your display name"); - println!(" register [keywords...] Register default persona in the network registry (30d, re-run to renew)"); - println!(" unregister Remove your registry entry (signed delete)"); - println!(" search Search the registry (pulls chain, queries locally)"); - println!(" greet Send a sealed greeting to a bio post's author"); - println!(" greetings List unsealed greetings on your bio (with reply/dismiss index)"); - println!(" reply Sealed reply to a greeting's return path (fresh reply key)"); - println!(" dismiss Dismiss a greeting (local only)"); - println!(" greetings-open Toggle the greeting slot on your bio (republishes bio)"); println!(" stats Show node stats"); println!(" export-key Export identity key (KEEP SECRET)"); println!(" id Show this node's ID"); @@ -253,22 +214,14 @@ async fn main() -> anyhow::Result<()> { // Start background tasks (v2: mesh connections) let _accept_handle = node.start_accept_loop(); - let _sync_handle = node.start_sync_cycle(); // 60s: uniques-index exchange + content sync + let _pull_handle = node.start_pull_cycle(300); // 5 min pull cycle let _diff_handle = node.start_diff_cycle(120); // 2 min routing diff let _rebalance_handle = node.start_rebalance_cycle(600); // 10 min rebalance - // 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 _growth_handle = node.start_growth_loop(); // reactive mesh growth 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 _convection_handle = node.start_convection_loop(); // stochastic anchor convection + anchor self-probe + let _anchor_handle = node.start_anchor_register_cycle(600); // 10 min anchor register + let _upnp_handle = node.start_upnp_renewal_cycle(); // UPnP lease renewal (if mapped) 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 @@ -301,10 +254,6 @@ async fn main() -> anyhow::Result<()> { let stdin = io::stdin(); let reader = stdin.lock(); - // A3: cached greeting list so `reply ` / `dismiss ` indices - // refer to the last `greetings` output. - let mut last_greetings: Vec = Vec::new(); - print!("> "); io::stdout().flush()?; @@ -393,13 +342,9 @@ async fn main() -> anyhow::Result<()> { if follows.is_empty() { println!("(not following anyone)"); } - let own_ids: Vec = node - .list_posting_identities().await.unwrap_or_default() - .into_iter().map(|p| p.node_id).collect(); for nid in follows { let name = node.get_display_name(&nid).await.unwrap_or(None); - // Follows are posting ids — "you" = any of our personas. - let label = if own_ids.contains(&nid) { " (you)" } else { "" }; + let label = if nid == node.node_id { " (you)" } else { "" }; if let Some(name) = name { println!(" {} ({}){}", name, &hex::encode(nid)[..12], label); } else { @@ -752,7 +697,7 @@ async fn main() -> anyhow::Result<()> { "create-persona" => { let name = arg.unwrap_or("").to_string(); - match node.create_posting_identity(name, None).await { + match node.create_posting_identity(name).await { Ok(id) => { println!("Created posting identity: {}", hex::encode(id.node_id)); } @@ -872,7 +817,7 @@ async fn main() -> anyhow::Result<()> { println!("(no mesh connections)"); } else { println!("Mesh connections ({}):", conns.len()); - for (nid, slot, connected_at) in conns { + for (nid, slot_kind, 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)) @@ -884,95 +829,7 @@ async fn main() -> anyhow::Result<()> { .as_millis() as u64; (now.saturating_sub(connected_at)) / 1000 }; - 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 "); - } 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(",")); + println!(" {} [{:?}] connected {}s ago", label, slot_kind, duration_secs); } } } @@ -1004,166 +861,6 @@ async fn main() -> anyhow::Result<()> { } } - "register" => { - if let Some(rest) = arg { - let mut words = rest.split_whitespace(); - let name = words.next().unwrap_or("").to_string(); - let keywords: Vec = words.map(|w| w.to_string()).collect(); - if name.is_empty() { - println!("Usage: register [keywords...]"); - } else { - let pid = node.default_posting_id; - match node.register_persona(&pid, &name, &keywords).await { - Ok(()) => println!( - "Registered '{}' ({} keywords). Listed for 30 days; auto-renews while listed.", - name, keywords.len() - ), - Err(e) => println!("Error: {}", e), - } - } - } else { - println!("Usage: register [keywords...]"); - } - } - - "unregister" => { - let pid = node.default_posting_id; - match node.unregister_persona(&pid).await { - Ok(()) => println!("Registry entry removed (signed delete propagated)."), - Err(e) => println!("Error: {}", e), - } - } - - "search" => { - let query = arg.unwrap_or(""); - match node.search_registry(query).await { - Ok(hits) => { - if hits.is_empty() { - println!("(no registry matches)"); - } - for h in hits { - println!( - " {} {} [{}]", - h.name, - hex::encode(h.author), - h.keywords.join(", "), - ); - } - } - Err(e) => println!("Error: {}", e), - } - } - - "greet" => { - if let Some(rest) = arg { - let parts: Vec<&str> = rest.splitn(2, ' ').collect(); - if parts.len() < 2 { - println!("Usage: greet "); - } else { - match itsgoin_core::parse_node_id_hex(parts[0]) { - Ok(bio_post_id) => { - match node.send_greeting(bio_post_id, parts[1].to_string()).await { - Ok(()) => println!("Greeting sent (sealed — only the bio author can read it)."), - Err(e) => println!("Error: {}", e), - } - } - Err(e) => println!("Invalid post ID: {}", e), - } - } - } else { - println!("Usage: greet "); - } - } - - "greetings" => { - match node.list_greetings().await { - Ok(greetings) => { - if greetings.is_empty() { - println!("(no greetings)"); - } - for (i, g) in greetings.iter().enumerate() { - let name = if g.sender_name.is_empty() { - hex::encode(g.sender_persona)[..12].to_string() - } else { - g.sender_name.clone() - }; - println!( - " [{}] {} ({}): {}", - i, name, &hex::encode(g.sender_persona)[..12], g.text - ); - } - last_greetings = greetings; - } - Err(e) => println!("Error: {}", e), - } - } - - "reply" => { - if let Some(rest) = arg { - let parts: Vec<&str> = rest.splitn(2, ' ').collect(); - match (parts.first().and_then(|s| s.parse::().ok()), parts.get(1)) { - (Some(idx), Some(text)) => { - match last_greetings.get(idx) { - Some(g) => { - match node.reply_to_greeting( - g.comment_author, g.post_id, g.timestamp_ms, - text.to_string(), - ).await { - Ok(()) => println!("Reply sent (sealed to the greeting's fresh reply key)."), - Err(e) => println!("Error: {}", e), - } - } - None => println!("No greeting at index {} — run `greetings` first.", idx), - } - } - _ => println!("Usage: reply "), - } - } else { - println!("Usage: reply "); - } - } - - "dismiss" => { - match arg.and_then(|a| a.trim().parse::().ok()) { - Some(idx) => match last_greetings.get(idx) { - Some(g) => { - match node.dismiss_greeting(g.comment_author, g.post_id, g.timestamp_ms).await { - Ok(()) => println!("Greeting dismissed (local only)."), - Err(e) => println!("Error: {}", e), - } - } - None => println!("No greeting at index {} — run `greetings` first.", idx), - }, - None => println!("Usage: dismiss "), - } - } - - "greetings-open" => { - match arg.map(|a| a.trim()) { - Some("on") | Some("off") => { - let open = arg.map(|a| a.trim()) == Some("on"); - let pid = node.default_posting_id; - match node.set_greetings_open(&pid, open).await { - Ok(()) => println!( - "Greetings {} (bio republished).", - if open { "OPEN — strangers can send sealed greetings" } else { "CLOSED" } - ), - Err(e) => println!("Error: {}", e), - } - } - _ => { - let pid = node.default_posting_id; - match node.get_greetings_open(&pid).await { - Ok(open) => println!( - "Greetings are {}. Usage: greetings-open ", - if open { "open" } else { "closed" } - ), - Err(e) => println!("Error: {}", e), - } - } - } - } - "quit" | "exit" | "q" => { println!("Shutting down..."); break; @@ -1190,9 +887,7 @@ async fn print_post( ) { let author_hex = hex::encode(post.author); let author_short = &author_hex[..12]; - // Posts are authored by posting identities — "me" = any of our personas. - let is_me = node.list_posting_identities().await.unwrap_or_default() - .iter().any(|p| p.node_id == post.author); + let is_me = &post.author == &node.node_id; let author_name = node.get_display_name(&post.author).await.unwrap_or(None); let author_label = match (author_name, is_me) { diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index eed04d9..71a4f85 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "itsgoin-core" -version = "0.8.0-alpha" +version = "0.7.3" edition = "2021" [dependencies] diff --git a/crates/core/src/connection.rs b/crates/core/src/connection.rs index f1367ac..07b6633 100644 --- a/crates/core/src/connection.rs +++ b/crates/core/src/connection.rs @@ -1,4 +1,4 @@ -use std::collections::{HashMap, HashSet, VecDeque}; +use std::collections::{HashMap, HashSet}; use std::net::SocketAddr; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::Arc; @@ -11,90 +11,56 @@ use crate::blob::BlobStore; use crate::content::verify_post_id; use crate::crypto; use crate::protocol::{ - read_message_type, read_payload, write_typed_message, - ConvectionReferral, ConvectionRequestPayload, ConvectionResponsePayload, + read_message_type, read_payload, write_typed_message, AnchorReferral, + AnchorReferralRequestPayload, AnchorReferralResponsePayload, AnchorRegisterPayload, BlobHeaderDiffPayload, BlobHeaderRequestPayload, BlobHeaderResponsePayload, BlobRequestPayload, BlobResponsePayload, - CircleProfileUpdatePayload, InitialExchangePayload, - MessageType, PostDownstreamRegisterPayload, - ProfileUpdatePayload, ContentSyncRequestPayload, ContentSyncResponsePayload, UniquesPullPayload, + CircleProfileUpdatePayload, GroupKeyRequestPayload, + GroupKeyResponsePayload, InitialExchangePayload, MeshPreferPayload, + MessageType, NodeListUpdatePayload, PostDownstreamRegisterPayload, + ProfileUpdatePayload, PullSyncRequestPayload, PullSyncResponsePayload, RefuseRedirectPayload, RelayIntroducePayload, RelayIntroduceResultPayload, SessionRelayPayload, - SocialAddressUpdatePayload, SocialCheckinPayload, - SyncPost, WormQueryPayload, WormResponsePayload, - ReplicationRequestPayload, ReplicationResponsePayload, ALPN, + SocialAddressUpdatePayload, SocialCheckinPayload, SocialDisconnectNoticePayload, + SyncPost, VisibilityUpdatePayload, WormQueryPayload, WormResponsePayload, + ReplicationRequestPayload, ReplicationResponsePayload, ALPN_V2, }; use crate::storage::StoragePool; -use crate::protocol::{ - pack_ids, unpack_ids, AnchorEntry, UniquesAnnouncePayload, UniquesSlice, -}; use crate::types::{ - DeviceProfile, IdClass, MeshSlot, NodeId, PeerWithAddress, PostId, PostVisibility, - ReachEntry, ReachMethod, SessionReachMethod, SocialRouteEntry, SocialStatus, UniquesPools, - WormId, WormResult, + DeviceProfile, NodeId, PeerSlotKind, PeerWithAddress, PostId, PostVisibility, ReachMethod, + SessionReachMethod, SocialRouteEntry, SocialStatus, WormId, WormResult, }; const MAX_PAYLOAD: usize = 64 * 1024 * 1024; // 64 MB -/// Read ceiling for the uniques opcodes (0x01 announce, 0x40/0x41 pull). -/// -/// `MAX_PAYLOAD` exists for FILE transfer. Applying it to the index exchange -/// let one announce decode into ~1.5M packed IDs and the same number of row -/// inserts, per bounce, per reporter, across ~20 reporters — a storage/CPU -/// amplifier reachable by any mesh peer. design.html §layers budgets N2 <= 400 -/// and N3 <= 8,000, i.e. well under 400 KB of packed IDs; 2 MB is an order of -/// magnitude of headroom over the design ceiling and 32x under the file cap. -const MAX_UNIQUES_PAYLOAD: usize = 2 * 1024 * 1024; // 2 MB - const WORM_FAN_OUT_TIMEOUT_MS: u64 = 500; const WORM_BLOOM_TIMEOUT_MS: u64 = 1500; const WORM_TOTAL_TIMEOUT_MS: u64 = 3000; const WORM_COOLDOWN_MS: i64 = 300_000; // 5 min const WORM_DEDUP_EXPIRY_MS: u64 = 10_000; // 10 sec const SESSION_IDLE_TIMEOUT_MS: u64 = 300_000; // 5 min +#[allow(dead_code)] +const RELAY_COOLDOWN_MS: i64 = 300_000; // 5 min const RELAY_INTRO_DEDUP_EXPIRY_MS: u64 = 30_000; // 30 sec +#[allow(dead_code)] +const RELAY_INTRO_TIMEOUT_MS: u64 = 15_000; // 15 sec const HOLE_PUNCH_TIMEOUT_MS: u64 = 30_000; // 30 sec overall window const HOLE_PUNCH_ATTEMPT_MS: u64 = 2_000; // 2 sec per attempt before retry /// Max bytes relayed per pipe before closing const RELAY_MAX_BYTES: u64 = 50 * 1024 * 1024; // 50 MB /// Relay pipe idle timeout const RELAY_PIPE_IDLE_MS: u64 = 120_000; // 2 min +/// How long a preferred peer can be unreachable before being pruned (7 days) +const PREFERRED_UNREACHABLE_PRUNE_MS: u64 = 7 * 24 * 60 * 60 * 1000; /// How long reconnect watchers live before expiry (30 days) const WATCHER_EXPIRY_MS: i64 = 30 * 24 * 60 * 60 * 1000; +/// Max pending introductions per target in 5 minutes +#[allow(dead_code)] +const RELAY_TARGET_RATE_LIMIT: usize = 5; -// --- Anchor convection (design.html §anchors) --- -// -// No registration database. The anchor keeps a small ROLLING WINDOW of recent -// callers, fed by the convection request itself. A caller gets the 2 most -// recent viable prior callers; its own address then goes to the next 2 callers -// and rotates out. That is the whole service. - -/// How many recent callers the rolling window holds. Small on purpose: the -/// point is *recency*, not coverage — a stale address is worse than none, -/// because it costs the caller a hole-punch timeout. -const CONVECTION_WINDOW_SIZE: usize = 16; -/// How many callers an entry is handed to before it rotates out ("2 before, -/// 2 after"). -const CONVECTION_HANDOUT_CAP: u32 = 2; -/// How many referrals a served request gets. -const CONVECTION_REFERRALS: usize = 2; -/// An entry older than this is not worth handing out — the caller has probably -/// moved or gone. Bounds the window in time as well as in count. -const CONVECTION_ENTRY_MAX_AGE_MS: u64 = 15 * 60 * 1000; // 15 min -/// How often the anchor-density prior is recomputed (it changes slowly, and -/// the disconnect path must never pay for a COUNT DISTINCT). -const ANCHOR_DENSITY_REFRESH_MS: u64 = 60_000; // 1 min -/// How long a refusing anchor stays in the penalty box. -const ANCHOR_REFUSAL_PENALTY_MS: u64 = 5 * 60 * 1000; // 5 min -/// A young anchor with an empty window may supplement referrals from its own -/// live mesh peers. Those peers never called and never offered themselves for -/// redistribution, so the supplement gets its own hand-out budget: without one -/// it spent no window budget at all and the same peer could be disclosed to -/// every caller, turning unconditional entry-class service into a topology -/// enumeration oracle. -const CONVECTION_SUPPLEMENT_CAP: u32 = 2; -const CONVECTION_SUPPLEMENT_WINDOW_MS: u64 = 15 * 60 * 1000; // 15 min -/// How many self-reported addresses a caller may add beyond the observed one. -const CONVECTION_MAX_SELF_REPORTED: usize = 2; +/// Grace period before removing disconnected peers from referral list +const REFERRAL_DISCONNECT_GRACE_MS: u64 = 120_000; // 2 min +/// Soft cap on referral list size (affects max_uses tiering) +const REFERRAL_LIST_CAP: usize = 50; /// Zombie connection timeout: no stream activity for this long = dead const ZOMBIE_TIMEOUT_MS: u64 = 600_000; // 10 minutes @@ -119,7 +85,7 @@ pub(crate) async fn hole_punch_parallel( target: &NodeId, addresses: &[String], ) -> Option { - use crate::protocol::ALPN; + use crate::protocol::ALPN_V2; // Filter to address families this endpoint can actually reach let reachable = filter_reachable_families(endpoint, addresses); @@ -151,7 +117,7 @@ pub(crate) async fn hole_punch_parallel( handles.push(tokio::spawn(async move { tokio::time::timeout( std::time::Duration::from_millis(HOLE_PUNCH_ATTEMPT_MS), - ep.connect(a, ALPN), + ep.connect(a, ALPN_V2), ).await })); } @@ -383,7 +349,7 @@ async fn edm_port_scan_disabled_v0_7_3( join_set.spawn(async move { if let Ok(Ok(conn)) = tokio::time::timeout( std::time::Duration::from_millis(SCAN_CONNECT_TIMEOUT_MS), - ep.connect(addr, ALPN), + ep.connect(addr, ALPN_V2), ).await { let _ = tx.send(conn).await; } @@ -413,7 +379,7 @@ async fn edm_port_scan_disabled_v0_7_3( join_set.spawn(async move { if let Ok(Ok(conn)) = tokio::time::timeout( std::time::Duration::from_millis(SCAN_CONNECT_TIMEOUT_MS), - ep.connect(addr, ALPN), + ep.connect(addr, ALPN_V2), ).await { let _ = tx.send(conn).await; } @@ -454,7 +420,7 @@ async fn edm_port_scan_disabled_v0_7_3( join_set.spawn(async move { if let Ok(Ok(conn)) = tokio::time::timeout( std::time::Duration::from_millis(SCAN_CONNECT_TIMEOUT_MS), - ep.connect(addr, ALPN), + ep.connect(addr, ALPN_V2), ).await { let _ = tx.send(conn).await; } @@ -560,7 +526,7 @@ async fn hole_punch_single( match tokio::time::timeout( std::time::Duration::from_millis(HOLE_PUNCH_ATTEMPT_MS), - endpoint.connect(endpoint_addr, ALPN), + endpoint.connect(endpoint_addr, ALPN_V2), ).await { Ok(Ok(conn)) => { tracing::info!(peer = hex::encode(target), "Quick single punch succeeded"); @@ -609,23 +575,10 @@ pub fn normalize_addr(addr: std::net::SocketAddr) -> std::net::SocketAddr { } } -/// How long a temporary referral slot lives before it is reaped, if it has not -/// graduated into a freed mesh slot by then. Expiry is enforced in the rebalance -/// dead-sweep — no separate loop. -pub const TEMP_REFERRAL_TTL_MS: u64 = 5 * 60 * 1000; - -/// Default depth assumed for a peer that hasn't told us its retention horizon. -const DEFAULT_PEER_DEPTH: u8 = 4; - pub struct MeshConnection { pub node_id: NodeId, pub connection: iroh::endpoint::Connection, - /// Mesh vs temporary referral. Temp slots live ABOVE the mesh cap, carry no - /// knowledge exchange, and are never persisted to `mesh_peers`. - pub slot: MeshSlot, - /// Deepest bounce this peer retains (from their announce). We skip building - /// the terminal pool for peers that advertise 3. - pub peer_depth: u8, + pub slot_kind: PeerSlotKind, pub connected_at: u64, /// Remote address as seen from our side of the QUIC connection pub remote_addr: Option, @@ -643,309 +596,19 @@ pub struct SessionConnection { pub remote_addr: Option, } -/// Result of a transitional content sync (0x46/0x47). pub struct PullSyncStats { pub posts_received: usize, + pub visibility_updates: usize, } -/// One caller in the anchor's rolling convection window. -/// -/// Deliberately in-memory only: an anchor that restarts should forget, because -/// everything it knew is by then a cold address. Liveness is NOT stored — it is -/// resolved against `connections`/`sessions` at hand-out time, so an entry can -/// never claim a peer is connected when it isn't. -#[derive(Clone)] -pub(crate) struct ConvectionEntry { - pub(crate) node_id: NodeId, - pub(crate) addresses: Vec, - pub(crate) arrived_at: u64, - /// Times this entry has been handed to a later caller (cap - /// `CONVECTION_HANDOUT_CAP`, then it rotates out). - pub(crate) handouts: u32, -} - -/// May this address go into the convection window / a referral? -/// -/// Publicly routable only (bugs-fixed #8: an unroutable referral address costs -/// the next caller a hole-punch timeout), with one TEST-ONLY escape hatch: -/// `ITSGOIN_TEST_ALLOW_LOOPBACK_REFERRALS` lets the whole topology run on -/// 127.0.0.1. Same debug-gate pattern as `ITSGOIN_TEST_ALLOW_REGISTRY_GENESIS`. -pub(crate) fn convection_addr_ok(sa: &SocketAddr) -> bool { - if crate::network::is_publicly_routable(sa) { - return true; - } - static ALLOW_LOOPBACK: std::sync::OnceLock = std::sync::OnceLock::new(); - // `== "1"`, not `.is_ok()`: an empty or accidental value must not silently - // open loopback referral circulation in a shipped build. Matches the - // `ITSGOIN_TEST_ALLOW_REGISTRY_GENESIS` check. - *ALLOW_LOOPBACK - .get_or_init(|| std::env::var("ITSGOIN_TEST_ALLOW_LOOPBACK_REFERRALS").as_deref() == Ok("1")) - && sa.ip().is_loopback() -} - -/// Everything the uniques-index exchange needs from connection state, captured -/// under a brief lock so the storage half runs with the lock released. -pub(crate) struct UniquesCtx { - storage: Arc, - our_node_id: NodeId, - max_bounce: u8, - anchor_addr: Option, - seq: u64, - pub(crate) is_mesh: bool, - pub(crate) peer_depth: u8, - /// Snapshot of who we are connected to — used only to keep already-meshed - /// peers out of the bounce-2 growth-candidate pool. - connected: HashSet, -} - -impl UniquesCtx { - pub(crate) async fn build(&self, peer_depth: u8) -> Option { - let storage = self.storage.get().await; - build_uniques_payload( - &storage, - &self.our_node_id, - self.anchor_addr.as_deref(), - self.seq, - self.max_bounce, - peer_depth, - ) - .ok() - } - - pub(crate) async fn apply( - &self, - reporter: &NodeId, - payload: &UniquesAnnouncePayload, - ) -> anyhow::Result { - let storage = self.storage.get().await; - apply_uniques_to_storage( - &storage, - &self.our_node_id, - reporter, - payload, - self.max_bounce, - |nid| self.connected.contains(nid), - ) - } -} - -/// Everything a convection response needs, gathered under a brief lock so the -/// write and the coordinated introductions happen outside it. -pub struct ConvectionGathered { - pub response: ConvectionResponsePayload, - /// Referral targets that are live on us right now — each gets an - /// anchor-initiated RelayIntroduce naming the caller as requester. - pub introductions: Vec<(NodeId, iroh::endpoint::Connection)>, - pub requester: NodeId, - /// The caller's usable (observed, routable) addresses, for the intro push. - pub requester_addresses: Vec, - pub nat_mapping: Option, - pub nat_filtering: Option, -} - -/// What the stochastic per-disconnect trigger decided to do (round-4/5). -/// -/// There is no threshold and no jitter timer: the randomness itself is what -/// de-synchronises the network's response to a shared disconnect event. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum ConvectionAction { - /// Sit this one out. Churn is normal; not every disconnect needs a reaction. - Nothing, - /// Ask a random known anchor for an introduction. - AskAnchor, - /// Ask via a connected mesh peer (this is the existing growth loop, whose - /// introduction arm already exists). - AskMesh, -} - -/// Adaptive action weights for the stochastic trigger (round-5 ruling). -/// -/// The anchor-vs-mesh balance is NOT fixed at design time — the willing-anchor -/// ratio is unknowable before deployment. Two runtime inputs set it: -/// * the local anchor-density prior — anchor entries are the only -/// address-bearing rows in the uniques pools, so their share of known -/// node-class uniques is a free local statistic, no census needed; -/// * refusal feedback — an anchor refusing a top-up shifts weight toward -/// mesh introductions and other anchors; successes drift it back (AIMD). -#[derive(Debug, Clone, Copy)] -pub struct ConvectionWeights { - pub nothing: f64, - pub anchor: f64, - pub mesh: f64, -} - -/// Pure, directly-testable action choice. -/// -/// * `roll` — uniform in [0, 1). -/// * `density` — anchor-flagged share of known node-class uniques, [0, 1]. -/// * `anchor_bias` — AIMD feedback term, (0, 1]. Halved on refusal, drifts back -/// up on success. -/// * `fill_ratio` — mesh_count / mesh_slots, [0, 1]. Leaning toward action when -/// far under target is the "lean toward action" half of the ruling: at -/// fill_ratio 0 the do-nothing weight is 0, so we always act. -pub fn convection_weights(density: f64, anchor_bias: f64, fill_ratio: f64) -> ConvectionWeights { - let density = density.clamp(0.0, 1.0); - let anchor_bias = anchor_bias.clamp(0.05, 1.0); - let fill_ratio = fill_ratio.clamp(0.0, 1.0); - ConvectionWeights { - // Quadratic so a nearly-full mesh mostly ignores churn while a badly - // depleted one almost always reacts. - nothing: 2.0 * fill_ratio * fill_ratio, - // Abundant anchors (IPv6 world) → anchor-led growth keeps convection - // windows fresh. Scarce or unhelpful anchors → this collapses toward 0 - // and mesh introductions carry growth. Same code, both worlds. - anchor: anchor_bias * (0.25 + 1.75 * density), - mesh: 1.0, - } -} - -pub fn choose_convection_action( - roll: f64, - density: f64, - anchor_bias: f64, - fill_ratio: f64, -) -> ConvectionAction { - let w = convection_weights(density, anchor_bias, fill_ratio); - let total = w.nothing + w.anchor + w.mesh; - if total <= 0.0 { - return ConvectionAction::Nothing; - } - let point = roll.clamp(0.0, 1.0) * total; - if point < w.nothing { - ConvectionAction::Nothing - } else if point < w.nothing + w.anchor { - ConvectionAction::AskAnchor - } else { - ConvectionAction::AskMesh - } -} - -/// How a mesh disconnect is answered. Recovery PRE-EMPTS the dice: below 2 mesh -/// peers there is nothing stochastic to decide. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum DisconnectResponse { - /// mesh < 2 — act immediately and unconditionally. - Recover, - /// Healthy mesh with room — roll the stochastic action. - Roll, - /// Healthy and full — nothing to do. - Idle, -} - -pub fn disconnect_response(mesh_count: usize, available_mesh_slots: usize) -> DisconnectResponse { - if mesh_count < 2 { - DisconnectResponse::Recover - } else if available_mesh_slots > 0 { - DisconnectResponse::Roll - } else { - DisconnectResponse::Idle - } -} - -// ---- Rolling convection window, as free functions over the deque ---- -// -// Split out of `ConnectionManager` so the rotation rules are directly testable -// without standing up an iroh endpoint. - -/// Admit or refresh a caller. A fresh arrival replaces any older entry and -/// resets its hand-out budget — this caller is available again. -pub(crate) fn convection_window_admit( - window: &mut VecDeque, +/// Entry in the anchor's referral list — connection-backed, self-pruning. +struct ReferralEntry { node_id: NodeId, addresses: Vec, - now: u64, -) { - if addresses.is_empty() { - return; - } - window.retain(|e| e.node_id != node_id); - window.push_back(ConvectionEntry { node_id, addresses, arrived_at: now, handouts: 0 }); - convection_window_prune(window, now); - while window.len() > CONVECTION_WINDOW_SIZE { - window.pop_front(); - } -} - -/// Drop entries that aged out or used up their hand-out budget. -pub(crate) fn convection_window_prune(window: &mut VecDeque, now: u64) { - window.retain(|e| { - e.handouts < CONVECTION_HANDOUT_CAP - && now.saturating_sub(e.arrived_at) <= CONVECTION_ENTRY_MAX_AGE_MS - }); -} - -/// How many entries could be handed out right now. -pub(crate) fn convection_window_viable( - window: &VecDeque, - exclude: &NodeId, - now: u64, -) -> usize { - window - .iter() - .filter(|e| { - e.node_id != *exclude - && e.handouts < CONVECTION_HANDOUT_CAP - && now.saturating_sub(e.arrived_at) <= CONVECTION_ENTRY_MAX_AGE_MS - }) - .count() -} - -/// Take up to `count` referrals: most recent first, still-connected preferred. -/// Each pick spends one hand-out; an entry that reaches the cap rotates out. -pub(crate) fn convection_window_pick( - window: &mut VecDeque, - exclude: &NodeId, - count: usize, - now: u64, - is_live: impl Fn(&NodeId) -> bool, -) -> Vec { - convection_window_prune(window, now); - - // Newest first. - let ordered: Vec = window - .iter() - .rev() - .filter(|e| e.node_id != *exclude) - .map(|e| e.node_id) - .collect(); - // Still-connected callers first: their address is provably current, and - // they are the case where an introduction can be coordinated instead of a - // cold address handed over. - let mut chosen: Vec = ordered.iter().copied().filter(|n| is_live(n)).collect(); - for n in ordered { - if chosen.len() >= count { - break; - } - if !chosen.contains(&n) { - chosen.push(n); - } - } - chosen.truncate(count); - - let mut picked = Vec::with_capacity(chosen.len()); - for n in &chosen { - if let Some(e) = window.iter_mut().find(|e| e.node_id == *n) { - e.handouts += 1; - picked.push(e.clone()); - } - } - convection_window_prune(window, now); - picked -} - -/// Should this request be served? ENTRY is unconditional (round-5 ruling). -pub fn convection_should_serve( - class: crate::protocol::ConvectionClass, - viable_referrals: usize, - saturated: bool, -) -> bool { - if class.is_entry() { - // A node that cannot get in cannot come back, and a network that - // refuses re-entry partitions permanently. Always served — even with - // an empty window, where the mesh-peer supplement takes over. - return true; - } - viable_referrals > 0 && !saturated + #[allow(dead_code)] + registered_at: u64, + use_count: u32, + disconnected_at: Option, } /// Data gathered under brief lock for anchor probe I/O. @@ -1004,19 +667,21 @@ pub struct ConnectionManager { #[allow(dead_code)] is_anchor: Arc, diff_seq: AtomicU64, + #[allow(dead_code)] + secret_seed: [u8; 32], blob_store: Arc, /// Dedup map for worm queries: worm_id → timestamp_ms seen_worms: HashMap, - /// Digest of the last uniques announce we broadcast. The announce is always - /// a full snapshot; this is what lets us skip the send when nothing changed, - /// which recovers most of what incremental diffing would have bought. - last_announce_digest: u64, - /// The single mesh pool cap (v0.8: 20 desktop / 15 mobile) - mesh_slots: usize, - /// Temporary referral slots, allocated strictly ABOVE `mesh_slots` - temp_referral_slots: usize, - /// Deepest bounce WE retain (4 desktop / 3 mobile) - max_bounce: u8, + /// Last broadcast N1 set (for computing diffs) + last_n1_set: HashSet, + /// Last broadcast N2 set (for computing diffs) + last_n2_set: HashSet, + /// Max preferred (bilateral) mesh slots + preferred_slots: usize, + /// Max local (diverse) mesh slots + local_slots: usize, + /// Max wide (bloom-sourced) mesh slots + wide_slots: usize, /// Session connections: short-lived, tracked, separate from mesh slots sessions: HashMap, /// Max session slots @@ -1038,25 +703,12 @@ pub struct ConnectionManager { /// Peers known to be unreachable directly: node_id → last_failed_at_ms /// Learned over the session — cleared on restart, updated on connect success/failure unreachable_peers: HashMap, - /// Anchor-side rolling window of recent callers. Newest at the back. - /// In-memory only — no registration DB, no persistence. - convection_window: VecDeque, - /// Cached anchor-density prior for the stochastic trigger, refreshed on a - /// throttle so the hot disconnect path never pays for the query. - anchor_density: f64, - anchor_density_at_ms: u64, - /// AIMD feedback term on the anchor arm of the action choice. - anchor_bias: f64, - /// Anchors that recently refused us: node_id → penalty expiry (ms). - /// Shifts the *choice of anchor*, while `anchor_bias` shifts the choice of - /// arm — a single unhelpful anchor should not make us stop using anchors. - refused_anchors: HashMap, + /// Anchor-side referral list: connected peers available for referral + referral_list: HashMap, /// Channel to signal the growth loop to wake up and seek diverse peers growth_tx: Option>, /// Channel to signal the recovery loop when mesh drops below threshold recovery_tx: Option>, - /// Channel to signal the convection loop to ask an anchor for peers. - convection_tx: Option>, activity_log: Arc>, /// UPnP external address (prepended to self-reported addresses in anchor registration) upnp_external_addr: Option, @@ -1074,10 +726,7 @@ pub struct ConnectionManager { last_probe_success_ms: u64, /// Anchor probe: consecutive failure count probe_failure_streak: u8, - /// When this ConnectionManager was created. Kept for diagnostics; the - /// 2-hour uptime bar it used to gate anchor candidacy on was the retired - /// popularity model (round-2 ruling: candidacy = reachability + opt-in). - #[allow(dead_code)] + /// When this ConnectionManager was created started_at_ms: u64, /// Dedup map for anchor probes: probe_id → timestamp_ms seen_probes: HashMap<[u8; 16], u64>, @@ -1085,11 +734,9 @@ pub struct ConnectionManager { pub(crate) http_capable: bool, /// External HTTP address (ip:port) if known pub(crate) http_addr: Option, - /// Hand-out ledger for convection referrals SUPPLEMENTED from our live - /// mesh (node id → (count, window start ms)). Window entries have their own - /// `handouts` counter; supplemented peers had none, so a single peer could - /// be named to unlimited callers. - supplement_handouts: HashMap, + /// Sticky N1 entries: NodeIds to report in N1 share until expiry (ms). + /// Used to advertise the bootstrap anchor for 24h after isolation recovery. + sticky_n1: HashMap, /// NodeIds with an outgoing connect attempt currently in flight. /// Used by `try_begin_connect` to suppress duplicate concurrent outgoing /// connects from racing paths (auto-reconnect, rebalance, relay @@ -1123,6 +770,7 @@ impl ConnectionManager { storage: Arc, our_node_id: NodeId, is_anchor: Arc, + secret_seed: [u8; 32], blob_store: Arc, profile: DeviceProfile, activity_log: Arc>, @@ -1144,12 +792,14 @@ impl ConnectionManager { our_node_id, is_anchor, diff_seq: AtomicU64::new(0), + secret_seed, blob_store, seen_worms: HashMap::new(), - last_announce_digest: 0, - mesh_slots: profile.mesh_slots(), - temp_referral_slots: profile.temp_referral_slots(), - max_bounce: profile.max_bounce(), + last_n1_set: HashSet::new(), + last_n2_set: HashSet::new(), + preferred_slots: profile.preferred_slots(), + local_slots: profile.local_slots(), + wide_slots: profile.wide_slots(), sessions: HashMap::new(), session_slots: profile.session_slots(), seen_intros: HashMap::new(), @@ -1158,14 +808,9 @@ impl ConnectionManager { session_relay_enabled, device_profile: profile, unreachable_peers: HashMap::new(), - convection_window: VecDeque::new(), - anchor_density: 0.0, - anchor_density_at_ms: 0, - anchor_bias: 1.0, - refused_anchors: HashMap::new(), + referral_list: HashMap::new(), growth_tx: None, recovery_tx: None, - convection_tx: None, activity_log, upnp_external_addr, observed_external_addr: std::sync::Mutex::new(None), @@ -1181,7 +826,7 @@ impl ConnectionManager { seen_probes: HashMap::new(), http_capable: false, http_addr: None, - supplement_handouts: HashMap::new(), + sticky_n1: HashMap::new(), pending_connects: Arc::new(std::sync::Mutex::new(HashSet::new())), } } @@ -1227,37 +872,52 @@ impl ConnectionManager { crate::types::NatProfile::new(self.nat_mapping, self.nat_filtering) } - /// Whether this node is a candidate for anchor status. - /// - /// v0.8 (round-2 ruling): candidacy is **stable direct reachability**, full - /// stop. The old ">=50 connections and 2h uptime" bar was the retired - /// popularity model — an anchor's job is to be dialable for network - /// (re)entry, and a node with 3 peers and a public address does that job - /// exactly as well as one with 80. Mobile is still excluded (battery and - /// roaming addresses, not popularity). + /// Whether this node is a candidate for anchor status (has UPnP/public, enough connections, uptime) pub fn is_anchor_candidate(&self) -> bool { + let now = now_ms(); + // Must have UPnP mapping or public IPv6 let has_public_addr = self.upnp_external_addr.is_some() || self.endpoint.addr().ip_addrs().any(|s| crate::network::is_publicly_routable(&s)); - has_public_addr && self.device_profile != crate::types::DeviceProfile::Mobile + if !has_public_addr { + return false; + } + // Must have enough connections (at least 50) + if self.connections.len() < 50 { + return false; + } + // Must have been running for at least 2 hours + let two_hours_ms = 2 * 60 * 60 * 1000; + if now.saturating_sub(self.started_at_ms) < two_hours_ms { + return false; + } + // Not mobile + if self.device_profile == crate::types::DeviceProfile::Mobile { + return false; + } + true } - /// Whether an anchor self-verification probe is due: we are a candidate and - /// the last successful probe was more than 30 minutes ago. - /// - /// Driven by the convection loop's maintenance tick (and, on address - /// change, by the reachability watcher) now that the register cycle it used - /// to hang off is retired. + /// Whether an anchor probe is due (candidate minus probe-success check, last probe > 30 min ago) pub fn probe_due(&self) -> bool { - if !self.is_anchor_candidate() { + let now = now_ms(); + let has_public_addr = self.upnp_external_addr.is_some() + || self.endpoint.addr().ip_addrs().any(|s| crate::network::is_publicly_routable(&s)); + if !has_public_addr { return false; } - // A probe needs a witness from the index and a reporter to route - // through — with no peers at all there is nobody to ask. - if self.connections.is_empty() { + if self.connections.len() < 50 { return false; } + let two_hours_ms = 2 * 60 * 60 * 1000; + if now.saturating_sub(self.started_at_ms) < two_hours_ms { + return false; + } + if self.device_profile == crate::types::DeviceProfile::Mobile { + return false; + } + // Last probe must be > 30 min ago let thirty_min_ms = 30 * 60 * 1000; - now_ms().saturating_sub(self.last_probe_success_ms) > thirty_min_ms + now.saturating_sub(self.last_probe_success_ms) > thirty_min_ms } /// Initiate an anchor self-verification probe. @@ -1529,7 +1189,7 @@ impl ConnectionManager { let addr = iroh::EndpointAddr::from(eid).with_ip_addr(sock_addr); match tokio::time::timeout( std::time::Duration::from_secs(15), - self.endpoint.connect(addr, ALPN), + self.endpoint.connect(addr, ALPN_V2), ).await { Ok(Ok(_conn)) => true, _ => false, @@ -1682,18 +1342,9 @@ impl ConnectionManager { && !self.connections.contains_key(nid) && !self.is_likely_unreachable(nid) }) - // Depth is a GRADED bonus, not a boolean. With the horizon widened - // to 2..4, `+0.3 unless deeper` let a bounce-4 ID seen by ONE - // reporter (1.0) outrank a genuine bounce-2 ID seen by three - // (0.33 + 0.3 = 0.63) — i.e. the growth loop preferentially picked - // the deepest, least-resolvable candidates. - .map(|(nid, reporter_count, shallowest)| { - let depth_bonus = match shallowest { - 0..=2 => 0.3, - 3 => 0.1, - _ => 0.0, - }; - let score = 1.0 / (reporter_count.max(1) as f64) + depth_bonus; + .map(|(nid, reporter_count, in_n3)| { + let score = 1.0 / (reporter_count as f64) + + if !in_n3 { 0.3 } else { 0.0 }; (nid, score) }) .collect(); @@ -1702,104 +1353,69 @@ impl ConnectionManager { scored } - /// Live count of MESH-slot connections (temp referral slots excluded). - pub fn mesh_count(&self) -> usize { - self.connections.values().filter(|pc| pc.slot.is_mesh()).count() + /// How many local slots are available for the growth loop to fill. + pub fn available_local_slots(&self) -> usize { + let local_count = self.count_kind(PeerSlotKind::Local); + self.local_slots.saturating_sub(local_count) } - /// Live count of temporary referral slots. - pub fn temp_count(&self) -> usize { - self.connections.values().filter(|pc| pc.slot.is_temp()).count() - } - - /// How many mesh slots are free for the growth loop to fill. - /// - /// v0.8: this counts the WHOLE pool. Under the old Local/Wide split the - /// growth gate counted Local only while every outbound path hardcoded - /// Local — so growth stopped at the Local cap and the Wide slots could - /// only ever be filled by inbound connections. Collapsing to one pool - /// removes that bug structurally. - pub fn available_mesh_slots(&self) -> usize { - self.mesh_slots.saturating_sub(self.mesh_count()) - } - - /// Decide which slot class a new connection may occupy. - /// `None` = no room at all, refuse. - fn allocate_slot(&self, peer_id: &NodeId) -> Option { - if let Some(existing) = self.connections.get(peer_id) { - // Replacing a live connection keeps its class. - return Some(existing.slot); - } - decide_slot( - self.mesh_count(), - self.mesh_slots, - self.temp_count(), - self.temp_referral_slots, - now_ms(), - ) - } - - /// Accept an incoming connection. Returns the slot class it was given, or - /// `None` if both the mesh pool and the temp referral band are full. - /// - /// The caller MUST propagate `slot.is_mesh()` into the initial exchange: - /// a temp referral slot carries no uniques announce in either direction. + /// Accept an incoming connection, returning false if slots are full. pub fn accept_connection( &mut self, conn: iroh::endpoint::Connection, remote_node_id: NodeId, remote_addr: Option, - ) -> Option { + ) -> bool { if self.connections.contains_key(&remote_node_id) { debug!(peer = hex::encode(remote_node_id), "Replacing existing connection"); } - let slot = match self.allocate_slot(&remote_node_id) { - Some(s) => s, - None => { - debug!(peer = hex::encode(remote_node_id), "Slots full (mesh + temp), rejecting"); - return None; - } - }; + let total_slots = self.preferred_slots + self.local_slots + self.wide_slots; + let total = self.connections.len(); + if total >= total_slots && !self.connections.contains_key(&remote_node_id) { + debug!(peer = hex::encode(remote_node_id), "Slots full, rejecting"); + return false; + } let now = now_ms(); + let slot_kind = if self.count_kind(PeerSlotKind::Local) < self.local_slots { + PeerSlotKind::Local + } else { + PeerSlotKind::Wide + }; + self.connections.insert( remote_node_id, MeshConnection { node_id: remote_node_id, connection: conn, - slot, - peer_depth: DEFAULT_PEER_DEPTH, + slot_kind, connected_at: now, remote_addr: remote_addr.map(normalize_addr), last_activity: Arc::new(AtomicU64::new(now)), }, ); - Some(slot) + // If peer was on referral list and reconnected, clear disconnected_at + self.mark_referral_reconnected(&remote_node_id); + + true } /// Register an already-established connection into the mesh. /// The QUIC connect must happen OUTSIDE the conn_mgr lock to avoid blocking /// all other tasks (diff cycle, accept loop, keepalive, rebalance) during /// the potentially long connection timeout. - /// - /// The slot class is DECIDED here rather than supplied by the caller: every - /// outbound path used to pass `Local` unconditionally and none of them - /// checked capacity, so only inbound connections were ever capped. At 20 - /// slots that overshoot would be load-bearing. - /// Returns the slot the connection was placed in, or `None` if refused. pub async fn register_connection( &mut self, peer_id: NodeId, conn: iroh::endpoint::Connection, addrs: &[std::net::SocketAddr], - ) -> Option { + slot_kind: PeerSlotKind, + ) { let now = now_ms(); let connect_addr = addrs.first().copied(); - let slot = self.allocate_slot(&peer_id)?; - // Direct connect succeeded — mark peer as directly reachable self.mark_reachable(&peer_id); @@ -1808,31 +1424,27 @@ impl ConnectionManager { MeshConnection { node_id: peer_id, connection: conn, - slot, - peer_depth: DEFAULT_PEER_DEPTH, + slot_kind, connected_at: now, remote_addr: connect_addr.map(normalize_addr), last_activity: Arc::new(AtomicU64::new(now)), }, ); - // Persist address to peers table so it survives restart. - // bugs-fixed #2: ALWAYS do this, temp referral slots included — the - // `peers` table is addresses, `mesh_peers` is knowledge membership. + // If peer was on referral list and reconnected, clear disconnected_at + self.mark_referral_reconnected(&peer_id); + + // Persist address to peers table so it survives restart if !addrs.is_empty() { let storage = self.storage.get().await; let _ = storage.upsert_peer(&peer_id, addrs, None); drop(storage); } - // Record in mesh_peers table + touch social route. - // Temp referral slots are deliberately NOT written to mesh_peers — - // that omission is what keeps them out of our uniques announce. + // Record in mesh_peers table + touch social route { let storage = self.storage.get().await; - if slot.is_mesh() { - let _ = storage.add_mesh_peer(&peer_id); - } + let _ = storage.add_mesh_peer(&peer_id, slot_kind, 0); if storage.has_social_route(&peer_id).unwrap_or(false) { let _ = storage.touch_social_route_connect(&peer_id, addrs, ReachMethod::Direct); // Gather watcher data before dropping storage @@ -1849,8 +1461,7 @@ impl ConnectionManager { } } - info!(peer = hex::encode(peer_id), slot = %slot, "Connected to peer"); - Some(slot) + info!(peer = hex::encode(peer_id), kind = %slot_kind, "Connected to peer"); } /// Establish an outgoing mesh connection with a 15s timeout on the QUIC connect. @@ -1861,18 +1472,12 @@ impl ConnectionManager { peer_id: NodeId, conn: iroh::endpoint::Connection, addrs: &[std::net::SocketAddr], - ) -> Option { - if let Some(pc) = self.connections.get(&peer_id) { - return Some(pc.slot); // Already connected - } - self.register_connection(peer_id, conn, addrs).await - } - - /// Record a peer's advertised retention depth (from their announce). - pub fn set_peer_depth(&mut self, peer_id: &NodeId, depth: u8) { - if let Some(pc) = self.connections.get_mut(peer_id) { - pc.peer_depth = depth.clamp(2, 4); + slot_kind: PeerSlotKind, + ) { + if self.connections.contains_key(&peer_id) { + return; // Already connected } + self.register_connection(peer_id, conn, addrs, slot_kind).await; } /// QUIC connect with 15s timeout — call this OUTSIDE the conn_mgr lock. @@ -1882,23 +1487,16 @@ impl ConnectionManager { ) -> anyhow::Result { let conn = tokio::time::timeout( std::time::Duration::from_secs(15), - endpoint.connect(addr, ALPN), + endpoint.connect(addr, ALPN_V2), ).await .map_err(|_| anyhow::anyhow!("connect timed out (15s)"))? .map_err(|e| anyhow::anyhow!("connect failed: {e}"))?; Ok(conn) } - /// TRANSITIONAL content sync from a peer (0x46/0x47) — standalone version - /// that doesn't require the conn_mgr lock. Takes a cloned connection and - /// storage Arc. - /// - /// This is NOT the pull. A pull is the uniques-index exchange - /// (`uniques_pull_unlocked`). Content moves here only until Iteration D - /// gives the CDN a carrier for non-public visibilities — `PostFetchRequest` - /// (0xD4) serves Public posts only, so DMs, group posts, Friends and - /// FoFClosed posts have no other path today. - pub async fn content_sync_unlocked( + /// Pull posts from a peer — standalone version that doesn't require conn_mgr lock. + /// Takes a cloned connection and storage Arc. + pub async fn pull_from_peer_unlocked( conn: iroh::endpoint::Connection, storage: &Arc, peer_id: &NodeId, @@ -1923,22 +1521,24 @@ impl ConnectionManager { } } - let request = ContentSyncRequestPayload { + let request = PullSyncRequestPayload { follows: query_list, + have_post_ids: vec![], since_ms: follows_sync, }; let (mut send, mut recv) = conn.open_bi().await?; - write_typed_message(&mut send, MessageType::ContentSyncRequest, &request).await?; + write_typed_message(&mut send, MessageType::PullSyncRequest, &request).await?; send.finish()?; let msg_type = read_message_type(&mut recv).await?; - if msg_type != MessageType::ContentSyncResponse { - anyhow::bail!("expected ContentSyncResponse, got {:?}", msg_type); + if msg_type != MessageType::PullSyncResponse { + anyhow::bail!("expected PullSyncResponse, got {:?}", msg_type); } - let response: ContentSyncResponsePayload = read_payload(&mut recv, MAX_PAYLOAD).await?; + let response: PullSyncResponsePayload = read_payload(&mut recv, MAX_PAYLOAD).await?; let mut posts_received = 0; + let mut vis_updates = 0; let mut new_post_ids: Vec = Vec::new(); let now_ms = crate::connection::now_ms(); let mut synced_authors: HashSet = HashSet::new(); @@ -1966,7 +1566,7 @@ impl ConnectionManager { } } - // Brief storage lock: upstream + last_sync + // Brief storage lock: upstream + last_sync + visibility updates { let s = storage.get().await; for pid in &new_post_ids { @@ -1980,6 +1580,15 @@ impl ConnectionManager { for author in &synced_authors { let _ = s.update_follow_last_sync(author, now_ms); } + for vu in response.visibility_updates { + if let Some(post) = s.get_post(&vu.post_id)? { + if post.author == vu.author { + if s.update_post_visibility(&vu.post_id, &vu.visibility)? { + vis_updates += 1; + } + } + } + } } // Register as downstream (spawned, no lock needed) @@ -1996,7 +1605,7 @@ impl ConnectionManager { }); } - Ok(PullSyncStats { posts_received }) + Ok(PullSyncStats { posts_received, visibility_updates: vis_updates }) } /// Fetch engagement headers from a peer — standalone version that doesn't require conn_mgr lock. @@ -2050,12 +1659,8 @@ impl ConnectionManager { if let Some((json, header)) = header_opt { let _ = s.store_blob_header(&header.post_id, &header.author, json, header.updated_at); for reaction in &header.reactions { let _ = s.store_reaction(reaction); } - // v0.8 (A3): pull-path comments go through the - // SAME accept gate as the diff path (this site - // historically stored with no verification). - let _ = ingest_header_comments(&s, header, now_ms); - // v0.8: peer-supplied header.policy is NOT applied - // (unverifiable — see the SetPolicy arm note). + for comment in &header.comments { let _ = s.store_comment(comment); } + let _ = s.set_comment_policy(&header.post_id, &header.policy); let _ = s.update_post_last_engagement(post_id, now_ms); updated += 1; } @@ -2066,133 +1671,505 @@ impl ConnectionManager { Ok(updated) } - /// Snapshot everything the uniques exchange needs from connection state, so - /// the (bulk, slow) storage half can run with the ConnectionManager mutex - /// released. See `handle_uniques_pull`. - pub(crate) fn uniques_ctx(&self, peer: &NodeId) -> UniquesCtx { - let (is_mesh, peer_depth) = match self.connections.get(peer) { - Some(pc) => (pc.slot.is_mesh(), pc.peer_depth), - // Not a mesh connection at all (ephemeral/session) — no knowledge - // crosses. - None => (false, DEFAULT_PEER_DEPTH), - }; - UniquesCtx { - storage: Arc::clone(&self.storage), - our_node_id: self.our_node_id, - max_bounce: self.max_bounce, - anchor_addr: self.build_anchor_advertised_addr(), - seq: self.diff_seq.fetch_add(1, Ordering::Relaxed) + 1, - is_mesh, - peer_depth, - connected: self.connections.keys().copied().collect(), - } - } - - /// Responder for 0x40 — the uniques-index exchange. - /// - /// design.html §sync: a pull is not a post transfer. It is the peer's list - /// of unique IDs they have a live path to — "if you want these IDs, talk to - /// me and I'll help you find them". Symmetric: their pools arrive in the - /// request, ours go back in the response, so one round trip refreshes both - /// indexes. Content travels via the CDN (and, until Iteration D, via the - /// transitional 0x46/0x47 content sync). - pub async fn handle_uniques_pull( - conn_mgr: &Arc>, - mut recv: iroh::endpoint::RecvStream, - mut send: iroh::endpoint::SendStream, + /// Do the initial exchange after connecting: N1/N2 node lists + profile + deletes + peer addresses (both directions). + pub async fn do_initial_exchange( + &self, + conn: &iroh::endpoint::Connection, remote_node_id: NodeId, ) -> anyhow::Result<()> { - let request: UniquesPullPayload = read_payload(&mut recv, MAX_UNIQUES_PAYLOAD).await?; - - // Gather the SMALL bits of connection state under a brief lock, then - // drop it and do every storage operation against the pool directly — - // the `gather_anchor_probe_data` / `run_anchor_probe_unlocked` pattern. - // - // Both the build and the apply are bulk SQLite work (the apply is a - // per-row upsert loop sized in thousands). Doing them under the - // ConnectionManager mutex blocked slot allocation, disconnects, - // referral serving and relay introductions for the whole write, once - // per pull per peer across ~20 mesh peers. - // - // Knowledge boundary: a temp referral slot exchanges no knowledge in - // either direction. We answer (so the caller isn't left hanging) but - // with `uniques: None`, and we drop whatever they sent. - let ctx = { - let cm = conn_mgr.lock().await; - cm.uniques_ctx(&remote_node_id) + // Build our payload + let our_payload = { + let storage = self.storage.get().await; + let n1 = storage.build_n1_share()?; + let n2 = storage.build_n2_share()?; + // Profile keyed by network id (used for N1/N2/N3 routing). + // Strip persona display data before sending so peers don't learn + // a human-readable name for our network id. + let profile = storage.get_profile(&self.our_node_id)? + .map(|p| p.sanitized_for_network_broadcast()); + let deletes = storage.list_delete_records()?; + let post_ids = storage.list_post_ids()?; + let peer_addresses = storage.build_peer_addresses_for(&self.our_node_id)?; + let our_profile = crate::types::NatProfile::from_nat_type(self.nat_type); + InitialExchangePayload { + n1_node_ids: n1, + n2_node_ids: n2, + profile, + deletes, + post_ids, + peer_addresses, + anchor_addr: self.build_anchor_advertised_addr(), + your_observed_addr: None, + nat_type: Some(self.nat_type.to_string()), + nat_mapping: Some(our_profile.mapping.to_string()), + nat_filtering: Some(our_profile.filtering.to_string()), + http_capable: self.http_capable, + http_addr: self.http_addr.clone(), + device_role: None, + cache_pressure: None, + duplicate_active: None, + } }; - let ours = if ctx.is_mesh { - let depth = request.uniques.as_ref().map(|u| u.depth).unwrap_or(ctx.peer_depth); - ctx.build(depth).await - } else { - None - }; + // Open bi-stream for initial exchange + let (mut send, mut recv) = conn.open_bi().await?; - // Write BEFORE applying theirs — applying is a storage write of up to - // a few thousand rows and the caller should not wait on it. - let response = UniquesPullPayload { uniques: ours }; - write_typed_message(&mut send, MessageType::UniquesPullResponse, &response).await?; + // Send our payload + write_typed_message(&mut send, MessageType::InitialExchange, &our_payload).await?; send.finish()?; - if ctx.is_mesh { - if let Some(theirs) = request.uniques { - let applied = ctx.apply(&remote_node_id, &theirs).await?; - let mut cm = conn_mgr.lock().await; - cm.set_peer_depth(&remote_node_id, theirs.depth); - if applied > 0 { - debug!(peer = hex::encode(remote_node_id), applied, "Uniques pull: applied peer index"); - cm.notify_growth(); + // Read their payload + let msg_type = read_message_type(&mut recv).await?; + if msg_type != MessageType::InitialExchange { + anyhow::bail!("expected InitialExchange, got {:?}", msg_type); + } + let their_payload: InitialExchangePayload = read_payload(&mut recv, MAX_PAYLOAD).await?; + + // Process their data + let storage = self.storage.get().await; + + // Their N1 → our N2 (tagged to this reporter) + // Filter out our own ID and already-connected peers (they'd waste candidate slots) + let filtered_n1: Vec = their_payload.n1_node_ids.iter() + .filter(|nid| **nid != self.our_node_id && !self.connections.contains_key(*nid)) + .copied() + .collect(); + storage.set_peer_n1(&remote_node_id, &filtered_n1)?; + debug!(peer = hex::encode(remote_node_id), raw = their_payload.n1_node_ids.len(), stored = filtered_n1.len(), "Stored peer N1 as our N2 (filtered self+mesh)"); + + // Their N2 → our N3 (tagged to this reporter) + let filtered_n2: Vec = their_payload.n2_node_ids.iter() + .filter(|nid| **nid != self.our_node_id) + .copied() + .collect(); + storage.set_peer_n2(&remote_node_id, &filtered_n2)?; + debug!(peer = hex::encode(remote_node_id), raw = their_payload.n2_node_ids.len(), stored = filtered_n2.len(), "Stored peer N2 as our N3 (filtered self)"); + + // Store their profile + if let Some(profile) = their_payload.profile { + let _ = storage.store_profile(&profile); + } + + // Process delete records + for dr in their_payload.deletes { + if crypto::verify_delete_signature(&dr.author, &dr.post_id, &dr.signature) { + let _ = storage.store_delete(&dr); + let _ = storage.apply_delete(&dr); + } + } + + // Store their peer_addresses (N+10:Addresses) + for pa in &their_payload.peer_addresses { + if let Ok(nid) = crate::parse_node_id_hex(&pa.n) { + let addrs: Vec = pa.a.iter().filter_map(|a| a.parse().ok()).collect(); + if !addrs.is_empty() { + let _ = storage.upsert_peer(&nid, &addrs, None); } } } + + // Record replicas from overlapping post_ids + let our_post_ids: HashSet<[u8; 32]> = storage.list_post_ids()?.into_iter().collect(); + for pid in &their_payload.post_ids { + if our_post_ids.contains(pid) { + let _ = storage.record_replica(pid, &remote_node_id); + } + } + + // Process anchor's advertised address + if let Some(ref anchor_addr_str) = their_payload.anchor_addr { + if let Ok(sock) = anchor_addr_str.parse::() { + let _ = storage.upsert_known_anchor(&remote_node_id, &[sock]); + let _ = storage.upsert_peer(&remote_node_id, &[sock], None); + info!(peer = hex::encode(remote_node_id), addr = %sock, "Stored anchor's advertised address"); + } + } + + // Store observed address (STUN-like feedback from peer) + if let Some(ref observed) = their_payload.your_observed_addr { + info!(observed_addr = %observed, reporter = hex::encode(remote_node_id), "Peer reports our address as"); + if let Ok(addr) = observed.parse::() { + *self.observed_external_addr.lock().unwrap() = Some(addr); + } + } + + // Store peer's NAT type + if let Some(ref nat_str) = their_payload.nat_type { + let nat = crate::types::NatType::from_str_label(nat_str); + let _ = storage.set_peer_nat_type(&remote_node_id, nat); + } + + // Store peer's NAT profile (mapping + filtering) if provided + if their_payload.nat_mapping.is_some() || their_payload.nat_filtering.is_some() { + let mapping = their_payload.nat_mapping.as_deref() + .map(crate::types::NatMapping::from_str_label) + .unwrap_or(crate::types::NatMapping::Unknown); + let filtering = their_payload.nat_filtering.as_deref() + .map(crate::types::NatFiltering::from_str_label) + .unwrap_or(crate::types::NatFiltering::Unknown); + let profile = crate::types::NatProfile::new(mapping, filtering); + let _ = storage.set_peer_nat_profile(&remote_node_id, &profile); + } + Ok(()) } - /// Client side of the uniques-index exchange — standalone, no conn_mgr lock - /// held across I/O. Returns how many index rows the peer's pools produced. - pub async fn uniques_pull_unlocked( - conn_mgr: &Arc>, - conn: iroh::endpoint::Connection, - peer_id: &NodeId, - ) -> anyhow::Result { - let ctx = { - let cm = conn_mgr.lock().await; - cm.uniques_ctx(peer_id) + /// Handle the responder side of initial exchange. + pub async fn handle_initial_exchange( + &self, + mut send: iroh::endpoint::SendStream, + mut recv: iroh::endpoint::RecvStream, + remote_node_id: NodeId, + ) -> anyhow::Result<()> { + // Read their payload (message type byte already consumed by caller) + let their_payload: InitialExchangePayload = read_payload(&mut recv, MAX_PAYLOAD).await?; + + // Build and send our payload + let our_payload = { + let storage = self.storage.get().await; + let n1 = storage.build_n1_share()?; + let n2 = storage.build_n2_share()?; + // Profile keyed by network id (used for N1/N2/N3 routing). + // Strip persona display data before sending so peers don't learn + // a human-readable name for our network id. + let profile = storage.get_profile(&self.our_node_id)? + .map(|p| p.sanitized_for_network_broadcast()); + let deletes = storage.list_delete_records()?; + let post_ids = storage.list_post_ids()?; + let peer_addresses = storage.build_peer_addresses_for(&self.our_node_id)?; + let our_profile = crate::types::NatProfile::from_nat_type(self.nat_type); + InitialExchangePayload { + n1_node_ids: n1, + n2_node_ids: n2, + profile, + deletes, + post_ids, + peer_addresses, + anchor_addr: self.build_anchor_advertised_addr(), + your_observed_addr: None, // no remote_addr available in this method + nat_type: Some(self.nat_type.to_string()), + nat_mapping: Some(our_profile.mapping.to_string()), + nat_filtering: Some(our_profile.filtering.to_string()), + http_capable: self.http_capable, + http_addr: self.http_addr.clone(), + device_role: None, + cache_pressure: None, + duplicate_active: None, + } }; - if !ctx.is_mesh { - anyhow::bail!("uniques pull only runs on mesh slots"); + + write_typed_message(&mut send, MessageType::InitialExchange, &our_payload).await?; + send.finish()?; + + // Process their data + let storage = self.storage.get().await; + + // Their N1 → our N2 (filter out self + already-connected peers) + let filtered_n1: Vec = their_payload.n1_node_ids.iter() + .filter(|nid| **nid != self.our_node_id && !self.connections.contains_key(*nid)) + .copied() + .collect(); + storage.set_peer_n1(&remote_node_id, &filtered_n1)?; + debug!(peer = hex::encode(remote_node_id), raw = their_payload.n1_node_ids.len(), stored = filtered_n1.len(), "Stored peer N1 as our N2 (filtered self+mesh)"); + + // Their N2 → our N3 (filter out self) + let filtered_n2: Vec = their_payload.n2_node_ids.iter() + .filter(|nid| **nid != self.our_node_id) + .copied() + .collect(); + storage.set_peer_n2(&remote_node_id, &filtered_n2)?; + + if let Some(profile) = their_payload.profile { + let _ = storage.store_profile(&profile); } - // Built with the lock RELEASED — see `handle_uniques_pull`. - let ours = ctx.build(ctx.peer_depth).await; + for dr in their_payload.deletes { + if crypto::verify_delete_signature(&dr.author, &dr.post_id, &dr.signature) { + let _ = storage.store_delete(&dr); + let _ = storage.apply_delete(&dr); + } + } - let (mut send, mut recv) = conn.open_bi().await?; - write_typed_message( - &mut send, - MessageType::UniquesPullRequest, - &UniquesPullPayload { uniques: ours }, - ) - .await?; + // Store their peer_addresses (N+10:Addresses) + for pa in &their_payload.peer_addresses { + if let Ok(nid) = crate::parse_node_id_hex(&pa.n) { + let addrs: Vec = pa.a.iter().filter_map(|a| a.parse().ok()).collect(); + if !addrs.is_empty() { + let _ = storage.upsert_peer(&nid, &addrs, None); + } + } + } + + let our_post_ids: HashSet<[u8; 32]> = storage.list_post_ids()?.into_iter().collect(); + for pid in &their_payload.post_ids { + if our_post_ids.contains(pid) { + let _ = storage.record_replica(pid, &remote_node_id); + } + } + + Ok(()) + } + + /// Compute N1/N2 changes and broadcast NodeListUpdate to all connected peers. + pub async fn broadcast_routing_diff(&mut self) -> anyhow::Result { + let seq = self.diff_seq.fetch_add(1, Ordering::Relaxed) + 1; + + let (current_n1, current_n2) = { + let storage = self.storage.get().await; + let n1: HashSet = storage.build_n1_share()?.into_iter().collect(); + let n2: HashSet = storage.build_n2_share()?.into_iter().collect(); + (n1, n2) + }; + + let n1_added: Vec = current_n1.difference(&self.last_n1_set).copied().collect(); + let n1_removed: Vec = self.last_n1_set.difference(¤t_n1).copied().collect(); + let n2_added: Vec = current_n2.difference(&self.last_n2_set).copied().collect(); + let n2_removed: Vec = self.last_n2_set.difference(¤t_n2).copied().collect(); + + if n1_added.is_empty() && n1_removed.is_empty() && n2_added.is_empty() && n2_removed.is_empty() { + return Ok(0); + } + + let payload = NodeListUpdatePayload { + seq, + n1_added, + n1_removed, + n2_added, + n2_removed, + }; + let mut sent_count = 0; + + for (peer_id, pc) in &self.connections { + let result = async { + let mut send = pc.connection.open_uni().await?; + write_typed_message(&mut send, MessageType::NodeListUpdate, &payload).await?; + send.finish()?; + anyhow::Ok(()) + } + .await; + + match result { + Ok(()) => sent_count += 1, + Err(e) => { + debug!( + peer = hex::encode(peer_id), + error = %e, + "Failed to send node list update" + ); + } + } + } + + // Update last sets + self.last_n1_set = current_n1; + self.last_n2_set = current_n2; + + Ok(sent_count) + } + + /// Process a node list update from a peer: their N1 changes → our N2, their N2 changes → our N3. + pub async fn process_routing_diff( + &self, + reporter: &NodeId, + diff: NodeListUpdatePayload, + ) -> anyhow::Result { + let storage = self.storage.get().await; + let mut count = 0; + + // Their N1 added → add to our N2 (filter self + already-connected) + if !diff.n1_added.is_empty() { + let filtered: Vec = diff.n1_added.iter() + .filter(|nid| **nid != self.our_node_id && !self.connections.contains_key(*nid)) + .copied() + .collect(); + if !filtered.is_empty() { + storage.add_peer_n1(reporter, &filtered)?; + } + count += filtered.len(); + } + + // Their N1 removed → remove from our N2 + if !diff.n1_removed.is_empty() { + storage.remove_peer_n1(reporter, &diff.n1_removed)?; + count += diff.n1_removed.len(); + } + + // Their N2 added → add to our N3 (filter self) + if !diff.n2_added.is_empty() { + let filtered: Vec = diff.n2_added.iter() + .filter(|nid| **nid != self.our_node_id) + .copied() + .collect(); + if !filtered.is_empty() { + storage.add_peer_n2(reporter, &filtered)?; + } + count += filtered.len(); + } + + // Their N2 removed → remove from our N3 + if !diff.n2_removed.is_empty() { + storage.remove_peer_n2(reporter, &diff.n2_removed)?; + count += diff.n2_removed.len(); + } + + // Update last_diff_seq + let _ = storage.update_mesh_peer_seq(reporter, diff.seq); + + Ok(count) + } + + /// Broadcast a visibility update to all connected peers. + pub async fn broadcast_visibility_update( + &self, + update: &crate::types::VisibilityUpdate, + ) -> usize { + let payload = VisibilityUpdatePayload { + updates: vec![update.clone()], + }; + + let mut sent = 0; + for (peer_id, pc) in &self.connections { + let result = async { + let mut send = pc.connection.open_uni().await?; + write_typed_message(&mut send, MessageType::VisibilityUpdate, &payload).await?; + send.finish()?; + anyhow::Ok(()) + } + .await; + + match result { + Ok(()) => sent += 1, + Err(e) => { + debug!(peer = hex::encode(peer_id), error = %e, "Failed to push visibility update"); + } + } + } + + sent + } + + /// Pull posts from a connected peer. + pub async fn pull_from_peer(&self, peer_id: &NodeId) -> anyhow::Result { + let pc = self + .connections + .get(peer_id) + .ok_or_else(|| anyhow::anyhow!("not connected to {}", hex::encode(peer_id)))?; + + let (our_follows, follows_sync, our_personas) = { + let storage = self.storage.get().await; + ( + storage.list_follows()?, + storage.get_follows_with_last_sync().unwrap_or_default(), + storage.list_posting_identities().unwrap_or_default(), + ) + }; + + // Merged pull: include every posting identity so DMs match recipient. + let mut query_list = our_follows; + for pi in &our_personas { + if !query_list.contains(&pi.node_id) { + query_list.push(pi.node_id); + } + } + + let request = PullSyncRequestPayload { + follows: query_list, + have_post_ids: vec![], // v4: empty, using since_ms instead + since_ms: follows_sync, + }; + + let (mut send, mut recv) = pc.connection.open_bi().await?; + write_typed_message(&mut send, MessageType::PullSyncRequest, &request).await?; send.finish()?; let msg_type = read_message_type(&mut recv).await?; - if msg_type != MessageType::UniquesPullResponse { - anyhow::bail!("expected UniquesPullResponse, got {:?}", msg_type); + if msg_type != MessageType::PullSyncResponse { + anyhow::bail!("expected PullSyncResponse, got {:?}", msg_type); } - let response: UniquesPullPayload = read_payload(&mut recv, MAX_UNIQUES_PAYLOAD).await?; + let response: PullSyncResponsePayload = read_payload(&mut recv, MAX_PAYLOAD).await?; - let theirs = match response.uniques { - Some(t) => t, - None => return Ok(0), - }; - let applied = ctx.apply(peer_id, &theirs).await?; - let mut cm = conn_mgr.lock().await; - cm.set_peer_depth(peer_id, theirs.depth); - if applied > 0 { - cm.notify_growth(); + let mut posts_received = 0; + let mut vis_updates = 0; + let mut new_post_ids: Vec = Vec::new(); + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; + + // Brief lock 1: store posts + let mut synced_authors: HashSet = HashSet::new(); + { + let storage = self.storage.get().await; + for sp in &response.posts { + if storage.is_deleted(&sp.id)? { + continue; + } + if verify_post_id(&sp.id, &sp.post) { + match crate::control::receive_post(&storage, &sp.id, &sp.post, &sp.visibility, sp.intent.as_ref()) { + Ok(true) => { + new_post_ids.push(sp.id); + posts_received += 1; + synced_authors.insert(sp.post.author); + } + Ok(false) => { + synced_authors.insert(sp.post.author); + } + Err(e) => { + warn!(post_id = hex::encode(sp.id), error = %e, "rejecting post"); + } + } + } + } } - Ok(applied) + // Lock RELEASED + + // Brief lock 2: upstream + last_sync + visibility updates + { + let storage = self.storage.get().await; + for pid in &new_post_ids { + let _ = storage.touch_file_holder( + pid, + peer_id, + &[], + crate::storage::HolderDirection::Received, + ); + } + for author in &synced_authors { + let _ = storage.update_follow_last_sync(author, now_ms); + } + for vu in response.visibility_updates { + if vu.author != *peer_id { + // Only accept visibility updates authored by the responding peer + // (or their forwarded data — but for now, only from the peer) + } + if let Some(post) = storage.get_post(&vu.post_id)? { + if post.author == vu.author { + if storage.update_post_visibility(&vu.post_id, &vu.visibility)? { + vis_updates += 1; + } + } + } + } + } + + // Register as downstream with the sender for new posts (cap at 50 to avoid flooding) + if !new_post_ids.is_empty() { + let conn = pc.connection.clone(); + tokio::spawn(async move { + for post_id in new_post_ids.into_iter().take(50) { + let payload = PostDownstreamRegisterPayload { post_id }; + if let Ok(mut send) = conn.open_uni().await { + let _ = write_typed_message(&mut send, MessageType::PostDownstreamRegister, &payload).await; + let _ = send.finish(); + } + } + }); + } + + Ok(PullSyncStats { + posts_received, + visibility_updates: vis_updates, + }) } /// Fetch engagement headers (reactions, comments, policies) for posts due for check from a peer. @@ -2284,12 +2261,10 @@ impl ConnectionManager { for reaction in &header.reactions { let _ = storage.store_reaction(reaction); } - // v0.8 (A3): pull-path comments go through the - // SAME accept gate as the diff path (this site - // historically stored with no verification). - let _ = ingest_header_comments(&storage, header, now_ms); - // v0.8: peer-supplied header.policy is NOT applied - // (unverifiable — see the SetPolicy arm note). + for comment in &header.comments { + let _ = storage.store_comment(comment); + } + let _ = storage.set_comment_policy(&header.post_id, &header.policy); let _ = storage.update_post_last_engagement(post_id, now_ms); updated += 1; } @@ -2302,25 +2277,23 @@ impl ConnectionManager { Ok(updated) } - /// Handle a TRANSITIONAL content sync request (0x46) — no conn_mgr lock - /// needed, only storage. `should_send_post` is the ONLY non-public content - /// gate in the tree and it is wired only here; do not lose it when - /// Iteration D moves content onto the CDN. - /// (`_our_node_id` is the NETWORK id; own-post checks inside use posting - /// identities from storage instead.) - pub async fn handle_content_sync_request_unlocked( + /// Handle an incoming pull request from a peer. + /// Handle a pull sync request — no conn_mgr lock needed, only storage + our_node_id. + pub async fn handle_pull_request_unlocked( storage: &StoragePool, - _our_node_id: NodeId, + our_node_id: NodeId, remote_node_id: NodeId, mut recv: iroh::endpoint::RecvStream, mut send: iroh::endpoint::SendStream, ) -> anyhow::Result<()> { - let request: ContentSyncRequestPayload = read_payload(&mut recv, MAX_PAYLOAD).await?; + let request: PullSyncRequestPayload = read_payload(&mut recv, MAX_PAYLOAD).await?; let their_follows: HashSet = request.follows.into_iter().collect(); + let their_post_ids: HashSet<[u8; 32]> = request.have_post_ids.into_iter().collect(); - // Per-author since_ms lookup + // Protocol v4: build per-author since_ms lookup let since_ms_map: HashMap = request.since_ms.into_iter().collect(); + let use_since_ms = !since_ms_map.is_empty(); // Phase 1: Brief lock — load data let (all_posts, group_members) = { @@ -2332,6 +2305,7 @@ impl ConnectionManager { // Phase 2: Filter without lock (pure CPU) let mut candidates_to_send = Vec::new(); + let mut vis_updates_to_send = Vec::new(); for (id, post, visibility) in all_posts { let should_send = @@ -2341,19 +2315,31 @@ impl ConnectionManager { continue; } - let peer_has_post = if let Some(&since) = since_ms_map.get(&post.author) { - post.timestamp_ms <= since + 60_000 + let peer_has_post = if use_since_ms { + if let Some(&since) = since_ms_map.get(&post.author) { + post.timestamp_ms <= since + 60_000 + } else { + false + } } else { - false + their_post_ids.contains(&id) }; if !peer_has_post { candidates_to_send.push((id, post, visibility)); + } else { + if post.author == our_node_id { + vis_updates_to_send.push(crate::types::VisibilityUpdate { + post_id: id, + author: our_node_id, + visibility, + }); + } } } // Phase 3: Brief re-lock for is_deleted checks + intent fetch on filtered posts - let posts = { + let (posts, vis_updates) = { let s = storage.get().await; let posts_to_send: Vec = candidates_to_send.into_iter() .filter(|(id, _, _)| !s.is_deleted(id).unwrap_or(false)) @@ -2362,14 +2348,15 @@ impl ConnectionManager { SyncPost { id, post, visibility, intent } }) .collect(); - posts_to_send + (posts_to_send, vis_updates_to_send) }; - let response = ContentSyncResponsePayload { + let response = PullSyncResponsePayload { posts, + visibility_updates: vis_updates, }; - write_typed_message(&mut send, MessageType::ContentSyncResponse, &response).await?; + write_typed_message(&mut send, MessageType::PullSyncResponse, &response).await?; send.finish()?; Ok(()) @@ -2398,24 +2385,35 @@ impl ConnectionManager { } } - // Referral cascade over the whole stored horizon, shallowest first: - // N2 (bounce 2) → N3 → N4. N4 is USED here even though it is never - // re-announced — that is precisely the "terminal, but not useless" - // property from design.html §layers. - let reporters: Vec = { + // N2 lookup: ask tagged reporter for address + let n2_reporters = { let storage = self.storage.get().await; - let mut seen: HashSet = HashSet::new(); - let mut ordered = Vec::new(); - for bounce in 2u8..=4 { - for r in storage.find_reporters_at(target, bounce).unwrap_or_default() { - if seen.insert(r) { - ordered.push(r); - } + storage.find_in_n2(target)? + }; + for reporter in &n2_reporters { + if let Some(pc) = self.connections.get(reporter) { + let result = async { + let (mut send, mut recv) = pc.connection.open_bi().await?; + let req = crate::protocol::AddressRequestPayload { target: *target }; + write_typed_message(&mut send, MessageType::AddressRequest, &req).await?; + send.finish()?; + let _resp_type = read_message_type(&mut recv).await?; + let resp: crate::protocol::AddressResponsePayload = + read_payload(&mut recv, MAX_PAYLOAD).await?; + anyhow::Ok(resp.address) + }.await; + if let Ok(Some(addr)) = result { + return Ok(Some(addr)); } } - ordered + } + + // N3 lookup: ask tagged reporter (chains one more hop) + let n3_reporters = { + let storage = self.storage.get().await; + storage.find_in_n3(target)? }; - for reporter in &reporters { + for reporter in &n3_reporters { if let Some(pc) = self.connections.get(reporter) { let result = async { let (mut send, mut recv) = pc.connection.open_bi().await?; @@ -2614,7 +2612,7 @@ impl ConnectionManager { // Check N2/N3 let storage = self.storage.get().await; - let found_entries = storage.find_any_reachable(all_needles)?; + let found_entries = storage.find_any_in_n2_n3(all_needles)?; if let Some((found_id, _reporter, _level)) = found_entries.first() { drop(storage); let address = self.resolve_address(found_id).await.unwrap_or(None); @@ -2803,7 +2801,7 @@ impl ConnectionManager { addr = addr.with_ip_addr(sock); } - let conn = endpoint.connect(addr, ALPN).await.ok()?; + let conn = endpoint.connect(addr, ALPN_V2).await.ok()?; let resp = Self::send_worm_query_raw(&conn, &payload).await.ok()?; let is_hit = resp.found || resp.post_holder.is_some() || resp.blob_holder.is_some(); @@ -2955,7 +2953,7 @@ impl ConnectionManager { } if found.is_none() { let storage = self.storage.get().await; - let entries = storage.find_any_reachable(&all_needles)?; + let entries = storage.find_any_in_n2_n3(&all_needles)?; if let Some((found_id, _reporter, _level)) = entries.first() { drop(storage); let address = self.resolve_address(found_id).await.unwrap_or(None); @@ -3073,26 +3071,38 @@ impl ConnectionManager { Ok(()) } - /// Pick a random mesh peer with a known address, to include as a referral - /// in worm responses. - /// - /// v0.8: "prefer Wide" is meaningless with one pool. Temporary referral - /// slots are excluded — we should never point a stranger at a peer we - /// ourselves hold only provisionally. + /// Pick a random wide-connected peer to include as a referral in worm responses. + /// Returns (node_id, address_string) if a suitable peer with a known address is found. async fn pick_random_wide_referral(&self, exclude: &NodeId) -> Option<(NodeId, String)> { - let candidates: Vec = self + // Prefer wide-slot peers, but any connected peer with a known address works + let candidates: Vec<(NodeId, PeerSlotKind)> = self .connections .iter() - .filter(|(nid, pc)| *nid != exclude && **nid != self.our_node_id && pc.slot.is_mesh()) - .map(|(nid, _)| *nid) + .filter(|(nid, _)| *nid != exclude && **nid != self.our_node_id) + .map(|(nid, pc)| (*nid, pc.slot_kind)) .collect(); if candidates.is_empty() { return None; } + // Prefer wide peers + let wide: Vec<_> = candidates + .iter() + .filter(|(_, kind)| *kind == PeerSlotKind::Wide) + .collect(); + + // Shuffle candidates to pick randomly + let ordered: Vec<&(NodeId, PeerSlotKind)> = if !wide.is_empty() { + wide + } else { + candidates.iter().collect() + }; + + // Try each candidate until we find one with a known address let storage = self.storage.get().await; - for nid in candidates { + for candidate in ordered { + let nid = candidate.0; if let Ok(Some(rec)) = storage.get_peer_record(&nid) { if let Some(addr) = rec.addresses.first() { return Some((nid, addr.to_string())); @@ -3103,116 +3113,44 @@ impl ConnectionManager { None } - /// Disconnect a peer. - /// - /// The departing peer's index rows are DELIBERATELY RETAINED. Round-4/5 - /// ruling and design.html §anchors: "Pool knowledge is overwritten memory: - /// a slot's contributed entries are wiped only when a new handshake - /// replaces them — so a node knocked down from 20 peers to 4 still holds 16 - /// dead peers' pools full of anchor addresses to reconnect through." - /// Wiping on disconnect defeated the exact recovery scenario the design was - /// written for: a mass disconnect emptied the index, so `list_pool_anchors` - /// (phase 0 of the anchor mine) and `score_n2_candidates_batch` both - /// returned nothing precisely when the node needed them most, and - /// `anchor_density` collapsed toward 0 — steering the adaptive weights - /// toward mesh introductions at the moment the mesh was gone. - /// - /// Retention is safe without any new bookkeeping: `set_reach` DELETEs per - /// (reporter, bounce) before inserting, so a returning peer's rows are - /// REPLACED rather than duplicated, and the 5h `prune_reach` sweep in - /// `rebalance_slots` ages out anything genuinely stale. + /// Disconnect a peer, cleaning up N2/N3 entries. pub async fn disconnect_peer(&mut self, peer_id: &NodeId) { - // The slot class decides whether this teardown rolls the growth dice. - // Captured BEFORE the remove. - let was_mesh = self - .connections - .get(peer_id) - .map(|pc| pc.slot.is_mesh()) - .unwrap_or(false); if let Some(pc) = self.connections.remove(peer_id) { drop(pc); } + // Mark disconnected in referral list (anchor-side) + self.mark_referral_disconnected(peer_id); + let storage = self.storage.get().await; + // Remove their N2 contributions (their N1 share → our N2) + let _ = storage.clear_peer_n2(peer_id); + // Remove their N3 contributions (their N2 share → our N3) + let _ = storage.clear_peer_n3(peer_id); // Remove from active mesh peers (but keep in peers table for reconnection) let _ = storage.remove_mesh_peer(peer_id); // Mark social route as disconnected if storage.has_social_route(peer_id).unwrap_or(false) { let _ = storage.set_social_route_status(peer_id, SocialStatus::Disconnected); } - // Piggyback the anchor-density prior on a guard we already hold, on a - // throttle — the stochastic roll below must never trigger a - // COUNT DISTINCT of its own on this hot path. - let fresh_density = if self.anchor_density_refresh_due() { - storage.anchor_density().ok() - } else { - None - }; - - // Release the storage guard BEFORE graduation — StoragePool is a - // non-reentrant tokio Mutex, so re-locking under it would deadlock. - drop(storage); - if let Some(d) = fresh_density { - self.set_anchor_density(d); - } - - // A freed mesh slot is the trigger for temp referral graduation. - // Doing it here rather than only in the 10-minute rebalance means a - // temp peer is promoted the moment room appears. - self.graduate_temp_referrals().await; let remaining = self.connections.len(); debug!(peer = hex::encode(peer_id), remaining, "Disconnected peer"); self.log_activity(ActivityLevel::Info, ActivityCategory::Connection, format!("Disconnected, {} remaining", remaining), Some(*peer_id)); - // The ruling is "on EACH MESH-PEER disconnect". A temp referral slot - // expiring (5 min TTL, up to 10 of them on desktop) is not mesh churn, - // and letting temp churn roll the dice would inflate convection and - // growth traffic well past the rate the design intends. Graduation - // above still runs for every teardown — a freed mesh slot is a freed - // mesh slot however it was freed. - if !was_mesh { - return; + // If mesh is completely empty, trigger immediate recovery + if self.connections.is_empty() { + info!("Mesh empty, triggering recovery"); + self.log_activity(ActivityLevel::Warn, ActivityCategory::Connection, "Mesh empty".into(), None); + self.notify_recovery(); } - // RECOVERY stays non-stochastic: below 2 mesh peers a node cannot - // function, so it always acts immediately. The threshold counts MESH - // peers, not connections — a pool of temp referral slots is not a mesh. - match disconnect_response(self.mesh_count(), self.available_mesh_slots()) { - DisconnectResponse::Recover => { - info!(mesh = self.mesh_count(), "Mesh below 2, triggering recovery"); - self.log_activity(ActivityLevel::Warn, ActivityCategory::Connection, "Mesh below 2".into(), None); - self.notify_recovery(); - return; - } - DisconnectResponse::Idle => return, - DisconnectResponse::Roll => {} + // Signal growth loop to fill the empty slot (don't wait 10min for rebalance) + let total_slots = self.preferred_slots + self.local_slots + self.wide_slots; + if remaining < total_slots { + self.notify_growth(); } - // STOCHASTIC per-disconnect action (round-4/5). No threshold, no jitter - // timer: the randomness itself de-synchronises the network's response - // to a shared disconnect event, which is what a timer with jitter was - // only approximating. Weights are adaptive — anchor-density prior plus - // refusal feedback — and lean toward acting the further under target we - // are. - // - // The dice are rolled here, under the lock (pure state read + RNG); the - // resulting network action is dispatched by a loop on the other end of - // a channel, never inline. `disconnect_peer` is on the hot path of - // every teardown. - match self.roll_convection_action() { - ConvectionAction::Nothing => { - debug!("Convection: rolled do-nothing on disconnect"); - } - ConvectionAction::AskAnchor => { - debug!("Convection: rolled anchor introduction on disconnect"); - self.notify_convection(); - } - ConvectionAction::AskMesh => { - debug!("Convection: rolled mesh introduction on disconnect"); - self.notify_growth(); - } - } } /// Notify watchers that a previously disconnected peer has reconnected. @@ -3273,78 +3211,18 @@ impl ConnectionManager { Ok(reply) } - /// Promote temporary referral slots into freed mesh slots, oldest first. - /// - /// Graduation is in-place: the connection already lives in `connections`, - /// so there is no new QUIC connect, no reconnect, and no protocol round - /// trip. It becomes a knowledge-exchanging peer at the next announce, which - /// is exactly when `mesh_peers` starts feeding `build_own_uniques`. - /// - /// This can NEVER evict an established mesh peer: it only ever fills slots - /// that are already free. - pub async fn graduate_temp_referrals(&mut self) -> Vec { - let free = self.available_mesh_slots(); - if free == 0 { - return Vec::new(); - } - let mut temps: Vec<(NodeId, u64)> = self - .connections - .values() - .filter(|pc| pc.slot.is_temp()) - .map(|pc| (pc.node_id, pc.connected_at)) - .collect(); - if temps.is_empty() { - return Vec::new(); - } - temps.sort_by_key(|(_, at)| *at); - - let mut promoted = Vec::new(); - for (peer_id, _) in temps.into_iter().take(free) { - if let Some(pc) = self.connections.get_mut(&peer_id) { - pc.slot = MeshSlot::Mesh; - promoted.push(peer_id); - } - } - if !promoted.is_empty() { - let storage = self.storage.get().await; - for peer_id in &promoted { - let _ = storage.add_mesh_peer(peer_id); - info!(peer = hex::encode(peer_id), "Temp referral graduated to mesh slot"); - } - drop(storage); - for peer_id in &promoted { - self.log_activity( - ActivityLevel::Info, - ActivityCategory::Rebalance, - "Temp referral graduated to mesh".into(), - Some(*peer_id), - ); - } - } - promoted - } - - /// Rebalance connection slots: reap dead/zombie/expired connections, prune - /// the stale uniques index, graduate temp referral slots, refill the mesh. + /// Rebalance connection slots: remove dead connections, prune stale N2/N3 entries. /// Returns (newly_connected, pending_connects). Caller should QUIC-connect the pending /// list outside the lock, then register them. - pub async fn rebalance_slots(&mut self) -> anyhow::Result<(Vec, Vec<(NodeId, iroh::EndpointAddr, String)>)> { + pub async fn rebalance_slots(&mut self) -> anyhow::Result<(Vec, Vec<(NodeId, iroh::EndpointAddr, String, PeerSlotKind)>)> { self.log_activity(ActivityLevel::Info, ActivityCategory::Rebalance, "Rebalance started".into(), None); - // 1. Remove dead + zombie + expired-temp connections. - // Temp referral expiry is one extra condition in this sweep — that - // IS the whole expiry mechanism, no separate loop. + // 1. Remove dead + zombie connections let mut dead = Vec::new(); let now = now_ms(); for (peer_id, pc) in &self.connections { if pc.connection.close_reason().is_some() { dead.push(*peer_id); - } else if let MeshSlot::TempReferral { expires_at_ms } = pc.slot { - if now >= expires_at_ms { - info!(peer = hex::encode(peer_id), "Temp referral slot expired"); - self.log_activity(ActivityLevel::Info, ActivityCategory::Rebalance, "Temp referral expired".into(), Some(*peer_id)); - dead.push(*peer_id); - } } else if now.saturating_sub(pc.last_activity.load(Ordering::Relaxed)) > ZOMBIE_TIMEOUT_MS { let idle_secs = now.saturating_sub(pc.last_activity.load(Ordering::Relaxed)) / 1000; info!(peer = hex::encode(peer_id), idle_secs, "Zombie connection detected (no activity)"); @@ -3359,38 +3237,117 @@ impl ConnectionManager { self.disconnect_peer(peer_id).await; } - // 2. Graduate temp referral slots into freed mesh slots. - // Oldest temp first. This NEVER evicts an established mesh peer: - // graduation is gated purely on a mesh slot already being free, and - // no path in the tree evicts a mesh peer at all. - self.graduate_temp_referrals().await; - - // 3. Prune the stale uniques index (5 hours, every bounce incl. N4) - // + stale watchers (30 days) + // 2. Prune stale N2/N3 entries (5 hours) + stale watchers (30 days) { let storage = self.storage.get().await; - let pruned = storage.prune_reach(5 * 60 * 60 * 1000)?; + let pruned = storage.prune_n2_n3(5 * 60 * 60 * 1000)?; if pruned > 0 { - info!(pruned, "Pruned stale uniques entries"); + info!(pruned, "Pruned stale N2/N3 entries"); } let _ = storage.prune_stale_watchers(WATCHER_EXPIRY_MS); } - let newly_connected: Vec = Vec::new(); - let mut pending_connects: Vec<(NodeId, iroh::EndpointAddr, String)> = Vec::new(); + // 3. Diversity scoring: find low-diversity peers for potential eviction + { + let storage = self.storage.get().await; + let connected: Vec = self.connections.keys().copied().collect(); + let mut zero_diversity = Vec::new(); + for peer_id in &connected { + let unique = storage.count_unique_n2_for_reporter(peer_id, &[]).unwrap_or(0); + if unique == 0 { + zero_diversity.push(*peer_id); + } + } + if !zero_diversity.is_empty() { + debug!(count = zero_diversity.len(), "Peers with zero unique N2 contributions"); + } + } - // Priority 1+2: Refill free MESH slots with diverse candidates - let mesh_count = self.mesh_count(); - if mesh_count < self.mesh_slots { + let newly_connected: Vec = Vec::new(); + let mut pending_connects: Vec<(NodeId, iroh::EndpointAddr, String, PeerSlotKind)> = Vec::new(); + + // Priority 0 (NEW): Reconnect preferred peers + { + let preferred_peers = { + let storage = self.storage.get().await; + storage.list_preferred_peers().unwrap_or_default() + }; + + let now = now_ms(); + for peer_id in &preferred_peers { + if self.connections.contains_key(peer_id) || *peer_id == self.our_node_id { + continue; + } + // Prune preferred peers unreachable for 7+ days + if let Some(&failed_at) = self.unreachable_peers.get(peer_id) { + if now.saturating_sub(failed_at) > PREFERRED_UNREACHABLE_PRUNE_MS { + info!(peer = hex::encode(peer_id), "Removing preferred peer unreachable for 7 days+"); + let storage = self.storage.get().await; + let _ = storage.remove_preferred_peer(peer_id); + continue; + } + } + + // Evict lowest-diversity non-preferred peer if at capacity + let total_slots = self.preferred_slots + self.local_slots + self.wide_slots; + if self.connections.len() >= total_slots { + let evict_candidate = self.find_non_preferred_eviction_candidate().await; + if let Some(evict_id) = evict_candidate { + info!( + evicting = hex::encode(evict_id), + for_preferred = hex::encode(peer_id), + "Evicting non-preferred peer for preferred reconnection" + ); + self.log_activity(ActivityLevel::Info, ActivityCategory::Rebalance, format!("Evicting {} for preferred {}", &hex::encode(evict_id)[..8], &hex::encode(peer_id)[..8]), Some(evict_id)); + self.disconnect_peer(&evict_id).await; + } else { + debug!(peer = hex::encode(peer_id), "No non-preferred peer to evict"); + continue; + } + } + + // Collect for connection outside the lock + let addr_str = if !self.is_likely_unreachable(peer_id) { + let storage = self.storage.get().await; + let addr = storage.get_peer_record(peer_id).ok().flatten() + .and_then(|r| r.addresses.first().map(|a| a.to_string())) + .or_else(|| { + storage.get_social_route(peer_id).ok().flatten() + .and_then(|r| r.addresses.first().map(|a| a.to_string())) + }); + drop(storage); + addr + } else { + None + }; + if let Some(addr_s) = addr_str { + if let Ok(eid) = iroh::EndpointId::from_bytes(peer_id) { + let mut addr = iroh::EndpointAddr::from(eid); + if let Ok(sock) = addr_s.parse::() { + addr = addr.with_ip_addr(sock); + } + pending_connects.push((*peer_id, addr, addr_s, PeerSlotKind::Preferred)); + } + } + } + } + + // Priority 1+2: Fill empty local slots with diverse candidates + let local_count = self.count_kind(PeerSlotKind::Local); + if local_count < self.local_slots { let candidates: Vec<(NodeId, Option)> = { let storage = self.storage.get().await; let mut cands = Vec::new(); - // Priority 1: reconnect recently-dead peers + // Priority 1: reconnect recently-dead non-preferred peers for peer_id in &dead { if *peer_id == self.our_node_id { continue; } + // Skip preferred peers — handled above + if storage.is_preferred_peer(peer_id).unwrap_or(false) { + continue; + } if let Ok(Some(rec)) = storage.get_peer_record(peer_id) { let addr = rec.addresses.first().map(|a| a.to_string()); cands.push((*peer_id, addr)); @@ -3401,10 +3358,10 @@ impl ConnectionManager { cands }; - let slots_available = self.mesh_slots - mesh_count; + let slots_available = self.local_slots - local_count; let to_connect = candidates.len().min(slots_available); if to_connect > 0 { - debug!(candidates = candidates.len(), connecting = to_connect, "Filling mesh slots"); + debug!(candidates = candidates.len(), connecting = to_connect, "Filling local slots"); } for (peer_id, addr_str) in candidates.into_iter().take(slots_available) { @@ -3427,7 +3384,7 @@ impl ConnectionManager { addr = addr.with_ip_addr(sock); } // Collect for connection outside the lock - pending_connects.push((peer_id, addr, addr_s)); + pending_connects.push((peer_id, addr, addr_s, PeerSlotKind::Local)); } } } @@ -3438,18 +3395,13 @@ impl ConnectionManager { // 5. Reap idle session connections (keeps anchor + referral sessions alive) self.reap_idle_sessions(SESSION_IDLE_TIMEOUT_MS).await; - // 6. Prune stale relay intro dedup entries + the convection penalty box - // and the supplement hand-out ledger. All three are network-driven - // maps keyed by node id, so all three need a sweep. + // 6. Prune stale relay intro dedup entries { let now = now_ms(); if self.seen_intros.len() > 500 { self.seen_intros .retain(|_, ts| now - *ts < RELAY_INTRO_DEDUP_EXPIRY_MS); } - self.refused_anchors.retain(|_, until| now < *until); - self.supplement_handouts - .retain(|_, (_, since)| now.saturating_sub(*since) < CONVECTION_SUPPLEMENT_WINDOW_MS); } if !dead.is_empty() { @@ -3459,12 +3411,30 @@ impl ConnectionManager { self.log_activity(ActivityLevel::Info, ActivityCategory::Rebalance, "Complete, no changes".into(), None); } - // Backstop: signal growth loop to fill any remaining mesh slots + // Backstop: signal growth loop to fill any remaining local slots self.notify_growth(); Ok((newly_connected, pending_connects)) } + /// Find the lowest-diversity non-preferred peer to evict. + async fn find_non_preferred_eviction_candidate(&self) -> Option { + let storage = self.storage.get().await; + let mut worst: Option<(NodeId, usize)> = None; + for (peer_id, mc) in &self.connections { + if mc.slot_kind == PeerSlotKind::Preferred { + continue; // Never evict preferred + } + let unique = storage.count_unique_n2_for_reporter(peer_id, &[]).unwrap_or(0); + match &worst { + None => worst = Some((*peer_id, unique)), + Some((_, worst_unique)) if unique < *worst_unique => worst = Some((*peer_id, unique)), + _ => {} + } + } + worst.map(|(nid, _)| nid) + } + pub fn is_connected(&self, peer_id: &NodeId) -> bool { self.connections.contains_key(peer_id) } @@ -3483,13 +3453,131 @@ impl ConnectionManager { &self.connections } - pub fn connection_info(&self) -> Vec<(NodeId, MeshSlot, u64)> { + pub fn connection_info(&self) -> Vec<(NodeId, PeerSlotKind, u64)> { self.connections .values() - .map(|pc| (pc.node_id, pc.slot, pc.connected_at)) + .map(|pc| (pc.node_id, pc.slot_kind, pc.connected_at)) .collect() } + fn count_kind(&self, kind: PeerSlotKind) -> usize { + self.connections.values().filter(|pc| pc.slot_kind == kind).count() + } + + // ---- Preferred peer negotiation ---- + + /// Request bilateral preferred peer status with a connected mesh peer. + /// On success, both sides persist the agreement and upgrade the slot. + pub async fn request_prefer(&mut self, peer_id: &NodeId) -> anyhow::Result { + let pc = self.connections.get(peer_id) + .ok_or_else(|| anyhow::anyhow!("peer not connected"))?; + + // Check if we have room + let preferred_count = self.count_kind(PeerSlotKind::Preferred); + if preferred_count >= self.preferred_slots { + anyhow::bail!("preferred slots full ({}/{})", preferred_count, self.preferred_slots); + } + + let request = MeshPreferPayload { + requesting: true, + accepted: false, + reject_reason: None, + }; + + let (mut send, mut recv) = pc.connection.open_bi().await?; + write_typed_message(&mut send, MessageType::MeshPrefer, &request).await?; + send.finish()?; + + let msg_type = read_message_type(&mut recv).await?; + if msg_type != MessageType::MeshPrefer { + anyhow::bail!("expected MeshPrefer response, got {:?}", msg_type); + } + let response: MeshPreferPayload = read_payload(&mut recv, 4096).await?; + + if response.accepted { + // Persist agreement + let storage = self.storage.get().await; + storage.add_preferred_peer(peer_id)?; + storage.add_mesh_peer(peer_id, PeerSlotKind::Preferred, 100)?; + drop(storage); + + // Upgrade slot in-memory + if let Some(mc) = self.connections.get_mut(peer_id) { + mc.slot_kind = PeerSlotKind::Preferred; + } + info!(peer = hex::encode(peer_id), "Preferred peer agreement established"); + Ok(true) + } else { + debug!( + peer = hex::encode(peer_id), + reason = ?response.reject_reason, + "Preferred peer request rejected" + ); + Ok(false) + } + } + + /// Handle an incoming MeshPrefer request from a connected peer. + pub async fn handle_mesh_prefer( + &mut self, + from_peer: NodeId, + mut send: iroh::endpoint::SendStream, + mut recv: iroh::endpoint::RecvStream, + ) -> anyhow::Result<()> { + let request: MeshPreferPayload = read_payload(&mut recv, 4096).await?; + + if !request.requesting { + // Not a request — ignore + return Ok(()); + } + + // Check if we have room for a preferred peer + let preferred_count = self.count_kind(PeerSlotKind::Preferred); + let can_accept = preferred_count < self.preferred_slots + && self.connections.contains_key(&from_peer); + + let response = if can_accept { + // Persist agreement + let storage = self.storage.get().await; + storage.add_preferred_peer(&from_peer)?; + storage.add_mesh_peer(&from_peer, PeerSlotKind::Preferred, 100)?; + drop(storage); + + // Upgrade slot in-memory + if let Some(mc) = self.connections.get_mut(&from_peer) { + mc.slot_kind = PeerSlotKind::Preferred; + } + + info!(peer = hex::encode(from_peer), "Accepted preferred peer request"); + + MeshPreferPayload { + requesting: false, + accepted: true, + reject_reason: None, + } + } else { + let reason = if !self.connections.contains_key(&from_peer) { + "not connected".to_string() + } else { + format!("preferred slots full ({}/{})", preferred_count, self.preferred_slots) + }; + debug!(peer = hex::encode(from_peer), reason = %reason, "Rejecting preferred peer request"); + MeshPreferPayload { + requesting: false, + accepted: false, + reject_reason: Some(reason), + } + }; + + write_typed_message(&mut send, MessageType::MeshPrefer, &response).await?; + send.finish()?; + Ok(()) + } + + // reconnect_preferred removed — direct connect now happens outside the lock + // via pending_connects in the actor dispatch. Relay fallback for preferred peers + // is handled by the growth loop's normal relay introduction path. + // ---- Session connection management ---- /// Add a session connection. Evicts oldest idle session if at capacity. @@ -3555,13 +3643,10 @@ impl ConnectionManager { // Build set of sessions that have a reason to stay alive let mut keep_alive: std::collections::HashSet = std::collections::HashSet::new(); - // Anchor side: a caller sitting in the convection window is exactly the - // session we need alive — it is the end we coordinate introductions - // through. Reaping it would make the anchor hand out cold addresses - // instead. - for e in &self.convection_window { - if self.sessions.contains_key(&e.node_id) && !self.connections.contains_key(&e.node_id) { - keep_alive.insert(e.node_id); + // Anchor side: peers on our referral list need their session kept + for nid in self.referral_list.keys() { + if self.sessions.contains_key(nid) && !self.connections.contains_key(nid) { + keep_alive.insert(*nid); } } @@ -3721,7 +3806,38 @@ impl ConnectionManager { let mut candidates = Vec::new(); let storage = self.storage.get().await; - // Step 1: Our mesh peers that have target in their N1 (our N2 entry tagged to them) + // Step 1 (NEW): Check target's preferred_tree from social_routes (~100 NodeIds) + // Intersect with our connections → TTL=0 candidates (they know target or are stably nearby) + if let Ok(Some(route)) = storage.get_social_route(target) { + if !route.preferred_tree.is_empty() { + // Prefer our own preferred peers first within the tree + for tree_node in &route.preferred_tree { + if candidates.len() >= 3 { + break; + } + if let Some(mc) = self.connections.get(tree_node) { + if mc.slot_kind == PeerSlotKind::Preferred + && !candidates.iter().any(|(nid, _)| nid == tree_node) + { + candidates.push((*tree_node, 0)); + } + } + } + // Then any connected tree node + for tree_node in &route.preferred_tree { + if candidates.len() >= 3 { + break; + } + if self.connections.contains_key(tree_node) + && !candidates.iter().any(|(nid, _)| nid == tree_node) + { + candidates.push((*tree_node, 0)); + } + } + } + } + + // Step 2: Our mesh peers that have target in their N1 (our N2 entry tagged to them) if candidates.len() < 3 { if let Ok(reporters) = storage.find_in_n2(target) { for reporter in reporters { @@ -3735,13 +3851,28 @@ impl ConnectionManager { } } - // Step 2: Fallback — full N3 scan for target. - // - // Deliberately NOT extended to N4 (bounce 4) in Iteration C: the - // forward chain only ever forwards a relay request via N2 reporters - // (`handle_relay_introduce` + its actor twin), so a ttl=2 request would - // dead-end. The ttl semantics and the forward lookup have to be widened - // together — left for whoever does the relay-depth work. + // Step 3: Intersect preferred_tree with N3 → TTL=1 candidates + if candidates.len() < 3 { + if let Ok(Some(route)) = storage.get_social_route(target) { + for tree_node in &route.preferred_tree { + if candidates.len() >= 3 { + break; + } + if let Ok(reporters) = storage.find_in_n3(tree_node) { + for reporter in reporters { + if self.connections.contains_key(&reporter) + && !candidates.iter().any(|(nid, _)| *nid == reporter) + && candidates.len() < 3 + { + candidates.push((reporter, 1)); + } + } + } + } + } + } + + // Step 4: Fallback — full N3 scan for target if candidates.len() < 3 { if let Ok(reporters) = storage.find_in_n3(target) { for reporter in reporters { @@ -3936,7 +4067,7 @@ impl ConnectionManager { if let Some(session) = cm.sessions.get(&requester) { let session_conn = session.connection.clone(); drop(cm); // release lock before async work - match initial_exchange_connect(&storage_clone, &our_node_id, &session_conn, requester, None, our_nat_type, our_http_capable, our_http_addr.clone(), None, None, DEFAULT_PEER_DEPTH, false).await { + match initial_exchange_connect(&storage_clone, &our_node_id, &session_conn, requester, None, our_nat_type, our_http_capable, our_http_addr.clone(), None, None).await { Ok(ExchangeResult::Accepted { .. }) => { tracing::info!(peer = hex::encode(requester), "Target-side: initial exchange after hole punch"); } @@ -4455,7 +4586,7 @@ impl ConnectionManager { } }; - // Attempt reconnect for unexpected disconnects + // Attempt reconnect for unexpected disconnects (not intentional SocialDisconnectNotice) if is_current { if let Some(addr) = peer_addr { let cm_arc = Arc::clone(&conn_mgr); @@ -4482,7 +4613,7 @@ impl ConnectionManager { Ok(conn) => { let mut cm = cm_arc.lock().await; if !cm.connections.contains_key(&remote_node_id) { - cm.register_new_connection(remote_node_id, conn, &[addr]).await; + cm.register_new_connection(remote_node_id, conn, &[addr], PeerSlotKind::Local).await; info!(peer = hex::encode(remote_node_id), "Auto-reconnected after unexpected disconnect"); cm.log_activity(ActivityLevel::Info, ActivityCategory::Connection, format!("Auto-reconnected to {}", &hex::encode(remote_node_id)[..8]), Some(remote_node_id)); @@ -4505,432 +4636,251 @@ impl ConnectionManager { } } - // ---- Anchor convection service (design.html §anchors) ---- - // - // The registration model is gone: there is no 0xC0, no referral database, - // no use-count tiering, no disconnect grace period. An anchor keeps a small - // rolling window of recent callers, fed by the convection request itself. - // A caller receives the 2 most recent viable prior callers; its own address - // is then handed to the next 2 callers and rotates out. + // ---- Anchor referral methods ---- - /// The address we OBSERVED for a caller (their NAT-mapped public IP as seen - /// from our side of the QUIC connection). Preferred over anything - /// self-reported, which for a NAT'd peer is usually a LAN address. - /// - /// Reads the live connection first and only then the `peers` row, which - /// keeps the DB off this path entirely in the common case. - fn convection_observed_addr(&self, nid: &NodeId) -> Option { - self.connections.get(nid).and_then(|pc| pc.remote_addr) - .or_else(|| self.sessions.get(nid).and_then(|s| s.remote_addr)) - } - - /// Admit (or refresh) a caller in the rolling window. - /// - /// Addresses are normalised (bugs-fixed #4) and filtered to publicly - /// routable (bugs-fixed #8) before they can ever be handed out — a private - /// or IPv4-mapped-IPv6 address costs the next caller a punch timeout. A - /// caller with no usable address is simply not admitted. - pub(crate) fn convection_admit( - &mut self, - node_id: NodeId, - self_reported: &[String], - observed: Option, - ) { - let addresses = self.convection_usable_addrs(&node_id, self_reported, observed); - if addresses.is_empty() { - debug!(peer = hex::encode(node_id), "Convection: no routable address, not admitting"); + /// Anchor-side: register a peer in the referral list. + /// Uses the observed remote address from the peers table (the NAT-mapped public IP + /// seen by the anchor from the QUIC connection) rather than self-reported addresses, + /// which are often private/LAN IPs for NAT'd peers. + pub async fn handle_anchor_register(&mut self, payload: AnchorRegisterPayload) { + if !self.is_anchor.load(Ordering::Relaxed) { return; } - convection_window_admit(&mut self.convection_window, node_id, addresses, now_ms()); + if !self.connections.contains_key(&payload.node_id) && !self.sessions.contains_key(&payload.node_id) { + debug!(peer = hex::encode(payload.node_id), "AnchorRegister from non-connected/session peer, ignoring"); + return; + } + + // Prefer observed remote address (NAT-mapped public IP) over self-reported + let addresses = { + let storage = self.storage.get().await; + let observed = storage.get_peer_record(&payload.node_id) + .ok().flatten() + .map(|r| r.addresses).unwrap_or_default(); + if observed.is_empty() { + // Fall back to self-reported addresses + payload.addresses + } else { + // Use observed addresses (public IP as seen by anchor) + observed.iter().map(|a| a.to_string()).collect() + } + }; + + let now = now_ms(); + self.referral_list.insert(payload.node_id, ReferralEntry { + node_id: payload.node_id, + addresses, + registered_at: now, + use_count: 0, + disconnected_at: None, + }); debug!( - peer = hex::encode(node_id), - window = self.convection_window.len(), - "Convection: admitted caller to rolling window" + peer = hex::encode(payload.node_id), + list_size = self.referral_list.len(), + "Anchor: registered peer in referral list" ); } - /// The addresses of a caller that are actually worth circulating: - /// normalised (bugs-fixed #4) and publicly routable (bugs-fixed #8), - /// observed first because that is the one that provably works. Empty means - /// "we have nothing usable for this caller" — it is neither admitted to the - /// window nor named as the requester of a coordinated introduction. - fn convection_usable_addrs( - &self, - node_id: &NodeId, - self_reported: &[String], - observed: Option, - ) -> Vec { - if *node_id == self.our_node_id { - return Vec::new(); - } - let mut out: Vec = Vec::new(); - let push = |sa: SocketAddr, out: &mut Vec| { - let sa = normalize_addr(sa); - if convection_addr_ok(&sa) { - let s = sa.to_string(); - if !out.contains(&s) { - out.push(s); - } - } - }; - let observed = observed.or_else(|| self.convection_observed_addr(node_id)); - if let Some(sa) = observed { - push(sa, &mut out); - } - // SELF-REPORTED addresses are a caller's unverified claim. `convection_- - // addr_ok` only proves an address is public — not that the caller owns - // it — so an unbounded self-reported list let one cheap request make the - // anchor advertise arbitrary third-party IPs under the caller's node id. - // Capped, and once we have an observed address (essentially always, for - // a caller connected over QUIC) restricted to the SAME IP: a different - // port is the legitimate EDM case, a different host is not. - let mut extra = 0usize; - for a in self_reported { - if extra >= CONVECTION_MAX_SELF_REPORTED { - break; - } - if let Ok(sa) = a.parse::() { - if let Some(obs) = observed { - if normalize_addr(sa).ip() != normalize_addr(obs).ip() { - continue; - } - } - let before = out.len(); - push(sa, &mut out); - if out.len() > before { - extra += 1; - } - } - } - out - } - - /// The ONLY address the anchor may instruct a third party to punch at: the - /// one it observed on the caller's own QUIC connection. - /// - /// `serve_convection` pushes an anchor-INITIATED `RelayIntroduce` at up to - /// two live peers naming these addresses. Feeding self-reported claims into - /// that turned one cheap `ConvectionRequest` into "make two third parties - /// start hole-punching at an attacker-chosen victim" — an amplifier the old - /// retired 0xC0 register path did not have — it never pushed anything. - /// Self-reported entries still ride the referral payload as a secondary - /// hint, so the referred peer can try them itself rather than the anchor - /// instructing others to. - fn convection_introduce_addrs( - &self, - node_id: &NodeId, - observed: Option, - ) -> Vec { - if *node_id == self.our_node_id { - return Vec::new(); - } - observed - .or_else(|| self.convection_observed_addr(node_id)) - .map(normalize_addr) - .filter(convection_addr_ok) - .map(|sa| vec![sa.to_string()]) - .unwrap_or_default() - } - - /// Take up to `count` referrals out of the window. - pub(crate) fn convection_pick( - &mut self, - exclude: &NodeId, - count: usize, - ) -> Vec { + /// Anchor-side: pick referrals from the list, applying tiered usage and self-pruning. + pub fn pick_referrals(&mut self, exclude: &NodeId, count: usize) -> Vec { let now = now_ms(); - let live: HashSet = self - .connections - .keys() - .chain(self.sessions.keys()) - .copied() - .collect(); - convection_window_pick(&mut self.convection_window, exclude, count, now, |n| live.contains(n)) - } - /// Can we serve a TOP-UP right now? (ENTRY class never consults this.) - /// - /// Two honest signals, both cheap: nothing fresh to circulate, or we are - /// already saturated — mesh full AND the session table full, so every - /// referral we serve comes back to us as load we cannot absorb. - fn convection_saturated(&self) -> bool { - self.mesh_count() >= self.mesh_slots && self.sessions.len() >= self.session_slots - } - - /// Everything the convection response needs, gathered under a brief lock. - /// - /// The old handler held the conn_mgr lock across `write_typed_message` + - /// `send.finish()` — the same lock-contention class as the three known - /// TODOs. Gathering here and writing outside closes that. - pub fn gather_convection( - &mut self, - payload: &ConvectionRequestPayload, - observed: Option, - ) -> ConvectionGathered { - let requester = payload.requester; - - // ENTRY class is ALWAYS served: a node with <2 connections cannot - // function, and a network that refuses re-entry is a network that - // partitions permanently. TOP-UP is served capacity-permitting and - // refused with a single small message — no timeout burned. - let viable = convection_window_viable(&self.convection_window, &requester, now_ms()); - if !convection_should_serve(payload.class, viable, self.convection_saturated()) { - // Still admit them: their address is fresh material for the next - // caller. Top-ups are the convection MEDIUM, not freeloading. - self.convection_admit(requester, &payload.requester_addresses, observed); - debug!(requester = hex::encode(requester), "Convection: refusing top-up (no capacity)"); - return ConvectionGathered { - response: ConvectionResponsePayload { referrals: vec![], refused: true }, - introductions: Vec::new(), - requester, - requester_addresses: Vec::new(), - nat_mapping: None, - nat_filtering: None, - }; - } - - // Can we actually coordinate an introduction? Only if we have a - // routable address for the CALLER to name as the requester. Deciding - // this up front matters: `introduced` tells the caller to skip its own - // introduction round trip, so claiming it without pushing one would - // strand the caller on a failed direct dial. - let requester_addrs = self.convection_introduce_addrs(&requester, observed); - let can_introduce = !requester_addrs.is_empty(); - - let picked = self.convection_pick(&requester, CONVECTION_REFERRALS); - - let mut referrals: Vec = Vec::with_capacity(picked.len()); - let mut introductions: Vec<(NodeId, iroh::endpoint::Connection)> = Vec::new(); - for entry in picked { - // Both ends live on us → coordinate an introduction rather than - // handing over a cold address. The peer starts punching outward - // before the caller has even read our response. - let conn = self - .connections - .get(&entry.node_id) - .map(|pc| pc.connection.clone()) - .or_else(|| self.sessions.get(&entry.node_id).map(|s| s.connection.clone())); - let introduced = conn.is_some() && can_introduce; - if introduced { - if let Some(c) = conn { - introductions.push((entry.node_id, c)); - } + // Prune: remove entries where disconnected_at is >2 min ago + self.referral_list.retain(|_, entry| { + match entry.disconnected_at { + Some(disc_at) if now.saturating_sub(disc_at) > REFERRAL_DISCONNECT_GRACE_MS => false, + _ => true, } - debug!( - requester = hex::encode(requester), - referred = hex::encode(entry.node_id), - introduced, - "Convection: referring peer" - ); - referrals.push(ConvectionReferral { - node_id: entry.node_id, - addresses: entry.addresses, - introduced, - }); - } + }); - // Supplement from live mesh peers when the window is sparse (a young - // anchor genuinely has no prior callers yet). CONSENT-SHAPED: a mesh - // peer never called us and never offered its address for - // redistribution, unlike a window entry whose address arrived in its - // own `ConvectionRequestPayload.requester_addresses`. So: - // - // * we hand out NO address — the referral carries an empty vec and - // relies on the anchor-initiated RelayIntroduce, which makes the - // TARGET disclose its own addresses on its own terms; - // * therefore only peers we can actually introduce qualify (an - // address-free referral we cannot introduce is useless anyway); - // * and each supplemented peer gets its own hand-out budget, so - // unconditional entry-class service cannot be turned into a - // "shuffle + repeat" enumeration oracle over the anchor's mesh. - if referrals.len() < CONVECTION_REFERRALS && can_introduce { - let now = now_ms(); - let already: HashSet = referrals.iter().map(|r| r.node_id).collect(); - let mut mesh_candidates: Vec = self - .connections - .iter() - .filter(|(nid, pc)| { - **nid != requester - && **nid != self.our_node_id - && !already.contains(*nid) - && pc.slot.is_mesh() - }) - .map(|(nid, _)| *nid) - .filter(|nid| match self.supplement_handouts.get(nid) { - Some((count, since)) => { - now.saturating_sub(*since) >= CONVECTION_SUPPLEMENT_WINDOW_MS - || *count < CONVECTION_SUPPLEMENT_CAP - } - None => true, - }) - .collect(); - use rand::seq::SliceRandom; - mesh_candidates.shuffle(&mut rand::rng()); - for nid in mesh_candidates.into_iter().take(CONVECTION_REFERRALS - referrals.len()) { - if let Some(pc) = self.connections.get(&nid) { - introductions.push((nid, pc.connection.clone())); - } - let slot = self.supplement_handouts.entry(nid).or_insert((0, now)); - if now.saturating_sub(slot.1) >= CONVECTION_SUPPLEMENT_WINDOW_MS { - *slot = (0, now); - } - slot.0 += 1; - referrals.push(ConvectionReferral { - node_id: nid, - addresses: Vec::new(), - introduced: true, + // Tiered usage policy + let list_len = self.referral_list.len(); + let max_uses: u32 = if list_len < REFERRAL_LIST_CAP { + 3 + } else if list_len == REFERRAL_LIST_CAP { + 2 + } else { + 1 + }; + + // Filter eligible entries + let mut eligible: Vec<&NodeId> = self.referral_list.iter() + .filter(|(nid, entry)| { + *nid != exclude + && entry.use_count < max_uses + && entry.disconnected_at.is_none() + }) + .map(|(nid, _)| nid) + .collect(); + + // Sort: lowest use_count first, then most recent registration + eligible.sort_by(|a, b| { + let ea = &self.referral_list[*a]; + let eb = &self.referral_list[*b]; + ea.use_count.cmp(&eb.use_count) + .then(eb.registered_at.cmp(&ea.registered_at)) + }); + + eligible.truncate(count); + let picked_ids: Vec = eligible.into_iter().copied().collect(); + + let mut result = Vec::with_capacity(picked_ids.len()); + for nid in &picked_ids { + if let Some(entry) = self.referral_list.get_mut(nid) { + entry.use_count += 1; + result.push(AnchorReferral { + node_id: entry.node_id, + addresses: entry.addresses.clone(), }); } } - // The request IS the registration. Admitted AFTER picking so a caller - // is never referred to itself, and so its address goes to the NEXT two - // callers rather than this one. - self.convection_admit(requester, &payload.requester_addresses, observed); + // Self-prune: remove entries that reached max_uses + self.referral_list.retain(|_, entry| entry.use_count < max_uses); - info!( - requester = hex::encode(requester), - class = if payload.class.is_entry() { "entry" } else { "top-up" }, - referrals = referrals.len(), - introductions = introductions.len(), - window = self.convection_window.len(), - "Convection: serving referrals" - ); + result + } - let profile = self.our_nat_profile(); - ConvectionGathered { - response: ConvectionResponsePayload { referrals, refused: false }, - introductions, - requester, - requester_addresses: requester_addrs, - nat_mapping: Some(profile.mapping.to_string()), - nat_filtering: Some(profile.filtering.to_string()), + /// Mark a peer as disconnected in the referral list. + pub fn mark_referral_disconnected(&mut self, node_id: &NodeId) { + if let Some(entry) = self.referral_list.get_mut(node_id) { + entry.disconnected_at = Some(now_ms()); } } - /// Write the convection response and fire any coordinated introductions. - /// Static: runs entirely OUTSIDE the conn_mgr lock. - pub async fn serve_convection( - gathered: ConvectionGathered, + /// Mark a peer as reconnected in the referral list. + pub fn mark_referral_reconnected(&mut self, node_id: &NodeId) { + if let Some(entry) = self.referral_list.get_mut(node_id) { + entry.disconnected_at = None; + } + } + + /// Anchor-side: handle a referral request (bi-stream). + /// Supplements from mesh peers when the explicit referral list is sparse. + pub async fn handle_anchor_referral_request( + &mut self, + payload: AnchorReferralRequestPayload, mut send: iroh::endpoint::SendStream, ) -> anyhow::Result<()> { - // Response first — a refusal must reach the caller immediately, and a - // served caller should start dialling while the introductions fly. - write_typed_message(&mut send, MessageType::ConvectionResponse, &gathered.response).await?; + // Also register the requester (they provide addresses, they're connected) + self.handle_anchor_register(AnchorRegisterPayload { + node_id: payload.requester, + addresses: payload.requester_addresses, + }).await; + + let mut referrals = self.pick_referrals(&payload.requester, 3); + + // Auto-refer from mesh peers when referral list is sparse + if referrals.len() < 3 { + let referred_ids: std::collections::HashSet = referrals.iter().map(|r| r.node_id).collect(); + let mut mesh_candidates: Vec<_> = self.connections.iter() + .filter(|(nid, _)| **nid != payload.requester && !referred_ids.contains(*nid) && **nid != self.our_node_id) + .filter_map(|(nid, pc)| pc.remote_addr.map(|a| (*nid, a.to_string()))) + .collect(); + // Shuffle for variety + use rand::seq::SliceRandom; + mesh_candidates.shuffle(&mut rand::rng()); + for (nid, addr) in mesh_candidates.into_iter().take(3 - referrals.len()) { + referrals.push(AnchorReferral { node_id: nid, addresses: vec![addr] }); + } + } + + info!( + requester = hex::encode(payload.requester), + referral_count = referrals.len(), + "Anchor: serving referrals" + ); + + let response = AnchorReferralResponsePayload { referrals }; + write_typed_message(&mut send, MessageType::AnchorReferralResponse, &response).await?; send.finish()?; - - if gathered.introductions.is_empty() || gathered.requester_addresses.is_empty() { - return Ok(()); - } - - // Anchor-INITIATED introduction. Today's introductions are always - // requester-initiated; here the anchor holds both ends, so it pushes a - // RelayIntroduce toward the referral naming the caller as requester. - // The target's handler responds with its addresses and starts punching - // (bugs-fixed #3 registration path), so the caller's direct dial lands. - // - // This is signalling, NOT a session relay — no bytes are piped. - for (target, conn) in gathered.introductions { - let payload = RelayIntroducePayload { - intro_id: rand::random(), - target, - requester: gathered.requester, - requester_addresses: gathered.requester_addresses.clone(), - ttl: 0, - nat_mapping: gathered.nat_mapping.clone(), - nat_filtering: gathered.nat_filtering.clone(), - }; - tokio::spawn(async move { - let result = async { - let (mut s, mut r) = conn.open_bi().await?; - write_typed_message(&mut s, MessageType::RelayIntroduce, &payload).await?; - s.finish()?; - let _ = read_message_type(&mut r).await?; - let _: RelayIntroduceResultPayload = read_payload(&mut r, MAX_PAYLOAD).await?; - anyhow::Ok(()) - }; - match tokio::time::timeout(std::time::Duration::from_secs(10), result).await { - Ok(Ok(())) => debug!(target = hex::encode(target), "Convection: coordinated introduction"), - Ok(Err(e)) => debug!(target = hex::encode(target), error = %e, "Convection: introduction push failed"), - Err(_) => debug!(target = hex::encode(target), "Convection: introduction push timed out"), - } - }); - } Ok(()) } - // ---- Stochastic growth trigger (round-4/5) ---- - - /// Is the cached anchor-density prior stale? Split from the setter because - /// the storage guard borrows `self`, so a caller holding it cannot also - /// take `&mut self`. - fn anchor_density_refresh_due(&self) -> bool { - now_ms().saturating_sub(self.anchor_density_at_ms) >= ANCHOR_DENSITY_REFRESH_MS - } - - fn set_anchor_density(&mut self, density: f64) { - self.anchor_density = density.clamp(0.0, 1.0); - self.anchor_density_at_ms = now_ms(); - } - - /// Roll the dice for one mesh disconnect. Pure state read + RNG — no I/O, - /// no network dispatch, safe to call under the lock. - pub fn roll_convection_action(&self) -> ConvectionAction { - let fill_ratio = if self.mesh_slots == 0 { - 1.0 + /// Client-side: request referrals from an anchor peer (mesh or session). + pub async fn request_anchor_referrals( + &mut self, + anchor_peer: &NodeId, + ) -> anyhow::Result> { + let conn = if let Some(pc) = self.connections.get(anchor_peer) { + pc.connection.clone() + } else if let Some(session) = self.sessions.get(anchor_peer) { + session.connection.clone() } else { - self.mesh_count() as f64 / self.mesh_slots as f64 + anyhow::bail!("anchor peer not connected (mesh or session)"); }; - let roll: f64 = rand::random(); - choose_convection_action(roll, self.anchor_density, self.anchor_bias, fill_ratio) - } - /// An anchor refused us: multiplicative decrease on the anchor arm, plus a - /// penalty box for that specific anchor so we try a different one next. - pub fn record_convection_refusal(&mut self, anchor: &NodeId) { - self.anchor_bias = (self.anchor_bias * 0.5).max(0.1); - // This is the ONLY insertion site, so sweeping here bounds the map with - // no extra timer. Without it the penalty box grew one permanent entry - // per anchor that ever refused us — a network-driven map with no - // eviction, over an anchor population the design expects to be 20-30% - // of all nodes. (`rebalance_slots` sweeps it too, for long idles.) - let now = now_ms(); - self.refused_anchors.retain(|_, until| now < *until); - self.refused_anchors.insert(*anchor, now + ANCHOR_REFUSAL_PENALTY_MS); - debug!(anchor = hex::encode(anchor), bias = self.anchor_bias, "Convection: refusal feedback"); - } + let our_addrs: Vec = self.endpoint.addr().ip_addrs() + .map(|s| s.to_string()) + .collect(); - /// An anchor served us: additive increase, drifting back toward parity. - pub fn record_convection_success(&mut self, anchor: &NodeId) { - self.anchor_bias = (self.anchor_bias + 0.15).min(1.0); - self.refused_anchors.remove(anchor); - } + let request = AnchorReferralRequestPayload { + requester: self.our_node_id, + requester_addresses: our_addrs, + }; - /// Is this anchor in the penalty box? - pub fn is_anchor_penalized(&self, anchor: &NodeId) -> bool { - match self.refused_anchors.get(anchor) { - Some(&until) => now_ms() < until, - None => false, + let (mut send, mut recv) = conn.open_bi().await?; + write_typed_message(&mut send, MessageType::AnchorReferralRequest, &request).await?; + send.finish()?; + + let msg_type = read_message_type(&mut recv).await?; + if msg_type != MessageType::AnchorReferralResponse { + anyhow::bail!("expected AnchorReferralResponse, got {:?}", msg_type); } - } + let response: AnchorReferralResponsePayload = read_payload(&mut recv, 4096).await?; - pub fn anchor_bias(&self) -> f64 { - self.anchor_bias - } - - pub fn cached_anchor_density(&self) -> f64 { - self.anchor_density - } - - pub fn set_convection_tx(&mut self, tx: tokio::sync::mpsc::Sender<()>) { - self.convection_tx = Some(tx); - } - - /// Wake the convection loop (the "ask a random known anchor" arm). - pub fn notify_convection(&self) { - if let Some(tx) = &self.convection_tx { - let _ = tx.try_send(()); + // Touch session last_active to prevent idle reaping + if let Some(session) = self.sessions.get_mut(anchor_peer) { + session.last_active_at = now_ms(); } + + Ok(response.referrals) + } + + /// Client-side: register our address with an anchor peer (mesh or session). + pub async fn send_anchor_register(&mut self, anchor_peer: &NodeId) -> anyhow::Result<()> { + let conn = if let Some(pc) = self.connections.get(anchor_peer) { + pc.connection.clone() + } else if let Some(session) = self.sessions.get(anchor_peer) { + session.connection.clone() + } else { + anyhow::bail!("anchor peer not connected (mesh or session)"); + }; + + let mut our_addrs: Vec = self.endpoint.addr().ip_addrs() + .map(|s| s.to_string()) + .collect(); + // Prepend UPnP external address (most useful for remote peers) + if let Some(ref ext) = self.upnp_external_addr { + let ext_str = ext.to_string(); + if !our_addrs.contains(&ext_str) { + our_addrs.insert(0, ext_str); + } + } + // Prepend stable anchor advertised address + if let Some(anchor_addr) = self.build_anchor_advertised_addr() { + if !our_addrs.contains(&anchor_addr) { + our_addrs.insert(0, anchor_addr); + } + } + + let payload = AnchorRegisterPayload { + node_id: self.our_node_id, + addresses: our_addrs, + }; + + let mut send = conn.open_uni().await?; + write_typed_message(&mut send, MessageType::AnchorRegister, &payload).await?; + send.finish()?; + + // Touch session last_active to prevent idle reaping + if let Some(session) = self.sessions.get_mut(anchor_peer) { + session.last_active_at = now_ms(); + } + + debug!(anchor = hex::encode(anchor_peer), "Registered with anchor"); + + Ok(()) } /// Handle an incoming NAT filter probe request (anchor side). @@ -4989,7 +4939,7 @@ impl ConnectionManager { // Build a temporary endpoint with a random port let secret_key = iroh::SecretKey::generate(&mut rand::rng()); let temp_ep = iroh::Endpoint::builder() - .alpns(vec![ALPN.to_vec()]) + .alpns(vec![ALPN_V2.to_vec()]) .secret_key(secret_key) .bind() .await?; @@ -5000,7 +4950,7 @@ impl ConnectionManager { // Try connecting with a short timeout (2s) let result = tokio::time::timeout( std::time::Duration::from_secs(2), - temp_ep.connect(addr, ALPN), + temp_ep.connect(addr, ALPN_V2), ).await; // Clean up the temporary endpoint @@ -5021,25 +4971,15 @@ impl ConnectionManager { let msg_type = read_message_type(recv).await?; match msg_type { - MessageType::UniquesAnnounce => { - let announce: UniquesAnnouncePayload = read_payload(recv, MAX_UNIQUES_PAYLOAD).await?; - // Same gather-outside-lock discipline as the pull handlers: - // the apply is a bulk per-row write and must not block slot - // allocation, disconnects or referral serving. - let ctx = { - let mut cm = conn_mgr.lock().await; - cm.set_peer_depth(&remote_node_id, announce.depth); - cm.uniques_ctx(&remote_node_id) - }; - // A temp referral slot carries no knowledge — drop the announce. - if !ctx.is_mesh { - debug!(peer = hex::encode(remote_node_id), "Ignoring announce from temp referral slot"); - return Ok(()); - } - let count = ctx.apply(&remote_node_id, &announce).await?; + MessageType::NodeListUpdate => { + let diff: NodeListUpdatePayload = read_payload(recv, MAX_PAYLOAD).await?; + let has_n1_additions = !diff.n1_added.is_empty(); + let cm = conn_mgr.lock().await; + let count = cm.process_routing_diff(&remote_node_id, diff).await?; if count > 0 { - debug!(peer = hex::encode(remote_node_id), count, "Applied uniques announce"); - let cm = conn_mgr.lock().await; + debug!(peer = hex::encode(remote_node_id), count, "Applied node list update"); + } + if has_n1_additions { cm.notify_growth(); } } @@ -5051,6 +4991,54 @@ impl ConnectionManager { let _ = storage.store_profile(&profile); } } + MessageType::DeleteRecord => { + let payload: crate::protocol::DeleteRecordPayload = + read_payload(recv, MAX_PAYLOAD).await?; + let cm = conn_mgr.lock().await; + + // Collect blob CIDs + CDN peers before async work + let mut blob_cleanup: Vec<([u8; 32], Vec<(NodeId, Vec)>)> = Vec::new(); + { + let storage = cm.storage.get().await; + for dr in &payload.records { + if crypto::verify_delete_signature(&dr.author, &dr.post_id, &dr.signature) { + // Collect blobs for CDN cleanup before deleting + let blob_cids = storage.get_blobs_for_post(&dr.post_id).unwrap_or_default(); + for cid in blob_cids { + let holders = storage.get_file_holders(&cid).unwrap_or_default(); + blob_cleanup.push((cid, holders)); + } + let _ = storage.store_delete(dr); + let _ = storage.apply_delete(dr); + // Delete blob metadata + CDN metadata + let deleted_cids = storage.delete_blobs_for_post(&dr.post_id).unwrap_or_default(); + for cid in &deleted_cids { + let _ = storage.cleanup_cdn_for_blob(cid); + let _ = cm.blob_store.delete(cid); + } + } + } + } + + drop(cm); + // BlobDeleteNotice removed in v0.6.2: orphaned blobs on remote + // holders are evicted naturally via LRU rather than by a + // persona-signed push. + let _ = blob_cleanup; + } + MessageType::VisibilityUpdate => { + let payload: crate::protocol::VisibilityUpdatePayload = + read_payload(recv, MAX_PAYLOAD).await?; + let cm = conn_mgr.lock().await; + let storage = cm.storage.get().await; + for vu in payload.updates { + if let Some(post) = storage.get_post(&vu.post_id)? { + if post.author == vu.author { + let _ = storage.update_post_visibility(&vu.post_id, &vu.visibility); + } + } + } + } MessageType::SocialAddressUpdate => { let payload: SocialAddressUpdatePayload = read_payload(recv, MAX_PAYLOAD).await?; let cm = conn_mgr.lock().await; @@ -5080,20 +5068,15 @@ impl ConnectionManager { if !crate::crypto::verify_manifest_signature(&entry.manifest.author_manifest) { continue; } - // Only store if newer than what we have (stored rows are - // AuthorManifest JSON — the canonical stored format). + // Only store if newer than what we have let dominated = storage.get_cdn_manifest(&entry.cid).ok().flatten() - .and_then(|json| serde_json::from_str::(&json).ok()) - .map(|existing| existing.updated_at >= entry.manifest.author_manifest.updated_at) + .and_then(|json| serde_json::from_str::(&json).ok()) + .map(|existing| existing.author_manifest.updated_at >= entry.manifest.author_manifest.updated_at) .unwrap_or(false); if dominated { continue; } - // Store ONLY the author-signed part. Persisting the full - // CdnManifest kept third-party host/source device metadata - // and produced dual-format rows that later readers parsed - // as AuthorManifest (silently getting updated_at = 0). - let manifest_json = match serde_json::to_string(&entry.manifest.author_manifest) { + let manifest_json = match serde_json::to_string(&entry.manifest) { Ok(j) => j, Err(_) => continue, }; @@ -5306,6 +5289,19 @@ impl ConnectionManager { debug!(peer = hex::encode(remote_node_id), stored, relayed = relay_conns.len(), "Received manifest push"); } + MessageType::SocialDisconnectNotice => { + let payload: SocialDisconnectNoticePayload = read_payload(recv, MAX_PAYLOAD).await?; + let cm = conn_mgr.lock().await; + let storage = cm.storage.get().await; + if storage.has_social_route(&payload.node_id).unwrap_or(false) { + let _ = storage.set_social_route_status(&payload.node_id, SocialStatus::Disconnected); + } + debug!( + peer = hex::encode(remote_node_id), + target = hex::encode(payload.node_id), + "Received social disconnect notice" + ); + } MessageType::CircleProfileUpdate => { let payload: CircleProfileUpdatePayload = read_payload(recv, MAX_PAYLOAD).await?; let cm = conn_mgr.lock().await; @@ -5385,6 +5381,11 @@ impl ConnectionManager { } } } + MessageType::AnchorRegister => { + let payload: AnchorRegisterPayload = read_payload(recv, 4096).await?; + let mut cm = conn_mgr.lock().await; + cm.handle_anchor_register(payload).await; + } MessageType::MeshKeepalive => { // No-op — last_activity already updated on stream accept } @@ -5438,28 +5439,22 @@ impl ConnectionManager { msg_type: MessageType, ) -> anyhow::Result<()> { match msg_type { - MessageType::ContentSyncRequest => { + MessageType::PullSyncRequest => { let (storage, our_node_id) = { let cm = conn_mgr.lock().await; (Arc::clone(&cm.storage), *cm.our_node_id()) }; // Lock RELEASED — handler does its own brief storage locks + network I/O - ConnectionManager::handle_content_sync_request_unlocked(&storage, our_node_id, remote_node_id, recv, send).await?; - } - MessageType::UniquesPullRequest => { - ConnectionManager::handle_uniques_pull(conn_mgr, recv, send, remote_node_id).await?; + ConnectionManager::handle_pull_request_unlocked(&storage, our_node_id, remote_node_id, recv, send).await?; } MessageType::InitialExchange => { - let (storage, our_node_id, anchor_addr, our_nat_type, our_http_capable, our_http_addr, is_duplicate, our_depth, carry_knowledge) = { + let (storage, our_node_id, anchor_addr, our_nat_type, our_http_capable, our_http_addr, is_duplicate) = { let cm = conn_mgr.lock().await; // Duplicate identity detection: is this NodeId already mesh-connected? let dup = cm.connections.contains_key(&remote_node_id); - // Knowledge boundary: only a MESH slot exchanges uniques. - // An unregistered peer (session/relay path) carries none either. - let carry = cm.connections.get(&remote_node_id).map(|pc| pc.slot.is_mesh()).unwrap_or(false); - (cm.storage_ref(), *cm.our_node_id(), cm.build_anchor_advertised_addr(), cm.nat_type(), cm.http_capable, cm.http_addr.clone(), dup, cm.max_bounce, carry) + (cm.storage_ref(), *cm.our_node_id(), cm.build_anchor_advertised_addr(), cm.nat_type(), cm.http_capable, cm.http_addr.clone(), dup) }; - initial_exchange_accept(&storage, &our_node_id, send, recv, remote_node_id, anchor_addr, None, our_nat_type, our_http_capable, our_http_addr, None, None, is_duplicate, our_depth, carry_knowledge) + initial_exchange_accept(&storage, &our_node_id, send, recv, remote_node_id, anchor_addr, None, our_nat_type, our_http_capable, our_http_addr, None, None, is_duplicate) .await?; } MessageType::AddressRequest => { @@ -5563,15 +5558,12 @@ impl ConnectionManager { let cm = conn_mgr.lock().await; Arc::clone(&cm.blob_store) }; - // Snapshot referral candidates — MESH slots only. We never - // point a stranger at a peer we hold in a temp slot. - let wide_candidates: Vec = { + // Also snapshot wide-referral candidates (node_id, slot_kind) + let wide_candidates: Vec<(NodeId, PeerSlotKind)> = { let cm = conn_mgr.lock().await; cm.connections.iter() - .filter(|(nid, pc)| { - **nid != remote_node_id && **nid != cm.our_node_id && pc.slot.is_mesh() - }) - .map(|(nid, _)| *nid) + .filter(|(nid, _)| **nid != remote_node_id && **nid != cm.our_node_id) + .map(|(nid, pc)| (*nid, pc.slot_kind)) .collect() }; tokio::spawn(async move { @@ -5667,8 +5659,10 @@ impl ConnectionManager { use base64::Engine; let storage = storage.get().await; let manifest: Option = storage.get_cdn_manifest(&payload.cid).ok().flatten().and_then(|json| { - serde_json::from_str::(&json).ok() - .map(|am| crate::types::CdnManifest { author_manifest: am, host: our_node_id }) + if let Ok(am) = serde_json::from_str::(&json) { + let ds_count = storage.get_file_holder_count(&payload.cid).unwrap_or(0); + Some(crate::types::CdnManifest { author_manifest: am, host: our_node_id, host_addresses: vec![], source: our_node_id, source_addresses: vec![], downstream_count: ds_count }) + } else { serde_json::from_str(&json).ok() } }); let (cdn_registered, cdn_redirect_peers) = if !payload.requester_addresses.is_empty() { let prior_count = storage.get_file_holder_count(&payload.cid).unwrap_or(0); @@ -5714,7 +5708,8 @@ impl ConnectionManager { Some(json) => { let manifest = if let Ok(am) = serde_json::from_str::(&json) { if am.updated_at > payload.current_updated_at { - Some(crate::types::CdnManifest { author_manifest: am, host: our_node_id }) + let ds_count = store.get_file_holder_count(&payload.cid).unwrap_or(0); + Some(crate::types::CdnManifest { author_manifest: am, host: our_node_id, host_addresses: vec![], source: our_node_id, source_addresses: vec![], downstream_count: ds_count }) } else { None } } else { None }; crate::protocol::ManifestRefreshResponsePayload { cid: payload.cid, updated: manifest.is_some(), manifest } @@ -5726,6 +5721,66 @@ impl ConnectionManager { send.finish()?; debug!(peer = hex::encode(remote_node_id), updated = response.updated, "Handled manifest refresh request"); } + MessageType::GroupKeyRequest => { + let payload: GroupKeyRequestPayload = read_payload(&mut recv, 4096).await?; + let cm = conn_mgr.lock().await; + let storage = cm.storage.get().await; + + let response = match storage.get_group_key(&payload.group_id)? { + Some(record) if record.admin == cm.our_node_id => { + // We're the admin — check if requester is still a circle member + let members = storage.get_circle_members(&record.circle_name)?; + if members.contains(&remote_node_id) { + // Wrap group seed for requester + let seed = storage.get_group_seed(&payload.group_id, record.epoch)?; + let member_key = if let Some(seed) = seed { + crypto::wrap_group_key_for_member( + &cm.secret_seed, + &remote_node_id, + &seed, + ).ok().map(|wrapped| crate::types::GroupMemberKey { + member: remote_node_id, + epoch: record.epoch, + wrapped_group_key: wrapped, + }) + } else { + None + }; + GroupKeyResponsePayload { + group_id: payload.group_id, + epoch: record.epoch, + group_public_key: record.group_public_key, + admin: cm.our_node_id, + member_key, + } + } else { + // Requester not a member — no key + GroupKeyResponsePayload { + group_id: payload.group_id, + epoch: record.epoch, + group_public_key: record.group_public_key, + admin: cm.our_node_id, + member_key: None, + } + } + } + _ => { + // Not admin or no record + GroupKeyResponsePayload { + group_id: payload.group_id, + epoch: 0, + group_public_key: [0u8; 32], + admin: cm.our_node_id, + member_key: None, + } + } + }; + drop(storage); + drop(cm); + write_typed_message(&mut send, MessageType::GroupKeyResponse, &response).await?; + send.finish()?; + debug!(peer = hex::encode(remote_node_id), group_id = hex::encode(payload.group_id), "Handled group key request"); + } MessageType::RelayIntroduce => { let payload: RelayIntroducePayload = read_payload(&mut recv, MAX_PAYLOAD).await?; debug!( @@ -5848,7 +5903,7 @@ impl ConnectionManager { if let Some(session) = cm.sessions.get(&requester) { let session_conn = session.connection.clone(); drop(cm); - let _ = initial_exchange_connect(&storage, &our_node_id, &session_conn, requester, None, our_nat_type, our_http_capable, our_http_addr, None, None, DEFAULT_PEER_DEPTH, false).await; + let _ = initial_exchange_connect(&storage, &our_node_id, &session_conn, requester, None, our_nat_type, our_http_capable, our_http_addr, None, None).await; } } }); @@ -5931,20 +5986,14 @@ impl ConnectionManager { let cm = Arc::clone(conn_mgr); Self::handle_session_relay(cm, recv, send, remote_node_id).await?; } - MessageType::ConvectionRequest => { - let mut payload: ConvectionRequestPayload = read_payload(&mut recv, 4096).await?; - // The QUIC peer identity is authoritative. Without this a - // caller could enrol a THIRD party's node id in our window and - // have us circulate an address it does not own. - payload.requester = remote_node_id; - // Gather under a brief lock, write outside it. The old handler - // held the lock across write + finish. - let gathered = { - let mut cm = conn_mgr.lock().await; - let observed = cm.convection_observed_addr(&remote_node_id); - cm.gather_convection(&payload, observed) - }; - ConnectionManager::serve_convection(gathered, send).await?; + MessageType::MeshPrefer => { + let mut cm = conn_mgr.lock().await; + cm.handle_mesh_prefer(remote_node_id, send, recv).await?; + } + MessageType::AnchorReferralRequest => { + let payload: AnchorReferralRequestPayload = read_payload(&mut recv, 4096).await?; + let mut cm = conn_mgr.lock().await; + cm.handle_anchor_referral_request(payload, send).await?; } MessageType::AnchorProbeRequest => { let payload: crate::protocol::AnchorProbeRequestPayload = read_payload(&mut recv, 4096).await?; @@ -6202,75 +6251,93 @@ impl ConnectionManager { } } BlobHeaderDiffOp::AddComment(comment) => { - // v0.8 (A3): the single shared accept gate — - // signature (digest v2 incl. expiry), TTL sanity, - // policy, FoF four-check gated on - // post.fof_gating (not the policy enum), - // open-slot size buckets + greeting cap, - // registry entry validation + newest-wins. - // Same gate runs on both pull-path merges. - let parent = storage.get_post(&payload.post_id).ok().flatten(); - if accept_incoming_comment( - &storage, - parent.as_ref(), - &policy, - &followers_set, - comment, - now_ms(), - ) { - // Registry newest-wins pruning runs only after - // a SUCCESSFUL store, so a store error can't - // leave the persona with no entry (the gate - // itself is side-effect free). - if storage.store_comment(comment).is_ok() - && comment.post_id == crate::registry::REGISTRY_POST_ID - { - let _ = storage.prune_older_registry_entries( - &comment.post_id, - &comment.author, - comment.timestamp_ms, - ); + if policy.blocklist.contains(&comment.author) { + continue; + } + match policy.allow_comments { + crate::types::CommentPermission::None => continue, + crate::types::CommentPermission::FollowersOnly => { + if !followers_set.contains(&comment.author) { + continue; + } + } + crate::types::CommentPermission::Public => {} + crate::types::CommentPermission::FriendsOfFriends => { + // FoF Layer 2 CDN four-check accept rule: + // 1. parent post must carry fof_gating + // (otherwise the policy is ambient + // with no key material to verify); + // 2. pub_x_index must point at a real + // entry in pub_post_set; + // 3. group_sig must validate against + // pub_post_set[pub_x_index]; + // 4. revocation_list must not contain + // pub_post_set[pub_x_index]; + // 5. identity_sig (existing comment + // signature field) verified below. + // + // Failures drop the comment without + // forwarding — kills the bandwidth-DoS + // attack an admitted-but-malicious FoF + // member could otherwise mount. + let parent = match storage.get_post(&payload.post_id) { + Ok(Some(p)) => p, + _ => continue, + }; + let Some(gating) = parent.fof_gating.as_ref() else { continue; }; + if !crate::fof::verify_fof_group_sig(comment, gating) { + continue; + } + // Revocation check (step 4). Two + // sources: the post's snapshot + // revocation_list (rarely populated on + // publish) and the live local table + // fof_revocations (accumulated as + // revocation diffs arrive on the wire). + if let Some(idx) = comment.pub_x_index { + let pub_x = gating.pub_post_set + .get(idx as usize) + .copied(); + if let Some(pub_x) = pub_x { + if gating.revocation_list.iter() + .any(|r| r.revoked_pub_x == pub_x) { + continue; + } + if storage.is_fof_pub_x_revoked(&payload.post_id, &pub_x).unwrap_or(false) { + continue; + } + } + } } } + if !crate::crypto::verify_comment_signature( + &comment.author, + &payload.post_id, + &comment.content, + comment.timestamp_ms, + &comment.signature, + comment.ref_post_id.as_ref(), + ) { + continue; // Skip forged comments + } + let _ = storage.store_comment(comment); } - BlobHeaderDiffOp::EditComment { .. } => { - // v0.8: remote edits are IGNORED. The old arm - // trusted the attacker-controlled `payload.author` - // (`sender == payload.author` is trivially forged) - // and rewrote stored content without any signature - // — and post-A1 a network-id sender can never - // legitimately match a posting-id author anyway. - // Edits = the author re-sends AddComment with the - // same (author, post_id, timestamp_ms) key and a - // fresh v2 signature; the upsert replaces the row. + BlobHeaderDiffOp::EditComment { author, post_id, timestamp_ms, new_content } => { + // Trust-based: only the comment author can edit + if *author == sender || sender == payload.author { + let _ = storage.edit_comment(author, post_id, *timestamp_ms, new_content); + } } - BlobHeaderDiffOp::DeleteComment { author, post_id, timestamp_ms, signature } => { - // v0.8 (A3): self-certifying delete ONLY — the - // comment author's signature verifies from the op - // alone (works on registry holders that never met - // the persona). The old parent-post-author sender - // override compared against attacker-controlled - // `payload.author` (forgeable), and could never - // fire legitimately anyway (sender is a NETWORK - // id, post authors are POSTING ids). Post-author - // moderation returns later as a SIGNED override; - // bulk moderation = the RevocationEntry off-switch. - if delete_comment_authorized(author, post_id, *timestamp_ms, signature) { + BlobHeaderDiffOp::DeleteComment { author, post_id, timestamp_ms } => { + // Trust-based: comment author or post author can delete + if *author == sender || sender == payload.author { let _ = storage.delete_comment(author, post_id, *timestamp_ms); } } - BlobHeaderDiffOp::SetPolicy(_) => { - // v0.8: remote policy writes are IGNORED. The old - // `sender == payload.author` check compared against - // an attacker-controlled field, and A3's accept - // gate consumes the stored policy as its FIRST - // drop condition — one forged SetPolicy(None) - // against REGISTRY_POST_ID would poison this - // holder into rejecting ALL future registrations. - // Policy rows are written only by the author's own - // device (node.set_comment_policy / - // persist_gated_post_author_state); a signed - // policy-change op can reintroduce remote writes. + BlobHeaderDiffOp::SetPolicy(new_policy) => { + if sender == payload.author { + let _ = storage.set_comment_policy(&payload.post_id, new_policy); + } } BlobHeaderDiffOp::FoFRevocation { post_id, revoked_pub_x, revoked_at_ms, reason_code, author_sig, @@ -6488,7 +6555,7 @@ pub enum ConnResponse { NodeId(NodeId), OptConnection(Option), Peers(Vec), - ConnectionInfo(Vec<(NodeId, MeshSlot, u64)>), + ConnectionInfo(Vec<(NodeId, PeerSlotKind, u64)>), SessionInfo(Vec<(NodeId, SessionReachMethod, u64)>), NatProfile(crate::types::NatProfile), NatType(crate::types::NatType), @@ -6497,36 +6564,30 @@ pub enum ConnResponse { OptString(Option), OptEndpointAddr(Option), Endpoint(iroh::Endpoint), + SecretSeed([u8; 32]), Storage(Arc), BlobStore(Arc), ActiveRelayPipes(Arc), + Referrals(Vec), OptSocketAddr(Option), IsAnchorAtomicBool(Arc), ActivityLogRef(Arc>), ScoreVec(Vec<(NodeId, f64)>), OptPeerWithAddress(Option), - ConnectionMap(Vec<(NodeId, iroh::endpoint::Connection, MeshSlot, Arc)>), - AnnounceData(AnnounceSnapshot), + ConnectionMap(Vec<(NodeId, iroh::endpoint::Connection, PeerSlotKind, Arc)>), + DiffData(DiffSnapshot), Unit, } -/// Snapshot for broadcasting the uniques announce outside the lock. -/// -/// One built payload plus the per-peer connections it goes to. Only MESH-slot -/// peers appear in `connections`: a temporary referral slot carries no -/// knowledge exchange in either direction. -pub struct AnnounceSnapshot { +/// Snapshot of data needed for routing diff computation. +pub struct DiffSnapshot { pub our_node_id: NodeId, - /// (peer, connection, peer's advertised retention depth) - pub connections: Vec<(NodeId, iroh::endpoint::Connection, u8)>, - pub seq: u64, - /// Our announce, built with the full terminal pool. Peers that advertise - /// depth < 4 get it stripped at send time. - pub payload: Option, - /// True when the pools are unchanged since the last broadcast — the caller - /// skips the send. (The announce is always a full snapshot, so this is what - /// stands in for incremental diffing.) - pub unchanged: bool, + pub connections: Vec<(NodeId, iroh::endpoint::Connection)>, + pub diff_seq: u64, + pub n1_added: Vec, + pub n1_removed: Vec, + pub n2_added: Vec, + pub n2_removed: Vec, } /// Commands sent to the ConnectionActor via the ConnHandle channel. @@ -6539,16 +6600,11 @@ pub enum ConnCommand { ConnectionCount { reply: oneshot::Sender, }, - /// Mesh-slot count only (temp referral slots excluded) — the number the - /// recovery threshold and the ENTRY/TOP-UP class bit are defined against. - MeshCount { - reply: oneshot::Sender, - }, ConnectedPeers { reply: oneshot::Sender>, }, ConnectionInfo { - reply: oneshot::Sender>, + reply: oneshot::Sender>, }, GetConnection { peer: NodeId, @@ -6560,7 +6616,7 @@ pub enum ConnCommand { }, /// Get all mesh connections (node_id, connection, slot_kind, last_activity) GetConnectionMap { - reply: oneshot::Sender)>>, + reply: oneshot::Sender)>>, }, OurNodeId { reply: oneshot::Sender, @@ -6591,7 +6647,7 @@ pub enum ConnCommand { SessionPeerIds { reply: oneshot::Sender>, }, - AvailableMeshSlots { + AvailableLocalSlots { reply: oneshot::Sender, }, IsLikelyUnreachable { @@ -6601,6 +6657,9 @@ pub enum ConnCommand { GetEndpoint { reply: oneshot::Sender, }, + GetSecretSeed { + reply: oneshot::Sender<[u8; 32]>, + }, GetStorage { reply: oneshot::Sender>, }, @@ -6660,13 +6719,14 @@ pub enum ConnCommand { conn: iroh::endpoint::Connection, remote_node_id: NodeId, remote_addr: Option, - reply: oneshot::Sender>, + reply: oneshot::Sender, }, RegisterConnection { peer_id: NodeId, conn: iroh::endpoint::Connection, addrs: Vec, - reply: oneshot::Sender>, + slot_kind: PeerSlotKind, + reply: oneshot::Sender<()>, }, DisconnectPeer { peer: NodeId, @@ -6700,6 +6760,10 @@ pub enum ConnCommand { SetNatFiltering { filtering: crate::types::NatFiltering, }, + AddStickyN1 { + peer: NodeId, + duration_ms: u64, + }, SetGrowthTx { tx: tokio::sync::mpsc::Sender<()>, }, @@ -6733,36 +6797,33 @@ pub enum ConnCommand { RecordProbeSuccess, RecordProbeFailure, - // --- Anchor convection (adaptive weights, round-5) --- - RecordConvectionRefusal { - anchor: NodeId, + // --- Referral management --- + HandleAnchorRegister { + payload: AnchorRegisterPayload, + reply: oneshot::Sender<()>, }, - RecordConvectionSuccess { - anchor: NodeId, + PickReferrals { + exclude: NodeId, + count: usize, + reply: oneshot::Sender>, }, - IsAnchorPenalized { - anchor: NodeId, - reply: oneshot::Sender, + MarkReferralDisconnected { + node_id: NodeId, }, - ConvectionState { - reply: oneshot::Sender<(f64, f64, usize)>, - }, - SetConvectionTx { - tx: tokio::sync::mpsc::Sender<()>, + MarkReferralReconnected { + node_id: NodeId, }, - // --- Uniques announce --- - ProcessUniquesAnnounce { + // --- Diff/routing --- + ProcessRoutingDiff { from_peer: NodeId, - payload: UniquesAnnouncePayload, + payload: NodeListUpdatePayload, reply: oneshot::Sender>, }, - /// Get snapshot of mesh connections + our built announce for broadcast - GetAnnounceData { - reply: oneshot::Sender, + /// Get snapshot of connections + diff data for external broadcast + GetDiffData { + reply: oneshot::Sender, }, - /// Reset the change detector so the next announce is sent unconditionally. - ForceAnnounce, // --- Relay/address lookups (state reads, no network I/O) --- FindRelaysFor { @@ -6802,7 +6863,7 @@ pub enum ConnCommand { target: NodeId, reply: oneshot::Sender>>, }, - ContentSyncFromPeer { + PullFromPeer { peer: NodeId, reply: oneshot::Sender>, }, @@ -6832,29 +6893,19 @@ pub struct ConnHandle { http_addr: Arc>>, /// CDN device role (set once at startup by Network) device_role_val: Arc>>, - /// Deepest bounce this device retains (4 desktop / 3 mobile). Hoisted out - /// of the manager so the accept path can read it without a lock. - max_bounce: u8, } impl ConnHandle { /// Create a ConnHandle from a command channel sender. - pub fn new(tx: mpsc::Sender, max_bounce: u8) -> Self { + pub fn new(tx: mpsc::Sender) -> Self { Self { tx, http_capable: Arc::new(AtomicBool::new(false)), http_addr: Arc::new(std::sync::Mutex::new(None)), device_role_val: Arc::new(std::sync::Mutex::new(None)), - max_bounce, } } - /// Deepest bounce we retain — goes on the wire so peers can skip building - /// the terminal pool for us. - pub fn max_bounce(&self) -> u8 { - self.max_bounce - } - /// Set HTTP capability info (called once after HTTP server starts). pub fn set_http_info(&self, capable: bool, addr: Option) { self.http_capable.store(capable, Ordering::Relaxed); @@ -6895,20 +6946,13 @@ impl ConnHandle { rx.await.unwrap_or(0) } - /// Mesh-slot count (temp referral slots excluded). - pub async fn mesh_count(&self) -> usize { - let (tx, rx) = oneshot::channel(); - let _ = self.tx.send(ConnCommand::MeshCount { reply: tx }).await; - rx.await.unwrap_or(0) - } - pub async fn connected_peers(&self) -> Vec { let (tx, rx) = oneshot::channel(); let _ = self.tx.send(ConnCommand::ConnectedPeers { reply: tx }).await; rx.await.unwrap_or_default() } - pub async fn connection_info(&self) -> Vec<(NodeId, MeshSlot, u64)> { + pub async fn connection_info(&self) -> Vec<(NodeId, PeerSlotKind, u64)> { let (tx, rx) = oneshot::channel(); let _ = self.tx.send(ConnCommand::ConnectionInfo { reply: tx }).await; rx.await.unwrap_or_default() @@ -6927,7 +6971,7 @@ impl ConnHandle { } /// Get all mesh connections as (node_id, connection, slot_kind, last_activity). - pub async fn get_connection_map(&self) -> Vec<(NodeId, iroh::endpoint::Connection, MeshSlot, Arc)> { + pub async fn get_connection_map(&self) -> Vec<(NodeId, iroh::endpoint::Connection, PeerSlotKind, Arc)> { let (tx, rx) = oneshot::channel(); let _ = self.tx.send(ConnCommand::GetConnectionMap { reply: tx }).await; rx.await.unwrap_or_default() @@ -6984,9 +7028,9 @@ impl ConnHandle { rx.await.unwrap_or_default() } - pub async fn available_mesh_slots(&self) -> usize { + pub async fn available_local_slots(&self) -> usize { let (tx, rx) = oneshot::channel(); - let _ = self.tx.send(ConnCommand::AvailableMeshSlots { reply: tx }).await; + let _ = self.tx.send(ConnCommand::AvailableLocalSlots { reply: tx }).await; rx.await.unwrap_or(0) } @@ -7002,6 +7046,12 @@ impl ConnHandle { rx.await.expect("actor dropped") } + pub async fn secret_seed(&self) -> [u8; 32] { + let (tx, rx) = oneshot::channel(); + let _ = self.tx.send(ConnCommand::GetSecretSeed { reply: tx }).await; + rx.await.unwrap_or([0u8; 32]) + } + pub async fn storage(&self) -> Arc { let (tx, rx) = oneshot::channel(); let _ = self.tx.send(ConnCommand::GetStorage { reply: tx }).await; @@ -7100,15 +7150,12 @@ impl ConnHandle { // === Mutation operations === - /// Accept an inbound connection. `None` = refused (mesh + temp both full); - /// otherwise the slot class it was placed in — the caller must propagate - /// `is_mesh()` into the initial exchange as the knowledge-carry flag. pub async fn accept_connection( &self, conn: iroh::endpoint::Connection, remote_node_id: NodeId, remote_addr: Option, - ) -> Option { + ) -> bool { let (tx, rx) = oneshot::channel(); let _ = self.tx.send(ConnCommand::AcceptConnection { conn, @@ -7116,25 +7163,25 @@ impl ConnHandle { remote_addr, reply: tx, }).await; - rx.await.ok().flatten() + rx.await.unwrap_or(false) } - /// Register an outbound connection. The slot class is decided by the - /// manager (mesh if there is room, else a temp referral slot, else `None`). pub async fn register_connection( &self, peer_id: NodeId, conn: iroh::endpoint::Connection, addrs: Vec, - ) -> Option { + slot_kind: PeerSlotKind, + ) { let (tx, rx) = oneshot::channel(); let _ = self.tx.send(ConnCommand::RegisterConnection { peer_id, conn, addrs, + slot_kind, reply: tx, }).await; - rx.await.ok().flatten() + let _ = rx.await; } pub async fn disconnect_peer(&self, peer: &NodeId) { @@ -7187,6 +7234,11 @@ impl ConnHandle { let _ = self.tx.try_send(ConnCommand::MarkUnreachable { peer: *peer }); } + /// Add a node to the sticky N1 set (reported in N1 share until expiry). + pub fn add_sticky_n1(&self, peer: &NodeId, duration_ms: u64) { + let _ = self.tx.try_send(ConnCommand::AddStickyN1 { peer: *peer, duration_ms }); + } + pub fn set_nat_filtering(&self, filtering: crate::types::NatFiltering) { let _ = self.tx.try_send(ConnCommand::SetNatFiltering { filtering }); } @@ -7243,43 +7295,35 @@ impl ConnHandle { // === Referral management === - /// Refusal feedback: multiplicative decrease on the anchor arm + penalty - /// box for that anchor. - pub fn record_convection_refusal(&self, anchor: &NodeId) { - let _ = self.tx.try_send(ConnCommand::RecordConvectionRefusal { anchor: *anchor }); - } - - /// Success feedback: additive increase, drifting back toward parity. - pub fn record_convection_success(&self, anchor: &NodeId) { - let _ = self.tx.try_send(ConnCommand::RecordConvectionSuccess { anchor: *anchor }); - } - - pub async fn is_anchor_penalized(&self, anchor: &NodeId) -> bool { + pub async fn handle_anchor_register(&self, payload: AnchorRegisterPayload) { let (tx, rx) = oneshot::channel(); - let _ = self.tx.send(ConnCommand::IsAnchorPenalized { anchor: *anchor, reply: tx }).await; - rx.await.unwrap_or(false) + let _ = self.tx.send(ConnCommand::HandleAnchorRegister { payload, reply: tx }).await; + let _ = rx.await; } - /// (anchor_density, anchor_bias, mesh_count) — diagnostics + tests. - pub async fn convection_state(&self) -> (f64, f64, usize) { + pub async fn pick_referrals(&self, exclude: &NodeId, count: usize) -> Vec { let (tx, rx) = oneshot::channel(); - let _ = self.tx.send(ConnCommand::ConvectionState { reply: tx }).await; - rx.await.unwrap_or((0.0, 1.0, 0)) + let _ = self.tx.send(ConnCommand::PickReferrals { exclude: *exclude, count, reply: tx }).await; + rx.await.unwrap_or_default() } - pub async fn set_convection_tx(&self, tx: tokio::sync::mpsc::Sender<()>) { - let _ = self.tx.send(ConnCommand::SetConvectionTx { tx }).await; + pub fn mark_referral_disconnected(&self, node_id: &NodeId) { + let _ = self.tx.try_send(ConnCommand::MarkReferralDisconnected { node_id: *node_id }); + } + + pub fn mark_referral_reconnected(&self, node_id: &NodeId) { + let _ = self.tx.try_send(ConnCommand::MarkReferralReconnected { node_id: *node_id }); } // === Diff/routing === - pub async fn process_uniques_announce( + pub async fn process_routing_diff( &self, from_peer: &NodeId, - payload: UniquesAnnouncePayload, + payload: NodeListUpdatePayload, ) -> anyhow::Result { let (tx, rx) = oneshot::channel(); - let _ = self.tx.send(ConnCommand::ProcessUniquesAnnounce { + let _ = self.tx.send(ConnCommand::ProcessRoutingDiff { from_peer: *from_peer, payload, reply: tx, @@ -7310,23 +7354,20 @@ impl ConnHandle { let _ = self.tx.try_send(ConnCommand::TouchSessionIfExists { peer: *peer }); } - pub async fn get_announce_data(&self) -> AnnounceSnapshot { + pub async fn get_diff_data(&self) -> DiffSnapshot { let (tx, rx) = oneshot::channel(); - let _ = self.tx.send(ConnCommand::GetAnnounceData { reply: tx }).await; - rx.await.unwrap_or_else(|_| AnnounceSnapshot { + let _ = self.tx.send(ConnCommand::GetDiffData { reply: tx }).await; + rx.await.unwrap_or_else(|_| DiffSnapshot { our_node_id: [0u8; 32], connections: vec![], - seq: 0, - payload: None, - unchanged: true, + diff_seq: 0, + n1_added: vec![], + n1_removed: vec![], + n2_added: vec![], + n2_removed: vec![], }) } - /// Force the next `get_announce_data` to send even if nothing changed. - pub async fn force_announce(&self) { - let _ = self.tx.send(ConnCommand::ForceAnnounce).await; - } - // === Complex operations === pub async fn rebalance_slots(&self) -> anyhow::Result> { @@ -7393,11 +7434,9 @@ impl ConnHandle { rx.await.map_err(|_| anyhow::anyhow!("actor dropped"))? } - /// TRANSITIONAL content sync (0x46/0x47) — the real path: uses - /// `control::receive_post`, `touch_file_holder`, and PostDownstreamRegister. - pub async fn content_sync_from_peer(&self, peer: &NodeId) -> anyhow::Result { + pub async fn pull_from_peer(&self, peer: &NodeId) -> anyhow::Result { let (tx, rx) = oneshot::channel(); - let _ = self.tx.send(ConnCommand::ContentSyncFromPeer { peer: *peer, reply: tx }).await; + let _ = self.tx.send(ConnCommand::PullFromPeer { peer: *peer, reply: tx }).await; rx.await.map_err(|_| anyhow::anyhow!("actor dropped"))? } @@ -7434,7 +7473,7 @@ impl ConnectionActor { pub async fn spawn_with_arc(cm: Arc>) -> ConnHandle { let (tx, rx) = mpsc::channel(256); // Hoist frequently-needed Arcs so handlers can skip the conn_mgr lock - let (storage, blob_store, endpoint, our_node_id, activity_log, is_anchor, max_bounce) = { + let (storage, blob_store, endpoint, our_node_id, activity_log, is_anchor) = { let cm_guard = cm.lock().await; ( Arc::clone(&cm_guard.storage), @@ -7443,12 +7482,11 @@ impl ConnectionActor { cm_guard.our_node_id, Arc::clone(&cm_guard.activity_log), Arc::clone(&cm_guard.is_anchor), - cm_guard.max_bounce, ) }; let actor = ConnectionActor { cm, rx, storage, blob_store, endpoint, our_node_id, activity_log, is_anchor }; tokio::spawn(actor.run()); - ConnHandle::new(tx, max_bounce) + ConnHandle::new(tx) } /// Spawn the actor owning the ConnectionManager directly (Phase 5+). @@ -7493,28 +7531,42 @@ impl ConnectionActor { } } - // Referral cascade over the whole stored horizon (bounce 2 → 3 → 4), - // shallowest first. Must stay in step with - // `ConnectionManager::resolve_address` — these two copies have diverged - // before. - let queries: Vec = { + // N2 lookup: brief lock to get reporters + their connections + let n2_queries: Vec = { let s = storage.get().await; - let mut seen: HashSet = HashSet::new(); - let mut ordered = Vec::new(); - for bounce in 2u8..=4 { - for r in s.find_reporters_at(target, bounce).unwrap_or_default() { - if seen.insert(r) { - ordered.push(r); - } - } - } + let reporters = s.find_in_n2(target)?; drop(s); let cm_guard = cm.lock().await; - ordered.iter() + reporters.iter() .filter_map(|r| cm_guard.connections.get(r).map(|pc| pc.connection.clone())) .collect() }; - for conn in &queries { + for conn in &n2_queries { + let result = tokio::time::timeout(std::time::Duration::from_secs(5), async { + let (mut send, mut recv) = conn.open_bi().await?; + let req = crate::protocol::AddressRequestPayload { target: *target }; + write_typed_message(&mut send, MessageType::AddressRequest, &req).await?; + send.finish()?; + let _resp_type = read_message_type(&mut recv).await?; + let resp: crate::protocol::AddressResponsePayload = read_payload(&mut recv, MAX_PAYLOAD).await?; + anyhow::Ok(resp.address) + }).await; + if let Ok(Ok(Some(addr))) = result { + return Ok(Some(addr)); + } + } + + // N3 lookup: same pattern + let n3_queries: Vec = { + let s = storage.get().await; + let reporters = s.find_in_n3(target)?; + drop(s); + let cm_guard = cm.lock().await; + reporters.iter() + .filter_map(|r| cm_guard.connections.get(r).map(|pc| pc.connection.clone())) + .collect() + }; + for conn in &n3_queries { let result = tokio::time::timeout(std::time::Duration::from_secs(5), async { let (mut send, mut recv) = conn.open_bi().await?; let req = crate::protocol::AddressRequestPayload { target: *target }; @@ -7632,7 +7684,7 @@ impl ConnectionActor { } { let s = ctx.storage.get().await; - let found_entries = s.find_any_reachable(all_needles)?; + let found_entries = s.find_any_in_n2_n3(all_needles)?; if let Some((found_id, _reporter, _level)) = found_entries.first() { drop(s); let address = Self::resolve_address_unlocked(&ctx.storage, &ctx.cm, &ctx.endpoint, found_id).await.ok().flatten(); @@ -7710,7 +7762,7 @@ impl ConnectionActor { let endpoint_id = iroh::EndpointId::from_bytes(&ref_id).ok()?; let mut addr = iroh::EndpointAddr::from(endpoint_id); if let Ok(sock) = ref_addr.parse::() { addr = addr.with_ip_addr(sock); } - let conn = endpoint.connect(addr, ALPN).await.ok()?; + let conn = endpoint.connect(addr, ALPN_V2).await.ok()?; let resp = ConnectionManager::send_worm_query_raw(&conn, &payload).await.ok()?; let is_hit = resp.found || resp.post_holder.is_some() || resp.blob_holder.is_some(); if is_hit { @@ -7729,14 +7781,14 @@ impl ConnectionActor { None } - /// Pick a random mesh referral from a pre-snapshotted candidate list. - /// v0.8: one pool, so there is no "prefer wide". Temp referral slots were - /// already filtered out when the snapshot was taken. - async fn pick_wide_referral(storage: &Arc, candidates: &[NodeId], exclude: &NodeId) -> Option<(NodeId, String)> { - let filtered: Vec = candidates.iter().filter(|nid| *nid != exclude).copied().collect(); + /// Pick a random wide referral from a pre-snapshotted list of (node_id, slot_kind). + async fn pick_wide_referral(storage: &Arc, candidates: &[(NodeId, PeerSlotKind)], exclude: &NodeId) -> Option<(NodeId, String)> { + let filtered: Vec<(NodeId, PeerSlotKind)> = candidates.iter().filter(|(nid, _)| nid != exclude).copied().collect(); if filtered.is_empty() { return None; } + let wide: Vec<(NodeId, PeerSlotKind)> = filtered.iter().filter(|(_, kind)| *kind == PeerSlotKind::Wide).copied().collect(); + let ordered = if !wide.is_empty() { wide } else { filtered }; let s = storage.get().await; - for nid in &filtered { + for (nid, _) in &ordered { if let Ok(Some(rec)) = s.get_peer_record(nid) { if let Some(addr) = rec.addresses.first() { return Some((*nid, addr.to_string())); } } @@ -7748,7 +7800,7 @@ impl ConnectionActor { async fn handle_worm_query_unlocked( ctx: WormContext, blob_store: Arc, - wide_candidates: Vec, + wide_candidates: Vec<(NodeId, PeerSlotKind)>, payload: WormQueryPayload, mut send: iroh::endpoint::SendStream, from_peer: NodeId, @@ -7795,7 +7847,7 @@ impl ConnectionActor { } if found.is_none() { let s = ctx.storage.get().await; - let entries = s.find_any_reachable(&all_needles)?; + let entries = s.find_any_in_n2_n3(&all_needles)?; if let Some((found_id, _, _)) = entries.first() { drop(s); let address = Self::resolve_address_unlocked(&ctx.storage, &ctx.cm, &ctx.endpoint, found_id).await.ok().flatten(); @@ -7868,10 +7920,6 @@ impl ConnectionActor { let cm = self.cm.lock().await; let _ = reply.send(cm.connection_count()); } - ConnCommand::MeshCount { reply } => { - let cm = self.cm.lock().await; - let _ = reply.send(cm.mesh_count()); - } ConnCommand::ConnectedPeers { reply } => { let cm = self.cm.lock().await; let _ = reply.send(cm.connected_peers()); @@ -7892,7 +7940,7 @@ impl ConnectionActor { ConnCommand::GetConnectionMap { reply } => { let cm = self.cm.lock().await; let map: Vec<_> = cm.connections_ref().iter().map(|(nid, pc)| { - (*nid, pc.connection.clone(), pc.slot, Arc::clone(&pc.last_activity)) + (*nid, pc.connection.clone(), pc.slot_kind, Arc::clone(&pc.last_activity)) }).collect(); let _ = reply.send(map); } @@ -7932,9 +7980,9 @@ impl ConnectionActor { let cm = self.cm.lock().await; let _ = reply.send(cm.session_peer_ids()); } - ConnCommand::AvailableMeshSlots { reply } => { + ConnCommand::AvailableLocalSlots { reply } => { let cm = self.cm.lock().await; - let _ = reply.send(cm.available_mesh_slots()); + let _ = reply.send(cm.available_local_slots()); } ConnCommand::IsLikelyUnreachable { peer, reply } => { let cm = self.cm.lock().await; @@ -7944,6 +7992,10 @@ impl ConnectionActor { let cm = self.cm.lock().await; let _ = reply.send(cm.endpoint().clone()); } + ConnCommand::GetSecretSeed { reply } => { + let cm = self.cm.lock().await; + let _ = reply.send(cm.secret_seed); + } ConnCommand::GetStorage { reply } => { let cm = self.cm.lock().await; let _ = reply.send(cm.storage_ref()); @@ -8017,10 +8069,10 @@ impl ConnectionActor { let r = cm.accept_connection(conn, remote_node_id, remote_addr); let _ = reply.send(r); } - ConnCommand::RegisterConnection { peer_id, conn, addrs, reply } => { + ConnCommand::RegisterConnection { peer_id, conn, addrs, slot_kind, reply } => { let mut cm = self.cm.lock().await; - let r = cm.register_connection(peer_id, conn, &addrs).await; - let _ = reply.send(r); + cm.register_connection(peer_id, conn, &addrs, slot_kind).await; + let _ = reply.send(()); } ConnCommand::DisconnectPeer { peer, reply } => { let mut cm = self.cm.lock().await; @@ -8059,6 +8111,11 @@ impl ConnectionActor { let mut cm = self.cm.lock().await; cm.nat_filtering = filtering; } + ConnCommand::AddStickyN1 { peer, duration_ms } => { + let mut cm = self.cm.lock().await; + let expiry = now_ms() + duration_ms; + cm.sticky_n1.insert(peer, expiry); + } ConnCommand::SetGrowthTx { tx } => { let mut cm = self.cm.lock().await; cm.set_growth_tx(tx); @@ -8124,83 +8181,85 @@ impl ConnectionActor { } // --- Referral management --- - ConnCommand::RecordConvectionRefusal { anchor } => { + ConnCommand::HandleAnchorRegister { payload, reply } => { let mut cm = self.cm.lock().await; - cm.record_convection_refusal(&anchor); + cm.handle_anchor_register(payload).await; + let _ = reply.send(()); } - ConnCommand::RecordConvectionSuccess { anchor } => { + ConnCommand::PickReferrals { exclude, count, reply } => { let mut cm = self.cm.lock().await; - cm.record_convection_success(&anchor); - } - ConnCommand::IsAnchorPenalized { anchor, reply } => { - let cm = self.cm.lock().await; - let _ = reply.send(cm.is_anchor_penalized(&anchor)); - } - ConnCommand::ConvectionState { reply } => { - let cm = self.cm.lock().await; - let _ = reply.send((cm.cached_anchor_density(), cm.anchor_bias(), cm.mesh_count())); - } - ConnCommand::SetConvectionTx { tx } => { - let mut cm = self.cm.lock().await; - cm.set_convection_tx(tx); - } - - // --- Uniques announce --- - ConnCommand::ProcessUniquesAnnounce { from_peer, payload, reply } => { - let ctx = { - let mut cm = self.cm.lock().await; - cm.set_peer_depth(&from_peer, payload.depth); - cm.uniques_ctx(&from_peer) - }; - // Lock released for the bulk write. - let r = if ctx.is_mesh { - ctx.apply(&from_peer, &payload).await - } else { - Ok(0) - }; + let r = cm.pick_referrals(&exclude, count); let _ = reply.send(r); } - ConnCommand::ForceAnnounce => { + ConnCommand::MarkReferralDisconnected { node_id } => { let mut cm = self.cm.lock().await; - cm.last_announce_digest = 0; + cm.mark_referral_disconnected(&node_id); } - ConnCommand::GetAnnounceData { reply } => { + ConnCommand::MarkReferralReconnected { node_id } => { let mut cm = self.cm.lock().await; - let our_node_id = *cm.our_node_id(); - let anchor_addr = cm.build_anchor_advertised_addr(); - let our_depth = cm.max_bounce; - let seq = cm.diff_seq.fetch_add(1, Ordering::Relaxed) + 1; - - // Knowledge boundary: MESH slots only. - let conns: Vec<(NodeId, iroh::endpoint::Connection, u8)> = cm - .connections_ref() - .iter() - .filter(|(_, pc)| pc.slot.is_mesh()) - .map(|(nid, pc)| (*nid, pc.connection.clone(), pc.peer_depth)) - .collect(); + cm.mark_referral_reconnected(&node_id); + } + // --- Diff/routing --- + ConnCommand::ProcessRoutingDiff { from_peer, payload, reply } => { + let cm = self.cm.lock().await; + let r = cm.process_routing_diff(&from_peer, payload).await; + let _ = reply.send(r); + } + ConnCommand::GetDiffData { reply } => { + let mut cm = self.cm.lock().await; + // Prune expired sticky N1 entries first + let now = now_ms(); + cm.sticky_n1.retain(|_, expiry| *expiry > now); + let sticky_peers: Vec = cm.sticky_n1.keys().copied().collect(); + // Compute diff snapshot let storage = cm.storage.get().await; - let payload = build_uniques_payload( - &storage, - &our_node_id, - anchor_addr.as_deref(), - seq, - our_depth, - 4, - ) - .ok(); + let current_n1: HashSet = { + let mut set = HashSet::new(); + for nid in cm.connections_ref().keys() { + set.insert(*nid); + } + if let Ok(routes) = storage.list_social_routes() { + for route in &routes { + if route.status == crate::types::SocialStatus::Online { + set.insert(route.node_id); + } + } + } + for nid in &sticky_peers { + set.insert(*nid); + } + set + }; + let current_n2: HashSet = storage.build_n2_share() + .unwrap_or_default() + .into_iter() + .collect(); drop(storage); - let digest = payload.as_ref().map(announce_digest).unwrap_or(0); - let unchanged = digest != 0 && digest == cm.last_announce_digest; - cm.last_announce_digest = digest; + let n1_added: Vec = current_n1.difference(&cm.last_n1_set).copied().collect(); + let n1_removed: Vec = cm.last_n1_set.difference(¤t_n1).copied().collect(); + let n2_added: Vec = current_n2.difference(&cm.last_n2_set).copied().collect(); + let n2_removed: Vec = cm.last_n2_set.difference(¤t_n2).copied().collect(); - let _ = reply.send(AnnounceSnapshot { - our_node_id, + cm.last_n1_set = current_n1; + cm.last_n2_set = current_n2; + + let seq = cm.diff_seq.fetch_add(1, Ordering::Relaxed); + + let conns: Vec<(NodeId, iroh::endpoint::Connection)> = cm.connections_ref() + .iter() + .map(|(nid, pc)| (*nid, pc.connection.clone())) + .collect(); + + let _ = reply.send(DiffSnapshot { + our_node_id: *cm.our_node_id(), connections: conns, - seq, - payload, - unchanged, + diff_seq: seq, + n1_added, + n1_removed, + n2_added, + n2_removed, }); } @@ -8239,7 +8298,7 @@ impl ConnectionActor { // outgoing-connect slot per peer so we don't race with // auto-reconnect / relay-introduction paths for the same // target; skip peers already mid-connect. - for (peer_id, addr, _addr_s) in pending_connects { + for (peer_id, addr, _addr_s, slot_kind) in pending_connects { let _connect_guard = { let cm = self.cm.lock().await; match cm.try_begin_connect(peer_id) { @@ -8258,7 +8317,7 @@ impl ConnectionActor { match ConnectionManager::connect_to_unlocked(&endpoint, addr).await { Ok(conn) => { let mut cm = self.cm.lock().await; - let _ = cm.register_new_connection(peer_id, conn, &addrs).await; + cm.register_new_connection(peer_id, conn, &addrs, slot_kind).await; info!(peer = hex::encode(peer_id), "Auto-connected to peer"); newly_connected.push(peer_id); } @@ -8351,7 +8410,7 @@ impl ConnectionActor { let r = Self::resolve_address_unlocked(&self.storage, &self.cm, &self.endpoint, &target).await; let _ = reply.send(r); } - ConnCommand::ContentSyncFromPeer { peer, reply } => { + ConnCommand::PullFromPeer { peer, reply } => { // Brief lock: grab connection clone + our_node_id let gather = { let cm = self.cm.lock().await; @@ -8361,7 +8420,7 @@ impl ConnectionActor { let r = match gather { Some((conn, our_node_id)) => { // All I/O outside the lock, storage accessed via hoisted Arc - ConnectionManager::content_sync_unlocked(conn, &self.storage, &peer, our_node_id).await + ConnectionManager::pull_from_peer_unlocked(conn, &self.storage, &peer, our_node_id).await } None => Err(anyhow::anyhow!("not connected to {}", hex::encode(peer))), }; @@ -8410,293 +8469,8 @@ impl ConnectionActor { } } -/// The slot-allocation rule, isolated so it is testable without an endpoint. -/// -/// Mesh first, then the temporary referral band that sits strictly ABOVE the -/// mesh cap, then refuse. Nothing here can evict anything: an established mesh -/// peer is never displaced by an arriving stranger. -pub fn decide_slot( - mesh_count: usize, - mesh_slots: usize, - temp_count: usize, - temp_slots: usize, - now: u64, -) -> Option { - if mesh_count < mesh_slots { - Some(MeshSlot::Mesh) - } else if temp_count < temp_slots { - Some(MeshSlot::TempReferral { expires_at_ms: now + TEMP_REFERRAL_TTL_MS }) - } else { - None - } -} - -// ---- Uniques announce: build / apply (design.html §layers) ---- - -/// Content hash of an announce, ignoring `seq`. Lets the broadcaster skip a -/// send when the pools have not changed. -pub fn announce_digest(p: &UniquesAnnouncePayload) -> u64 { - use std::hash::{Hash, Hasher}; - let mut h = std::collections::hash_map::DefaultHasher::new(); - for slice in &p.fwd { - slice.nodes.hash(&mut h); - slice.authors.hash(&mut h); - for a in &slice.anchors { - a.i.hash(&mut h); - a.a.hash(&mut h); - } - } - p.term.nodes.hash(&mut h); - p.term.authors.hash(&mut h); - for a in &p.term.anchors { - a.i.hash(&mut h); - a.a.hash(&mut h); - } - let d = h.finish(); - if d == 0 { 1 } else { d } -} - -/// Serialise a slice of the pool for the wire. -/// -/// Node-class and author-class IDs go into separate packed blobs; anchors go -/// into the only address-bearing array. A receiver therefore cannot mistake a -/// persona ID for a connectable node ID, and cannot find an address on a -/// non-anchor entry — both invariants are structural, not conventional. -pub fn entries_to_slice(entries: &[ReachEntry]) -> UniquesSlice { - let mut nodes: Vec = Vec::new(); - let mut authors: Vec = Vec::new(); - let mut anchors: Vec = Vec::new(); - for e in entries { - // `id_class == Node` is required STRUCTURALLY, not incidentally: the - // class and the anchor flag are stored in independent columns, so an - // author-class row carrying `is_anchor = 1` would otherwise serialise - // as an address-bearing entry — a persona ID published next to a device - // address, the exact linkage §21 forbids. An author-class entry falls - // through to the bare `authors` array regardless of its flag. - if e.id_class == IdClass::Node && e.is_anchor && !e.addresses.is_empty() { - // bugs-fixed #8: only publicly-routable addresses go on the wire. - let addrs: Vec = e - .addresses - .iter() - .filter(|a| { - a.parse::() - .map(|s| crate::network::is_publicly_routable(&s)) - .unwrap_or(false) - }) - .cloned() - .collect(); - if !addrs.is_empty() { - anchors.push(AnchorEntry { i: hex::encode(e.id), a: addrs }); - continue; - } - // No routable address left — degrade to a bare node entry. - nodes.push(e.id); - continue; - } - match e.id_class { - IdClass::Node => nodes.push(e.id), - IdClass::Author => authors.push(e.id), - } - } - UniquesSlice { nodes: pack_ids(&nodes), authors: pack_ids(&authors), anchors } -} - -/// Deserialise a wire slice back into entries, truncated to `cap`. -/// -/// RECEIVE-SIDE ADDRESS FILTERING is mandatory here and was previously absent, -/// leaving the ingest asymmetric with `entries_to_slice`: a peer could hand us -/// `127.0.0.1:22`, `10.0.0.1:4433` or `192.168.1.1:443`, we would store it and -/// later dial it. That is attacker-chosen internal QUIC probing from the -/// victim's own host, and — per the v0.7.3 EDM lesson — every such `connect()` -/// permanently enters iroh's per-endpoint path store and is probed in the -/// background forever. An anchor entry left with no usable address degrades to -/// a bare node entry rather than being stored address-free-but-anchor-flagged. -pub fn slice_to_entries_capped(slice: &UniquesSlice, cap: usize) -> Vec { - let mut out = Vec::new(); - for id in unpack_ids(&slice.nodes) { - if out.len() >= cap { - return out; - } - out.push(ReachEntry::node(id)); - } - for id in unpack_ids(&slice.authors) { - if out.len() >= cap { - return out; - } - out.push(ReachEntry::author(id)); - } - for a in &slice.anchors { - if out.len() >= cap { - return out; - } - if let Ok(id) = crate::parse_node_id_hex(&a.i) { - // bugs-fixed #4: normalise IPv4-mapped IPv6 before storing. - // bugs-fixed #8 (receive side): routable addresses only. - let addrs: Vec = a - .a - .iter() - .filter_map(|s| s.parse::().ok()) - .map(normalize_addr) - .filter(convection_addr_ok) - .map(|s| s.to_string()) - .collect(); - if addrs.is_empty() { - out.push(ReachEntry::node(id)); - } else { - out.push(ReachEntry::anchor(id, addrs)); - } - } - } - out -} - -/// Uncapped convenience wrapper (tests and the local build path). -pub fn slice_to_entries(slice: &UniquesSlice) -> Vec { - slice_to_entries_capped(slice, usize::MAX) -} - -/// Build our announce payload from storage. -pub fn build_uniques_payload( - storage: &crate::storage::Storage, - our_node_id: &NodeId, - our_anchor_addr: Option<&str>, - seq: u64, - our_depth: u8, - peer_depth: u8, -) -> anyhow::Result { - let pools: UniquesPools = storage.build_uniques_pools(our_node_id, our_anchor_addr)?; - let fwd: Vec = pools.fwd.iter().map(|e| entries_to_slice(e)).collect(); - // A peer that only retains 3 bounces has nowhere to put the terminal pool — - // skip building it for them. Biggest single saving on a cellular link. - let term = if peer_depth >= 4 { - entries_to_slice(&pools.term) - } else { - UniquesSlice::default() - }; - Ok(UniquesAnnouncePayload { seq, full: true, fwd, term, depth: our_depth }) -} - -/// Apply a received announce, shifting pool 1 one bounce deeper and landing -/// pool 2 at the terminal bounce 4. -pub fn apply_uniques_to_storage( - storage: &crate::storage::Storage, - our_node_id: &NodeId, - reporter: &NodeId, - payload: &UniquesAnnouncePayload, - our_max_bounce: u8, - is_connected: impl Fn(&NodeId) -> bool, -) -> anyhow::Result { - // Every reader of `fwd` assumes exactly 3 slices (N0, N1, N2). Nothing - // enforced it, so a peer sending 6 slices wrote rows at bounce 5 and 6 — - // depths the rest of the system has no semantics for (invisible to - // `list_reach_entries_at(2)/(3)` and to `resolve_address`'s 2..=4 cascade, - // but very visible to `find_any_reachable`, `score_n2_candidates_batch`, - // `anchor_density` and `list_pool_anchors`). - if payload.fwd.len() != 3 { - anyhow::bail!("uniques announce: fwd must carry exactly 3 slices, got {}", payload.fwd.len()); - } - - let mut stored = 0usize; - - // fwd[0] is the reporter itself. Nothing to store at bounce 1 (they are a - // direct by definition), but this is where their own anchor address rides. - // This is the ONE first-party claim in the payload — the sender is on the - // other end of the QUIC connection we are reading — so it is the only one - // allowed to touch `peers`, and even then by UNION, never by replacement. - if let Some(self_slice) = payload.fwd.first() { - for e in slice_to_entries_capped(self_slice, 8) { - if e.is_anchor && e.id == *reporter { - persist_direct_anchor_claim(storage, &e); - } - } - } - - // fwd[i] → bounce i + 1, for i >= 1 (so bounce 2 and 3). - for (i, slice) in payload.fwd.iter().enumerate().skip(1) { - let bounce = (i + 1) as u8; - if bounce > our_max_bounce || bounce > 4 { - continue; - } - let cap = crate::storage::uniques_accept_cap(bounce); - let raw = slice_to_entries_capped(slice, cap); - let truncated = raw.len() >= cap; - let entries: Vec = raw - .into_iter() - .filter(|e| { - e.id != *our_node_id - // A peer we are already meshed with is not a growth - // candidate; keeping it out saves candidate-slot churn. - && !(bounce == 2 && e.id_class == IdClass::Node && is_connected(&e.id)) - }) - .collect(); - if truncated { - warn!( - reporter = hex::encode(reporter), - bounce, cap, "Uniques announce truncated to the §layers budget" - ); - } - stored += entries.len(); - storage.set_reach(reporter, bounce, &entries)?; - } - - // term → bounce 4. Dropped entirely when we only retain 3 bounces. - // - // Third-party anchor entries at ANY depth stay in `reachable` only. They - // are an INDEX ("someone says this ID is an anchor at this address"), not - // an address record: nothing has proven the address belongs to the node ID. - // Mirroring them into `peers`/`known_anchors` made a single hostile mesh - // peer able to (a) overwrite our stored address for any node — including - // the DNS-resolved bootstrap anchor (bugs-fixed #5, remotely triggerable), - // (b) get thousands of entries pointing at one victim IP dialled by us and - // by everyone we re-announce to, and (c) re-inflate `known_anchors` from - // gossip, undoing its round-4 demotion to a bootstrap cache. - // `list_pool_anchors` mines `reachable` directly, so the anchor directory - // is unaffected. - if our_max_bounce >= 4 { - let cap = crate::storage::uniques_accept_cap(4); - let raw = slice_to_entries_capped(&payload.term, cap); - if raw.len() >= cap { - warn!( - reporter = hex::encode(reporter), - bounce = 4, cap, "Uniques announce truncated to the §layers budget" - ); - } - let entries: Vec = - raw.into_iter().filter(|e| e.id != *our_node_id).collect(); - stored += entries.len(); - storage.set_reach(reporter, 4, &entries)?; - } - - let _ = storage.update_mesh_peer_seq(reporter, payload.seq); - Ok(stored) -} - -/// The reporter's claim about ITS OWN anchor address (payload `fwd[0]`). -/// -/// First-party and from a peer we are connected to right now — the same trust -/// class as `InitialExchangePayload.anchor_addr`. It may therefore mark the -/// peer as an anchor and add the address, but it may NOT replace addresses we -/// already have (one of which is the address we actually connected on), and it -/// does NOT seed `known_anchors`: that cache is now reserved for anchors we -/// have completed a handshake to, which is what makes its `last_seen_ms` -/// ordering meaningful again. -fn persist_direct_anchor_claim(storage: &crate::storage::Storage, e: &ReachEntry) { - let socks: Vec = e - .addresses - .iter() - .filter_map(|a| a.parse::().ok()) - .map(normalize_addr) - .filter(convection_addr_ok) - .collect(); - if socks.is_empty() { - return; - } - let _ = storage.add_peer_addresses(&e.id, &socks); - let _ = storage.set_peer_anchor(&e.id, true); -} - /// Standalone initial exchange (connector side) — does NOT require conn_mgr lock. -/// Opens a bi-stream, sends our uniques announce/profile/deletes/post_ids, reads theirs. +/// Opens a bi-stream, sends our N1/N2/profile/deletes/post_ids, reads theirs. pub async fn initial_exchange_connect( storage: &Arc, our_node_id: &NodeId, @@ -8708,42 +8482,20 @@ pub async fn initial_exchange_connect( our_http_addr: Option, our_device_role: Option, our_cache_pressure: Option, - our_depth: u8, - carry_knowledge: bool, ) -> anyhow::Result { let our_payload = { let storage = storage.get().await; - // A temporary referral slot carries NO uniques exchange — explicit - // `None` on the wire rather than empty vectors, so the peer can tell - // "nothing to share" from "not sharing". - let uniques = if carry_knowledge { - Some(build_uniques_payload( - &storage, - our_node_id, - anchor_addr.as_deref(), - 0, - our_depth, - DEFAULT_PEER_DEPTH, - )?) - } else { - None - }; + let n1 = storage.build_n1_share()?; + let n2 = storage.build_n2_share()?; // Profile keyed by network id; strip persona display before send. let profile = storage.get_profile(our_node_id)? .map(|p| p.sanitized_for_network_broadcast()); let deletes = storage.list_delete_records()?; let post_ids = storage.list_post_ids()?; - // Knowledge boundary: a temporary referral slot carries NO knowledge - // exchange (design.html §connections). Peer addresses ARE knowledge — - // up to 10 of our mesh peers with their addresses — so they are gated - // on the same flag as the uniques pools, not sent unconditionally. - let peer_addresses = if carry_knowledge { - storage.build_peer_addresses_for(our_node_id)? - } else { - Vec::new() - }; + let peer_addresses = storage.build_peer_addresses_for(our_node_id)?; InitialExchangePayload { - uniques, + n1_node_ids: n1, + n2_node_ids: n2, profile, deletes, post_ids, @@ -8780,7 +8532,7 @@ pub async fn initial_exchange_connect( if dup { tracing::warn!(peer = hex::encode(remote_node_id), "Anchor reports duplicate identity active on network"); } - process_exchange_payload(storage, our_node_id, &remote_node_id, &their_payload, our_depth, carry_knowledge).await?; + process_exchange_payload(storage, our_node_id, &remote_node_id, &their_payload).await?; Ok(ExchangeResult::Accepted { duplicate_active: dup }) }; @@ -8809,44 +8561,22 @@ pub async fn initial_exchange_accept( our_device_role: Option, our_cache_pressure: Option, duplicate_detected: bool, - our_depth: u8, - carry_knowledge: bool, ) -> anyhow::Result<()> { let their_payload: InitialExchangePayload = read_payload(&mut recv, MAX_PAYLOAD).await?; let our_payload = { let storage = storage.get().await; - // Temporary referral slot => no uniques in either direction. - // Their advertised depth lets us skip building the terminal pool. - let peer_depth = their_payload.uniques.as_ref().map(|u| u.depth).unwrap_or(DEFAULT_PEER_DEPTH); - let uniques = if carry_knowledge { - Some(build_uniques_payload( - &storage, - our_node_id, - anchor_addr.as_deref(), - 0, - our_depth, - peer_depth, - )?) - } else { - None - }; + let n1 = storage.build_n1_share()?; + let n2 = storage.build_n2_share()?; // Profile keyed by network id; strip persona display before send. let profile = storage.get_profile(our_node_id)? .map(|p| p.sanitized_for_network_broadcast()); let deletes = storage.list_delete_records()?; let post_ids = storage.list_post_ids()?; - // Knowledge boundary: a temporary referral slot carries NO knowledge - // exchange (design.html §connections). Peer addresses ARE knowledge — - // up to 10 of our mesh peers with their addresses — so they are gated - // on the same flag as the uniques pools, not sent unconditionally. - let peer_addresses = if carry_knowledge { - storage.build_peer_addresses_for(our_node_id)? - } else { - Vec::new() - }; + let peer_addresses = storage.build_peer_addresses_for(our_node_id)?; InitialExchangePayload { - uniques, + n1_node_ids: n1, + n2_node_ids: n2, profile, deletes, post_ids, @@ -8871,7 +8601,7 @@ pub async fn initial_exchange_accept( write_typed_message(&mut send, MessageType::InitialExchange, &our_payload).await?; send.finish()?; - process_exchange_payload(storage, our_node_id, &remote_node_id, &their_payload, our_depth, carry_knowledge).await?; + process_exchange_payload(storage, our_node_id, &remote_node_id, &their_payload).await?; Ok(()) } @@ -8881,34 +8611,23 @@ async fn process_exchange_payload( our_node_id: &NodeId, remote_node_id: &NodeId, payload: &InitialExchangePayload, - our_max_bounce: u8, - store_knowledge: bool, ) -> anyhow::Result<()> { let storage = storage.get().await; - // Uniques announce: shift pool 1 one bounce deeper, land pool 2 at N4. - // `uniques == None` means the sender put us in a temporary referral slot; - // `store_knowledge == false` means WE put them in one. Either way the - // connection carries no knowledge, in either direction. - match (&payload.uniques, store_knowledge) { - (Some(announce), true) => { - let n = apply_uniques_to_storage( - &storage, - our_node_id, - remote_node_id, - announce, - our_max_bounce, - |_| false, - )?; - debug!(peer = hex::encode(remote_node_id), stored = n, "Applied uniques announce"); - } - _ => { - debug!( - peer = hex::encode(remote_node_id), - "No uniques exchanged (temporary referral slot)" - ); - } - } + // Filter out our own ID from their N1 before storing as our N2 + let filtered_n1: Vec = payload.n1_node_ids.iter() + .filter(|nid| *nid != our_node_id) + .copied() + .collect(); + storage.set_peer_n1(remote_node_id, &filtered_n1)?; + debug!(peer = hex::encode(remote_node_id), raw = payload.n1_node_ids.len(), stored = filtered_n1.len(), "Stored peer N1 as our N2 (filtered self)"); + + let filtered_n2: Vec = payload.n2_node_ids.iter() + .filter(|nid| *nid != our_node_id) + .copied() + .collect(); + storage.set_peer_n2(remote_node_id, &filtered_n2)?; + debug!(peer = hex::encode(remote_node_id), raw = payload.n2_node_ids.len(), stored = filtered_n2.len(), "Stored peer N2 as our N3 (filtered self)"); if let Some(ref profile) = payload.profile { let _ = storage.store_profile(profile); @@ -8921,20 +8640,11 @@ async fn process_exchange_payload( } } - // Third-party address hearsay: filter to routable (bugs-fixed #8, receive - // side) and MERGE rather than replace (bugs-fixed #5 — a peer must not be - // able to overwrite the address we are successfully using for a node). for pa in &payload.peer_addresses { if let Ok(nid) = crate::parse_node_id_hex(&pa.n) { - let addrs: Vec = pa - .a - .iter() - .filter_map(|a| a.parse::().ok()) - .map(normalize_addr) - .filter(convection_addr_ok) - .collect(); + let addrs: Vec = pa.a.iter().filter_map(|a| a.parse().ok()).collect(); if !addrs.is_empty() { - let _ = storage.add_peer_addresses(&nid, &addrs); + let _ = storage.upsert_peer(&nid, &addrs, None); } } } @@ -8946,17 +8656,12 @@ async fn process_exchange_payload( } } - // Process anchor's advertised address. First-party (we are connected to - // them) but still self-declared: UNION it in, never replace the address we - // dialled. `known_anchors` is written on a proven connect, not here. + // Process anchor's advertised address if let Some(ref anchor_addr_str) = payload.anchor_addr { if let Ok(sock) = anchor_addr_str.parse::() { - let sock = normalize_addr(sock); - if convection_addr_ok(&sock) { - let _ = storage.add_peer_addresses(remote_node_id, &[sock]); - let _ = storage.set_peer_anchor(remote_node_id, true); - info!(peer = hex::encode(remote_node_id), addr = %sock, "Stored anchor's advertised address"); - } + let _ = storage.upsert_known_anchor(remote_node_id, &[sock]); + let _ = storage.upsert_peer(remote_node_id, &[sock], None); + info!(peer = hex::encode(remote_node_id), addr = %sock, "Stored anchor's advertised address"); } } @@ -9016,702 +8721,6 @@ fn now_ms() -> u64 { .as_millis() as u64 } -// --- v0.8 (A3): the single comment accept gate --- -// -// EVERY comment ingest path — the diff handler AND both pull-side header -// merges — must go through `accept_incoming_comment`. Any cap enforced -// only in the diff handler is void: an attacker just serves the same -// comment through a header pull instead. (Historically the two pull -// merges stored comments with NO verification at all.) - -/// Per-bio unexpired greeting cap (holder-side flood limit; round 8: -/// rate caps + size buckets ARE the flood limits, no PoW anywhere). -pub const MAX_GREETINGS_PER_BIO: u64 = 64; - -/// Max accepted TTL: a comment may not claim an expiry more than 366 -/// days out (ordinary TTLs top out at 365d). -pub const MAX_COMMENT_TTL_MS: u64 = 366 * 24 * 3600 * 1000; - -/// Generous ceiling on a sealed-greeting `encrypted_payload` relative to -/// the declared plaintext bucket: sealed blob (32B eph + 4B len + bucket -/// + 16B tag) → base64 (×4/3) → JSON payload wrapper → outer AEAD. -fn greeting_max_payload_bytes(body_bucket: u16) -> usize { - (body_bucket as usize) * 2 + 512 -} - -/// The single accept/drop decision for an incoming comment, shared by -/// all three ingest sites. `parent_post` is the locally-held parent (if -/// any); `policy` + `followers_set` are the parent's engagement policy -/// inputs. Returns `true` = store, `false` = drop without forwarding. -/// -/// Checks, in order (spec A3 §T9): -/// 1. identity signature (digest v2, expiry included); -/// 2. TTL sanity: v0.8 REQUIRES a TTL — `expires_at_ms == 0` rejected, -/// already-expired rejected (stateless re-ingest guard, the -/// comment-analog of `deleted_posts`), >366d rejected; -/// 3. policy (blocklist / None / FollowersOnly) + FoF four-check gated -/// on `post.fof_gating.is_some()` — NOT the `CommentPolicy` enum -/// (closing the pre-existing dead-gate gap: nothing ever set the -/// FriendsOfFriends policy, so the enum arm was unreachable); -/// 4. open-slot size buckets + per-bio greeting cap; -/// 5. registry-post entry validation + newest-wins upsert. -pub fn accept_incoming_comment( - storage: &crate::storage::Storage, - parent_post: Option<&crate::types::Post>, - policy: &crate::types::CommentPolicy, - followers_set: &HashSet, - comment: &crate::types::InlineComment, - now: u64, -) -> bool { - // 0. Tombstone injection guard: the identity signature does NOT - // cover `deleted_at`, so an attacker could take any valid signed - // comment off the chain, set `deleted_at`, and re-send it — an - // unsigned effective delete (store_comment's upsert propagates the - // field). Tombstones may ONLY travel on the signed DeleteComment op. - if comment.deleted_at.is_some() { - return false; - } - - // 1. Identity signature — v0.8: ALWAYS verified (empty = forged). - if !crate::crypto::verify_inline_comment_signature(comment) { - return false; - } - - // 2. TTL sanity. - if comment.expires_at_ms == 0 { - return false; // v0.8 requires a TTL - } - if comment.expires_at_ms <= now { - return false; // already expired — never (re-)ingest - } - if comment.expires_at_ms > now.saturating_add(MAX_COMMENT_TTL_MS) { - return false; // TTL beyond the allowed window - } - // Future-dated timestamps: allow only small clock skew. Without this - // a registration ~336d in the future permanently wins newest-wins - // against honest renewals and pins itself atop timestamp-sorted - // search results. - if comment.timestamp_ms > now.saturating_add(crate::registry::REGISTRATION_TTL_SLACK_MS) { - return false; - } - - // 3a. Ambient policy checks. - if policy.blocklist.contains(&comment.author) { - return false; - } - match policy.allow_comments { - crate::types::CommentPermission::None => return false, - crate::types::CommentPermission::FollowersOnly => { - if !followers_set.contains(&comment.author) { - return false; - } - } - crate::types::CommentPermission::Public - | crate::types::CommentPermission::FriendsOfFriends => {} - } - - // 3b. FoF four-check, gated on the POST carrying gating (not the - // policy enum). Comments claiming a slot (`pub_x_index`) on a post - // we don't hold can't be verified — drop them. - let gating = match parent_post { - Some(p) => p.fof_gating.as_ref(), - None => { - if comment.pub_x_index.is_some() { - return false; - } - None - } - }; - if let Some(gating) = gating { - // Gated post: every comment must sign under an admitted slot. - if !crate::fof::verify_fof_group_sig(comment, gating) { - return false; - } - // Revocation check: publish-time snapshot + live local table. - if let Some(idx) = comment.pub_x_index { - if let Some(pub_x) = gating.pub_post_set.get(idx as usize).copied() { - if gating.revocation_list.iter().any(|r| r.revoked_pub_x == pub_x) { - return false; - } - if storage - .is_fof_pub_x_revoked(&comment.post_id, &pub_x) - .unwrap_or(false) - { - return false; - } - } - } - - // 4. Open-slot size buckets + rate caps. - if let Some(decl) = gating.open_slot.as_ref() { - if comment.pub_x_index == Some(decl.slot_index) { - match decl.kind { - crate::types::OpenSlotKind::Greeting => { - // Superseded-bio guard (belt-and-suspenders for - // the consent off-switch): greetings are only - // accepted on the author's CURRENT bio post. Old - // bios never expire, and each would otherwise - // contribute its own greeting slot + 64-cap even - // after the author republished without a slot. - if let Some(parent) = parent_post { - if let Ok(Some(latest)) = - storage.get_latest_profile_post_id_by_author(&parent.author) - { - if latest != comment.post_id { - return false; - } - } - } - let Some(payload) = comment.encrypted_payload.as_ref() else { - return false; - }; - if payload.len() > greeting_max_payload_bytes(decl.body_bucket) { - return false; - } - if !comment.content.is_empty() { - return false; // greeting bodies are sealed-only - } - let live = storage - .count_unexpired_open_slot_comments(&comment.post_id, decl.slot_index) - .unwrap_or(u64::MAX); - if live >= MAX_GREETINGS_PER_BIO { - return false; - } - } - crate::types::OpenSlotKind::Registry => { - if comment.content.len() > decl.body_bucket as usize { - return false; - } - } - } - } - } - } - - // 5. Registry-post entry validation + newest-wins. - if comment.post_id == crate::registry::REGISTRY_POST_ID { - if crate::registry::parse_registration(&comment.content).is_err() { - return false; - } - if !crate::registry::registration_ttl_ok(comment.timestamp_ms, comment.expires_at_ms) { - return false; - } - // Newest-wins per persona: READ-ONLY check here — drop the - // incoming comment when a newer entry is already stored. The - // destructive half (hard-deleting the older row) happens in the - // storing callers AFTER a successful store_comment, so a store - // failure can never leave the persona with no entry at all. - match storage.registry_entry_is_newest( - &comment.post_id, - &comment.author, - comment.timestamp_ms, - ) { - Ok(true) => {} - _ => return false, - } - } - - true -} - -/// v0.8 (A3): DeleteComment acceptance — a self-certifying signature by -/// the comment author (verifiable by holders that never met the persona) -/// is REQUIRED. Empty/invalid signature = drop. There is deliberately no -/// sender-identity override: senders authenticate as NETWORK ids while -/// comment/post authors are POSTING ids, so an identity match can only -/// ever be forged (via attacker-controlled payload fields), never -/// legitimate. Post-author moderation will return as a SIGNED override. -pub fn delete_comment_authorized( - author: &NodeId, - post_id: &crate::types::PostId, - timestamp_ms: u64, - signature: &[u8], -) -> bool { - !signature.is_empty() - && crate::crypto::verify_comment_delete(author, post_id, timestamp_ms, signature) -} - -/// Pull-path merge: run every comment in a fetched `BlobHeader` through -/// the accept gate and store the survivors. Called by BOTH pull-side -/// header merges (`fetch_engagement_unlocked` and -/// `fetch_engagement_from_peer`) — the two historic zero-verification -/// bypass sites. Returns the number of comments stored. -pub fn ingest_header_comments( - storage: &crate::storage::Storage, - header: &crate::types::BlobHeader, - now: u64, -) -> usize { - let parent = storage.get_post(&header.post_id).ok().flatten(); - let policy = storage - .get_comment_policy(&header.post_id) - .ok() - .flatten() - .unwrap_or_default(); - let followers: HashSet = storage - .list_public_follows() - .unwrap_or_default() - .into_iter() - .collect(); - let mut stored = 0usize; - for comment in &header.comments { - if accept_incoming_comment(storage, parent.as_ref(), &policy, &followers, comment, now) { - if storage.store_comment(comment).is_ok() { - stored += 1; - // Registry newest-wins pruning only after a SUCCESSFUL - // store (the accept gate is side-effect free). - if comment.post_id == crate::registry::REGISTRY_POST_ID { - let _ = storage.prune_older_registry_entries( - &comment.post_id, - &comment.author, - comment.timestamp_ms, - ); - } - } - } - } - // Re-derive the stored header from vetted DB state so we never - // re-serve peer-supplied comments the gate rejected (callers store - // the peer's raw header JSON before ingesting). - let _ = storage.rebuild_blob_header_from_db(&header.post_id, &header.author, now); - stored -} - -#[cfg(test)] -mod accept_gate_tests { - //! v0.8 (A3) T9: the accept-gate matrix, exercised BOTH through the - //! gate function directly (the diff-path decision) AND through - //! `ingest_header_comments` (the pull-path merge — the two historic - //! zero-verification bypass sites share this exact code). - - use super::{accept_incoming_comment, delete_comment_authorized, ingest_header_comments, - MAX_COMMENT_TTL_MS, MAX_GREETINGS_PER_BIO}; - use crate::storage::Storage; - use crate::types::{ - BlobHeader, CommentPolicy, InlineComment, NodeId, OpenSlotKind, Post, PostId, - PostVisibility, PostingIdentity, VisibilityIntent, - }; - use ed25519_dalek::SigningKey; - use rand::RngCore; - use std::collections::HashSet; - - fn temp_storage() -> Storage { - Storage::open(":memory:").unwrap() - } - - fn make_persona(seed_byte: u8) -> (NodeId, [u8; 32]) { - let mut seed = [0u8; 32]; - seed[0] = seed_byte; - let sk = SigningKey::from_bytes(&seed); - (*sk.verifying_key().as_bytes(), seed) - } - - fn now() -> u64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_millis() as u64 - } - - /// Storage + a stored bio post with a Greeting open slot, owned by - /// a persona with a V_me. Returns (storage, bio_post_id, bio_post). - fn setup_greeting_bio(seed_byte: u8) -> (Storage, PostId, Post) { - let s = temp_storage(); - let (alice_id, alice_seed) = make_persona(seed_byte); - s.upsert_posting_identity(&PostingIdentity { - node_id: alice_id, - secret_seed: alice_seed, - display_name: "Alice".into(), - created_at: 1000, - }).unwrap(); - let mut v_me = [0u8; 32]; - rand::rng().fill_bytes(&mut v_me); - s.insert_own_vouch_key(&alice_id, 1, &v_me, 1000).unwrap(); - - let built = crate::fof::build_fof_comment_gating( - &s, &alice_id, Some((OpenSlotKind::Greeting, 1024)), - ).unwrap().expect("gating built"); - let post = Post { - author: alice_id, - content: String::new(), - attachments: vec![], - timestamp_ms: 3000, - fof_gating: Some(built.gating), - supersedes_post_id: None, - }; - let post_id = crate::content::compute_post_id(&post); - s.store_post_with_intent( - &post_id, &post, &PostVisibility::Public, &VisibilityIntent::Profile, - ).unwrap(); - (s, post_id, post) - } - - /// A valid sealed greeting comment from a fresh throwaway identity. - fn make_greeting(post: &Post, post_id: &PostId, seed_byte: u8, ts: u64, expires: u64) -> InlineComment { - use base64::Engine; - let (stranger_id, stranger_seed) = make_persona(seed_byte); - let unlock = crate::fof::derive_open_slot_unlock(post, &stranger_id).expect("unlock"); - let decl = post.fof_gating.as_ref().unwrap().open_slot.as_ref().unwrap(); - let recipient = crate::crypto::ed25519_pubkey_to_x25519_public(&post.author).unwrap(); - let sealed = crate::crypto::seal_greeting_body( - &recipient, post_id, b"{\"v\":1}", decl.body_bucket as usize, - ).unwrap(); - let sealed_b64 = base64::engine::general_purpose::STANDARD.encode(&sealed); - crate::fof::build_fof_comment( - post_id, - &unlock, - &post.fof_gating.as_ref().unwrap().slot_binder_nonce, - &stranger_id, - &stranger_seed, - &sealed_b64, - None, - ts, - expires, - ).unwrap() - } - - fn gate(s: &Storage, post: Option<&Post>, c: &InlineComment) -> bool { - accept_incoming_comment(s, post, &CommentPolicy::default(), &HashSet::new(), c, now()) - } - - /// Pull-path variant: wrap the comment in a BlobHeader and run the - /// shared header merge; returns whether the comment got stored. - fn gate_via_pull(s: &Storage, post_id: &PostId, author: &NodeId, c: &InlineComment) -> bool { - let header = BlobHeader { - post_id: *post_id, - author: *author, - reactions: vec![], - comments: vec![c.clone()], - policy: CommentPolicy::default(), - updated_at: now(), - thread_splits: vec![], - receipt_slots: vec![], - comment_slots: vec![], - prior_author: None, - }; - let before = s.get_comments_with_tombstones(post_id).unwrap().len(); - let stored = ingest_header_comments(s, &header, now()); - let after = s.get_comments_with_tombstones(post_id).unwrap().len(); - assert_eq!(stored > 0, after > before, "store count consistent"); - stored > 0 - } - - #[test] - fn good_greeting_accepted_both_paths() { - let (s, post_id, post) = setup_greeting_bio(10); - let c = make_greeting(&post, &post_id, 50, now(), now() + 40 * 24 * 3600 * 1000); - assert!(gate(&s, Some(&post), &c), "diff path accepts a valid greeting"); - assert!(gate_via_pull(&s, &post_id, &post.author, &c), "pull path accepts too"); - } - - #[test] - fn expired_and_ttl_violations_rejected_both_paths() { - let (s, post_id, post) = setup_greeting_bio(11); - let t = now(); - - // Already expired. - let c = make_greeting(&post, &post_id, 51, t - 1000, t - 1); - assert!(!gate(&s, Some(&post), &c)); - assert!(!gate_via_pull(&s, &post_id, &post.author, &c)); - - // Zero TTL (v0.8 requires one). - let c = make_greeting(&post, &post_id, 52, t, 0); - assert!(!gate(&s, Some(&post), &c)); - assert!(!gate_via_pull(&s, &post_id, &post.author, &c)); - - // TTL beyond the 366-day window. - let c = make_greeting(&post, &post_id, 53, t, t + MAX_COMMENT_TTL_MS + 60_000); - assert!(!gate(&s, Some(&post), &c)); - assert!(!gate_via_pull(&s, &post_id, &post.author, &c)); - } - - #[test] - fn tampered_expiry_fails_signature() { - let (s, post_id, post) = setup_greeting_bio(12); - let t = now(); - let mut c = make_greeting(&post, &post_id, 54, t, t + 40 * 24 * 3600 * 1000); - // A holder tries to silently extend the TTL → digest v2 catches it. - c.expires_at_ms += 1; - assert!(!gate(&s, Some(&post), &c)); - assert!(!gate_via_pull(&s, &post_id, &post.author, &c)); - } - - #[test] - fn oversized_greeting_rejected() { - let (s, post_id, post) = setup_greeting_bio(13); - let t = now(); - let mut c = make_greeting(&post, &post_id, 55, t, t + 40 * 24 * 3600 * 1000); - // Inflate the payload past the bucket ceiling. (Signature stays - // valid — the outer identity sig doesn't cover encrypted_payload; - // the size cap must therefore hold on its own.) group_sig breaks - // too, but check the size arm by re-signing group material is - // unnecessary: EITHER rejection means drop. - c.encrypted_payload = Some(vec![0u8; 1024 * 2 + 513]); - assert!(!gate(&s, Some(&post), &c)); - assert!(!gate_via_pull(&s, &post_id, &post.author, &c)); - } - - #[test] - fn greeting_cap_enforced() { - let (s, post_id, post) = setup_greeting_bio(14); - let t = now(); - // Fill to the cap with valid greetings from distinct throwaways. - for i in 0..MAX_GREETINGS_PER_BIO { - let c = make_greeting( - &post, &post_id, 60 + (i % 180) as u8, - t + i, t + 40 * 24 * 3600 * 1000, - ); - // distinct (author, ts) needed for distinct rows; seed_byte - // wraps but ts differs so keys are unique per row. - s.store_comment(&c).unwrap(); - } - assert_eq!( - s.count_unexpired_open_slot_comments( - &post_id, - post.fof_gating.as_ref().unwrap().open_slot.as_ref().unwrap().slot_index, - ).unwrap(), - MAX_GREETINGS_PER_BIO, - ); - // One more greeting → over cap → dropped on both paths. (ts kept - // within the future-skew allowance so the cap arm is what fires.) - let c = make_greeting(&post, &post_id, 55, t + 60_000, t + 40 * 24 * 3600 * 1000); - assert!(!gate(&s, Some(&post), &c), "cap enforced on diff path"); - assert!(!gate_via_pull(&s, &post_id, &post.author, &c), "cap enforced on pull path"); - } - - #[test] - fn revoked_open_slot_pub_x_rejected() { - let (s, post_id, post) = setup_greeting_bio(15); - let t = now(); - let c = make_greeting(&post, &post_id, 56, t, t + 40 * 24 * 3600 * 1000); - assert!(gate(&s, Some(&post), &c), "accepted pre-revocation"); - - // Author revokes the open slot's pub_x — the global off-switch. - let decl_idx = post.fof_gating.as_ref().unwrap().open_slot.as_ref().unwrap().slot_index; - let pub_x = post.fof_gating.as_ref().unwrap().pub_post_set[decl_idx as usize]; - s.add_fof_revocation(&post_id, &pub_x, t, 0, &[0u8; 64]).unwrap(); - - let c2 = make_greeting(&post, &post_id, 57, t + 1, t + 40 * 24 * 3600 * 1000); - assert!(!gate(&s, Some(&post), &c2), "revoked pub_x rejected (diff)"); - assert!(!gate_via_pull(&s, &post_id, &post.author, &c2), "revoked pub_x rejected (pull)"); - } - - #[test] - fn fof_marked_comment_without_held_parent_rejected() { - let (s, post_id, post) = setup_greeting_bio(16); - let t = now(); - let c = make_greeting(&post, &post_id, 58, t, t + 40 * 24 * 3600 * 1000); - // Parent unknown → slot claims can't be verified → drop. - assert!(!gate(&s, None, &c)); - } - - /// Registration matrix: valid entry accepted; wrong TTL rejected; - /// bad shape rejected; older-than-stored dropped — both paths. - #[test] - fn registration_matrix_both_paths() { - let s = temp_storage(); - crate::registry::materialize_registry_post(&s).unwrap(); - let registry = s.get_post(&crate::registry::REGISTRY_POST_ID).unwrap().unwrap(); - let (persona_id, persona_seed) = make_persona(70); - let t = now(); - - let build_reg = |content: &str, ts: u64, expires: u64| -> InlineComment { - let unlock = crate::fof::derive_open_slot_unlock(®istry, &persona_id).unwrap(); - let group_sig = { - use ed25519_dalek::Signer; - let signer = SigningKey::from_bytes(&unlock.priv_x_seed); - let mut to_sign = Vec::new(); - to_sign.extend_from_slice(content.as_bytes()); - to_sign.extend_from_slice(&crate::registry::REGISTRY_POST_ID); - to_sign.extend_from_slice(&unlock.slot_index.to_le_bytes()); - signer.sign(&to_sign).to_bytes().to_vec() - }; - let signature = crate::crypto::sign_comment( - &persona_seed, &persona_id, &crate::registry::REGISTRY_POST_ID, - content, ts, None, expires, - ); - InlineComment { - author: persona_id, - post_id: crate::registry::REGISTRY_POST_ID, - content: content.to_string(), - timestamp_ms: ts, - signature, - deleted_at: None, - ref_post_id: None, - pub_x_index: Some(unlock.slot_index), - group_sig: Some(group_sig), - encrypted_payload: None, - expires_at_ms: expires, - } - }; - - let good = r#"{"v":1,"name":"Alice","keywords":["rust","p2p"]}"#; - - // Wrong TTL (not fixed-30d): rejected. - let wrong_ttl = build_reg(good, t, t + crate::registry::REGISTRATION_TTL_MS / 2); - assert!(!gate(&s, Some(®istry), &wrong_ttl)); - assert!(!gate_via_pull(&s, &crate::registry::REGISTRY_POST_ID, ®istry.author, &wrong_ttl)); - - // Bad shape: rejected. - let bad_shape = build_reg( - r#"{"v":1,"name":""}"#, t, t + crate::registry::REGISTRATION_TTL_MS, - ); - assert!(!gate(&s, Some(®istry), &bad_shape)); - - // Valid: accepted via the pull path (stores it). - let valid = build_reg(good, t, t + crate::registry::REGISTRATION_TTL_MS); - assert!(gate_via_pull(&s, &crate::registry::REGISTRY_POST_ID, ®istry.author, &valid)); - - // Newer entry replaces it. (Mirror the diff-arm caller: the gate - // is side-effect free; pruning runs after a successful store.) - let newer = build_reg(good, t + 1000, t + 1000 + crate::registry::REGISTRATION_TTL_MS); - assert!(gate(&s, Some(®istry), &newer)); - s.store_comment(&newer).unwrap(); - s.prune_older_registry_entries( - &crate::registry::REGISTRY_POST_ID, &persona_id, newer.timestamp_ms, - ).unwrap(); - - // Older-than-stored: dropped on both paths (newest wins). - let older = build_reg(good, t + 500, t + 500 + crate::registry::REGISTRATION_TTL_MS); - assert!(!gate(&s, Some(®istry), &older)); - assert!(!gate_via_pull(&s, &crate::registry::REGISTRY_POST_ID, ®istry.author, &older)); - - // Exactly one live row remains, at the newest timestamp. - let rows = s.get_comments(&crate::registry::REGISTRY_POST_ID).unwrap(); - assert_eq!(rows.len(), 1); - assert_eq!(rows[0].timestamp_ms, t + 1000); - } - - #[test] - fn delete_comment_authorization_matrix() { - let (author_id, author_seed) = make_persona(80); - let (wrong_author, wrong_seed) = make_persona(81); - let post_id: PostId = [9u8; 32]; - let ts = 12345u64; - - let sig = crate::crypto::sign_comment_delete(&author_seed, &author_id, &post_id, ts); - - // Self-certified: accepted (regardless of sender — no sender - // identity is consulted at all). - assert!(delete_comment_authorized(&author_id, &post_id, ts, &sig)); - // Unsigned: rejected — there is NO sender override anymore (the - // old one compared against attacker-controlled payload.author). - assert!(!delete_comment_authorized(&author_id, &post_id, ts, &[])); - // Garbage signature: rejected. - assert!(!delete_comment_authorized(&author_id, &post_id, ts, &[0u8; 64])); - // Signature by a different key than the named author: rejected. - let wrong_sig = crate::crypto::sign_comment_delete(&wrong_seed, &wrong_author, &post_id, ts); - assert!(!delete_comment_authorized(&author_id, &post_id, ts, &wrong_sig)); - } - - /// Blocker regression: a valid signed comment with an injected - /// `deleted_at` (the identity signature does not cover it) must be - /// dropped on both paths, and must NOT tombstone a stored copy. - #[test] - fn injected_tombstone_rejected_both_paths() { - let (s, post_id, post) = setup_greeting_bio(20); - let t = now(); - let c = make_greeting(&post, &post_id, 90, t, t + 40 * 24 * 3600 * 1000); - - // Store the honest copy first. - assert!(gate(&s, Some(&post), &c)); - s.store_comment(&c).unwrap(); - assert_eq!(s.get_comments(&post_id).unwrap().len(), 1); - - // Attacker re-sends the same signed bytes with deleted_at set. - let mut forged = c.clone(); - forged.deleted_at = Some(t); - assert!(!gate(&s, Some(&post), &forged), "diff path drops injected tombstone"); - assert!( - !gate_via_pull(&s, &post_id, &post.author, &forged), - "pull path drops injected tombstone" - ); - - // The stored copy is still live — no unsigned delete happened. - assert_eq!(s.get_comments(&post_id).unwrap().len(), 1); - } - - /// Future-dated comments beyond the skew allowance are rejected - /// (ranking/newest-wins squatting guard). - #[test] - fn far_future_timestamp_rejected() { - let (s, post_id, post) = setup_greeting_bio(21); - let t = now(); - let day = 24 * 3600 * 1000u64; - // 30 days in the future — well past the 5-minute skew allowance. - let c = make_greeting(&post, &post_id, 91, t + 30 * day, t + 60 * day); - assert!(!gate(&s, Some(&post), &c)); - assert!(!gate_via_pull(&s, &post_id, &post.author, &c)); - // Within the skew allowance: accepted. - let c2 = make_greeting(&post, &post_id, 92, t + 60_000, t + 60 * day); - assert!(gate(&s, Some(&post), &c2)); - } - - /// Consent off-switch belt-and-suspenders: greetings on a bio that - /// has been superseded by a newer profile post are rejected. - #[test] - fn greeting_on_superseded_bio_rejected() { - let (s, old_bio_id, old_bio) = setup_greeting_bio(22); - let t = now(); - - // Author republishes the bio WITHOUT a greeting slot (consent - // withdrawn) — a newer Profile post by the same author. - let new_bio = Post { - author: old_bio.author, - content: String::new(), - attachments: vec![], - timestamp_ms: old_bio.timestamp_ms + 1000, - fof_gating: None, - supersedes_post_id: None, - }; - let new_bio_id = crate::content::compute_post_id(&new_bio); - s.store_post_with_intent( - &new_bio_id, &new_bio, &PostVisibility::Public, &VisibilityIntent::Profile, - ).unwrap(); - - // A greeting aimed at the OLD bio is now rejected on both paths. - let c = make_greeting(&old_bio, &old_bio_id, 93, t, t + 40 * 24 * 3600 * 1000); - assert!(!gate(&s, Some(&old_bio), &c), "diff path rejects superseded-bio greeting"); - assert!( - !gate_via_pull(&s, &old_bio_id, &old_bio.author, &c), - "pull path rejects superseded-bio greeting" - ); - } - - /// Stateless re-ingest guard: an expired comment served by a stale - /// holder does NOT re-seed after local expiry sweep. - #[test] - fn expired_comment_does_not_reseed_via_pull() { - let (s, post_id, post) = setup_greeting_bio(17); - let t = now(); - // Short-lived greeting, stored while valid. - let c = make_greeting(&post, &post_id, 59, t - 10_000, t + 5_000); - s.store_comment(&c).unwrap(); - assert_eq!(s.get_comments(&post_id).unwrap().len(), 1); - - // Time passes; the sweep hard-deletes it. - let later = t + 10_000; - assert_eq!(s.expire_comments(later).unwrap(), 1); - assert!(s.get_comments(&post_id).unwrap().is_empty()); - - // A stale holder re-serves the exact same signed comment via a - // header pull → the gate's expiry check drops it statelessly. - let header = BlobHeader { - post_id, - author: post.author, - reactions: vec![], - comments: vec![c], - policy: CommentPolicy::default(), - updated_at: later, - thread_splits: vec![], - receipt_slots: vec![], - comment_slots: vec![], - prior_author: None, - }; - assert_eq!(ingest_header_comments(&s, &header, later), 0); - assert!(s.get_comments_with_tombstones(&post_id).unwrap().is_empty()); - } -} - #[cfg(test)] mod tests { use super::{scanner_semaphore, PendingConnectGuard}; @@ -9769,806 +8778,3 @@ mod tests { assert!(set.lock().unwrap().is_empty(), "all guards dropped — set should be empty"); } } - -#[cfg(test)] -mod uniques_tests { - //! v0.8 Iteration C: slot pool + two-pool uniques announce. - - use super::{ - announce_digest, apply_uniques_to_storage, build_uniques_payload, decide_slot, - entries_to_slice, slice_to_entries, MAX_UNIQUES_PAYLOAD, TEMP_REFERRAL_TTL_MS, - }; - use crate::protocol::{unpack_ids, AnchorEntry, UniquesAnnouncePayload, UniquesSlice}; - use crate::storage::Storage; - use crate::types::{IdClass, MeshSlot, NodeId, ReachEntry}; - - fn temp_storage() -> Storage { - let dir = std::env::temp_dir().join(format!("itsgoin-uniq-{}", rand::random::())); - std::fs::create_dir_all(&dir).unwrap(); - Storage::open(dir.join("test.db")).unwrap() - } - - fn nid(b: u8) -> NodeId { - [b; 32] - } - - // ---- Slot accounting ---- - - #[test] - fn mesh_slots_fill_before_temp_referral_slots() { - // Under the cap: always a mesh slot. - assert_eq!(decide_slot(0, 20, 0, 10, 0), Some(MeshSlot::Mesh)); - assert_eq!(decide_slot(19, 20, 0, 10, 0), Some(MeshSlot::Mesh)); - - // At the cap: temp referral, ABOVE the cap — the mesh pool is untouched. - let s = decide_slot(20, 20, 0, 10, 1_000).unwrap(); - assert_eq!(s, MeshSlot::TempReferral { expires_at_ms: 1_000 + TEMP_REFERRAL_TTL_MS }); - assert!(s.is_temp()); - - // Temp band full too: refuse. Note the total (20 + 10) exceeds the mesh - // cap by exactly the referral band — that is the point. - assert_eq!(decide_slot(20, 20, 10, 10, 0), None); - - // Mobile numbers. - assert_eq!(decide_slot(14, 15, 4, 4, 0), Some(MeshSlot::Mesh)); - assert_eq!(decide_slot(15, 15, 4, 4, 0), None); - } - - #[test] - fn graduation_requires_an_already_free_mesh_slot() { - // A temp peer can only become mesh when a mesh slot is genuinely free. - // Full mesh => the arriving peer is temp, and nothing is evicted. - assert!(decide_slot(20, 20, 0, 10, 0).unwrap().is_temp()); - // One mesh peer drops => the next allocation is mesh again. - assert!(decide_slot(19, 20, 1, 10, 0).unwrap().is_mesh()); - } - - // ---- Wire shape ---- - - #[test] - fn anchor_entries_carry_addresses_everything_else_is_bare() { - let plain = nid(1); - let persona = nid(2); - let anchor = nid(3); - - let slice = entries_to_slice(&[ - ReachEntry::node(plain), - ReachEntry::author(persona), - ReachEntry::anchor(anchor, vec!["203.0.113.5:4433".into()]), - ]); - - assert_eq!(unpack_ids(&slice.nodes), vec![plain]); - assert_eq!(unpack_ids(&slice.authors), vec![persona]); - assert_eq!(slice.anchors.len(), 1); - assert_eq!(slice.anchors[0].i, hex::encode(anchor)); - assert_eq!(slice.anchors[0].a, vec!["203.0.113.5:4433".to_string()]); - - // The wire type has no address field on the bare arrays at all, so a - // non-anchor entry structurally cannot carry one. Round-trip proves it. - let back = slice_to_entries(&slice); - for e in &back { - if e.id == anchor { - assert!(e.is_anchor && !e.addresses.is_empty()); - } else { - assert!(!e.is_anchor, "{:?} must not be an anchor", e.id); - assert!(e.addresses.is_empty(), "non-anchor entries are bare"); - } - } - - // Classes survive the round trip — a persona never becomes connectable. - assert_eq!( - back.iter().find(|e| e.id == persona).unwrap().id_class, - IdClass::Author - ); - assert_eq!(back.iter().find(|e| e.id == plain).unwrap().id_class, IdClass::Node); - } - - #[test] - fn unroutable_anchor_addresses_are_filtered_off_the_wire() { - // bugs-fixed #8: private/loopback ranges never get announced. - let anchor = nid(3); - let slice = entries_to_slice(&[ReachEntry::anchor( - anchor, - vec!["10.0.0.5:4433".into(), "192.168.1.9:4433".into()], - )]); - assert!(slice.anchors.is_empty(), "private addresses must not be announced"); - // It degrades to a bare node entry rather than vanishing. - assert_eq!(unpack_ids(&slice.nodes), vec![anchor]); - } - - // ---- Build / apply ---- - - #[test] - fn built_announce_places_pools_correctly_and_omits_n4() { - let s = temp_storage(); - let us = nid(0); - let reporter = nid(1); - let mesh_peer = nid(2); - let n2 = nid(3); - let n3 = nid(4); - let n4 = nid(5); - - s.add_mesh_peer(&mesh_peer).unwrap(); - s.set_reach(&reporter, 2, &[ReachEntry::node(n2)]).unwrap(); - s.set_reach(&reporter, 3, &[ReachEntry::node(n3)]).unwrap(); - s.set_reach(&reporter, 4, &[ReachEntry::node(n4)]).unwrap(); - - let p = build_uniques_payload(&s, &us, None, 7, 4, 4).unwrap(); - assert!(p.full); - assert_eq!(p.seq, 7); - assert_eq!(p.depth, 4); - assert_eq!(p.fwd.len(), 3); - - assert_eq!(unpack_ids(&p.fwd[0].nodes), vec![us], "fwd[0] is N0 = ourselves"); - assert!(unpack_ids(&p.fwd[1].nodes).contains(&mesh_peer), "fwd[1] is our own uniques"); - assert_eq!(unpack_ids(&p.fwd[2].nodes), vec![n2], "fwd[2] is our N2"); - assert_eq!(unpack_ids(&p.term.nodes), vec![n3], "term is our N3"); - - // The whole point: N4 is terminal and never re-announced. - let all: Vec = p - .fwd - .iter() - .chain(std::iter::once(&p.term)) - .flat_map(|sl| { - unpack_ids(&sl.nodes) - .into_iter() - .chain(unpack_ids(&sl.authors)) - .collect::>() - }) - .collect(); - assert!(!all.contains(&n4), "N4 must never appear in an announcement"); - } - - #[test] - fn shallow_peer_depth_strips_the_terminal_pool() { - let s = temp_storage(); - let us = nid(0); - s.set_reach(&nid(1), 3, &[ReachEntry::node(nid(9))]).unwrap(); - - let full = build_uniques_payload(&s, &us, None, 1, 4, 4).unwrap(); - assert!(!full.term.is_empty()); - - // Peer advertised depth 3 — nowhere to put N4, so don't build it. - let trimmed = build_uniques_payload(&s, &us, None, 1, 4, 3).unwrap(); - assert!(trimmed.term.is_empty()); - // Pool 1 is unaffected — only the terminal pool is skipped. - assert_eq!(unpack_ids(&trimmed.fwd[0].nodes), vec![us]); - } - - #[test] - fn apply_shifts_pool1_one_bounce_deeper_and_lands_pool2_at_n4() { - let s = temp_storage(); - let us = nid(0); - let reporter = nid(1); - - let their_n1 = nid(10); - let their_n2 = nid(11); - let their_n3 = nid(12); - - let payload = UniquesAnnouncePayload { - seq: 3, - full: true, - fwd: vec![ - entries_to_slice(&[ReachEntry::node(reporter)]), - entries_to_slice(&[ReachEntry::node(their_n1)]), - entries_to_slice(&[ReachEntry::node(their_n2)]), - ], - term: entries_to_slice(&[ReachEntry::node(their_n3)]), - depth: 4, - }; - - let stored = apply_uniques_to_storage(&s, &us, &reporter, &payload, 4, |_| false).unwrap(); - assert_eq!(stored, 3); - - assert_eq!(s.find_in_n2(&their_n1).unwrap(), vec![reporter], "fwd[1] -> our N2"); - assert_eq!(s.find_in_n3(&their_n2).unwrap(), vec![reporter], "fwd[2] -> our N3"); - assert_eq!(s.find_in_n4(&their_n3).unwrap(), vec![reporter], "term -> our N4"); - - // N4 is populated and usable... - let hits = s.find_any_reachable(&[their_n3]).unwrap(); - assert_eq!(hits.len(), 1); - assert_eq!(hits[0].2, 4); - - // ...but it does not leak back out into what WE announce. - let ours = build_uniques_payload(&s, &us, None, 1, 4, 4).unwrap(); - let announced: Vec = ours - .fwd - .iter() - .chain(std::iter::once(&ours.term)) - .flat_map(|sl| unpack_ids(&sl.nodes)) - .collect(); - assert!(announced.contains(&their_n1)); - assert!(announced.contains(&their_n2)); - assert!( - !announced.contains(&their_n3), - "an ID learned at N4 must never be forwarded (no N5)" - ); - } - - #[test] - fn shallow_device_drops_the_terminal_pool_on_receipt() { - let s = temp_storage(); - let us = nid(0); - let reporter = nid(1); - let deep = nid(12); - - let payload = UniquesAnnouncePayload { - seq: 1, - full: true, - fwd: vec![UniquesSlice::default(), UniquesSlice::default(), UniquesSlice::default()], - term: entries_to_slice(&[ReachEntry::node(deep)]), - depth: 4, - }; - - // max_bounce = 3 (mobile): the terminal pool is discarded, not stored. - apply_uniques_to_storage(&s, &us, &reporter, &payload, 3, |_| false).unwrap(); - assert_eq!(s.count_distinct_n4().unwrap(), 0); - - // max_bounce = 4 (desktop): stored. - apply_uniques_to_storage(&s, &us, &reporter, &payload, 4, |_| false).unwrap(); - assert_eq!(s.count_distinct_n4().unwrap(), 1); - } - - #[test] - fn apply_filters_self_and_already_connected_peers() { - let s = temp_storage(); - let us = nid(0); - let reporter = nid(1); - let already = nid(2); - let fresh = nid(3); - - let payload = UniquesAnnouncePayload { - seq: 1, - full: true, - fwd: vec![ - UniquesSlice::default(), - entries_to_slice(&[ - ReachEntry::node(us), - ReachEntry::node(already), - ReachEntry::node(fresh), - ]), - UniquesSlice::default(), - ], - term: UniquesSlice::default(), - depth: 4, - }; - - apply_uniques_to_storage(&s, &us, &reporter, &payload, 4, |n| *n == already).unwrap(); - assert!(s.find_in_n2(&us).unwrap().is_empty(), "never index ourselves"); - assert!(s.find_in_n2(&already).unwrap().is_empty(), "already meshed, not a candidate"); - assert_eq!(s.find_in_n2(&fresh).unwrap(), vec![reporter]); - } - - /// Third-party anchor entries are an INDEX, not an address record. They are - /// mineable from the pools and nothing else — no `peers` row, no - /// `known_anchors` seed, no `is_anchor` latch — because nothing has proven - /// the address belongs to that node ID. - #[test] - fn received_anchor_entries_are_index_only_never_authoritative() { - let s = temp_storage(); - let us = nid(0); - let reporter = nid(1); - let anchor = nid(7); - - let payload = UniquesAnnouncePayload { - seq: 1, - full: true, - fwd: vec![ - UniquesSlice::default(), - entries_to_slice(&[ReachEntry::anchor(anchor, vec!["198.51.100.9:4433".into()])]), - UniquesSlice::default(), - ], - term: UniquesSlice::default(), - depth: 4, - }; - apply_uniques_to_storage(&s, &us, &reporter, &payload, 4, |_| false).unwrap(); - - // Mineable from the pools — this IS the anchor directory. - let mined = s.list_pool_anchors(10).unwrap(); - assert_eq!(mined.len(), 1); - assert_eq!(mined[0].0, anchor); - - // But authoritative address state is untouched: a hostile peer must not - // be able to overwrite our address for any node (bugs-fixed #5, - // remotely triggerable) or re-inflate the bootstrap cache from gossip. - assert!(s.get_peer_record(&anchor).unwrap().is_none(), "no peers row from hearsay"); - assert!(s.list_known_anchors().unwrap().is_empty(), "bootstrap cache untouched"); - assert!(!s.is_peer_anchor(&anchor).unwrap(), "no is_anchor latch from hearsay"); - } - - /// Round-4/5: "a node knocked down from 20 peers to 4 still holds 16 dead - /// peers' pools full of anchor addresses to reconnect through." Disconnect - /// must NOT wipe the reporter's rows; a new handshake overwrites them. - #[test] - fn a_departed_reporters_pool_is_retained_until_a_new_handshake_replaces_it() { - let s = temp_storage(); - let reporter = nid(1); - let anchor = nid(7); - let other = nid(8); - - s.set_reach( - &reporter, - 2, - &[ - ReachEntry::anchor(anchor, vec!["198.51.100.9:4433".into()]), - ReachEntry::node(other), - ], - ) - .unwrap(); - - // The peer leaves. `disconnect_peer` no longer clears anything, so the - // mine and the growth-candidate reserve survive the exact mass- - // disconnect the retention rule was written for. - assert_eq!(s.list_pool_anchors(10).unwrap().len(), 1); - assert!(!s.score_n2_candidates_batch().unwrap().is_empty()); - assert!(s.anchor_density().unwrap() > 0.0); - - // It comes back with a different view: set_reach DELETEs per - // (reporter, bounce) first, so rows are replaced, never duplicated. - s.set_reach(&reporter, 2, &[ReachEntry::node(other)]).unwrap(); - assert!(s.list_pool_anchors(10).unwrap().is_empty(), "overwritten by the new handshake"); - assert_eq!(s.find_in_n2(&other).unwrap(), vec![reporter]); - } - - /// THE terminal-pool invariant, round-tripped through an ANCHOR entry — - /// the case the earlier tests missed. An anchor learned at bounce 4 must - /// appear in NO slice of the next announce. Before the fix it was mirrored - /// into `peers.is_anchor` and then re-emitted by `build_own_uniques` at - /// pool-1 depth 1, resetting its horizon on every hop forever. - #[test] - fn an_anchor_known_only_at_n4_is_never_re_announced() { - let s = temp_storage(); - let us = nid(0); - let reporter = nid(1); - let deep_anchor = nid(9); - - let payload = UniquesAnnouncePayload { - seq: 1, - full: true, - fwd: vec![UniquesSlice::default(), UniquesSlice::default(), UniquesSlice::default()], - term: entries_to_slice(&[ReachEntry::anchor( - deep_anchor, - vec!["198.51.100.9:4433".into()], - )]), - depth: 4, - }; - apply_uniques_to_storage(&s, &us, &reporter, &payload, 4, |_| false).unwrap(); - assert_eq!(s.find_in_n4(&deep_anchor).unwrap(), vec![reporter], "stored at N4"); - - let out = build_uniques_payload(&s, &us, None, 2, 4, 4).unwrap(); - for (i, slice) in out.fwd.iter().enumerate() { - let ids: Vec = slice_to_entries(slice).into_iter().map(|e| e.id).collect(); - assert!(!ids.contains(&deep_anchor), "N4 anchor leaked into fwd[{}]", i); - } - let term_ids: Vec = - slice_to_entries(&out.term).into_iter().map(|e| e.id).collect(); - assert!(!term_ids.contains(&deep_anchor), "N4 anchor leaked into the terminal pool"); - } - - /// `fwd` is assumed to be exactly 3 slices by every reader. A 6-slice - /// payload used to write rows at bounce 5 and 6 — depths with no semantics - /// anywhere, invisible to the announce builder but very visible to - /// `find_any_reachable`, the growth scorer and `anchor_density`. - #[test] - fn a_payload_with_the_wrong_fwd_length_is_rejected() { - let s = temp_storage(); - let us = nid(0); - let reporter = nid(1); - let mut fwd = vec![UniquesSlice::default(); 6]; - fwd[4] = entries_to_slice(&[ReachEntry::node(nid(42))]); - let payload = UniquesAnnouncePayload { - seq: 1, - full: true, - fwd, - term: UniquesSlice::default(), - depth: 4, - }; - assert!(apply_uniques_to_storage(&s, &us, &reporter, &payload, 4, |_| false).is_err()); - let max: i64 = s.max_stored_bounce().unwrap(); - assert_eq!(max, 0, "nothing written at all"); - } - - /// The §layers size budget must be ENFORCED, not merely documented: with - /// `MAX_PAYLOAD` at 64 MB a single announce could decode into ~1.5M IDs and - /// the same number of row inserts, per bounce, per reporter. - #[test] - fn oversized_slices_are_truncated_and_a_built_announce_stays_under_the_ceiling() { - let s = temp_storage(); - let us = nid(0); - let reporter = nid(1); - - let cap = crate::storage::uniques_accept_cap(2); - let flood: Vec = (0..(cap + 5_000)) - .map(|i| { - let mut id = [0u8; 32]; - id[0] = (i % 251) as u8; - id[1] = ((i / 251) % 251) as u8; - id[2] = (i / (251 * 251)) as u8; - ReachEntry::node(id) - }) - .collect(); - let payload = UniquesAnnouncePayload { - seq: 1, - full: true, - fwd: vec![ - UniquesSlice::default(), - entries_to_slice(&flood), - UniquesSlice::default(), - ], - term: UniquesSlice::default(), - depth: 4, - }; - let stored = apply_uniques_to_storage(&s, &us, &reporter, &payload, 4, |_| false).unwrap(); - assert!(stored <= cap, "accepted {} rows, cap is {}", stored, cap); - - // ...and what WE build back out of that index stays under the wire cap. - let out = build_uniques_payload(&s, &us, None, 2, 4, 4).unwrap(); - let bytes = serde_json::to_vec(&out).unwrap().len(); - assert!(bytes < MAX_UNIQUES_PAYLOAD, "built announce is {} bytes", bytes); - assert!(out.fwd[2].len() <= crate::storage::UNIQUES_BUILD_CAP_SHALLOW); - } - - /// bugs-fixed #8, receive side. The send side always filtered; the receive - /// side did not, so a peer could seed us with `127.0.0.1:22` / `10.x` / - /// `192.168.x` and we would store and later dial them. - #[test] - fn unroutable_addresses_are_dropped_on_receipt() { - let bad = UniquesSlice { - nodes: crate::protocol::pack_ids(&[]), - authors: crate::protocol::pack_ids(&[]), - anchors: vec![AnchorEntry { - i: hex::encode(nid(5)), - a: vec!["127.0.0.1:22".into(), "10.0.0.1:4433".into(), "192.168.1.1:443".into()], - }], - }; - let entries = slice_to_entries(&bad); - assert_eq!(entries.len(), 1); - assert!(!entries[0].is_anchor, "degrades to a bare node entry"); - assert!(entries[0].addresses.is_empty()); - } - - /// The address invariant must be STRUCTURAL. `id_class` and `is_anchor` are - /// independent columns, so an author-class row carrying the anchor flag - /// must still serialise as a bare persona ID — never next to a device - /// address (design.html §21). - #[test] - fn an_author_class_entry_never_serialises_as_an_address_bearing_anchor() { - let e = ReachEntry { - id: nid(3), - id_class: IdClass::Author, - is_anchor: true, - addresses: vec!["198.51.100.9:4433".into()], - }; - let slice = entries_to_slice(&[e]); - assert!(slice.anchors.is_empty(), "no address-bearing entry for a persona ID"); - assert_eq!(unpack_ids(&slice.authors), vec![nid(3)]); - assert!(unpack_ids(&slice.nodes).is_empty()); - } - - #[test] - fn announce_digest_ignores_seq_but_tracks_content() { - let s = temp_storage(); - let us = nid(0); - let a = build_uniques_payload(&s, &us, None, 1, 4, 4).unwrap(); - let b = build_uniques_payload(&s, &us, None, 99, 4, 4).unwrap(); - assert_eq!(announce_digest(&a), announce_digest(&b), "seq must not affect the digest"); - - s.add_mesh_peer(&nid(5)).unwrap(); - let c = build_uniques_payload(&s, &us, None, 1, 4, 4).unwrap(); - assert_ne!(announce_digest(&a), announce_digest(&c)); - } -} - -#[cfg(test)] -mod convection_tests { - //! v0.8 Iteration C: anchor convection + the stochastic growth trigger. - //! - //! The window rules and the action choice are pure functions precisely so - //! they can be tested without standing up an iroh endpoint. - - use super::{ - choose_convection_action, convection_should_serve, convection_weights, - convection_window_admit, convection_window_pick, convection_window_viable, - disconnect_response, ConvectionAction, ConvectionEntry, DisconnectResponse, - CONVECTION_ENTRY_MAX_AGE_MS, CONVECTION_HANDOUT_CAP, CONVECTION_REFERRALS, - CONVECTION_WINDOW_SIZE, - }; - use crate::protocol::ConvectionClass; - use crate::storage::Storage; - use crate::types::{NodeId, ReachEntry}; - use std::collections::VecDeque; - - fn nid(b: u8) -> NodeId { - [b; 32] - } - - fn addr(b: u8) -> Vec { - vec![format!("203.0.113.{}:4433", b)] - } - - fn window() -> VecDeque { - VecDeque::new() - } - - fn nobody_live(_: &NodeId) -> bool { - false - } - - fn temp_storage() -> Storage { - let dir = std::env::temp_dir().join(format!("itsgoin-conv-{}", rand::random::())); - std::fs::create_dir_all(&dir).unwrap(); - Storage::open(dir.join("test.db")).unwrap() - } - - // ---- Rolling window: 2 before, 2 after ---- - - #[test] - fn a_caller_gets_the_two_most_recent_prior_callers() { - let mut w = window(); - // Callers arrive 1, 2, 3. - for i in 1..=3u8 { - convection_window_admit(&mut w, nid(i), addr(i), 1_000 + i as u64); - } - // Caller 4 arrives and asks. It must get the two MOST RECENT priors - // (3 and 2), not the oldest and not all three. - let picked = convection_window_pick(&mut w, &nid(4), CONVECTION_REFERRALS, 2_000, nobody_live); - let got: Vec = picked.iter().map(|e| e.node_id[0]).collect(); - assert_eq!(got, vec![3, 2], "most recent first, exactly 2"); - } - - #[test] - fn an_entry_is_handed_to_two_callers_then_rotates_out() { - let mut w = window(); - convection_window_admit(&mut w, nid(1), addr(1), 1_000); - convection_window_admit(&mut w, nid(2), addr(2), 1_001); - - // Caller A takes both. - let a = convection_window_pick(&mut w, &nid(9), 2, 2_000, nobody_live); - assert_eq!(a.len(), 2); - assert_eq!(w.len(), 2, "one hand-out each, still under the cap"); - - // Caller B takes both again — that is hand-out #2 for each. - let b = convection_window_pick(&mut w, &nid(9), 2, 2_001, nobody_live); - assert_eq!(b.len(), 2); - assert_eq!(CONVECTION_HANDOUT_CAP, 2); - assert!(w.is_empty(), "both entries rotated out after 2 hand-outs"); - - // Caller C gets nothing from the window. - let c = convection_window_pick(&mut w, &nid(9), 2, 2_002, nobody_live); - assert!(c.is_empty()); - } - - #[test] - fn a_caller_is_never_referred_to_itself() { - let mut w = window(); - convection_window_admit(&mut w, nid(1), addr(1), 1_000); - let picked = convection_window_pick(&mut w, &nid(1), 2, 1_500, nobody_live); - assert!(picked.is_empty()); - assert_eq!(w.len(), 1, "and its own entry is not spent"); - } - - #[test] - fn still_connected_callers_are_preferred_over_more_recent_cold_ones() { - let mut w = window(); - // 1 is older but still connected; 2 and 3 are newer and gone. - convection_window_admit(&mut w, nid(1), addr(1), 1_000); - convection_window_admit(&mut w, nid(2), addr(2), 2_000); - convection_window_admit(&mut w, nid(3), addr(3), 3_000); - - let live = |n: &NodeId| n[0] == 1; - let picked = convection_window_pick(&mut w, &nid(9), 2, 4_000, live); - let got: Vec = picked.iter().map(|e| e.node_id[0]).collect(); - assert_eq!(got[0], 1, "the live caller leads even though it is oldest"); - assert_eq!(got[1], 3, "then the most recent of the rest"); - } - - #[test] - fn a_refreshed_caller_gets_a_new_handout_budget_and_moves_to_the_back() { - let mut w = window(); - convection_window_admit(&mut w, nid(1), addr(1), 1_000); - convection_window_admit(&mut w, nid(2), addr(2), 1_001); - // Spend one hand-out on caller 1. - let _ = convection_window_pick(&mut w, &nid(2), 1, 1_500, |n: &NodeId| n[0] == 1); - assert_eq!(w.iter().find(|e| e.node_id == nid(1)).unwrap().handouts, 1); - - // Caller 1 calls again — it is fresh material once more. - convection_window_admit(&mut w, nid(1), addr(1), 2_000); - let e = w.iter().find(|e| e.node_id == nid(1)).unwrap(); - assert_eq!(e.handouts, 0); - assert_eq!(w.back().unwrap().node_id, nid(1), "newest sits at the back"); - assert_eq!(w.len(), 2, "refreshed, not duplicated"); - } - - #[test] - fn the_window_is_bounded_in_both_count_and_age() { - let mut w = window(); - for i in 1..=(CONVECTION_WINDOW_SIZE as u8 + 5) { - convection_window_admit(&mut w, nid(i), addr(i), 1_000 + i as u64); - } - assert_eq!(w.len(), CONVECTION_WINDOW_SIZE); - assert_eq!(w.front().unwrap().node_id[0], 6, "oldest fell off the front"); - - // A stale entry is not worth handing out — it costs the next caller a - // punch timeout. - let mut w2 = window(); - convection_window_admit(&mut w2, nid(1), addr(1), 0); - let late = CONVECTION_ENTRY_MAX_AGE_MS + 1; - assert_eq!(convection_window_viable(&w2, &nid(9), late), 0); - assert!(convection_window_pick(&mut w2, &nid(9), 2, late, nobody_live).is_empty()); - } - - #[test] - fn an_address_less_caller_is_not_admitted() { - let mut w = window(); - convection_window_admit(&mut w, nid(1), vec![], 1_000); - assert!(w.is_empty(), "nothing to hand out means nothing to store"); - } - - // ---- Request classes ---- - - #[test] - fn entry_is_always_served_and_top_up_is_refusable() { - // ENTRY: served with an empty window, served while saturated. A node - // that cannot get in cannot come back. - assert!(convection_should_serve(ConvectionClass::Entry, 0, false)); - assert!(convection_should_serve(ConvectionClass::Entry, 0, true)); - assert!(convection_should_serve(ConvectionClass::Entry, 5, true)); - - // TOP-UP: refused when there is nothing fresh to circulate, or when we - // are saturated. Served otherwise — top-ups are the convection medium, - // not freeloading, so they are not throttled beyond that. - assert!(!convection_should_serve(ConvectionClass::TopUp, 0, false)); - assert!(!convection_should_serve(ConvectionClass::TopUp, 5, true)); - assert!(convection_should_serve(ConvectionClass::TopUp, 1, false)); - - // And the class bit is derived purely from "can this node function". - assert!(ConvectionClass::for_mesh_count(1).is_entry()); - assert!(!ConvectionClass::for_mesh_count(2).is_entry()); - } - - // ---- Adaptive weights ---- - - #[test] - fn the_anchor_density_prior_sets_the_anchor_arm() { - let sparse = convection_weights(0.0, 1.0, 0.5); - let dense = convection_weights(0.30, 1.0, 0.5); - assert!( - dense.anchor > sparse.anchor, - "abundant anchors (IPv6 world) => anchor-led growth: {} vs {}", - dense.anchor, - sparse.anchor - ); - // Same code in both worlds: with no anchors known the anchor arm is - // small but never zero — we still have to be able to discover one. - assert!(sparse.anchor > 0.0); - assert!(dense.mesh > 0.0, "and the mesh arm never disappears either"); - } - - #[test] - fn refusal_feedback_shifts_weight_away_from_the_anchor_arm() { - let density = 0.30; - let served = convection_weights(density, 1.0, 0.5); - // AIMD: a refusal halves the bias. - let after_refusal = convection_weights(density, 0.5, 0.5); - let after_two = convection_weights(density, 0.25, 0.5); - assert!(after_refusal.anchor < served.anchor); - assert!(after_two.anchor < after_refusal.anchor); - assert_eq!(after_refusal.mesh, served.mesh, "mesh arm is untouched…"); - // …so the SHARE going to mesh rises, which is the point. - let share = |w: super::ConvectionWeights| w.mesh / (w.nothing + w.anchor + w.mesh); - assert!(share(after_refusal) > share(served)); - - // A roll that picked the anchor while healthy now picks mesh. - let roll = 0.45; - assert_eq!(choose_convection_action(roll, density, 1.0, 0.5), ConvectionAction::AskAnchor); - assert_eq!(choose_convection_action(roll, density, 0.1, 0.5), ConvectionAction::AskMesh); - } - - #[test] - fn the_action_leans_toward_acting_the_further_under_target_we_are() { - // Far under target: do-nothing weight is zero, so EVERY roll acts. - for i in 0..20 { - let roll = i as f64 / 20.0; - assert_ne!( - choose_convection_action(roll, 0.2, 1.0, 0.0), - ConvectionAction::Nothing, - "empty mesh must always act (roll {})", - roll - ); - } - // Near target: do-nothing dominates. - let full = convection_weights(0.2, 1.0, 1.0); - assert!(full.nothing > full.anchor + full.mesh); - - // No threshold anywhere: the choice varies continuously with the roll, - // and all three arms are reachable at a mid fill ratio. - let mut seen = std::collections::HashSet::new(); - for i in 0..100 { - seen.insert(choose_convection_action(i as f64 / 100.0, 0.3, 1.0, 0.6)); - } - assert_eq!(seen.len(), 3, "all three arms reachable: {:?}", seen); - } - - // ---- Recovery is not stochastic ---- - - #[test] - fn recovery_pre_empts_the_dice_and_never_rolls() { - // Below 2 mesh peers: always recover, whatever the slot situation. - for mesh in 0..2usize { - for free in [0usize, 5, 20] { - assert_eq!( - disconnect_response(mesh, free), - DisconnectResponse::Recover, - "mesh {} free {}", - mesh, - free - ); - } - } - // At or above 2: stochastic when there is room, idle when there isn't. - assert_eq!(disconnect_response(2, 1), DisconnectResponse::Roll); - assert_eq!(disconnect_response(19, 1), DisconnectResponse::Roll); - assert_eq!(disconnect_response(20, 0), DisconnectResponse::Idle); - } - - // ---- Anchors mined from the pools ---- - - #[test] - fn anchor_density_and_the_anchor_directory_come_from_the_pools() { - let s = temp_storage(); - let reporter = nid(200); - // 4 node-class uniques, 1 of them anchor-flagged with an address. - let entries = vec![ - ReachEntry::anchor(nid(1), vec!["203.0.113.1:4433".into()]), - ReachEntry::node(nid(2)), - ReachEntry::node(nid(3)), - ReachEntry::node(nid(4)), - // Author-class IDs are NOT connect targets and must not skew the - // prior or appear in the directory. - ReachEntry::author(nid(5)), - ]; - s.set_reach(&reporter, 2, &entries).unwrap(); - - let density = s.anchor_density().unwrap(); - assert!( - (density - 0.25).abs() < 1e-9, - "1 anchor of 4 node-class uniques, authors excluded: got {}", - density - ); - - // The pools double as the anchor directory — no separate registry, and - // only anchor entries carry an address to mine. - let anchors = s.list_pool_anchors(16).unwrap(); - assert_eq!(anchors.len(), 1); - assert_eq!(anchors[0].0, nid(1)); - assert_eq!(anchors[0].1, vec!["203.0.113.1:4433".to_string()]); - - // Zero knowledge => zero prior, and the weights still work. - let empty = temp_storage(); - assert_eq!(empty.anchor_density().unwrap(), 0.0); - assert!(empty.list_pool_anchors(16).unwrap().is_empty()); - assert!(convection_weights(0.0, 1.0, 0.5).anchor > 0.0); - } - - #[test] - fn a_disconnected_reporters_anchor_addresses_survive_for_the_cold_start_mine() { - // Round-4/5: "cold reconnect mines the RETAINED pools for anchor - // addresses" — the pool itself is the thing that survives, because - // disconnect no longer wipes it. That, not a peers/known_anchors - // mirror, is what keeps the adaptive anchor-density prior meaningful - // after churn (`anchor_density` counts `reachable` rows only). - let s = temp_storage(); - let reporter = nid(200); - let anchor = nid(1); - let sa: std::net::SocketAddr = "203.0.113.7:4433".parse().unwrap(); - s.set_reach(&reporter, 2, &[ReachEntry::anchor(anchor, vec![sa.to_string()])]).unwrap(); - - // The reporter departs. Nothing is cleared. - assert_eq!(s.list_pool_anchors(16).unwrap().len(), 1, "the mine survives the disconnect"); - assert!(s.anchor_density().unwrap() > 0.0, "and so does the adaptive prior"); - - // Only the 5h staleness sweep removes it. - s.prune_reach(0).unwrap(); - assert!(s.list_pool_anchors(16).unwrap().is_empty()); - } -} diff --git a/crates/core/src/crypto.rs b/crates/core/src/crypto.rs index dbf3f0a..9130d6b 100644 --- a/crates/core/src/crypto.rs +++ b/crates/core/src/crypto.rs @@ -923,15 +923,15 @@ pub fn rotate_group_key( // --- CDN Manifest Signing --- /// Compute the canonical digest for an AuthorManifest (for signing/verification). -/// Digest = BLAKE3(post_id ‖ author ‖ created_at_le ‖ updated_at_le ‖ previous_posts_json ‖ following_posts_json) -/// v0.8: author_addresses removed from AuthorManifest (and from this digest) — -/// posting-identity manifests must not carry device addresses. +/// Digest = BLAKE3(post_id ‖ author ‖ created_at_le ‖ updated_at_le ‖ author_addresses_json ‖ previous_posts_json ‖ following_posts_json) fn manifest_digest(manifest: &crate::types::AuthorManifest) -> [u8; 32] { let mut hasher = blake3::Hasher::new(); hasher.update(&manifest.post_id); hasher.update(&manifest.author); hasher.update(&manifest.created_at.to_le_bytes()); hasher.update(&manifest.updated_at.to_le_bytes()); + let addrs_json = serde_json::to_string(&manifest.author_addresses).unwrap_or_default(); + hasher.update(addrs_json.as_bytes()); let prev_json = serde_json::to_string(&manifest.previous_posts).unwrap_or_default(); hasher.update(prev_json.as_bytes()); let next_json = serde_json::to_string(&manifest.following_posts).unwrap_or_default(); @@ -1010,20 +1010,8 @@ pub fn random_slot_noise(size: usize) -> Vec { // --- Engagement crypto --- const REACTION_WRAP_CONTEXT: &str = "itsgoin/private-reaction/v1"; -/// v0.8 (A3): digest v2 — expires_at_ms enters the signed digest so a -/// comment's TTL can never be silently extended by holders. v0.8 has -/// ZERO wire-compat obligations (zero-users ruling), so v1 is gone. -const COMMENT_SIGN_CONTEXT: &str = "itsgoin/comment-sig/v2"; +const COMMENT_SIGN_CONTEXT: &str = "itsgoin/comment-sig/v1"; const REACTION_SIGN_CONTEXT: &str = "itsgoin/reaction-sig/v1"; -/// v0.8 (A3): self-certifying comment-delete signature context. -const COMMENT_DELETE_CONTEXT: &str = "itsgoin/comment-delete/v1"; -/// v0.8 (A3): derivable open-slot V_x context (design §27). Anyone can -/// compute V_open from the post alone: author pubkey + slot_binder_nonce. -const OPEN_SLOT_VX_CONTEXT: &str = "itsgoin/open-slot-vx/v1"; -/// v0.8 (A3): greeting-body seal contexts. The bio post id is baked into -/// the derivation (same domain-separation style as vouch grants). -const GREETING_KEY_CONTEXT: &str = "itsgoin/greeting/v1/key"; -const GREETING_NONCE_CONTEXT: &str = "itsgoin/greeting/v1/nonce"; /// Encrypt a private reaction payload (only the post author can decrypt). /// Uses X25519 DH between reactor and author, then ChaCha20-Poly1305. @@ -1079,31 +1067,25 @@ pub fn decrypt_private_reaction( String::from_utf8(plaintext).map_err(|e| anyhow::anyhow!("invalid utf8: {}", e)) } -/// Sign a comment: ed25519 over BLAKE3(author || post_id || content || -/// timestamp_ms [|| ref:ref_post_id] || expires:expires_at_ms). -/// -/// Digest v2 (A3): `expires_at_ms` is part of the comment's identity — -/// holders cannot extend a comment's life without invalidating the sig. +/// Sign a comment: ed25519 over BLAKE3(author || post_id || content || timestamp_ms). fn comment_digest( author: &NodeId, post_id: &PostId, content: &str, timestamp_ms: u64, ref_post_id: Option<&PostId>, - expires_at_ms: u64, ) -> blake3::Hash { let mut hasher = blake3::Hasher::new_derive_key(COMMENT_SIGN_CONTEXT); hasher.update(author); hasher.update(post_id); hasher.update(content.as_bytes()); hasher.update(×tamp_ms.to_le_bytes()); - // Domain-separated appends (same pattern as the v0.6.2 `ref:` field). + // Domain-separated append: `None` yields the same digest as the v0.6.1 + // scheme, so plain comments keep verifying; `Some(ref)` adds the ref id. if let Some(rid) = ref_post_id { hasher.update(b"ref:"); hasher.update(rid); } - hasher.update(b"expires:"); - hasher.update(&expires_at_ms.to_le_bytes()); hasher.finalize() } @@ -1114,14 +1096,13 @@ pub fn sign_comment( content: &str, timestamp_ms: u64, ref_post_id: Option<&PostId>, - expires_at_ms: u64, ) -> Vec { let signing_key = SigningKey::from_bytes(seed); - let digest = comment_digest(author, post_id, content, timestamp_ms, ref_post_id, expires_at_ms); + let digest = comment_digest(author, post_id, content, timestamp_ms, ref_post_id); signing_key.sign(digest.as_bytes()).to_bytes().to_vec() } -/// Verify a comment's ed25519 signature (digest v2, expiry included). +/// Verify a comment's ed25519 signature. pub fn verify_comment_signature( author: &NodeId, post_id: &PostId, @@ -1129,7 +1110,6 @@ pub fn verify_comment_signature( timestamp_ms: u64, signature: &[u8], ref_post_id: Option<&PostId>, - expires_at_ms: u64, ) -> bool { let Ok(verifying_key) = VerifyingKey::from_bytes(author) else { return false; @@ -1137,177 +1117,10 @@ pub fn verify_comment_signature( let Ok(sig) = ed25519_dalek::Signature::from_slice(signature) else { return false; }; - let digest = comment_digest(author, post_id, content, timestamp_ms, ref_post_id, expires_at_ms); + let digest = comment_digest(author, post_id, content, timestamp_ms, ref_post_id); verifying_key.verify(digest.as_bytes(), &sig).is_ok() } -/// Convenience: verify an `InlineComment`'s identity signature. -pub fn verify_inline_comment_signature(comment: &crate::types::InlineComment) -> bool { - verify_comment_signature( - &comment.author, - &comment.post_id, - &comment.content, - comment.timestamp_ms, - &comment.signature, - comment.ref_post_id.as_ref(), - comment.expires_at_ms, - ) -} - -// --- v0.8 (A3): self-certifying comment deletes --- - -fn comment_delete_digest(author: &NodeId, post_id: &PostId, timestamp_ms: u64) -> blake3::Hash { - let mut hasher = blake3::Hasher::new_derive_key(COMMENT_DELETE_CONTEXT); - hasher.update(author); - hasher.update(post_id); - hasher.update(×tamp_ms.to_le_bytes()); - hasher.finalize() -} - -/// Sign a comment delete: ed25519 by the comment author's posting key -/// over BLAKE3(context="itsgoin/comment-delete/v1", author || post_id || -/// timestamp_ms). Verifiable from the delete op alone — works on holders -/// that never met the persona (registry unregister path). -pub fn sign_comment_delete( - seed: &[u8; 32], - author: &NodeId, - post_id: &PostId, - timestamp_ms: u64, -) -> Vec { - let signing_key = SigningKey::from_bytes(seed); - let digest = comment_delete_digest(author, post_id, timestamp_ms); - signing_key.sign(digest.as_bytes()).to_bytes().to_vec() -} - -/// Verify a self-certifying comment-delete signature. -pub fn verify_comment_delete( - author: &NodeId, - post_id: &PostId, - timestamp_ms: u64, - signature: &[u8], -) -> bool { - let Ok(verifying_key) = VerifyingKey::from_bytes(author) else { return false; }; - let Ok(sig) = ed25519_dalek::Signature::from_slice(signature) else { return false; }; - let digest = comment_delete_digest(author, post_id, timestamp_ms); - verifying_key.verify(digest.as_bytes(), &sig).is_ok() -} - -// --- v0.8 (A3): derivable open-slot V_x (design §27) --- - -/// Derive the open slot's V_x from the post alone: -/// `V_open = blake3::derive_key("itsgoin/open-slot-vx/v1", -/// author_posting_pubkey || slot_binder_nonce)`. -/// Computable by any stranger (author is on the Post, nonce is in the -/// gating); distinct per publish. The CEK recovered through an open slot -/// is PUBLIC — the outer CEK layer is camouflage only; confidentiality -/// (greetings) comes from the inner HPKE-style seal. -pub fn derive_open_slot_vx( - author_posting_pubkey: &NodeId, - slot_binder_nonce: &[u8; 32], -) -> [u8; 32] { - let mut input = [0u8; 64]; - input[..32].copy_from_slice(author_posting_pubkey); - input[32..].copy_from_slice(slot_binder_nonce); - blake3::derive_key(OPEN_SLOT_VX_CONTEXT, &input) -} - -// --- v0.8 (A3): sealed greeting bodies (bucketed) --- - -fn derive_greeting_key_nonce( - shared_secret: &[u8; 32], - bio_post_id: &PostId, -) -> ([u8; 32], [u8; 12]) { - let key_ctx = format!("{}/{}", GREETING_KEY_CONTEXT, hex_lower(bio_post_id)); - let nonce_ctx = format!("{}/{}", GREETING_NONCE_CONTEXT, hex_lower(bio_post_id)); - let key = blake3::derive_key(&key_ctx, shared_secret); - let nonce_full = blake3::derive_key(&nonce_ctx, shared_secret); - let mut nonce = [0u8; 12]; - nonce.copy_from_slice(&nonce_full[..12]); - (key, nonce) -} - -/// Generate a fresh x25519 keypair `(priv_scalar, pub)` for greeting -/// reply keys / ephemeral seal keys. Same ed25519→x25519 derivation path -/// the rest of the codebase uses. -pub fn generate_x25519_keypair() -> ([u8; 32], [u8; 32]) { - generate_vouch_batch_ephemeral() -} - -/// Seal a greeting body to `recipient_x25519_pub`, padded to exactly -/// `body_bucket` plaintext bytes. Output layout: -/// `eph_x25519_pub(32) || ChaCha20-Poly1305(len_u32_le || body || pad)`. -/// Total ciphertext length is `32 + 4 + body_bucket + 16` for every -/// greeting in the same bucket — all greetings on a bio are -/// size-identical ciphertexts. -/// -/// The recipient key is the bio author's posting key converted via -/// `ed25519_pubkey_to_x25519_public` for original greetings, or the raw -/// per-greeting `reply_pubkey` for replies. `bio_post_id` is the post the -/// comment is placed ON (binds the seal to that post). -pub fn seal_greeting_body( - recipient_x25519_pub: &[u8; 32], - bio_post_id: &PostId, - plaintext: &[u8], - body_bucket: usize, -) -> Result> { - if plaintext.len() > body_bucket { - bail!( - "greeting body too large: {} > bucket {}", - plaintext.len(), - body_bucket - ); - } - let (eph_priv, eph_pub) = generate_vouch_batch_ephemeral(); - let shared = x25519_dh(&eph_priv, recipient_x25519_pub); - let (key, nonce) = derive_greeting_key_nonce(&shared, bio_post_id); - - // len prefix + body + random pad to the bucket. - let mut padded = Vec::with_capacity(4 + body_bucket); - padded.extend_from_slice(&(plaintext.len() as u32).to_le_bytes()); - padded.extend_from_slice(plaintext); - let mut pad = vec![0u8; body_bucket - plaintext.len()]; - rand::rng().fill_bytes(&mut pad); - padded.extend_from_slice(&pad); - - let cipher = ChaCha20Poly1305::new_from_slice(&key) - .map_err(|e| anyhow::anyhow!("greeting cipher init: {}", e))?; - let ciphertext = cipher - .encrypt(Nonce::from_slice(&nonce), padded.as_slice()) - .map_err(|e| anyhow::anyhow!("greeting seal: {}", e))?; - - let mut out = Vec::with_capacity(32 + ciphertext.len()); - out.extend_from_slice(&eph_pub); - out.extend_from_slice(&ciphertext); - Ok(out) -} - -/// Try to open a sealed greeting body with the recipient's x25519 -/// private scalar. Returns `None` on any shape/AEAD failure (not sealed -/// to this key, or tampered). -pub fn open_greeting_body( - recipient_x25519_priv: &[u8; 32], - bio_post_id: &PostId, - sealed: &[u8], -) -> Option> { - if sealed.len() < 32 + 4 + 16 { - return None; - } - let mut eph_pub = [0u8; 32]; - eph_pub.copy_from_slice(&sealed[..32]); - let shared = x25519_dh(recipient_x25519_priv, &eph_pub); - let (key, nonce) = derive_greeting_key_nonce(&shared, bio_post_id); - let cipher = ChaCha20Poly1305::new_from_slice(&key).ok()?; - let padded = cipher.decrypt(Nonce::from_slice(&nonce), &sealed[32..]).ok()?; - if padded.len() < 4 { - return None; - } - let real_len = u32::from_le_bytes(padded[..4].try_into().ok()?) as usize; - if 4 + real_len > padded.len() { - return None; - } - Some(padded[4..4 + real_len].to_vec()) -} - /// Sign a reaction: ed25519 over BLAKE3(reactor || post_id || emoji || timestamp_ms). pub fn sign_reaction( seed: &[u8; 32], @@ -1546,133 +1359,20 @@ mod tests { let ref_post = [2u8; 32]; let content = "preview"; let ts = 1000u64; - let exp = 5000u64; // Signature including ref_post_id. - let sig_with_ref = sign_comment(&seed, &nid, &post_id, content, ts, Some(&ref_post), exp); + let sig_with_ref = sign_comment(&seed, &nid, &post_id, content, ts, Some(&ref_post)); // Verifies only when the ref is supplied. - assert!(verify_comment_signature(&nid, &post_id, content, ts, &sig_with_ref, Some(&ref_post), exp)); + assert!(verify_comment_signature(&nid, &post_id, content, ts, &sig_with_ref, Some(&ref_post))); // Same signature must NOT verify when the ref is dropped (binding). - assert!(!verify_comment_signature(&nid, &post_id, content, ts, &sig_with_ref, None, exp)); + assert!(!verify_comment_signature(&nid, &post_id, content, ts, &sig_with_ref, None)); // Nor when the ref is swapped. let other_ref = [3u8; 32]; - assert!(!verify_comment_signature(&nid, &post_id, content, ts, &sig_with_ref, Some(&other_ref), exp)); + assert!(!verify_comment_signature(&nid, &post_id, content, ts, &sig_with_ref, Some(&other_ref))); - // Plain-comment signature still works. - let sig_plain = sign_comment(&seed, &nid, &post_id, content, ts, None, exp); - assert!(verify_comment_signature(&nid, &post_id, content, ts, &sig_plain, None, exp)); - } - - /// A3: digest v2 covers expires_at_ms — mutating the expiry flips - /// verification (no silent TTL extension possible). - #[test] - fn comment_signature_binds_expiry() { - let (seed, nid) = make_keypair(8); - let post_id = [1u8; 32]; - let ts = 1000u64; - let exp = 90_000_000u64; - - let sig = sign_comment(&seed, &nid, &post_id, "hi", ts, None, exp); - assert!(verify_comment_signature(&nid, &post_id, "hi", ts, &sig, None, exp)); - // Extended expiry must fail. - assert!(!verify_comment_signature(&nid, &post_id, "hi", ts, &sig, None, exp + 1)); - // Stripped expiry (0) must fail. - assert!(!verify_comment_signature(&nid, &post_id, "hi", ts, &sig, None, 0)); - } - - /// A3: self-certifying comment-delete roundtrip + wrong-key reject. - #[test] - fn comment_delete_sign_verify() { - let (seed, nid) = make_keypair(9); - let (_mseed, mallory) = make_keypair(10); - let post_id = [4u8; 32]; - let ts = 123_456u64; - - let sig = sign_comment_delete(&seed, &nid, &post_id, ts); - assert_eq!(sig.len(), 64); - assert!(verify_comment_delete(&nid, &post_id, ts, &sig)); - // Wrong author key. - assert!(!verify_comment_delete(&mallory, &post_id, ts, &sig)); - // Wrong tuple. - assert!(!verify_comment_delete(&nid, &post_id, ts + 1, &sig)); - assert!(!verify_comment_delete(&nid, &[5u8; 32], ts, &sig)); - // Garbage signature. - assert!(!verify_comment_delete(&nid, &post_id, ts, &[0u8; 64])); - assert!(!verify_comment_delete(&nid, &post_id, ts, &[])); - } - - /// A3: derive_open_slot_vx determinism + distinctness across nonces - /// and authors. - #[test] - fn open_slot_vx_deterministic_and_distinct() { - let (_s1, a1) = make_keypair(21); - let (_s2, a2) = make_keypair(22); - let n1 = [0xAA; 32]; - let n2 = [0xBB; 32]; - - let v = derive_open_slot_vx(&a1, &n1); - assert_eq!(v, derive_open_slot_vx(&a1, &n1), "deterministic"); - assert_ne!(v, derive_open_slot_vx(&a1, &n2), "distinct per nonce"); - assert_ne!(v, derive_open_slot_vx(&a2, &n1), "distinct per author"); - } - - /// A3: seal/open_greeting_body roundtrip, exact-bucket ciphertext - /// length equality across different plaintext lengths, tamper reject, - /// wrong-key reject. - #[test] - fn greeting_body_roundtrip_and_bucketing() { - let (bob_priv, bob_pub) = make_persona_x25519(30); - let (carol_priv, _carol_pub) = make_persona_x25519(31); - let bio_post_id: PostId = [0x77; 32]; - const BUCKET: usize = 1024; - - let body = br#"{"v":1,"sender_persona":"aa","sender_name":"Al","text":"hi","return_path":"bb","reply_pubkey":"cc"}"#; - let sealed = seal_greeting_body(&bob_pub, &bio_post_id, body, BUCKET).unwrap(); - assert_eq!(sealed.len(), 32 + 4 + BUCKET + 16, "fixed-size ciphertext"); - - // Different plaintext length → same ciphertext length. - let sealed2 = seal_greeting_body(&bob_pub, &bio_post_id, b"x", BUCKET).unwrap(); - assert_eq!(sealed.len(), sealed2.len(), "bucket hides length"); - - // Recipient opens. - let opened = open_greeting_body(&bob_priv, &bio_post_id, &sealed).unwrap(); - assert_eq!(opened, body.to_vec()); - - // Non-recipient cannot. - assert!(open_greeting_body(&carol_priv, &bio_post_id, &sealed).is_none()); - - // Wrong bio post id cannot. - assert!(open_greeting_body(&bob_priv, &[0x88; 32], &sealed).is_none()); - - // Tampered ciphertext fails AEAD. - let mut tampered = sealed.clone(); - let last = tampered.len() - 1; - tampered[last] ^= 0x01; - assert!(open_greeting_body(&bob_priv, &bio_post_id, &tampered).is_none()); - - // Oversized body refused. - assert!(seal_greeting_body(&bob_pub, &bio_post_id, &vec![0u8; BUCKET + 1], BUCKET).is_err()); - } - - /// A3: a reply sealed to a fresh `reply_pubkey` opens with the stored - /// reply private key and NOT with the recipient's long-term key. - #[test] - fn greeting_reply_sealed_to_fresh_key_only() { - // Long-term persona key of the greeting's original sender. - let (longterm_priv, _longterm_pub) = make_persona_x25519(40); - // Fresh per-greeting reply keypair minted by that sender. - let (reply_priv, reply_pub) = generate_x25519_keypair(); - let return_path_post: PostId = [0x99; 32]; - - let reply_body = b"hello back"; - let sealed = seal_greeting_body(&reply_pub, &return_path_post, reply_body, 1024).unwrap(); - - // Stored fresh reply key opens it. - let opened = open_greeting_body(&reply_priv, &return_path_post, &sealed).unwrap(); - assert_eq!(opened, reply_body.to_vec()); - - // Long-term key does NOT. - assert!(open_greeting_body(&longterm_priv, &return_path_post, &sealed).is_none()); + // Plain-comment signature still works (backward compat with v0.6.1). + let sig_plain = sign_comment(&seed, &nid, &post_id, content, ts, None); + assert!(verify_comment_signature(&nid, &post_id, content, ts, &sig_plain, None)); } #[test] @@ -1683,6 +1383,7 @@ mod tests { let mut manifest = AuthorManifest { post_id: [42u8; 32], author: node_id, + author_addresses: vec!["10.0.0.1:4433".to_string()], created_at: 1000, updated_at: 2000, previous_posts: vec![ManifestEntry { @@ -1699,97 +1400,6 @@ mod tests { assert!(verify_manifest_signature(&manifest)); } - /// v0.8 (A2): manifests must never carry device addresses — neither the - /// author-signed part nor the CdnManifest wire wrapper. - #[test] - fn test_manifest_carries_no_addresses() { - use crate::types::{AuthorManifest, CdnManifest, ManifestEntry}; - let (seed, node_id) = make_keypair(1); - let (_hseed, host_id) = make_keypair(2); - - let mut manifest = AuthorManifest { - post_id: [42u8; 32], - author: node_id, - created_at: 1000, - updated_at: 2000, - previous_posts: vec![ManifestEntry { - post_id: [1u8; 32], - timestamp_ms: 900, - has_attachments: true, - }], - following_posts: vec![], - signature: vec![], - }; - manifest.signature = sign_manifest(&seed, &manifest); - assert!(verify_manifest_signature(&manifest)); - - let author_json = serde_json::to_string(&manifest).unwrap(); - assert!( - !author_json.contains("addresses"), - "AuthorManifest JSON must not contain any address field: {author_json}" - ); - - let cdn = CdnManifest { - author_manifest: manifest, - host: host_id, - }; - let cdn_json = serde_json::to_string(&cdn).unwrap(); - assert!( - !cdn_json.contains("addresses"), - "CdnManifest JSON must not contain any address field: {cdn_json}" - ); - assert!(!cdn_json.contains("downstream_count")); - assert!(!cdn_json.contains("\"source\"")); - } - - /// A1: crypto pairings must use the MATCHED persona's (id, seed) tuple. - /// The historical bug paired the default posting secret with the NETWORK - /// NodeId — a pair that can never unwrap anything. - #[test] - fn test_cek_unwrap_requires_matching_persona_pair() { - let (author_seed, author_id) = make_keypair(1); - let (persona_seed, persona_id) = make_keypair(2); // recipient persona - let (network_seed, network_id) = make_keypair(9); // device network key - - let (_encrypted, wrapped_keys) = - encrypt_post("hi", &author_seed, &author_id, &[persona_id]).unwrap(); - - // Mismatched pair (persona seed + network id): recipient lookup fails. - let r = unwrap_cek_for_recipient(&persona_seed, &network_id, &author_id, &wrapped_keys).unwrap(); - assert!(r.is_none(), "network id must not appear in recipients"); - - // Network pair entirely: also fails. - let r = unwrap_cek_for_recipient(&network_seed, &network_id, &author_id, &wrapped_keys).unwrap(); - assert!(r.is_none()); - - // Matched persona pair: succeeds. - let r = unwrap_cek_for_recipient(&persona_seed, &persona_id, &author_id, &wrapped_keys).unwrap(); - assert!(r.is_some(), "matched persona (id, seed) pair must unwrap the CEK"); - } - - /// A1 (revocation): rewrap_visibility works with the AUTHOR persona's - /// (seed, id) pair — and cannot work with the network pair the old code - /// passed. - #[test] - fn test_rewrap_visibility_uses_author_persona_pair() { - let (author_seed, author_id) = make_keypair(3); // non-default persona - let (_b_seed, bob_id) = make_keypair(4); - let (_c_seed, carol_id) = make_keypair(5); - let (network_seed, network_id) = make_keypair(9); - - let (_encrypted, wrapped_keys) = - encrypt_post("secret", &author_seed, &author_id, &[bob_id, carol_id]).unwrap(); - - // Old bug shape: (default/other secret, network id) — must fail. - assert!(rewrap_visibility(&network_seed, &network_id, &wrapped_keys, &[author_id, bob_id]).is_err()); - - // Correct: the author persona's own pair — succeeds and re-wraps - // for the reduced recipient set. - let new_wrapped = rewrap_visibility(&author_seed, &author_id, &wrapped_keys, &[author_id, bob_id]).unwrap(); - assert!(new_wrapped.iter().any(|wk| wk.recipient == bob_id)); - assert!(!new_wrapped.iter().any(|wk| wk.recipient == carol_id), "revoked recipient must be gone"); - } - #[test] fn test_forged_manifest_rejected() { use crate::types::AuthorManifest; @@ -1799,6 +1409,7 @@ mod tests { let mut manifest = AuthorManifest { post_id: [42u8; 32], author: node_id, + author_addresses: vec![], created_at: 1000, updated_at: 2000, previous_posts: vec![], diff --git a/crates/core/src/export.rs b/crates/core/src/export.rs index 3594b0e..66768dd 100644 --- a/crates/core/src/export.rs +++ b/crates/core/src/export.rs @@ -91,14 +91,7 @@ pub async fn export_data( let (posts, blob_cids) = if scope == ExportScope::IdentityOnly { (vec![], vec![]) } else { - // Own posts are authored by POSTING identities (personas) — the - // network NodeId never authors posts. Export across all personas. - let author_ids: Vec = { - let s = storage.get().await; - s.list_posting_identities()? - .into_iter().map(|p| p.node_id).collect() - }; - gather_posts(storage, &author_ids).await? + gather_posts(storage, node_id).await? }; let (follows, profiles, settings) = if scope == ExportScope::Everything { @@ -252,10 +245,10 @@ pub async fn export_data( }) } -/// Gather own posts (authored by any of our posting identities) and their blob CIDs. +/// Gather own posts and their blob CIDs. async fn gather_posts( storage: &StoragePool, - author_ids: &[NodeId], + node_id: &NodeId, ) -> anyhow::Result<(Vec, Vec<[u8; 32]>)> { let s = storage.get().await; let posts_with_vis = s.list_posts_with_visibility()?; @@ -264,7 +257,7 @@ async fn gather_posts( for (id, post, vis) in &posts_with_vis { // Only export our own posts - if !author_ids.contains(&post.author) { + if post.author != *node_id { continue; } diff --git a/crates/core/src/fof.rs b/crates/core/src/fof.rs index d50d849..f43cd90 100644 --- a/crates/core/src/fof.rs +++ b/crates/core/src/fof.rs @@ -20,11 +20,7 @@ use rand::RngCore; use crate::crypto::{seal_wrap_slot, SealedWrapSlot}; use crate::storage::Storage; -use crate::types::{FoFCommentGating, NodeId, OpenSlotDecl, OpenSlotKind, WrapSlot}; - -/// v0.8 (A3): hard cap on a declared open-slot body bucket. Anything -/// larger on receive is presumed attacker-shaped. -pub const MAX_OPEN_SLOT_BODY_BUCKET: u16 = 4096; +use crate::types::{FoFCommentGating, NodeId, WrapSlot}; /// Build the `FoFCommentGating` block for a post about to be published /// under `CommentPermission::FriendsOfFriends`. The author's keyring @@ -37,16 +33,9 @@ pub const MAX_OPEN_SLOT_BODY_BUCKET: u16 = 4096; /// /// Side effect: this function is pure; no storage writes. The caller /// owns persisting the resulting Post. -/// `open_slot`: when `Some((kind, body_bucket))`, one EXTRA real slot is -/// sealed under the derivable `V_open = -/// derive_open_slot_vx(author, slot_binder_nonce)` and declared in -/// `FoFCommentGating.open_slot` (A3 — greeting/registry open slots). -/// The open slot is a REAL wrap slot sealed by the normal path; what -/// makes it open is only that its V_x is derivable by anyone. pub fn build_fof_comment_gating( storage: &Storage, author_persona_id: &NodeId, - open_slot: Option<(OpenSlotKind, u16)>, ) -> Result> { // Gather the author's keyring with provenance: (V_x, owner, epoch). // The author's own V_me appears with owner=author_persona_id. @@ -81,9 +70,6 @@ pub fn build_fof_comment_gating( // (slot_index, owner, epoch, pub_x) afterward for provenance. enum EntryKind { Real { v_x_owner: NodeId, v_x_epoch: u32 }, - /// A3: the derivable open slot (greeting/registry). Not part of - /// V_x provenance — its key is public by construction. - Open, Dummy, } let mut entries: Vec<(EntryKind, [u8; 32], WrapSlot)> = Vec::with_capacity(tagged_keys.len()); @@ -102,25 +88,6 @@ pub fn build_fof_comment_gating( entries.push((EntryKind::Real { v_x_owner: *owner, v_x_epoch: *epoch }, pub_x, slot)); } - // A3: seal the open slot (if requested) under the derivable V_open. - if open_slot.is_some() { - let v_open = crate::crypto::derive_open_slot_vx(author_persona_id, &slot_binder_nonce); - let mut seed = [0u8; 32]; - rand::rng().fill_bytes(&mut seed); - let signing_key = SigningKey::from_bytes(&seed); - let pub_x = *signing_key.verifying_key().as_bytes(); - let sealed: SealedWrapSlot = seal_wrap_slot(&v_open, &slot_binder_nonce, &cek, &seed)?; - entries.push(( - EntryKind::Open, - pub_x, - WrapSlot { - prefilter_tag: sealed.prefilter_tag, - read_ciphertext: sealed.read_ciphertext, - sign_ciphertext: sealed.sign_ciphertext, - }, - )); - } - // Pad to bucket with dummies. let bucket = crate::profile::next_vouch_batch_bucket(entries.len()); let mut rng = rand::rng(); @@ -150,39 +117,24 @@ pub fn build_fof_comment_gating( let mut pub_post_set: Vec<[u8; 32]> = Vec::with_capacity(entries.len()); let mut wrap_slots: Vec = Vec::with_capacity(entries.len()); let mut real_slot_provenance: Vec = Vec::new(); - let mut open_slot_index: Option = None; for (idx, (kind, pub_x, slot)) in entries.into_iter().enumerate() { - match kind { - EntryKind::Real { v_x_owner, v_x_epoch } => { - real_slot_provenance.push(RealSlotProvenance { - slot_index: idx as u32, - v_x_owner, - v_x_epoch, - pub_x, - }); - } - EntryKind::Open => open_slot_index = Some(idx as u32), - EntryKind::Dummy => {} + if let EntryKind::Real { v_x_owner, v_x_epoch } = kind { + real_slot_provenance.push(RealSlotProvenance { + slot_index: idx as u32, + v_x_owner, + v_x_epoch, + pub_x, + }); } pub_post_set.push(pub_x); wrap_slots.push(slot); } - let open_slot_decl = match (open_slot, open_slot_index) { - (Some((kind, body_bucket)), Some(slot_index)) => Some(OpenSlotDecl { - slot_index, - kind, - body_bucket, - }), - _ => None, - }; - let gating = FoFCommentGating { slot_binder_nonce, pub_post_set, wrap_slots, revocation_list: Vec::new(), - open_slot: open_slot_decl, }; Ok(Some(FoFCommentGatingBuilt { @@ -190,7 +142,6 @@ pub fn build_fof_comment_gating( cek, slot_binder_nonce, real_slot_provenance, - open_slot_index, })) } @@ -213,9 +164,6 @@ pub struct FoFCommentGatingBuilt { /// `own_post_slot_provenance` for later cascade revocations. /// Each entry: (slot_index, v_x_owner, v_x_epoch, pub_x). pub real_slot_provenance: Vec, - /// A3: index of the derivable open slot, when one was requested. - /// Mirrors `gating.open_slot.slot_index`. - pub open_slot_index: Option, } /// One entry per real (non-dummy) slot in a published FoF post. @@ -366,37 +314,6 @@ pub fn sweep_unreadable_on_new_v_x( Ok(unlocked) } -/// v0.8 (A3): the stranger-side derive+open helper. Derives the post's -/// open-slot `V_open` from the post alone and opens the DECLARED slot, -/// returning a `PostUnlock` usable with [`build_fof_comment`]. -/// `persona_id` is filled with the caller-provided commenter id -/// (typically a throwaway identity minted per greeting). -/// -/// Returns `None` when the post has no gating, no open-slot declaration, -/// or the declared slot doesn't open under the derived key (malformed or -/// key-burned open slot = the author's global off-switch). -pub fn derive_open_slot_unlock( - post: &crate::types::Post, - commenter_persona_id: &NodeId, -) -> Option { - let gating = post.fof_gating.as_ref()?; - let decl = gating.open_slot.as_ref()?; - let slot = gating.wrap_slots.get(decl.slot_index as usize)?; - let v_open = crate::crypto::derive_open_slot_vx(&post.author, &gating.slot_binder_nonce); - let opened = crate::crypto::open_wrap_slot( - &v_open, - &gating.slot_binder_nonce, - &slot.read_ciphertext, - &slot.sign_ciphertext, - )?; - Some(PostUnlock { - persona_id: *commenter_persona_id, - slot_index: decl.slot_index, - cek: opened.cek, - priv_x_seed: opened.priv_x_seed, - }) -} - /// Inner helper: prefilter + AEAD-open against a single V_x. fn try_unlock_with_v_x( gating: &crate::types::FoFCommentGating, @@ -459,7 +376,6 @@ pub fn build_fof_comment( body: &str, parent_comment_id: Option<[u8; 32]>, now_ms: u64, - expires_at_ms: u64, ) -> Result { use ed25519_dalek::{Signer, SigningKey}; @@ -495,7 +411,6 @@ pub fn build_fof_comment( "", now_ms, None, - expires_at_ms, ); Ok(crate::types::InlineComment { @@ -509,7 +424,6 @@ pub fn build_fof_comment( pub_x_index: Some(unlock.slot_index), group_sig: Some(group_sig), encrypted_payload: Some(encrypted), - expires_at_ms, }) } @@ -525,14 +439,7 @@ pub fn verify_fof_group_sig( use ed25519_dalek::{Signature, Verifier, VerifyingKey}; let Some(pub_x_index) = comment.pub_x_index else { return false; }; let Some(group_sig) = comment.group_sig.as_ref() else { return false; }; - // A3: plaintext open-slot comments (registry entries) carry their - // body in `content` with no encrypted_payload — the group_sig then - // covers the content bytes instead. FoF/greeting comments always - // carry Some(encrypted_payload) as before. - let encrypted_payload: &[u8] = match comment.encrypted_payload.as_deref() { - Some(p) => p, - None => comment.content.as_bytes(), - }; + let Some(encrypted_payload) = comment.encrypted_payload.as_ref() else { return false; }; let idx = pub_x_index as usize; if idx >= gating.pub_post_set.len() { return false; } if group_sig.len() != 64 { return false; } @@ -572,10 +479,9 @@ pub fn decrypt_fof_comment_payload( // re-propagated via neighbor-manifest diffs. /// Maximum allowed wrap_slots / pub_post_set entries on an incoming -/// FoF post. The bucket rule (`next_vouch_batch_bucket` in profile.rs) is -/// deterministic: ≤8 → 8; ≤256 → next power of two; >256 → next multiple -/// of 128. A 4096-vouchee realistic max buckets to 4096; 8192 gives 2x -/// headroom, anything larger is presumed attacker-shaped. +/// FoF post. The bucket rule caps at `real + rand(0..=128)` above 256; +/// at a 4096-vouchee max realistic graph that's ~4224. Round up for +/// headroom; anything larger is presumed attacker-shaped. const MAX_FOF_WRAP_SLOTS: usize = 8192; /// Maximum allowed revocation_list entries in a t=0 published gating @@ -646,24 +552,6 @@ pub fn validate_fof_gating_on_receive(post: &crate::types::Post) -> Result<()> { ); } - // A3: open-slot declaration sanity. - if let Some(decl) = gating.open_slot.as_ref() { - if decl.slot_index as usize >= gating.wrap_slots.len() { - anyhow::bail!( - "open_slot.slot_index {} out of bounds ({} slots)", - decl.slot_index, - gating.wrap_slots.len(), - ); - } - if decl.body_bucket == 0 || decl.body_bucket > MAX_OPEN_SLOT_BODY_BUCKET { - anyhow::bail!( - "open_slot.body_bucket {} out of range (1..={})", - decl.body_bucket, - MAX_OPEN_SLOT_BODY_BUCKET, - ); - } - } - Ok(()) } @@ -1067,7 +955,7 @@ mod tests { rand::rng().fill_bytes(&mut v_x_bob); s.insert_received_vouch_key(&alice_id, &bob_id, 1, &v_x_bob, 2000, None).unwrap(); - let built = build_fof_comment_gating(&s, &alice_id, None).unwrap().expect("gating built"); + let built = build_fof_comment_gating(&s, &alice_id).unwrap().expect("gating built"); // Real count = 2 (own + Bob). Bucket = 8 (minimum floor). assert_eq!(built.gating.pub_post_set.len(), 8); assert_eq!(built.gating.wrap_slots.len(), 8); @@ -1121,7 +1009,7 @@ mod tests { let s = temp_storage(); let (alice_id, _) = make_persona(5); // No V_me inserted. - let built = build_fof_comment_gating(&s, &alice_id, None).unwrap(); + let built = build_fof_comment_gating(&s, &alice_id).unwrap(); assert!(built.is_none(), "no V_me → no gating block"); } @@ -1143,7 +1031,7 @@ mod tests { s.insert_received_vouch_key(&alice_id, &bob_id, 1, &same_key, 2000, None).unwrap(); s.insert_received_vouch_key(&alice_id, &carol_id, 1, &same_key, 3000, None).unwrap(); - let built = build_fof_comment_gating(&s, &alice_id, None).unwrap().expect("built"); + let built = build_fof_comment_gating(&s, &alice_id).unwrap().expect("built"); // Unique-key count = 2 (V_me_alice + same_key). Bucket = 8. // We can't assert real count directly without exposing internals, // but we can confirm exactly two distinct successful unwraps: @@ -1200,7 +1088,7 @@ mod tests { alice_storage.insert_received_vouch_key(&alice_id, &bob_id, 1, &v_x_bob, 2000, None).unwrap(); // Alice publishes the gating block. - let built = build_fof_comment_gating(&alice_storage, &alice_id, None).unwrap().expect("built"); + let built = build_fof_comment_gating(&alice_storage, &alice_id).unwrap().expect("built"); let parent_post_id = [0xCC; 32]; // Bob's device: has his V_me (which is v_x_bob since he handed it out). @@ -1240,7 +1128,6 @@ mod tests { "great post alice", None, 4000, - 4_000_000_000_000, ).unwrap(); assert!(comment.content.is_empty(), "FoF comment body is encrypted, not in content"); assert!(comment.pub_x_index.is_some()); @@ -1296,7 +1183,7 @@ mod tests { rand::rng().fill_bytes(&mut v_x_bob); s.insert_received_vouch_key(&alice_id, &bob_id, 1, &v_x_bob, 2000, None).unwrap(); - let built = build_fof_comment_gating(&s, &alice_id, None).unwrap().expect("built"); + let built = build_fof_comment_gating(&s, &alice_id).unwrap().expect("built"); let post_id = [0xDE; 32]; // Persist the post so apply_fof_revocation_locally can resolve @@ -1341,7 +1228,7 @@ mod tests { }; let comment = build_fof_comment( &post_id, &bob_unlock, &built.slot_binder_nonce, - &bob_id, &bob_seed, "hello", None, 4000, 4_000_000_000_000, + &bob_id, &bob_seed, "hello", None, 4000, ).unwrap(); s.store_comment(&comment).unwrap(); assert_eq!(s.get_comments(&post_id).unwrap().len(), 1, "Bob's comment stored"); @@ -1390,7 +1277,7 @@ mod tests { s.insert_own_vouch_key(&alice_id, 1, &v_me_alice, 1000).unwrap(); // Initial gating: Alice only. - let built = build_fof_comment_gating(&s, &alice_id, None).unwrap().expect("built"); + let built = build_fof_comment_gating(&s, &alice_id).unwrap().expect("built"); let post_id = [0xBC; 32]; let post = crate::types::Post { author: alice_id, content: "alice".into(), attachments: vec![], @@ -1492,7 +1379,7 @@ mod tests { rand::rng().fill_bytes(&mut v_me_old); s.insert_own_vouch_key(&alice_id, 1, &v_me_old, 1000).unwrap(); - let built = build_fof_comment_gating(&s, &alice_id, None).unwrap().expect("built"); + let built = build_fof_comment_gating(&s, &alice_id).unwrap().expect("built"); let post_id = [0xAB; 32]; let post = crate::types::Post { author: alice_id, content: "x".into(), attachments: vec![], @@ -1582,7 +1469,6 @@ mod tests { pub_post_set: (0..slot_count).map(|_| [0u8; 32]).collect(), wrap_slots: (0..slot_count).map(|_| dummy_wrap_slot()).collect(), revocation_list: vec![], - open_slot: None, } } @@ -1688,7 +1574,7 @@ mod tests { rand::rng().fill_bytes(&mut v_me_alice); s.insert_own_vouch_key(&alice_id, 1, &v_me_alice, 1000).unwrap(); - let built = build_fof_comment_gating(&s, &alice_id, None).unwrap().expect("built"); + let built = build_fof_comment_gating(&s, &alice_id).unwrap().expect("built"); let post_id = [0xEE; 32]; let post = crate::types::Post { author: alice_id, content: String::new(), attachments: vec![], @@ -1820,7 +1706,7 @@ mod tests { rand::rng().fill_bytes(&mut v_me_alice); alice_storage.insert_own_vouch_key(&alice_id, 1, &v_me_alice, 500).unwrap(); alice_storage.insert_received_vouch_key(&alice_id, &bob_id, 1, &v_x_bob, 600, None).unwrap(); - let built = build_fof_comment_gating(&alice_storage, &alice_id, None).unwrap().expect("built"); + let built = build_fof_comment_gating(&alice_storage, &alice_id).unwrap().expect("built"); let post = crate::types::Post { author: alice_id, content: String::new(), attachments: vec![], timestamp_ms: 3000, fof_gating: Some(built.gating.clone()), @@ -1864,7 +1750,7 @@ mod tests { rand::rng().fill_bytes(&mut v_x_carol); alice_storage.insert_received_vouch_key(&alice_id, &carol_id, 1, &v_x_carol, 200, None).unwrap(); - let built = build_fof_comment_gating(&alice_storage, &alice_id, None).unwrap().expect("built"); + let built = build_fof_comment_gating(&alice_storage, &alice_id).unwrap().expect("built"); // Bob's storage: holds his own V_me only (no Carol-V_x). The post // shouldn't unlock for him yet. @@ -1940,7 +1826,7 @@ mod tests { rand::rng().fill_bytes(&mut v_x_bob); s.insert_received_vouch_key(&alice_id, &bob_id, 1, &v_x_bob, 2000, None).unwrap(); - let built = build_fof_comment_gating(&s, &alice_id, None).unwrap().expect("built"); + let built = build_fof_comment_gating(&s, &alice_id).unwrap().expect("built"); let body_plaintext = "secret to the FoF set only"; let body_ct = encrypt_fof_body(body_plaintext, &built.cek, &built.slot_binder_nonce).unwrap(); @@ -2019,7 +1905,7 @@ mod tests { s.insert_received_vouch_key(&alice_id, &bob_id, 3, &v_x_bob, 2000, None).unwrap(); s.insert_received_vouch_key(&alice_id, &carol_id, 5, &v_x_carol, 3000, None).unwrap(); - let built = build_fof_comment_gating(&s, &alice_id, None).unwrap().expect("built"); + let built = build_fof_comment_gating(&s, &alice_id).unwrap().expect("built"); // 3 unique V_x's → 3 real slots, padded to bucket 8. assert_eq!(built.real_slot_provenance.len(), 3); assert_eq!(built.gating.pub_post_set.len(), 8); @@ -2076,105 +1962,4 @@ mod tests { assert!(verify_fof_revocation(&mallory_id, &post_id, &revoked_pub_x, 1000, 0, &sig)); let _ = alice_seed; } - - // --- v0.8 (A3): open-slot tests --- - - /// Gating built with an open slot: declaration present, validates on - /// receive; a stranger (no vouch keys at all) derives V_open from the - /// post alone, recovers CEK + signing seed, and a comment built with - /// the derived key passes the CDN group_sig check unmodified. - #[test] - fn open_slot_stranger_derive_and_comment() { - use crate::types::{OpenSlotKind, PostingIdentity}; - use ed25519_dalek::SigningKey; - - let s = temp_storage(); - let (alice_id, alice_seed) = make_persona(140); - s.upsert_posting_identity(&PostingIdentity { - node_id: alice_id, secret_seed: alice_seed, - display_name: "Alice".into(), created_at: 1000, - }).unwrap(); - let mut v_me_alice = [0u8; 32]; - rand::rng().fill_bytes(&mut v_me_alice); - s.insert_own_vouch_key(&alice_id, 1, &v_me_alice, 1000).unwrap(); - - let built = build_fof_comment_gating(&s, &alice_id, Some((OpenSlotKind::Greeting, 1024))) - .unwrap().expect("built"); - let decl = built.gating.open_slot.as_ref().expect("open slot declared"); - assert_eq!(decl.kind, OpenSlotKind::Greeting); - assert_eq!(decl.body_bucket, 1024); - assert_eq!(Some(decl.slot_index), built.open_slot_index); - assert!((decl.slot_index as usize) < built.gating.wrap_slots.len()); - - let post = crate::types::Post { - author: alice_id, content: String::new(), attachments: vec![], - timestamp_ms: 3000, fof_gating: Some(built.gating.clone()), - supersedes_post_id: None, - }; - - // Wire-shape validation passes. - validate_fof_gating_on_receive(&post).unwrap(); - - // A stranger mints a throwaway identity, derives the unlock. - let (stranger_id, stranger_seed) = make_persona(141); - let unlock = derive_open_slot_unlock(&post, &stranger_id) - .expect("stranger derives the open slot unlock"); - assert_eq!(unlock.cek, built.cek, "stranger recovers the (public) CEK"); - assert_eq!(unlock.slot_index, decl.slot_index); - - // The derived signing seed matches the published pub_x. - let derived_pub = SigningKey::from_bytes(&unlock.priv_x_seed).verifying_key().to_bytes(); - assert_eq!(built.gating.pub_post_set[decl.slot_index as usize], derived_pub); - - // Stranger authors a comment through the normal FoF path — must - // pass the CDN group_sig gate unmodified. - let comment = build_fof_comment( - &crate::content::compute_post_id(&post), &unlock, - &built.slot_binder_nonce, &stranger_id, &stranger_seed, - "hello, stranger here", None, 4000, 4_000_000_000_000, - ).unwrap(); - assert!(verify_fof_group_sig(&comment, &built.gating)); - } - - /// Open-slot declaration bounds are enforced on receive. - #[test] - fn open_slot_validation_bounds() { - use crate::types::{OpenSlotDecl, OpenSlotKind}; - - // slot_index out of bounds. - let mut g = dummy_gating(8); - g.open_slot = Some(OpenSlotDecl { slot_index: 8, kind: OpenSlotKind::Greeting, body_bucket: 1024 }); - let p = dummy_post(Some(g)); - let err = validate_fof_gating_on_receive(&p).unwrap_err().to_string(); - assert!(err.contains("out of bounds"), "got: {}", err); - - // body_bucket too big. - let mut g = dummy_gating(8); - g.open_slot = Some(OpenSlotDecl { slot_index: 0, kind: OpenSlotKind::Registry, body_bucket: 4097 }); - let p = dummy_post(Some(g)); - let err = validate_fof_gating_on_receive(&p).unwrap_err().to_string(); - assert!(err.contains("out of range"), "got: {}", err); - - // body_bucket zero. - let mut g = dummy_gating(8); - g.open_slot = Some(OpenSlotDecl { slot_index: 0, kind: OpenSlotKind::Greeting, body_bucket: 0 }); - let p = dummy_post(Some(g)); - assert!(validate_fof_gating_on_receive(&p).is_err()); - - // Well-formed decl passes. - let mut g = dummy_gating(8); - g.open_slot = Some(OpenSlotDecl { slot_index: 3, kind: OpenSlotKind::Greeting, body_bucket: 1024 }); - let p = dummy_post(Some(g)); - validate_fof_gating_on_receive(&p).unwrap(); - } - - /// No open slot declared → derive_open_slot_unlock returns None even - /// though the gating exists. - #[test] - fn open_slot_unlock_requires_declaration() { - let g = dummy_gating(8); - let p = dummy_post(Some(g)); - let (stranger, _) = make_persona(150); - assert!(derive_open_slot_unlock(&p, &stranger).is_none()); - } } diff --git a/crates/core/src/group_key_distribution.rs b/crates/core/src/group_key_distribution.rs index d835757..0a753c2 100644 --- a/crates/core/src/group_key_distribution.rs +++ b/crates/core/src/group_key_distribution.rs @@ -1,9 +1,15 @@ -//! Group-key distribution as an encrypted post: the group seed travels -//! inside `PostVisibility::Encrypted`. Each member is a recipient; the +//! Group-key distribution as an encrypted post. +//! +//! v0.6.2 replaces the v0.6.1 `GroupKeyDistribute` wire push (admin → +//! member, uni-stream) with a standard public post that carries the group +//! seed inside `PostVisibility::Encrypted`. Each member is a recipient; the //! post's CEK is wrapped per member using the admin's posting key. Members //! receive the post via normal CDN / pull paths, decrypt with their posting -//! secret, and recover the seed + metadata. (No direct wire push — that -//! would signal which endpoints coordinate group membership.) +//! secret, and recover the seed + metadata. +//! +//! Removing the direct push eliminates the wire-level signal that a given +//! network endpoint is coordinating group membership with another specific +//! endpoint. //! //! Note: Members are identified by their **posting** NodeIds (the //! author/recipient namespace since the v0.6.1 identity split), not network @@ -14,7 +20,7 @@ use crate::content::compute_post_id; use crate::crypto; use crate::storage::Storage; use crate::types::{ - GroupKeyDistributionContent, GroupKeyRecord, NodeId, Post, PostId, + GroupKeyDistributionContent, GroupKeyRecord, GroupMemberKey, NodeId, Post, PostId, PostVisibility, PostingIdentity, VisibilityIntent, }; @@ -291,40 +297,4 @@ mod tests { assert!(!applied2); assert!(s2.get_group_key(&group_id).unwrap().is_none()); } - - /// A1 (multi-persona): a distribution post addressed to our SECOND - /// persona must still be applied — the trial-unwrap iterates ALL held - /// posting identities, not just the default one. - #[test] - fn second_persona_member_applies_distribution() { - let s = temp_storage(); - let (admin_sec, admin_id) = make_keypair(1); - let (default_sec, default_id) = make_keypair(2); // default persona (not a member) - let (second_sec, second_id) = make_keypair(3); // second persona (the member) - - let group_id = [43u8; 32]; - let record = GroupKeyRecord { - group_id, - circle_name: "second".to_string(), - epoch: 1, - group_public_key: [8u8; 32], - admin: admin_id, - created_at: 100, - canonical_root_post_id: None, - }; - let group_seed = [11u8; 32]; - - // Addressed ONLY to the second persona. - let (_pid, post, visibility) = build_distribution_post( - &admin_id, &admin_sec, &record, &group_seed, &[second_id], - ).unwrap(); - - let personas = vec![ - mk_persona(default_sec, default_id), - mk_persona(second_sec, second_id), - ]; - let applied = try_apply_distribution_post(&s, &post, &visibility, &personas).unwrap(); - assert!(applied, "second persona must unwrap the group seed"); - assert_eq!(s.get_group_seed(&group_id, 1).unwrap().unwrap(), group_seed); - } } diff --git a/crates/core/src/http.rs b/crates/core/src/http.rs index 3e43d3a..d79fcb1 100644 --- a/crates/core/src/http.rs +++ b/crates/core/src/http.rs @@ -8,6 +8,7 @@ use std::sync::Arc; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpStream; +use tokio::sync::Mutex; use tracing::{debug, info}; use crate::blob::BlobStore; @@ -105,6 +106,7 @@ pub async fn run_http_server( port: u16, storage: Arc, blob_store: Arc, + downstream_addrs: Arc>>>, ) -> anyhow::Result<()> { let addr: SocketAddr = ([0, 0, 0, 0], port).into(); // Use SO_REUSEADDR + SO_REUSEPORT so TCP punch sockets can share the port @@ -154,9 +156,10 @@ pub async fn run_http_server( let storage = Arc::clone(&storage); let blob_store = Arc::clone(&blob_store); let budget = Arc::clone(&budget); + let downstream_addrs = Arc::clone(&downstream_addrs); tokio::spawn(async move { - handle_connection(stream, ip, slot, &storage, &blob_store).await; + handle_connection(stream, ip, slot, &storage, &blob_store, &downstream_addrs).await; let mut b = budget.lock().unwrap(); match slot { SlotKind::Content => b.release_content(ip), @@ -179,6 +182,7 @@ async fn handle_connection( slot: SlotKind, storage: &Arc, blob_store: &Arc, + downstream_addrs: &Arc>>>, ) { // Keep-alive loop: handle sequential requests on the same connection loop { @@ -218,7 +222,7 @@ async fn handle_connection( } } SlotKind::Redirect => { - if !try_redirect(&mut stream, &post_id, storage).await { + if !try_redirect(&mut stream, &post_id, storage, downstream_addrs).await { return; } } @@ -364,6 +368,7 @@ async fn try_redirect( stream: &mut TcpStream, post_id: &[u8; 32], storage: &Arc, + _downstream_addrs: &Arc>>>, ) -> bool { // Get downstream peers for this post let downstream_peers = { @@ -578,6 +583,114 @@ pub fn html_escape(s: &str) -> String { out } +// --- Share link generation --- + +/// Encode a list of socket addresses as compact binary, then base64url. +/// Per IPv4: [0x04][4 bytes IP][2 bytes port] = 7 bytes +/// Per IPv6: [0x06][16 bytes IP][2 bytes port] = 19 bytes +pub fn encode_hostlist(hosts: &[SocketAddr]) -> String { + let mut buf = Vec::with_capacity(hosts.len() * 19); + for host in hosts.iter().take(5) { + match host { + SocketAddr::V4(v4) => { + buf.push(0x04); + buf.extend_from_slice(&v4.ip().octets()); + buf.extend_from_slice(&v4.port().to_be_bytes()); + } + SocketAddr::V6(v6) => { + buf.push(0x06); + buf.extend_from_slice(&v6.ip().octets()); + buf.extend_from_slice(&v6.port().to_be_bytes()); + } + } + } + base64url_encode(&buf) +} + +/// Decode a base64url-encoded hostlist back to socket addresses. +pub fn decode_hostlist(encoded: &str) -> Vec { + let buf = match base64url_decode(encoded) { + Some(b) => b, + None => return Vec::new(), + }; + let mut addrs = Vec::new(); + let mut i = 0; + while i < buf.len() { + match buf[i] { + 0x04 if i + 7 <= buf.len() => { + let ip = std::net::Ipv4Addr::new(buf[i + 1], buf[i + 2], buf[i + 3], buf[i + 4]); + let port = u16::from_be_bytes([buf[i + 5], buf[i + 6]]); + addrs.push(SocketAddr::new(ip.into(), port)); + i += 7; + } + 0x06 if i + 19 <= buf.len() => { + let mut octets = [0u8; 16]; + octets.copy_from_slice(&buf[i + 1..i + 17]); + let ip = std::net::Ipv6Addr::from(octets); + let port = u16::from_be_bytes([buf[i + 17], buf[i + 18]]); + addrs.push(SocketAddr::new(ip.into(), port)); + i += 19; + } + _ => break, // malformed + } + } + addrs +} + +// --- Minimal base64url implementation (no external dependency) --- + +const B64_CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + +fn base64url_encode(data: &[u8]) -> String { + let mut out = String::with_capacity((data.len() * 4 + 2) / 3); + let mut i = 0; + while i + 2 < data.len() { + let n = ((data[i] as u32) << 16) | ((data[i + 1] as u32) << 8) | data[i + 2] as u32; + out.push(B64_CHARS[((n >> 18) & 0x3F) as usize] as char); + out.push(B64_CHARS[((n >> 12) & 0x3F) as usize] as char); + out.push(B64_CHARS[((n >> 6) & 0x3F) as usize] as char); + out.push(B64_CHARS[(n & 0x3F) as usize] as char); + i += 3; + } + let remaining = data.len() - i; + if remaining == 2 { + let n = ((data[i] as u32) << 16) | ((data[i + 1] as u32) << 8); + out.push(B64_CHARS[((n >> 18) & 0x3F) as usize] as char); + out.push(B64_CHARS[((n >> 12) & 0x3F) as usize] as char); + out.push(B64_CHARS[((n >> 6) & 0x3F) as usize] as char); + } else if remaining == 1 { + let n = (data[i] as u32) << 16; + out.push(B64_CHARS[((n >> 18) & 0x3F) as usize] as char); + out.push(B64_CHARS[((n >> 12) & 0x3F) as usize] as char); + } + out // no padding +} + +fn base64url_decode(s: &str) -> Option> { + let mut buf = Vec::with_capacity(s.len() * 3 / 4); + let mut accum: u32 = 0; + let mut bits: u32 = 0; + for c in s.bytes() { + let val = match c { + b'A'..=b'Z' => c - b'A', + b'a'..=b'z' => c - b'a' + 26, + b'0'..=b'9' => c - b'0' + 52, + b'-' => 62, + b'_' => 63, + b'=' => continue, // skip padding + _ => return None, + }; + accum = (accum << 6) | val as u32; + bits += 6; + if bits >= 8 { + bits -= 8; + buf.push((accum >> bits) as u8); + accum &= (1 << bits) - 1; + } + } + Some(buf) +} + #[cfg(test)] mod tests { use super::*; @@ -600,6 +713,26 @@ mod tests { assert_eq!(html_escape("a&b"), "a&b"); } + #[test] + fn test_base64url_roundtrip() { + let data = b"hello world"; + let encoded = base64url_encode(data); + let decoded = base64url_decode(&encoded).unwrap(); + assert_eq!(decoded, data); + } + + #[test] + fn test_hostlist_roundtrip() { + use std::net::{Ipv4Addr, Ipv6Addr}; + let hosts = vec![ + SocketAddr::new(Ipv4Addr::new(192, 168, 1, 1).into(), 4433), + SocketAddr::new(Ipv6Addr::LOCALHOST.into(), 8080), + ]; + let encoded = encode_hostlist(&hosts); + let decoded = decode_hostlist(&encoded); + assert_eq!(decoded, hosts); + } + #[test] fn test_parse_request_line() { let req = b"GET /p/abc123 HTTP/1.1\r\nHost: example.com\r\n\r\n"; diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 290d93f..3c679a0 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -17,7 +17,6 @@ pub mod network; pub mod node; pub mod profile; pub mod protocol; -pub mod registry; pub mod storage; pub mod stun; pub mod types; diff --git a/crates/core/src/network.rs b/crates/core/src/network.rs index 16274b9..6466eff 100644 --- a/crates/core/src/network.rs +++ b/crates/core/src/network.rs @@ -10,15 +10,16 @@ use tracing::{debug, error, info, warn}; use crate::activity::{ActivityCategory, ActivityLevel, ActivityLog}; use crate::blob::BlobStore; use crate::connection::{initial_exchange_accept, initial_exchange_connect, ConnHandle, ConnectionActor, ConnectionManager, ExchangeResult}; +use crate::content::verify_post_id; use crate::protocol::{ read_message_type, read_payload, write_typed_message, BlobRequestPayload, BlobResponsePayload, MessageType, ProfileUpdatePayload, - RefuseRedirectPayload, - ALPN, + PullSyncRequestPayload, PullSyncResponsePayload, RefuseRedirectPayload, + ALPN_V2, }; use crate::storage::StoragePool; use crate::types::{ - DeviceProfile, DeviceRole, NodeId, MeshSlot, Post, PostId, + DeviceProfile, DeviceRole, NodeId, PeerSlotKind, Post, PostId, PostVisibility, PublicProfile, SessionReachMethod, WormResult, }; @@ -94,6 +95,7 @@ impl Network { secret_key: iroh::SecretKey, storage: Arc, bind_addr: Option, + secret_seed: [u8; 32], blob_store: Arc, profile: DeviceProfile, activity_log: Arc>, @@ -101,7 +103,7 @@ impl Network { let mut builder = iroh::Endpoint::builder() .secret_key(secret_key) .relay_mode(iroh::RelayMode::Disabled) - .alpns(vec![ALPN.to_vec()]) + .alpns(vec![ALPN_V2.to_vec()]) .clear_address_lookup(); // Remove default pkarr + DNS (no dns.iroh.link publishing) // mDNS LAN discovery only: enables automatic peer discovery on local network @@ -234,6 +236,7 @@ impl Network { Arc::clone(&storage), our_node_id, Arc::clone(&is_anchor), + secret_seed, blob_store, profile, Arc::clone(&activity_log), @@ -733,16 +736,13 @@ impl Network { recv: iroh::endpoint::RecvStream, is_mesh: &AtomicBool, ) -> bool { - // Try to allocate a slot. `None` = both the mesh pool and the temp - // referral band are full. - let slot = conn_handle.accept_connection( + // Try to allocate a slot + let accepted = conn_handle.accept_connection( conn.clone(), remote_node_id, Some(remote_sock), ).await; - info!(peer = hex::encode(remote_node_id), accepted = slot.is_some(), "try_mesh_upgrade: accept_connection result"); - let slot = match slot { - Some(s) => s, - None => { + info!(peer = hex::encode(remote_node_id), accepted, "try_mesh_upgrade: accept_connection result"); + if !accepted { let redirect = conn_handle.pick_random_redirect_peer(&remote_node_id).await; let payload = RefuseRedirectPayload { reason: "slots full".to_string(), @@ -754,19 +754,13 @@ impl Network { debug!(peer = hex::encode(remote_node_id), "Refused mesh connection (slots full)"); conn_handle.add_session(remote_node_id, conn.clone(), crate::types::SessionReachMethod::Direct, Some(remote_sock)).await; conn_handle.log_activity(ActivityLevel::Info, ActivityCategory::Connection, format!("Refused {} mesh (slots full), added session", &hex::encode(remote_node_id)[..8]), Some(remote_node_id)); - return false; - } - }; + return false; + } { let s = storage.get().await; - // bugs-fixed #2: address always persisted, temp slots included. let _ = s.upsert_peer(&remote_node_id, &[remote_sock], None); - // But mesh_peers ONLY for a real mesh slot — that omission is what - // keeps temp referral peers out of our uniques announce. - if slot.is_mesh() { - let _ = s.add_mesh_peer(&remote_node_id); - } + let _ = s.add_mesh_peer(&remote_node_id, PeerSlotKind::Local, 0); if s.has_social_route(&remote_node_id).unwrap_or(false) { let _ = s.touch_social_route_connect( &remote_node_id, @@ -782,10 +776,9 @@ impl Network { let our_nat_type = conn_handle.nat_type().await; let our_http_capable = conn_handle.is_http_capable(); let our_http_addr = conn_handle.http_addr(); - let our_depth = conn_handle.max_bounce(); - match initial_exchange_accept(storage, &our_node_id, send, recv, remote_node_id, anchor_addr, Some(remote_sock), our_nat_type, our_http_capable, our_http_addr, conn_handle.device_role(), None, false, our_depth, slot.is_mesh()).await { + match initial_exchange_accept(storage, &our_node_id, send, recv, remote_node_id, anchor_addr, Some(remote_sock), our_nat_type, our_http_capable, our_http_addr, conn_handle.device_role(), None, false).await { Ok(()) => { - info!(peer = hex::encode(remote_node_id), slot = %slot, "Initial exchange complete (upgraded to mesh)"); + info!(peer = hex::encode(remote_node_id), "Initial exchange complete (upgraded to mesh)"); conn_handle.log_activity(ActivityLevel::Info, ActivityCategory::Connection, format!("Upgraded {} to mesh", &hex::encode(remote_node_id)[..8]), Some(remote_node_id)); } Err(e) => { @@ -828,15 +821,12 @@ impl Network { let conn = ConnectionManager::connect_to_unlocked(&self.endpoint, addr).await?; // Register the established connection - let slot = match self.conn_handle.register_connection(peer_id, conn.clone(), addrs).await { - Some(s) => s, - None => anyhow::bail!("no slot available (mesh + temp full)"), - }; + self.conn_handle.register_connection(peer_id, conn.clone(), addrs, PeerSlotKind::Local).await; let anchor_addr = self.conn_handle.build_anchor_advertised_addr().await; let our_nat_type = self.conn_handle.nat_type().await; // Initial exchange WITHOUT holding conn_mgr lock - match initial_exchange_connect(&self.storage, &self.our_node_id, &conn, peer_id, anchor_addr, our_nat_type, self.is_http_capable(), self.http_addr(), Some(self.device_role), None, self.conn_handle.max_bounce(), slot.is_mesh()).await? { + match initial_exchange_connect(&self.storage, &self.our_node_id, &conn, peer_id, anchor_addr, our_nat_type, self.is_http_capable(), self.http_addr(), Some(self.device_role), None).await? { ExchangeResult::Accepted { duplicate_active } => { if duplicate_active { self.duplicate_detected.store(true, std::sync::atomic::Ordering::Relaxed); @@ -882,38 +872,29 @@ impl Network { } } - /// TRANSITIONAL content sync from every connected peer, plus the - /// engagement fetch that Iteration A's registry and greetings ride on. - /// - /// Routes through the actor path (`content_sync_unlocked`), NOT a second - /// local implementation: the old `Network::pull_from_peer` stored posts - /// with `store_post_with_visibility` instead of `control::receive_post`, so - /// it silently dropped `intent` (control posts — deletes, revocations, key - /// distributions — were never processed) and never called - /// `touch_file_holder`, which is why posts received on the startup pull - /// registered no CDN holder. - pub async fn content_sync_all(&self) -> anyhow::Result { + /// Pull from all connected peers. + pub async fn pull_from_all(&self) -> anyhow::Result { let peers = self.conn_handle.connected_peers().await; let mut total_posts = 0; + let mut total_vis = 0; let mut success = 0; for peer_id in peers { - let result = self.conn_handle.content_sync_from_peer(&peer_id).await; + // Uses Network::pull_from_peer which doesn't hold conn_mgr lock during I/O + let result = self.pull_from_peer(&peer_id).await; match result { Ok(stats) => { total_posts += stats.posts_received; + total_vis += stats.visibility_updates; success += 1; - // Engagement fetch is what carries Iteration A's registry - // entries and greeting comments (BlobHeaderRequest/Response - // + BlobHeaderDiff). It MUST stay driven on a timer — this - // is the pull side of that safety net. + // Also fetch engagement data let _ = self.conn_handle.fetch_engagement_from_peer(&peer_id).await; if stats.posts_received > 0 { info!( peer = hex::encode(peer_id), posts = stats.posts_received, - "Content sync: received posts" + "Pulled posts" ); } } @@ -921,7 +902,7 @@ impl Network { debug!( peer = hex::encode(peer_id), error = %e, - "Content sync failed" + "Pull failed" ); } } @@ -930,97 +911,96 @@ impl Network { Ok(PullStats { peers_pulled: success, posts_received: total_posts, + visibility_updates: total_vis, }) } - /// Run the uniques-index exchange (0x40/0x41) against every MESH peer. - /// - /// This is what a "pull" means in v0.8: an exchange of the IDs each side - /// has a live path to. Symmetric, so one round trip refreshes both indexes. - /// Temp referral slots are skipped — they carry no knowledge. - pub async fn uniques_pull_all(&self) -> usize { - let conns = self.conn_handle.get_connection_map().await; - let mut exchanged = 0; - for (peer_id, conn, slot, _) in conns { - if !slot.is_mesh() { - continue; - } - let fut = crate::connection::ConnectionManager::uniques_pull_unlocked( - &self.conn_mgr, - conn, - &peer_id, - ); - match tokio::time::timeout(std::time::Duration::from_secs(20), fut).await { - Ok(Ok(n)) => { - exchanged += 1; - if n > 0 { - debug!(peer = hex::encode(peer_id), rows = n, "Uniques pull: index updated"); - } - } - Ok(Err(e)) => debug!(peer = hex::encode(peer_id), error = %e, "Uniques pull failed"), - Err(_) => debug!(peer = hex::encode(peer_id), "Uniques pull timed out"), - } + /// Broadcast routing diff to all connected peers. + /// Uses ConnHandle to get diff data, then sends outside the lock. + pub async fn broadcast_diff(&self) -> anyhow::Result { + use crate::protocol::{NodeListUpdatePayload, write_typed_message, MessageType}; + + let snapshot = self.conn_handle.get_diff_data().await; + + if snapshot.n1_added.is_empty() && snapshot.n1_removed.is_empty() + && snapshot.n2_added.is_empty() && snapshot.n2_removed.is_empty() + { + return Ok(0); } - exchanged - } - /// Broadcast our uniques announce to every MESH peer. - /// - /// Temporary referral slots are excluded — they carry no knowledge - /// exchange. Peers that advertised `depth < 4` get the terminal pool - /// stripped before send (the biggest saving on a cellular link). - /// - /// The announce is always a full snapshot; `snapshot.unchanged` is what - /// stands in for incremental diffing, so an idle mesh sends nothing. - pub async fn broadcast_uniques(&self) -> anyhow::Result { - use crate::protocol::{write_typed_message, MessageType, UniquesSlice}; - - let snapshot = self.conn_handle.get_announce_data().await; - let payload = match snapshot.payload { - Some(p) if !snapshot.unchanged => p, - _ => return Ok(0), + let payload = NodeListUpdatePayload { + seq: snapshot.diff_seq, + n1_added: snapshot.n1_added, + n1_removed: snapshot.n1_removed, + n2_added: snapshot.n2_added, + n2_removed: snapshot.n2_removed, }; let mut sent = 0; - for (peer_id, conn, peer_depth) in &snapshot.connections { - let per_peer = crate::protocol::UniquesAnnouncePayload { - seq: payload.seq, - full: payload.full, - fwd: payload.fwd.clone(), - term: if *peer_depth >= 4 { payload.term.clone() } else { UniquesSlice::default() }, - depth: payload.depth, - }; + for (peer_id, conn) in &snapshot.connections { let result = async { let mut send = conn.open_uni().await?; - write_typed_message(&mut send, MessageType::UniquesAnnounce, &per_peer).await?; + write_typed_message(&mut send, MessageType::NodeListUpdate, &payload).await?; send.finish()?; anyhow::Ok(()) }.await; if result.is_ok() { sent += 1; } else { - debug!(peer = hex::encode(peer_id), "Failed to send uniques announce"); + debug!(peer = hex::encode(peer_id), "Failed to send routing diff"); } } Ok(sent) } - /// Periodic catch-up: force the next announce even if the pools are - /// unchanged, so a peer that missed one resynchronises. - /// (The old `broadcast_full_state` is subsumed — every announce is already - /// a full snapshot.) + /// Broadcast full N1/N2 state to all mesh peers (periodic catch-up for missed diffs). pub async fn broadcast_full_state(&self) -> anyhow::Result { - self.conn_handle.force_announce().await; - self.broadcast_uniques().await + use crate::protocol::{NodeListUpdatePayload, write_typed_message, MessageType}; + + let snapshot = self.conn_handle.get_diff_data().await; + + // Build full state: all current N1 and N2 as "added", nothing removed + let all_n1 = self.conn_handle.connected_peers().await; + let all_n2 = { + let storage = self.storage.get().await; + storage.build_n2_share().unwrap_or_default() + }; + + if all_n1.is_empty() && all_n2.is_empty() { + return Ok(0); + } + + let payload = NodeListUpdatePayload { + seq: snapshot.diff_seq, + n1_added: all_n1, + n1_removed: vec![], + n2_added: all_n2, + n2_removed: vec![], + }; + + let mut sent = 0; + for (_peer_id, conn) in &snapshot.connections { + let result = async { + let mut send = conn.open_uni().await?; + write_typed_message(&mut send, MessageType::NodeListUpdate, &payload).await?; + send.finish()?; + anyhow::Ok(()) + }.await; + if result.is_ok() { + sent += 1; + } + } + Ok(sent) } /// Push a profile update to all audience members (ephemeral-capable). pub async fn push_profile(&self, profile: &PublicProfile) -> usize { - // Profiles broadcast on the wire are keyed by the network NodeId. - // They carry ONLY routing metadata (anchors, recent_peers) — no - // display name / bio / avatar. Attaching a human-readable name to - // the network id would correlate the QUIC endpoint to a specific - // person. Persona-level display data travels via signed posts. + // v0.6.1: profiles broadcast on the wire are keyed by the network + // NodeId. They carry ONLY routing metadata (anchors, recent_peers, + // preferred_peers) — no display name / bio / avatar. Attaching a + // human-readable name to the network id would correlate the QUIC + // endpoint to a specific person. Persona-level display data will + // travel via signed posts from v0.6.2 onward. let push_profile = profile.sanitized_for_network_broadcast(); let payload = ProfileUpdatePayload { profiles: vec![push_profile], @@ -1070,6 +1050,32 @@ impl Network { sent } +/// Push a visibility update to all connected peers. + /// Gets connections snapshot, sends I/O outside the lock. + pub async fn push_visibility(&self, update: &crate::types::VisibilityUpdate) -> usize { + use crate::protocol::{VisibilityUpdatePayload, write_typed_message, MessageType}; + + let conns = self.conn_handle.get_connection_map().await; + let payload = VisibilityUpdatePayload { + updates: vec![update.clone()], + }; + let mut sent = 0; + for (peer_id, conn, _, _) in &conns { + let result = async { + let mut send = conn.open_uni().await?; + write_typed_message(&mut send, MessageType::VisibilityUpdate, &payload).await?; + send.finish()?; + anyhow::Ok(()) + }.await; + if result.is_ok() { + sent += 1; + } else { + debug!(peer = hex::encode(peer_id), "Failed to push visibility update"); + } + } + sent + } + /// Push an updated manifest to all known holders of the file (flat set, /// up to 5 most-recent). Replaces the legacy downstream-tree push. pub async fn push_manifest_to_downstream( @@ -1217,7 +1223,7 @@ impl Network { } /// Get connection info for display. - pub async fn connection_info(&self) -> Vec<(NodeId, MeshSlot, u64)> { + pub async fn connection_info(&self) -> Vec<(NodeId, PeerSlotKind, u64)> { self.conn_handle.connection_info().await } @@ -1252,15 +1258,11 @@ impl Network { } match self.connect_to_peer(peer_id, addr.clone()).await { - Ok(()) => { - self.promote_proven_anchor(peer_id, &addr).await; - Ok(()) - } + Ok(()) => Ok(()), Err(e) if e.to_string().contains("mesh refused") => { // Anchor refused mesh — reconnect as session for registration - let conn = ConnectionManager::connect_to_unlocked(&self.endpoint, addr.clone()).await?; + let conn = ConnectionManager::connect_to_unlocked(&self.endpoint, addr).await?; self.conn_handle.add_session(peer_id, conn, crate::types::SessionReachMethod::Direct, None).await; - self.promote_proven_anchor(peer_id, &addr).await; self.conn_handle.log_activity( ActivityLevel::Info, ActivityCategory::Anchor, @@ -1273,28 +1275,6 @@ impl Network { } } - /// An anchor we have COMPLETED a QUIC handshake to. iroh has proven the - /// node ID owns this path, so — and only so — the entry may enter the - /// authoritative address state: `peers.is_anchor` and the `known_anchors` - /// bootstrap cache. - /// - /// This is the counterpart to removing the announce-path mirror. Hearsay - /// anchor claims live in `reachable` only (mineable via `list_pool_anchors`, - /// which is what "the pools ARE the anchor directory" means); proven ones - /// live here, which is also what makes `known_anchors.last_seen_ms` - /// ordering meaningful again — and keeps the DNS-refreshed bootstrap anchor - /// from being evicted by a flood of gossip-sourced entries. - async fn promote_proven_anchor(&self, peer_id: NodeId, addr: &iroh::EndpointAddr) { - let socks: Vec = addr.ip_addrs().copied().collect(); - if socks.is_empty() { - return; - } - let storage = self.storage.get().await; - let _ = storage.upsert_peer(&peer_id, &socks, None); - let _ = storage.set_peer_anchor(&peer_id, true); - let _ = storage.upsert_known_anchor(&peer_id, &socks); - } - /// Register an already-established QUIC connection as a mesh peer. /// Sends InitialExchange first — if the remote accepts (responds with /// InitialExchange), registers as mesh and spawns the stream loop. @@ -1311,15 +1291,13 @@ impl Network { let anchor_addr = self.conn_handle.build_anchor_advertised_addr().await; let our_nat_type = self.conn_handle.nat_type().await; - // Decide the slot BEFORE the exchange: the slot class is what says - // whether this handshake carries a uniques announce. - // bugs-fixed #3: a connection is registered before anything can drop it. - let slot = match self.conn_handle.register_connection(peer_id, conn.clone(), vec![]).await { - Some(s) => s, - None => anyhow::bail!("no slot available (mesh + temp full)"), - }; - match initial_exchange_connect(&self.storage, &self.our_node_id, &conn, peer_id, anchor_addr, our_nat_type, self.is_http_capable(), self.http_addr(), Some(self.device_role), None, self.conn_handle.max_bounce(), slot.is_mesh()).await { + match initial_exchange_connect(&self.storage, &self.our_node_id, &conn, peer_id, anchor_addr, our_nat_type, self.is_http_capable(), self.http_addr(), Some(self.device_role), None).await { Ok(ExchangeResult::Accepted { .. }) => { + self.conn_handle.register_connection(peer_id, conn.clone(), vec![], PeerSlotKind::Local).await; + { + let s = self.storage.get().await; + let _ = s.add_mesh_peer(&peer_id, PeerSlotKind::Local, 0); + } // Spawn the per-connection stream loop let conn_data = self.conn_handle.get_connection_map().await; @@ -1334,16 +1312,10 @@ impl Network { Ok(ExchangeResult::Refused { redirect }) => { let redir_info = redirect.as_ref().map(|r| r.n.clone()); info!(peer = hex::encode(peer_id), redirect = ?redir_info, "Mesh refused after hole punch, keeping as session"); - self.conn_handle.disconnect_peer(&peer_id).await; self.conn_handle.add_session(peer_id, conn, crate::types::SessionReachMethod::HolePunch, None).await; anyhow::bail!("mesh refused: slots full"); } - Err(e) => { - // Roll back the slot we reserved — otherwise a failed handshake - // holds a mesh slot until the zombie sweep. - self.conn_handle.disconnect_peer(&peer_id).await; - Err(e) - } + Err(e) => Err(e), } } @@ -1404,9 +1376,7 @@ impl Network { None, ); - // Recovery threshold is mesh < 2 (design.html §lifecycle), not zero: - // a single remaining peer is a partition waiting to happen. - if remaining < 2 { + if remaining == 0 { self.conn_handle.notify_recovery(); } else { self.conn_handle.notify_growth(); @@ -1426,7 +1396,7 @@ impl Network { for peer_id in &newly_connected { let conn = self.conn_handle.get_connection(peer_id).await; if let Some(conn) = conn { - match initial_exchange_connect(&self.storage, &self.our_node_id, &conn, *peer_id, anchor_addr.clone(), our_nat_type, self.is_http_capable(), self.http_addr(), Some(self.device_role), None, self.conn_handle.max_bounce(), true).await { + match initial_exchange_connect(&self.storage, &self.our_node_id, &conn, *peer_id, anchor_addr.clone(), our_nat_type, self.is_http_capable(), self.http_addr(), Some(self.device_role), None).await { Ok(ExchangeResult::Accepted { .. }) => {} Ok(ExchangeResult::Refused { redirect }) => { debug!(peer = hex::encode(peer_id), "Auto-connect refused, disconnecting"); @@ -1506,10 +1476,10 @@ impl Network { loop { // Check slots + pick candidate via ConnHandle (no lock contention) - let available = self.conn_handle.available_mesh_slots().await; + let available = self.conn_handle.available_local_slots().await; if available == 0 { - debug!("Growth loop: mesh slots full"); - self.log_activity(ActivityLevel::Info, ActivityCategory::Growth, "Mesh slots full".into(), None); + debug!("Growth loop: local slots full"); + self.log_activity(ActivityLevel::Info, ActivityCategory::Growth, "Local slots full".into(), None); break; } let candidate = { @@ -1538,39 +1508,25 @@ impl Network { self.log_activity(ActivityLevel::Info, ActivityCategory::Growth, format!("Trying candidate {} (score {:.1})", &hex::encode(candidate_id)[..8], score), Some(candidate_id)); // Resolve address via ConnHandle (no lock during I/O) - let asked_a_reporter; let addr_str = { let local_addr = self.conn_handle.resolve_peer_addr_local(&candidate_id).await; if let Some(endpoint_addr) = local_addr { - asked_a_reporter = true; endpoint_addr.ip_addrs().next().map(|a| a.to_string()) } else { - // Network resolution: get reporter connections, resolve - // outside lock. The horizon here must match the horizon - // the CANDIDATE SCORER uses (2..=4, same as - // `ConnectionManager::resolve_address`) — when it did - // not, the growth loop scored N4 candidates it then had - // no reporter to ask about, and burned a - // `consecutive_failures` slot plus an UNREACHABLE_EXPIRY - // suppression on every one of them. + // Network resolution: get reporter connections, resolve outside lock let reporters_and_conns = { let storage = self.storage.get().await; - let mut reporter_set: std::collections::HashSet = - std::collections::HashSet::new(); - for bounce in 2..=4u8 { - for r in storage.find_reporters_at(&candidate_id, bounce).unwrap_or_default() { - reporter_set.insert(r); - } - } + let n2 = storage.find_in_n2(&candidate_id).unwrap_or_default(); + let n3 = storage.find_in_n3(&candidate_id).unwrap_or_default(); drop(storage); let conn_map = self.conn_handle.get_connection_map().await; + let reporter_set: std::collections::HashSet = n2.into_iter().chain(n3).collect(); conn_map.into_iter() .filter(|(nid, _, _, _)| reporter_set.contains(nid)) .map(|(_, conn, _, _)| conn) .collect::>() }; - asked_a_reporter = !reporters_and_conns.is_empty(); let mut resolved = None; for conn in reporters_and_conns { let result: anyhow::Result> = async { @@ -1597,19 +1553,10 @@ impl Network { None => { debug!( peer = hex::encode(candidate_id), - asked_a_reporter, - "Growth loop: no address" + "Growth loop: no address, marking unreachable" ); self.log_activity(ActivityLevel::Warn, ActivityCategory::Growth, format!("No address for {}", &hex::encode(candidate_id)[..8]), Some(candidate_id)); - // Only a candidate we could actually ask about, and - // that came back with nothing, has earned an - // UNREACHABLE_EXPIRY suppression. Failing for want of a - // connected reporter says nothing about the candidate — - // suppressing it there would hide it even after a - // shallower reporter shows up. - if asked_a_reporter { - self.conn_handle.mark_unreachable(&candidate_id); - } + self.conn_handle.mark_unreachable(&candidate_id); consecutive_failures += 1; if consecutive_failures >= 3 { debug!("Growth loop: 3 consecutive failures, backing off"); @@ -1645,7 +1592,7 @@ impl Network { consecutive_failures = 0; // Broadcast diff so peers learn about our new connection - let _ = self.broadcast_uniques().await; + let _ = self.broadcast_diff().await; // Brief pause to let InitialExchange update N2/N3 before next pick tokio::time::sleep(std::time::Duration::from_millis(500)).await; @@ -1703,7 +1650,7 @@ impl Network { if introduced { consecutive_failures = 0; - let _ = self.broadcast_uniques().await; + let _ = self.broadcast_diff().await; tokio::time::sleep(std::time::Duration::from_millis(500)).await; } else { self.conn_handle.mark_unreachable(&candidate_id); @@ -1720,6 +1667,76 @@ impl Network { } } +/// Pull posts from a peer (persistent if available, ephemeral otherwise). + pub async fn pull_from_peer(&self, peer_id: &NodeId) -> anyhow::Result { + let conn = self.get_connection(peer_id).await?; + let (our_follows, follows_sync, our_personas) = { + let storage = self.storage.get().await; + ( + storage.list_follows()?, + storage.get_follows_with_last_sync().unwrap_or_default(), + storage.list_posting_identities().unwrap_or_default(), + ) + }; + // Merged pull: include every posting identity we hold so DMs addressed + // to any of our personas match on recipient. Our network NodeId is + // never an author nor a wrapped_key recipient — including it would + // never match and would leak the network↔posting boundary. + let mut query_list = our_follows; + for pi in &our_personas { + if !query_list.contains(&pi.node_id) { + query_list.push(pi.node_id); + } + } + let (mut send, mut recv) = conn.open_bi().await?; + write_typed_message( + &mut send, + MessageType::PullSyncRequest, + &PullSyncRequestPayload { + follows: query_list, + have_post_ids: vec![], // v4: empty, using since_ms instead + since_ms: follows_sync, + }, + ) + .await?; + send.finish()?; + let msg_type = read_message_type(&mut recv).await?; + if msg_type != MessageType::PullSyncResponse { + anyhow::bail!("expected PullSyncResponse, got {:?}", msg_type); + } + let response: PullSyncResponsePayload = read_payload(&mut recv, 64 * 1024 * 1024).await?; + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; + let storage = self.storage.get().await; + let mut posts_received = 0; + let mut vis_updates = 0; + for sp in &response.posts { + if !storage.is_deleted(&sp.id)? && verify_post_id(&sp.id, &sp.post) { + if storage.store_post_with_visibility(&sp.id, &sp.post, &sp.visibility)? { + posts_received += 1; + } + // Protocol v4: update last_sync_ms for the author + let _ = storage.update_follow_last_sync(&sp.post.author, now_ms); + } + } + for vu in response.visibility_updates { + if let Some(post) = storage.get_post(&vu.post_id)? { + if post.author == vu.author { + if storage.update_post_visibility(&vu.post_id, &vu.visibility)? { + vis_updates += 1; + } + } + } + } + Ok(PullStats { + peers_pulled: 1, + posts_received, + visibility_updates: vis_updates, + }) + } + /// Send a uni-stream message. Uses persistent connection if available, ephemeral otherwise. pub async fn send_to_peer_uni( &self, @@ -1847,7 +1864,7 @@ impl Network { if let Some(addr) = addr { match tokio::time::timeout( std::time::Duration::from_secs(5), - self.endpoint.connect(addr, ALPN), + self.endpoint.connect(addr, ALPN_V2), ).await { Ok(Ok(conn)) => { self.conn_handle.mark_reachable(peer_id); @@ -1967,146 +1984,41 @@ impl Network { Some(addr) } - // ---- Anchor convection delegation (design.html §anchors) ---- + // ---- Anchor referral delegation ---- - /// Ask an anchor for peers. The request itself enrols us in that anchor's - /// rolling window — there is no registration message; 0xC0 is retired. - /// - /// Does NOT hold the conn_mgr lock during network I/O. A refusal comes back - /// as a single small message, so the `refused` case costs one round trip, - /// not a timeout. - pub async fn request_convection( + /// Request peer referrals from an anchor peer (mesh or session). + /// Does NOT hold conn_mgr lock during network I/O. + pub async fn request_anchor_referrals( &self, anchor: &NodeId, - class: crate::protocol::ConvectionClass, - ) -> anyhow::Result { + ) -> anyhow::Result> { let conn = self.conn_handle.get_any_connection(anchor).await .ok_or_else(|| anyhow::anyhow!("anchor peer not connected (mesh or session)"))?; - // Self-reported addresses, best first: the stable advertised address - // (if we are an anchor ourselves), then the UPnP external mapping, then - // whatever the endpoint knows. The anchor prefers what it OBSERVES over - // all of these; these only help when it cannot observe us. let endpoint = self.conn_handle.endpoint().await; - let mut our_addrs: Vec = endpoint.addr().ip_addrs() - .filter(|s| is_publicly_routable(s)) + let our_addrs: Vec = endpoint.addr().ip_addrs() .map(|s| s.to_string()) .collect(); - if let Some(ext) = self.conn_handle.upnp_external_addr().await { - let ext_str = ext.to_string(); - if !our_addrs.contains(&ext_str) { - our_addrs.insert(0, ext_str); - } - } - if let Some(anchor_addr) = self.conn_handle.build_anchor_advertised_addr().await { - if !our_addrs.contains(&anchor_addr) { - our_addrs.insert(0, anchor_addr); - } - } - let request = crate::protocol::ConvectionRequestPayload { + let request = crate::protocol::AnchorReferralRequestPayload { requester: self.our_node_id, requester_addresses: our_addrs, - class, }; let (mut send, mut recv) = conn.open_bi().await?; - crate::protocol::write_typed_message(&mut send, crate::protocol::MessageType::ConvectionRequest, &request).await?; + crate::protocol::write_typed_message(&mut send, crate::protocol::MessageType::AnchorReferralRequest, &request).await?; send.finish()?; let msg_type = crate::protocol::read_message_type(&mut recv).await?; - if msg_type != crate::protocol::MessageType::ConvectionResponse { - anyhow::bail!("expected ConvectionResponse, got {:?}", msg_type); + if msg_type != crate::protocol::MessageType::AnchorReferralResponse { + anyhow::bail!("expected AnchorReferralResponse, got {:?}", msg_type); } - let response: crate::protocol::ConvectionResponsePayload = - crate::protocol::read_payload(&mut recv, 4096).await?; + let response: crate::protocol::AnchorReferralResponsePayload = crate::protocol::read_payload(&mut recv, 4096).await?; // Touch session to prevent idle reaping self.conn_handle.touch_session_if_exists(anchor); - // Adaptive feedback (round 5): a refusal shifts weight toward mesh - // introductions and other anchors; a served request drifts it back. - if response.refused { - debug!(anchor = hex::encode(anchor), "Convection: refused (top-up, no capacity)"); - self.conn_handle.record_convection_refusal(anchor); - } else { - self.conn_handle.record_convection_success(anchor); - } - - // The NAT filter probe used to ride the register message. It has to - // ride something, and this is now the only message we send an anchor. - if self.conn_handle.nat_filtering().await == crate::types::NatFiltering::Unknown { - if let Err(e) = self.request_nat_filter_probe(anchor).await { - debug!(error = %e, "NAT filter probe request failed"); - } - } - - Ok(response) - } - - /// Act on one convection response: connect to each referral, landing it in - /// whatever slot class is free (mesh first, then the temp referral band - /// above the cap). Returns how many connections were established. - /// - /// `introduced` referrals skip the extra introduction round trip — the - /// anchor already pushed a RelayIntroduce toward them naming us, so they - /// are punching outward while we dial. - pub async fn act_on_convection( - &self, - anchor: &NodeId, - response: &crate::protocol::ConvectionResponsePayload, - ) -> usize { - let mut connected = 0; - for referral in &response.referrals { - if referral.node_id == self.our_node_id { - continue; - } - if self.conn_handle.is_connected(&referral.node_id).await { - continue; - } - let mut ok = false; - // bugs-fixed #8 (receive side): a referral address is a third - // party's claim. Dialling `127.0.0.1:22` or `192.168.1.1:443` - // because an anchor said so is attacker-directed internal probing - // from our own host, and every such connect() enters iroh's - // per-endpoint path store permanently (v0.7.3 EDM lesson). - let usable: Option<&String> = referral.addresses.iter().find(|a| { - a.parse::() - .map(|sa| crate::connection::convection_addr_ok(&sa)) - .unwrap_or(false) - }); - if let Some(addr_str) = usable { - let connect_str = format!("{}@{}", hex::encode(referral.node_id), addr_str); - if let Ok((rid, raddr)) = crate::parse_connect_string(&connect_str) { - let fut = self.connect_to_peer(rid, raddr); - match tokio::time::timeout(std::time::Duration::from_secs(15), fut).await { - Ok(Ok(())) => { - info!(peer = hex::encode(rid), "Convection: connected to referred peer"); - ok = true; - } - Ok(Err(e)) => debug!(error = %e, peer = hex::encode(rid), "Convection: direct connect failed"), - Err(_) => debug!(peer = hex::encode(rid), "Convection: direct connect timed out"), - } - } - } - if !ok && !referral.introduced { - // No coordinated introduction was possible — ask for one. - match self.connect_via_introduction_as_mesh(referral.node_id, *anchor).await { - Ok(()) => { - info!(peer = hex::encode(referral.node_id), "Convection: connected via introduction"); - ok = true; - } - Err(e) => debug!(error = %e, peer = hex::encode(referral.node_id), "Convection: introduction failed"), - } - } - if ok { - connected += 1; - } - } - if connected > 0 { - self.notify_growth().await; - } - connected + Ok(response.referrals) } /// Request NAT filter probe from an anchor (determines address-restricted vs port-restricted). @@ -2160,6 +2072,55 @@ impl Network { Ok(()) } + /// Register our address with an anchor peer (mesh or session). + /// Also runs NAT filter probe if filtering is still Unknown. + /// Does NOT hold conn_mgr lock during network I/O. + pub async fn send_anchor_register(&self, anchor: &NodeId) -> anyhow::Result<()> { + let conn = self.conn_handle.get_any_connection(anchor).await + .ok_or_else(|| anyhow::anyhow!("anchor peer not connected (mesh or session)"))?; + + let endpoint = self.conn_handle.endpoint().await; + let mut our_addrs: Vec = endpoint.addr().ip_addrs() + .map(|s| s.to_string()) + .collect(); + // Prepend UPnP external address (most useful for remote peers) + if let Some(ext) = self.conn_handle.upnp_external_addr().await { + let ext_str = ext.to_string(); + if !our_addrs.contains(&ext_str) { + our_addrs.insert(0, ext_str); + } + } + // Prepend stable anchor advertised address + if let Some(anchor_addr) = self.conn_handle.build_anchor_advertised_addr().await { + if !our_addrs.contains(&anchor_addr) { + our_addrs.insert(0, anchor_addr); + } + } + + let payload = crate::protocol::AnchorRegisterPayload { + node_id: self.our_node_id, + addresses: our_addrs, + }; + + let mut send = conn.open_uni().await?; + crate::protocol::write_typed_message(&mut send, crate::protocol::MessageType::AnchorRegister, &payload).await?; + send.finish()?; + + // Touch session to prevent idle reaping + self.conn_handle.touch_session_if_exists(anchor); + + debug!(anchor = hex::encode(anchor), "Registered with anchor"); + + // Also run NAT filter probe if filtering is still Unknown + let needs_probe = self.conn_handle.nat_filtering().await == crate::types::NatFiltering::Unknown; + if needs_probe { + if let Err(e) = self.request_nat_filter_probe(anchor).await { + debug!(error = %e, "NAT filter probe request failed"); + } + } + Ok(()) + } + /// Check if a peer is a known anchor. pub async fn is_anchor_peer(&self, node_id: &NodeId) -> bool { let storage = self.storage.get().await; @@ -2330,22 +2291,6 @@ impl Network { sent += 1; } } - // v0.8 (A3): no-known-holders fallback — seed the diff through - // up to 3 current mesh connections. Without this, a first - // engagement on a post with no recorded file_holders (e.g. a - // fresh registration on the self-materialized registry post, or - // a greeting on a bio just pulled) goes nowhere until someone - // pulls the header on the slow cadence. - if sent == 0 { - for peer in self.connected_peers().await.into_iter().take(3) { - if &peer == exclude_peer { - continue; - } - if self.send_to_peer_uni(&peer, MessageType::BlobHeaderDiff, payload).await.is_ok() { - sent += 1; - } - } - } sent } } @@ -2353,6 +2298,7 @@ impl Network { pub struct PullStats { pub peers_pulled: usize, pub posts_received: usize, + pub visibility_updates: usize, } /// Decide whether a post should be sent to a requesting peer. diff --git a/crates/core/src/node.rs b/crates/core/src/node.rs index e9e9c4c..ede6096 100644 --- a/crates/core/src/node.rs +++ b/crates/core/src/node.rs @@ -13,7 +13,7 @@ use crate::network::Network; use crate::storage::StoragePool; use crate::types::{ Attachment, Circle, - DeviceProfile, DeviceRole, NodeId, PeerRecord, MeshSlot, PeerWithAddress, Post, PostId, + DeviceProfile, DeviceRole, NodeId, PeerRecord, PeerSlotKind, PeerWithAddress, Post, PostId, PostVisibility, PublicProfile, ReachMethod, RevocationMode, SessionReachMethod, SocialRelation, SocialRouteEntry, SocialStatus, VisibilityIntent, WormResult, }; @@ -31,11 +31,6 @@ use crate::types::{ /// with "UnknownIssuer" because they pin the wrong cert identity. const DEFAULT_ANCHOR: &str = "ab2b7258ef0b75b2c6ee8bf6595232055f6199d584d3c0fc10b15a1ed549aa13@itsgoin.net:4433"; -/// Cooldown between relay-introduction attempts toward the same target (5 min) -const RELAY_COOLDOWN_MS: i64 = 300_000; -/// Timeout for a single relay-introduction round trip -const RELAY_INTRO_TIMEOUT_SECS: u64 = 15; - /// A distsoc node: ties together identity, storage, and networking pub struct Node { pub data_dir: PathBuf, @@ -55,12 +50,11 @@ pub struct Node { bootstrap_anchors: tokio::sync::Mutex>, /// True if an anchor reported another instance of this identity is already active pub duplicate_detected: Arc, + #[allow(dead_code)] profile: DeviceProfile, pub activity_log: Arc>, pub last_rebalance_ms: Arc, - /// Last time the convection loop acted (replaces the retired anchor - /// register cycle's timer). - pub last_convection_ms: Arc, + pub last_anchor_register_ms: Arc, /// CDN replication budget: bytes remaining we're willing to pull and cache this hour replication_budget_remaining: Arc, /// CDN delivery budget: bytes remaining we're willing to serve this hour @@ -87,64 +81,6 @@ fn generate_and_store_initial_v_me( Ok(()) } -/// v0.8 (A3): the CommentPolicy stored for every FoF-gated post at -/// creation time. Fixes the historic dead gate — nothing ever set -/// `CommentPermission::FriendsOfFriends`, so receivers' policy-based -/// arm was unreachable. (The receive gate itself keys on -/// `post.fof_gating.is_some()`; this is belt-and-suspenders.) -fn fof_comment_policy() -> crate::types::CommentPolicy { - crate::types::CommentPolicy { - allow_comments: crate::types::CommentPermission::FriendsOfFriends, - ..Default::default() - } -} - -/// v0.8 (A3): plaintext bucket for sealed greeting bodies on bio posts. -pub const GREETING_BODY_BUCKET: u16 = 1024; - -/// v0.8 (A3): FoFRevocation reason code used when a persona withdraws -/// greeting consent — the open-slot pub_x of every prior bio is revoked -/// so holders stop accepting (and purge) greetings on superseded bios. -pub const GREETING_CONSENT_REVOKE_REASON: u8 = 2; - -/// v0.8 (A3): per-persona greeting consent. PRE-CHECKED default (round -/// 8): unset = ON. The UI presents the checkbox as an active choice at -/// first profile publish; unchecking sets the key to "0" and republishes -/// the bio without a greeting slot. -pub fn greetings_open_setting_key(posting_id: &NodeId) -> String { - format!("greetings_open.{}", hex::encode(posting_id)) -} - -fn greetings_open_setting(storage: &crate::storage::Storage, posting_id: &NodeId) -> bool { - storage - .get_setting(&greetings_open_setting_key(posting_id)) - .ok() - .flatten() - .map(|v| v != "0") - .unwrap_or(true) -} - -/// v0.8 (A3): persist the author-side state of a freshly-published -/// gated post: slot provenance (cascade revocation), cached CEK -/// (author-direct decrypt), and the FriendsOfFriends comment policy. -fn persist_gated_post_author_state( - storage: &crate::storage::Storage, - author_persona_id: &NodeId, - post_id: &PostId, - built: &crate::fof::FoFCommentGatingBuilt, -) { - for entry in &built.real_slot_provenance { - let _ = storage.record_post_slot_provenance( - author_persona_id, post_id, entry.slot_index, - &entry.v_x_owner, entry.v_x_epoch, &entry.pub_x, - ); - } - let _ = storage.cache_own_fof_post_cek( - author_persona_id, post_id, &built.cek, &built.slot_binder_nonce, - ); - let _ = storage.set_comment_policy(post_id, &fof_comment_policy()); -} - /// Async wrapper used by `Node::create_posting_identity`. Acquires the /// storage handle and delegates to the sync helper. async fn ensure_initial_v_me( @@ -251,127 +187,6 @@ async fn probe_anchors_batched( result } -/// Gather anchor candidates, POOL FIRST (round-4 ruling). -/// -/// Phase 0: anchor-flagged entries mined out of the uniques pools. Anchor -/// entries are the only address-bearing rows in the index, so the -/// pools double as the anchor directory — including the retained -/// pools of peers that have since disconnected (slot knowledge is -/// overwritten memory, wiped when a new handshake takes the slot, -/// not when a peer leaves). -/// Phase 1: `known_anchors` — DEMOTED to a bootstrap cache. Its `success_count` -/// ordering was a v0.7.x scarcity artifact and no longer means -/// anything now that pools supply anchors in bulk. -/// Phase 2: peers flagged `is_anchor`. -/// -/// Anchors currently in the refusal penalty box are pushed to the back rather -/// than dropped: a refusal is a load signal, not a blacklist. -async fn gather_anchor_candidates( - storage: &Arc, - network: &crate::network::Network, - self_node_id: NodeId, - limit: usize, -) -> Vec<(NodeId, Vec)> { - let mut out: Vec<(NodeId, Vec)> = Vec::new(); - let mut seen: std::collections::HashSet = std::collections::HashSet::new(); - seen.insert(self_node_id); - - { - let s = storage.get().await; - // Phase 0 — pool-mined. - for (nid, addrs) in s.list_pool_anchors(limit).unwrap_or_default() { - if !seen.insert(nid) { - continue; - } - let socks: Vec = - addrs.iter().filter_map(|a| a.parse().ok()).collect(); - if !socks.is_empty() { - out.push((nid, socks)); - } - } - // Phase 1 — bootstrap cache. - for (nid, addrs) in s.list_known_anchors().unwrap_or_default() { - if seen.insert(nid) && !addrs.is_empty() { - out.push((nid, addrs)); - } - } - // Phase 2 — anchor-flagged peers. - for r in s.list_anchor_peers().unwrap_or_default() { - if seen.insert(r.node_id) && !r.addresses.is_empty() { - out.push((r.node_id, r.addresses)); - } - } - } - - // De-prioritise (do not drop) anchors that recently refused us. - let mut penalized = Vec::new(); - let mut fresh = Vec::new(); - for entry in out { - if network.conn_handle().is_anchor_penalized(&entry.0).await { - penalized.push(entry); - } else { - fresh.push(entry); - } - } - fresh.extend(penalized); - fresh.truncate(limit); - fresh -} - -/// One convection exchange against one anchor: connect if needed, ask, act. -/// -/// Replaces the four hand-rolled `request_anchor_referrals` → `connect_to_peer` -/// → `connect_via_introduction` blocks (bootstrap x2, recovery, register cycle) -/// that had drifted apart. Returns how many peer connections it produced. -async fn run_convection( - network: &Arc, - anchor_nid: NodeId, - anchor_addrs: &[std::net::SocketAddr], - class: crate::protocol::ConvectionClass, - self_node_id: NodeId, -) -> usize { - if anchor_nid == self_node_id { - return 0; - } - if !network.is_peer_connected_or_session(&anchor_nid).await { - let endpoint_id = match iroh::EndpointId::from_bytes(&anchor_nid) { - Ok(eid) => eid, - Err(_) => return 0, - }; - let mut addr = iroh::EndpointAddr::from(endpoint_id); - for sa in anchor_addrs { - addr = addr.with_ip_addr(*sa); - } - if let Err(e) = network.connect_to_anchor(anchor_nid, addr).await { - debug!(error = %e, anchor = hex::encode(anchor_nid), "Convection: anchor connect failed"); - return 0; - } - } - - // A refusal is one small message — the 10s ceiling is for the connect leg, - // never for the refusal itself. - let response = match tokio::time::timeout( - std::time::Duration::from_secs(10), - network.request_convection(&anchor_nid, class), - ).await { - Ok(Ok(r)) => r, - Ok(Err(e)) => { - debug!(error = %e, anchor = hex::encode(anchor_nid), "Convection request failed"); - return 0; - } - Err(_) => { - debug!(anchor = hex::encode(anchor_nid), "Convection request timed out"); - return 0; - } - }; - - if response.refused { - // Feedback already recorded inside request_convection. - return 0; - } - network.act_on_convection(&anchor_nid, &response).await -} - async fn probe_one_anchor( network: &crate::network::Network, storage: &Arc, @@ -469,7 +284,7 @@ impl Node { // Load or generate identity key (network secret — QUIC endpoint only, // never used as content author under the v0.6.1+ clean model). let key_path = data_dir.join("identity.key"); - let (mut secret_key, secret_seed) = if key_path.exists() { + let (mut secret_key, mut secret_seed) = if key_path.exists() { let key_bytes = std::fs::read(&key_path)?; let bytes: [u8; 32] = key_bytes .try_into() @@ -490,7 +305,7 @@ impl Node { // Startup sweep: clear stale N2/N3 and mesh_peers from prior session { let s = storage.get().await; - let n_cleared = s.clear_all_reach().unwrap_or(0); + let n_cleared = s.clear_all_n2_n3().unwrap_or(0); let m_cleared = s.clear_all_mesh_peers().unwrap_or(0); if n_cleared > 0 || m_cleared > 0 { info!(n2_n3 = n_cleared, mesh_peers = m_cleared, "Startup sweep: cleared stale entries"); @@ -542,6 +357,7 @@ impl Node { std::fs::write(&key_path, new_seed)?; info!("v0.6.1 migration: rotated network key to decouple from default posting key"); secret_key = new_key; + secret_seed = new_seed; } } } @@ -550,12 +366,14 @@ impl Node { // Open blob store let blob_store = Arc::new(BlobStore::open(&data_dir)?); - // Activity log + // Activity log + timer atomics let activity_log = Arc::new(std::sync::Mutex::new(ActivityLog::new())); + let last_rebalance_ms = Arc::new(AtomicU64::new(0)); + let last_anchor_register_ms = Arc::new(AtomicU64::new(0)); - // Start network (single ALPN, connection manager) + // Start network (v2: single ALPN, connection manager) let network = Arc::new( - Network::new(secret_key, Arc::clone(&storage), bind_addr, Arc::clone(&blob_store), profile, Arc::clone(&activity_log)).await?, + Network::new(secret_key, Arc::clone(&storage), bind_addr, secret_seed, Arc::clone(&blob_store), profile, Arc::clone(&activity_log)).await?, ); let node_id = network.node_id_bytes(); @@ -579,7 +397,7 @@ impl Node { // Build the node (fast path — no network I/O beyond endpoint creation) let activity_log_ref = Arc::clone(&activity_log); let last_rebalance_ms = Arc::new(AtomicU64::new(0)); - let last_convection_ms = Arc::new(AtomicU64::new(0)); + let last_anchor_register_ms = Arc::new(AtomicU64::new(0)); let role = network.device_role(); let (replication_budget, delivery_budget) = (role.replication_limit(), role.delivery_limit()); @@ -591,7 +409,7 @@ impl Node { )); blob_store.set_delivery_budget(delivery_budget); - let node = Self { + let mut node = Self { data_dir: data_dir.clone(), storage: Arc::clone(&storage), network: Arc::clone(&network), @@ -604,81 +422,12 @@ impl Node { profile, activity_log: activity_log_ref, last_rebalance_ms, - last_convection_ms, + last_anchor_register_ms, replication_budget_remaining, delivery_budget_remaining, budget_last_reset_ms, }; - // v0.8 one-time migrations (best-effort; never block init): - // - // (a) Legacy (pre-posting/network split) profile rows keyed by the - // NETWORK id may still carry persona fields from the unified-id - // era. Strip them in place so no code path can ever re-broadcast - // persona data bound to the device network id. Topology fields - // (anchors/recent_peers) are preserved. - // - // (b) The manifest signature digest dropped author_addresses, so - // rows signed by pre-v0.8 builds no longer verify. Re-sign our - // own manifests with the matching persona key; purge cached - // foreign manifests that can never verify again (they'd sit - // silently un-propagatable otherwise). Guarded by a settings - // flag so it runs once per data dir. - { - let s = storage.get().await; - - // (a) strip persona fields from the network-id row - if let Ok(Some(p)) = s.get_profile(&node.node_id) { - if !p.display_name.is_empty() || !p.bio.is_empty() || p.avatar_cid.is_some() { - let mut stripped = p; - stripped.display_name = String::new(); - stripped.bio = String::new(); - stripped.avatar_cid = None; - if s.store_profile(&stripped).is_ok() { - info!("v0.8 migration: stripped persona fields from network-id profile row"); - } - } - } - - // (b) re-sign own / purge stale-foreign CDN manifests - if s.get_setting("v08_manifest_resign_done").ok().flatten().is_none() { - let personas: std::collections::HashMap = - s.list_posting_identities() - .unwrap_or_default() - .into_iter() - .map(|pi| (pi.node_id, pi.secret_seed)) - .collect(); - let mut resigned = 0usize; - let mut purged = 0usize; - for (cid, json) in s.list_all_cdn_manifests().unwrap_or_default() { - let Ok(mut m) = serde_json::from_str::(&json) else { - let _ = s.delete_cdn_manifest(&cid); - purged += 1; - continue; - }; - if crypto::verify_manifest_signature(&m) { - continue; // already valid under the v0.8 digest - } - if let Some(seed) = personas.get(&m.author) { - m.signature = crypto::sign_manifest(seed, &m); - if let Ok(updated_json) = serde_json::to_string(&m) { - let _ = s.store_cdn_manifest(&cid, &updated_json, &m.author, m.updated_at); - resigned += 1; - } - } else { - // Foreign manifest signed under the pre-v0.8 digest — - // can never verify again; drop it so it isn't re-served. - let _ = s.delete_cdn_manifest(&cid); - purged += 1; - } - } - let _ = s.set_setting("v08_manifest_resign_done", "1"); - if resigned > 0 || purged > 0 { - info!(resigned, purged, "v0.8 migration: manifest re-sign/purge complete"); - } - } - } - // Startup backfill: any named persona without a profile post gets // one synthesized at its own `created_at`. Makes legacy / imported // named personas Discover-able without requiring a manual rename. @@ -687,22 +436,6 @@ impl Node { warn!(error = %e, "Profile-post backfill failed; continuing init"); } - // v0.8 (A3): self-materialize the registry post (store-if-absent) - // so every node can hold/serve the registration chain — no fetch - // needed, and the existing engagement-check cadence keeps its - // comment chain refreshing. - { - let s = node.storage.get().await; - match crate::registry::materialize_registry_post(&s) { - Ok(true) => info!( - post_id = hex::encode(crate::registry::REGISTRY_POST_ID), - "Registry post self-materialized" - ), - Ok(false) => {} - Err(e) => warn!(error = %e, "Registry post materialization failed"), - } - } - Ok(node) } @@ -763,7 +496,7 @@ impl Node { Ok(()) => { info!(peer = hex::encode(nid), "Bootstrap: connected"); // Pull posts from the bootstrap peer - match network.content_sync_all().await { + match network.pull_from_all().await { Ok(stats) => { info!( "Bootstrap pull: {} posts from {} peers", @@ -786,25 +519,52 @@ impl Node { } } - // Convection, ENTRY class — we have no mesh - // yet, so this is always served. Spawned so - // startup isn't blocked on peer connects. - { - let net = Arc::clone(&network); - let my_id = node_id; - let anchor = nid; - let anchor_addrs = ip_addrs.clone(); - tokio::spawn(async move { - let n = run_convection( - &net, - anchor, - &anchor_addrs, - crate::protocol::ConvectionClass::Entry, - my_id, - ).await; - info!(connected = n, "Bootstrap: convection complete"); - net.notify_growth().await; - }); + // Request referrals from anchor (10s timeout) + match tokio::time::timeout(std::time::Duration::from_secs(10), network.request_anchor_referrals(&nid)).await { + Ok(Ok(referrals)) if !referrals.is_empty() => { + info!(count = referrals.len(), "Bootstrap: got anchor referrals"); + // Spawn referral connections in background — don't block startup + let net = Arc::clone(&network); + let my_id = node_id; + let anchor = nid; + tokio::spawn(async move { + for referral in referrals { + if referral.node_id == my_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)) = crate::parse_connect_string(&connect_str) { + let connect_fut = async { + match net.connect_to_peer(rid, raddr).await { + Ok(()) => { info!(peer = hex::encode(rid), "Connected to referred peer"); Ok(()) }, + Err(e) => { + debug!(error = %e, peer = hex::encode(rid), "One-sided connect failed, requesting introduction from anchor"); + match net.connect_via_introduction(rid, anchor).await { + Ok(()) => { info!(peer = hex::encode(rid), "Connected to referred peer via hole punch"); Ok(()) }, + Err(e2) => Err(e2), + } + } + } + }; + match tokio::time::timeout(std::time::Duration::from_secs(15), connect_fut).await { + Ok(Ok(())) => {}, + Ok(Err(e)) => debug!(error = %e, peer = hex::encode(rid), "Bootstrap referral connect failed"), + Err(_) => debug!(peer = hex::encode(rid), "Bootstrap referral connect timed out"), + } + } + } + } + net.notify_growth().await; + }); + } + Ok(Ok(_)) => debug!("Bootstrap: no referrals from anchor (first to register)"), + Ok(Err(e)) => debug!(error = %e, "Bootstrap: referral request failed"), + Err(_) => debug!("Bootstrap: referral request timed out"), } break; } @@ -885,9 +645,10 @@ impl Node { { let conn_count = network.connection_count().await; if conn_count < 5 { - // Pool-mined anchors FIRST (round-4), then the known_anchors - // bootstrap cache, then anchor-flagged peers. - let known = gather_anchor_candidates(storage, network, node_id, 32).await; + let known = { + let s = storage.get().await; + s.list_known_anchors().unwrap_or_default() + }; // Split into discovered anchors (priority) and bootstrap anchors (fallback) let (discovered, bootstrap_known): (Vec<_>, Vec<_>) = known.into_iter() .partition(|(nid, _)| !bootstrap_anchor_ids.contains(nid)); @@ -926,23 +687,49 @@ impl Node { Ok(Err(e)) => warn!(error = %e, "NAT filter probe failed during bootstrap"), Err(_) => warn!("NAT filter probe timed out during bootstrap"), } - // Convection, ENTRY class — same single code path as - // bootstrap and recovery. - { - let net = Arc::clone(&network); - let my_id = node_id; - let anchor = anchor_nid; - tokio::spawn(async move { - let n = run_convection( - &net, - anchor, - &[], - crate::protocol::ConvectionClass::Entry, - my_id, - ).await; - info!(connected = n, "Startup: convection complete"); - net.notify_growth().await; - }); + match tokio::time::timeout(std::time::Duration::from_secs(10), network.request_anchor_referrals(&anchor_nid)).await { + Ok(Ok(referrals)) if !referrals.is_empty() => { + info!(count = referrals.len(), "Got anchor referrals"); + let net = Arc::clone(&network); + let my_id = node_id; + let anchor = anchor_nid; + tokio::spawn(async move { + for referral in referrals { + if referral.node_id == my_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)) = crate::parse_connect_string(&connect_str) { + let connect_fut = async { + match net.connect_to_peer(rid, raddr).await { + Ok(()) => { info!(peer = hex::encode(rid), "Connected to referred peer"); Ok(()) }, + Err(_) => { + match net.connect_via_introduction(rid, anchor).await { + Ok(()) => { info!(peer = hex::encode(rid), "Connected via hole punch"); Ok(()) }, + Err(e) => Err(e), + } + } + } + }; + match tokio::time::timeout(std::time::Duration::from_secs(15), connect_fut).await { + Ok(Ok(())) => {}, + Ok(Err(e)) => debug!(error = %e, peer = hex::encode(rid), "Referral connect failed"), + Err(_) => debug!(peer = hex::encode(rid), "Referral connect timed out"), + } + } + } + } + net.notify_growth().await; + }); + } + Ok(Ok(_)) => debug!("No referrals from anchor"), + Ok(Err(e)) => debug!(error = %e, "Referral request failed"), + Err(_) => debug!("Referral request timed out"), } } } @@ -959,11 +746,11 @@ impl Node { self.activity_log.lock().unwrap().recent(limit) } - /// Get timer state: (last_rebalance_ms, last_convection_ms). + /// Get timer state: (last_rebalance_ms, last_anchor_register_ms). pub fn timer_state(&self) -> (u64, u64) { ( self.last_rebalance_ms.load(AtomicOrdering::Relaxed), - self.last_convection_ms.load(AtomicOrdering::Relaxed), + self.last_anchor_register_ms.load(AtomicOrdering::Relaxed), ) } @@ -1038,16 +825,9 @@ impl Node { /// Create a new posting identity with a fresh ed25519 key. Auto-follows /// the new identity so its own posts show in the merged feed. - /// - /// `greetings_open` is the persona's greeting-consent choice (round 8: - /// an ACTIVE pre-checked choice, never silently defaulted on the - /// wire). It is persisted BEFORE the initial bio publish so an - /// opted-out persona never ships a greeting slot at all. `None` - /// keeps the pre-checked default (ON). pub async fn create_posting_identity( &self, display_name: String, - greetings_open: Option, ) -> anyhow::Result { let key = iroh::SecretKey::generate(&mut rand::rng()); let seed: [u8; 32] = key.to_bytes(); @@ -1066,14 +846,6 @@ impl Node { s.upsert_posting_identity(&identity)?; // Auto-follow this persona so its own posts reach its own feed. s.add_follow(&node_id)?; - // Record greeting consent BEFORE the initial bio publish below - // reads it — an opt-out persona must never ship a slot. - if let Some(open) = greetings_open { - s.set_setting( - &greetings_open_setting_key(&node_id), - if open { "1" } else { "0" }, - )?; - } } // If the user supplied a non-empty display name at creation time, @@ -1110,22 +882,11 @@ impl Node { ) -> anyhow::Result<()> { // FoF Layer 1: build the vouch-grant batch (if this persona has // any current vouch targets) + bump the bio_epoch. - // v0.8 (A3): when the persona's `greetings_open` consent is on, - // the bio carries FoF gating with a Greeting open slot. - let (vouch_grants, bio_epoch, gating_built) = { + let (vouch_grants, bio_epoch) = { let storage = self.storage.get().await; let batch = crate::profile::build_vouch_grant_batch(&*storage, posting_id)?; let epoch = storage.next_bio_epoch_for(posting_id)?; - let gating = if greetings_open_setting(&storage, posting_id) { - crate::fof::build_fof_comment_gating( - &*storage, - posting_id, - Some((crate::types::OpenSlotKind::Greeting, GREETING_BODY_BUCKET)), - )? - } else { - None - }; - (batch, epoch, gating) + (batch, epoch) }; let profile_post = crate::profile::build_profile_post( posting_id, @@ -1135,7 +896,6 @@ impl Node { avatar_cid, vouch_grants, bio_epoch, - gating_built.as_ref().map(|b| b.gating.clone()), ); let profile_post_id = crate::content::compute_post_id(&profile_post); let timestamp_ms = profile_post.timestamp_ms; @@ -1153,9 +913,6 @@ impl Node { &profile_post, Some(&VisibilityIntent::Profile), )?; - if let Some(built) = &gating_built { - persist_gated_post_author_state(&storage, posting_id, &profile_post_id, built); - } } self.update_neighbor_manifests_as( posting_id, @@ -1365,13 +1122,19 @@ impl Node { /// Prefers social peers, then wide. async fn current_recent_peers(&self) -> Vec { let conns = self.network.connection_info().await; - // v0.8: one mesh pool. Temp referral slots are excluded — a peer we - // hold only provisionally should not be advertised as our neighborhood. - let mut result: Vec = conns - .into_iter() - .filter(|(nid, slot, _)| *nid != self.node_id && slot.is_mesh()) - .map(|(nid, _, _)| nid) - .collect(); + let mut social: Vec = Vec::new(); + let mut wide: Vec = Vec::new(); + for (nid, kind, _) in conns { + if nid == self.node_id { + continue; + } + match kind { + PeerSlotKind::Preferred | PeerSlotKind::Local => social.push(nid), + PeerSlotKind::Wide => wide.push(nid), + } + } + let mut result = social; + result.extend(wide); result.truncate(10); result } @@ -1442,7 +1205,7 @@ impl Node { // Build the gating block from the default persona's keyring. let built = { let storage = self.storage.get().await; - crate::fof::build_fof_comment_gating(&*storage, &self.default_posting_id, None)? + crate::fof::build_fof_comment_gating(&*storage, &self.default_posting_id)? .ok_or_else(|| anyhow::anyhow!( "default persona has no V_me; rotate or recreate before FoF posts" ))? @@ -1479,10 +1242,6 @@ impl Node { &cek, &gating.slot_binder_nonce, ); } - // v0.8 (A3): FIX THE DEAD GATE — store the FriendsOfFriends - // policy at creation for every gated post (belt-and- - // suspenders; the receive gate keys on fof_gating presence). - let _ = storage.set_comment_policy(&post_id, &fof_comment_policy()); } Ok((post_id, post, visibility, cek)) @@ -1545,7 +1304,7 @@ impl Node { ) -> anyhow::Result<(PostId, Post, [u8; 32])> { let built = { let storage = self.storage.get().await; - crate::fof::build_fof_comment_gating(&*storage, &self.default_posting_id, None)? + crate::fof::build_fof_comment_gating(&*storage, &self.default_posting_id)? .ok_or_else(|| anyhow::anyhow!( "default persona has no V_me; rotate or recreate before FoF posts" ))? @@ -1595,8 +1354,6 @@ impl Node { let _ = storage.cache_own_fof_post_cek( &self.default_posting_id, &post_id, &cek, &slot_binder_nonce, ); - // v0.8 (A3): store FriendsOfFriends policy at creation. - let _ = storage.set_comment_policy(&post_id, &fof_comment_policy()); } self.update_neighbor_manifests_as( @@ -1805,6 +1562,7 @@ impl Node { let manifest = crate::types::AuthorManifest { post_id, author: *posting_id, + author_addresses: self.network.our_addresses(), created_at: now, updated_at: now, previous_posts: previous, @@ -1831,13 +1589,16 @@ impl Node { let storage = self.storage.get().await; storage.get_manifests_for_author_blobs(posting_id).unwrap_or_default() }; + let our_addrs = self.network.our_addresses(); for (push_cid, push_json) in &manifests_to_push { if let Ok(author_manifest) = serde_json::from_str::(push_json) { - // v0.8: no device addresses ride the manifest — receivers - // learn the holder from the QUIC-authenticated connection. let cdn_manifest = crate::types::CdnManifest { - author_manifest, + author_manifest: author_manifest, host: self.node_id, + host_addresses: our_addrs.clone(), + source: self.node_id, + source_addresses: our_addrs.clone(), + downstream_count: 0, }; self.network.push_manifest_to_downstream(push_cid, &cdn_manifest).await; } @@ -2046,6 +1807,7 @@ impl Node { let peer_addresses = storage.build_peer_addresses_for(node_id)?; let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) .unwrap_or_default().as_millis() as u64; + let preferred_tree = storage.build_preferred_tree_for(node_id).unwrap_or_default(); storage.upsert_social_route(&SocialRouteEntry { node_id: *node_id, addresses, @@ -2055,6 +1817,7 @@ impl Node { last_connected_ms: 0, last_seen_ms: now, reach_method: ReachMethod::Direct, + preferred_tree, })?; Ok(()) @@ -2134,21 +1897,11 @@ impl Node { // FoF Layer 1: build the vouch-grant batch (if this persona has // any current vouch targets) + bump bio_epoch. - // v0.8 (A3): attach the Greeting open slot when consent is on. - let (vouch_grants, bio_epoch, gating_built) = { + let (vouch_grants, bio_epoch) = { let storage = self.storage.get().await; let batch = crate::profile::build_vouch_grant_batch(&*storage, &posting_id)?; let epoch = storage.next_bio_epoch_for(&posting_id)?; - let gating = if greetings_open_setting(&storage, &posting_id) { - crate::fof::build_fof_comment_gating( - &*storage, - &posting_id, - Some((crate::types::OpenSlotKind::Greeting, GREETING_BODY_BUCKET)), - )? - } else { - None - }; - (batch, epoch, gating) + (batch, epoch) }; let profile_post = crate::profile::build_profile_post( &posting_id, @@ -2158,7 +1911,6 @@ impl Node { avatar_cid, vouch_grants, bio_epoch, - gating_built.as_ref().map(|b| b.gating.clone()), ); let profile_post_id = crate::content::compute_post_id(&profile_post); let timestamp_ms = profile_post.timestamp_ms; @@ -2179,9 +1931,6 @@ impl Node { &profile_post, Some(&VisibilityIntent::Profile), )?; - if let Some(built) = &gating_built { - persist_gated_post_author_state(&storage, &posting_id, &profile_post_id, built); - } if !display_name.is_empty() { if let Ok(Some(marker)) = storage.get_setting("first_run_auto_persona_id") { if marker == hex::encode(posting_id) { @@ -2223,6 +1972,7 @@ impl Node { updated_at: timestamp_ms, anchors: vec![], recent_peers: vec![], + preferred_peers: vec![], public_visible: true, avatar_cid, }) @@ -2244,22 +1994,23 @@ impl Node { let recent_peers = self.current_recent_peers().await; let profile = { let storage = self.storage.get().await; + let existing = storage.get_profile(&self.node_id)?; + let (display_name, bio, public_visible, avatar_cid) = match existing { + Some(p) => (p.display_name, p.bio, p.public_visible, p.avatar_cid), + None => (String::new(), String::new(), true, None), + }; + let preferred_peers = storage.list_preferred_peers().unwrap_or_default(); - // v0.8: the network-id-keyed profile row is TOPOLOGY ONLY - // (anchors, recent_peers). Persona fields - // (display_name, bio, avatar_cid, public_visible) live - // exclusively on posting-id-keyed rows written by profile - // posts — never copy them onto the network row, or a legacy - // unified-id row would keep re-linking persona to device. let profile = PublicProfile { node_id: self.node_id, - display_name: String::new(), - bio: String::new(), + display_name, + bio, updated_at: now, anchors, recent_peers, - public_visible: true, - avatar_cid: None, + preferred_peers, + public_visible, + avatar_cid, }; storage.store_profile(&profile)?; @@ -2536,23 +2287,16 @@ impl Node { post: &Post, visibility: &PostVisibility, group_seeds: &std::collections::HashMap<([u8; 32], u64), ([u8; 32], [u8; 32])>, - personas: &[crate::types::PostingIdentity], ) -> anyhow::Result>> { match visibility { PostVisibility::Public => Ok(Some(data)), PostVisibility::Encrypted { recipients } => { - // Recipients are POSTING ids — try every persona's (id, seed) - // pair, same as decrypt_posts does for post bodies. - let cek = personas.iter().find_map(|pi| { - crypto::unwrap_cek_for_recipient( - &pi.secret_seed, - &pi.node_id, - &post.author, - recipients, - ) - .ok() - .flatten() - }); + let cek = crypto::unwrap_cek_for_recipient( + &self.default_posting_secret, + &self.node_id, + &post.author, + recipients, + )?; match cek { Some(cek) => { let plaintext = crypto::decrypt_bytes_with_cek(&data, &cek)?; @@ -2596,7 +2340,7 @@ impl Node { }; // Single lock acquisition for all DB reads - let (post, visibility, group_seeds, personas) = { + let (post, visibility, group_seeds) = { let storage = self.storage.get().await; let _ = storage.touch_blob_access(cid); match storage.get_post_with_visibility(post_id)? { @@ -2606,12 +2350,7 @@ impl Node { } else { std::collections::HashMap::new() }; - let personas = if matches!(vis, PostVisibility::Encrypted { .. }) { - storage.list_posting_identities().unwrap_or_default() - } else { - Vec::new() - }; - (post, vis, seeds, personas) + (post, vis, seeds) } None => return Ok(Some(raw_data)), // No post context — return raw } @@ -2619,7 +2358,7 @@ impl Node { // Lock released — decrypt without lock match &visibility { PostVisibility::Public => Ok(Some(raw_data)), - _ => self.decrypt_blob_for_post(raw_data, &post, &visibility, &group_seeds, &personas), + _ => self.decrypt_blob_for_post(raw_data, &post, &visibility, &group_seeds), } } @@ -2727,13 +2466,14 @@ impl Node { } } - // Record upstream source. v0.8: manifests carry no addresses; - // the holder's address is already known from the live connection - // (peers table) — record the holder id with no manifest addrs. + // Record upstream source + let source_addrs: Vec = response.manifest.as_ref() + .map(|m| m.host_addresses.clone()) + .unwrap_or_default(); let _ = storage.touch_file_holder( cid, from_peer, - &[], + &source_addrs, crate::storage::HolderDirection::Received, ); } @@ -2880,15 +2620,13 @@ impl Node { let post_to_propagate: Option<(PostId, u64, NodeId, [u8; 32])> = { let storage = self.storage.get().await; if let Ok(Some(gk)) = storage.get_group_key_by_circle(&circle_name) { - // "Am I the admin?" = admin ∈ ALL my posting identities; use - // the MATCHED persona's (id, seed) pair for all crypto below. - if let Ok(Some(admin_persona)) = storage.get_posting_identity(&gk.admin) { + if gk.admin == self.default_posting_id { if let Ok(Some(seed)) = storage.get_group_seed(&gk.group_id, gk.epoch) { // Record our own wrapped member key locally (so we // still track membership in group_member_keys for // rotation math). if let Ok(wrapped_new) = crypto::wrap_group_key_for_member( - &admin_persona.secret_seed, &node_id, &seed, + &self.default_posting_secret, &node_id, &seed, ) { let _ = storage.store_group_member_key( &gk.group_id, @@ -2901,8 +2639,8 @@ impl Node { } match crate::group_key_distribution::build_distribution_post( - &admin_persona.node_id, - &admin_persona.secret_seed, + &self.default_posting_id, + &self.default_posting_secret, &gk, &seed, &[node_id], @@ -2914,7 +2652,7 @@ impl Node { &visibility, &VisibilityIntent::GroupKeyDistribute, )?; - Some((post_id, post.timestamp_ms, admin_persona.node_id, admin_persona.secret_seed)) + Some((post_id, post.timestamp_ms, self.default_posting_id, self.default_posting_secret)) } Err(e) => { warn!(error = %e, "failed to build key-distribution post"); @@ -2977,15 +2715,8 @@ impl Node { } self.create_group_key_inner(&circle_name, Some(root_post_id)).await?; - // initial_members are posting ids — skip ALL of our own personas, - // not the network NodeId (which never appears in member lists). - let own_posting_ids: Vec = { - let storage = self.storage.get().await; - storage.list_posting_identities()? - .into_iter().map(|p| p.node_id).collect() - }; for member in initial_members { - if own_posting_ids.contains(&member) { + if member == self.node_id { continue; } if let Err(e) = self.add_to_circle(circle_name.clone(), member).await { @@ -3155,17 +2886,12 @@ impl Node { .duration_since(std::time::UNIX_EPOCH)? .as_millis() as u64; - // The admin of a group/circle is a POSTING identity (persona), never - // the network NodeId: the wire format ships admin == post.author - // (a posting id) and receivers verify exactly that - // (group_key_distribution.rs). Creation currently always acts as the - // default persona. let record = crate::types::GroupKeyRecord { group_id, circle_name: circle_name.to_string(), epoch: 1, group_public_key: pubkey, - admin: self.default_posting_id, + admin: self.node_id, created_at: now, canonical_root_post_id, }; @@ -3174,11 +2900,10 @@ impl Node { storage.create_group_key(&record, Some(&seed))?; storage.store_group_seed(&group_id, 1, &seed)?; - // Wrap for ourselves (as the admin persona — member rows are keyed - // by posting ids so the wrapped key is actually unwrappable). - let self_wrapped = crypto::wrap_group_key_for_member(&self.default_posting_secret, &self.default_posting_id, &seed)?; + // Wrap for ourselves + let self_wrapped = crypto::wrap_group_key_for_member(&self.default_posting_secret, &self.node_id, &seed)?; let self_mk = crate::types::GroupMemberKey { - member: self.default_posting_id, + member: self.node_id, epoch: 1, wrapped_group_key: self_wrapped, }; @@ -3188,12 +2913,10 @@ impl Node { // via a single encrypted key-distribution post. v0.6.2 replaces the // per-member uni-stream GroupKeyDistribute push with this // CDN-propagated post (one post per epoch, recipients = all non-self - // members). Circle members are posting ids — strip ALL our personas. - let own_posting_ids: Vec = storage.list_posting_identities()? - .into_iter().map(|p| p.node_id).collect(); + // members). let other_members: Vec = storage.get_circle_members(circle_name)? .into_iter() - .filter(|m| !own_posting_ids.contains(m)) + .filter(|m| *m != self.node_id) .collect(); for member in &other_members { @@ -3247,29 +2970,21 @@ impl Node { let rotate_result = { let storage = self.storage.get().await; let gk = match storage.get_group_key_by_circle(circle_name) { - Ok(Some(gk)) => gk, - _ => return, - }; - // "Am I the admin?" = admin ∈ my posting identities. Use the - // matched persona's (id, seed) for wrapping + signing. - let admin_persona = match storage.get_posting_identity(&gk.admin) { - Ok(Some(p)) => p, + Ok(Some(gk)) if gk.admin == self.node_id => gk, _ => return, }; let remaining_members = match storage.get_circle_members(circle_name) { Ok(m) => m, Err(_) => return, }; - // Always include ourselves — as the admin PERSONA (member sets - // hold posting ids; the network NodeId must never leak into a - // CDN-propagated key-distribution post). + // Always include ourselves let mut all_members = remaining_members; - if !all_members.contains(&admin_persona.node_id) { - all_members.push(admin_persona.node_id); + if !all_members.contains(&self.node_id) { + all_members.push(self.node_id); } - match crypto::rotate_group_key(&admin_persona.secret_seed, gk.epoch, &all_members) { + match crypto::rotate_group_key(&self.default_posting_secret, gk.epoch, &all_members) { Ok((new_seed, new_pubkey, new_epoch, member_keys)) => { - Some((gk.group_id, new_seed, new_pubkey, new_epoch, member_keys, circle_name.to_string(), gk.canonical_root_post_id, admin_persona)) + Some((gk.group_id, new_seed, new_pubkey, new_epoch, member_keys, circle_name.to_string(), gk.canonical_root_post_id)) } Err(e) => { warn!(error = %e, "Failed to rotate group key"); @@ -3278,28 +2993,23 @@ impl Node { } }; - if let Some((group_id, new_seed, new_pubkey, new_epoch, member_keys, circle_name, canonical_root, admin_persona)) = rotate_result { + if let Some((group_id, new_seed, new_pubkey, new_epoch, member_keys, circle_name, canonical_root)) = rotate_result { // Update storage - let own_posting_ids: Vec = { + { let storage = self.storage.get().await; let _ = storage.update_group_epoch(&group_id, new_epoch, &new_pubkey, Some(&new_seed)); let _ = storage.store_group_seed(&group_id, new_epoch, &new_seed); for mk in &member_keys { let _ = storage.store_group_member_key(&group_id, mk); } - storage.list_posting_identities() - .unwrap_or_default() - .into_iter().map(|p| p.node_id).collect() - }; + } // v0.6.2: distribute the new seed via an encrypted // key-distribution post instead of per-member unicast pushes. - // Strip ALL our personas (never just the default one) so no - // self-addressed wrapped CEK rides a propagated post. let recipients: Vec = member_keys .iter() .map(|mk| mk.member) - .filter(|m| !own_posting_ids.contains(m)) + .filter(|m| *m != self.default_posting_id) .collect(); if !recipients.is_empty() { @@ -3308,7 +3018,7 @@ impl Node { circle_name: circle_name.clone(), epoch: new_epoch, group_public_key: new_pubkey, - admin: admin_persona.node_id, + admin: self.default_posting_id, created_at: std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_millis() as u64) @@ -3316,8 +3026,8 @@ impl Node { canonical_root_post_id: canonical_root, }; match crate::group_key_distribution::build_distribution_post( - &admin_persona.node_id, - &admin_persona.secret_seed, + &self.default_posting_id, + &self.default_posting_secret, &record, &new_seed, &recipients, @@ -3331,7 +3041,7 @@ impl Node { ); } self.update_neighbor_manifests_as( - &admin_persona.node_id, &admin_persona.secret_seed, &post_id, ts, + &self.default_posting_id, &self.default_posting_secret, &post_id, ts, ).await; } Err(e) => { @@ -3363,8 +3073,17 @@ impl Node { .duration_since(std::time::UNIX_EPOCH)? .as_millis() as u64; + let cp = crate::types::CircleProfile { + author: self.default_posting_id, + circle_name: circle_name.clone(), + display_name, + bio, + avatar_cid, + updated_at: now, + }; + // Get group key for this circle - let (cp, encrypted_payload, wrapped_cek, group_id, epoch) = { + let (encrypted_payload, wrapped_cek, group_id, epoch) = { let storage = self.storage.get().await; // Verify circle exists let circles = storage.list_circles()?; @@ -3375,20 +3094,9 @@ impl Node { let gk = storage.get_group_key_by_circle(&circle_name)? .ok_or_else(|| anyhow::anyhow!("no group key for circle '{}'", circle_name))?; - // Admin is a posting identity — check membership across ALL our - // personas and author the profile as the MATCHED persona so the - // local row key matches what receivers store (cp.author). - let admin_persona = storage.get_posting_identity(&gk.admin)? - .ok_or_else(|| anyhow::anyhow!("not admin of circle '{}'", circle_name))?; - - let cp = crate::types::CircleProfile { - author: admin_persona.node_id, - circle_name: circle_name.clone(), - display_name, - bio, - avatar_cid, - updated_at: now, - }; + if gk.admin != self.node_id { + anyhow::bail!("not admin of circle '{}'", circle_name); + } let seed = storage.get_group_seed(&gk.group_id, gk.epoch)? .ok_or_else(|| anyhow::anyhow!("group seed not found for circle '{}'", circle_name))?; @@ -3397,11 +3105,10 @@ impl Node { let json = serde_json::to_string(&cp)?; let (encrypted, wrapped) = crypto::encrypt_post_for_group(&json, &seed, &gk.group_public_key)?; - // Store plaintext + encrypted form, both keyed by the authoring - // persona id (same key class as the pushed payload/remote rows). + // Store plaintext + encrypted form storage.set_circle_profile(&cp)?; storage.store_remote_circle_profile( - &admin_persona.node_id, + &self.node_id, &circle_name, &cp, &encrypted, @@ -3410,12 +3117,12 @@ impl Node { gk.epoch, )?; - (cp, encrypted, wrapped, gk.group_id, gk.epoch) + (encrypted, wrapped, gk.group_id, gk.epoch) }; // Push to all connected mesh peers let payload = crate::protocol::CircleProfileUpdatePayload { - author: cp.author, + author: self.default_posting_id, circle_name, group_id, epoch, @@ -3441,20 +3148,16 @@ impl Node { let storage = self.storage.get().await; let gk = storage.get_group_key_by_circle(&circle_name)? .ok_or_else(|| anyhow::anyhow!("no group key for circle '{}'", circle_name))?; - // Admin ∈ our posting identities; the local row is keyed by that - // persona id (matches set_circle_profile), so delete that row. - let admin_persona = storage.get_posting_identity(&gk.admin)? - .ok_or_else(|| anyhow::anyhow!("not admin of circle '{}'", circle_name))?; let seed = storage.get_group_seed(&gk.group_id, gk.epoch)? .ok_or_else(|| anyhow::anyhow!("group seed not found"))?; // Encrypt empty string as tombstone let (encrypted, wrapped) = crypto::encrypt_post_for_group("", &seed, &gk.group_public_key)?; - storage.delete_circle_profile(&admin_persona.node_id, &circle_name)?; + storage.delete_circle_profile(&self.node_id, &circle_name)?; crate::protocol::CircleProfileUpdatePayload { - author: admin_persona.node_id, + author: self.default_posting_id, circle_name, group_id: gk.group_id, epoch: gk.epoch, @@ -3468,40 +3171,40 @@ impl Node { Ok(()) } - /// Set public_visible flag on our own persona profile. - /// - /// v0.8: public_visible is a persona-class flag (it gates persona display - /// fields), so it lives on the posting-id-keyed profile row — matching - /// publish_profile / my_profile — NOT the network-id row. No wire push: - /// the ProfileUpdate receive path blind-REPLACEs rows, so pushing a - /// sanitized (persona-free) posting-id profile would wipe receivers' - /// stored display fields for this persona. The flag propagates locally; - /// carrying it in ProfilePostContent is tracked future work. + /// Set public_visible flag and push profile update. pub async fn set_public_visible(&self, visible: bool) -> anyhow::Result<()> { let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH)? .as_millis() as u64; - let storage = self.storage.get().await; - let pid = self.default_posting_id; - let profile = match storage.get_profile(&pid)? { - Some(mut p) => { - p.public_visible = visible; - p.updated_at = now; - p - } - None => PublicProfile { - node_id: pid, - display_name: String::new(), - bio: String::new(), + let recent_peers = self.current_recent_peers().await; + let profile = { + let storage = self.storage.get().await; + let existing = storage.get_profile(&self.node_id)?; + let (display_name, bio, avatar_cid) = match existing { + Some(p) => (p.display_name, p.bio, p.avatar_cid), + None => (String::new(), String::new(), None), + }; + let existing_anchors = storage.get_peer_anchors(&self.node_id).unwrap_or_default(); + let preferred_peers = storage.list_preferred_peers().unwrap_or_default(); + + let profile = PublicProfile { + node_id: self.node_id, + display_name, + bio, updated_at: now, - anchors: vec![], - recent_peers: vec![], + anchors: existing_anchors, + recent_peers, + preferred_peers, public_visible: visible, - avatar_cid: None, - }, + avatar_cid, + }; + + storage.store_profile(&profile)?; + profile }; - storage.store_profile(&profile)?; + + self.network.push_profile(&profile).await; Ok(()) } @@ -3511,42 +3214,23 @@ impl Node { author: &NodeId, ) -> anyhow::Result<(String, String, Option<[u8; 32]>)> { let storage = self.storage.get().await; - // Viewer identity for circle-profile resolution = ALL our posting - // identities (circle membership is posting-class, never network id). - let viewers: Vec = storage.list_posting_identities() - .unwrap_or_default() - .into_iter().map(|p| p.node_id).collect(); - storage.resolve_display_for_peer(author, &viewers) + storage.resolve_display_for_peer(author, &self.node_id) } - /// Get our own circle profile for a given circle. Own circle-profile rows - /// are keyed by the authoring persona (the circle's admin posting id). + /// Get our own circle profile for a given circle. pub async fn get_circle_profile( &self, circle_name: &str, ) -> anyhow::Result> { let storage = self.storage.get().await; - let gk = match storage.get_group_key_by_circle(circle_name)? { - Some(gk) => gk, - None => return Ok(None), - }; - // Own circles only: the admin must be one of OUR posting identities. - // Group-key records for circles we merely belong to (received via - // key-distribution posts) store the REMOTE admin's id; returning that - // row here would surface a foreign circle profile as "our own" in the - // edit dialog. Mirrors the set/delete_circle_profile gate. - if storage.get_posting_identity(&gk.admin)?.is_none() { - return Ok(None); - } - storage.get_circle_profile(&gk.admin, circle_name) + storage.get_circle_profile(&self.node_id, circle_name) } - /// Get the public_visible setting for our own persona profile. - /// v0.8: keyed by the default posting id (see set_public_visible). + /// Get the public_visible setting for our own profile. pub async fn get_public_visible(&self) -> anyhow::Result { let storage = self.storage.get().await; Ok(storage - .get_profile(&self.default_posting_id)? + .get_profile(&self.node_id)? .map(|p| p.public_visible) .unwrap_or(true)) } @@ -3589,24 +3273,20 @@ impl Node { let staleness_ms = 3600 * 1000; - let (candidates, follows, own_ids) = { + let (candidates, follows) = { let storage = self.storage.get().await; let candidates = storage.get_eviction_candidates(staleness_ms)?; let follows = storage.list_follows().unwrap_or_default(); - let own_ids: Vec = storage.list_posting_identities() - .unwrap_or_default() - .into_iter().map(|p| p.node_id).collect(); - (candidates, follows, own_ids) + (candidates, follows) }; if candidates.is_empty() { return Ok(255); // Empty cache = max willingness to accept } - // Filter to non-elevated blobs (not pinned, not own content, not followed - // author). Own content = authored by ANY of our posting identities. + // Filter to non-elevated blobs (not pinned, not own content, not followed author) let non_elevated: Vec<_> = candidates.iter().filter(|c| { - !c.pinned && !own_ids.contains(&c.author) && !follows.contains(&c.author) + !c.pinned && c.author != self.node_id && !follows.contains(&c.author) }).collect(); if non_elevated.is_empty() { @@ -3617,7 +3297,7 @@ impl Node { let mut min_priority = f64::MAX; let mut min_created_at = u64::MAX; for c in &non_elevated { - let priority = self.compute_blob_priority(c, &own_ids, &follows, now); + let priority = self.compute_blob_priority(c, &follows, now); if priority < min_priority { min_priority = priority; min_created_at = c.created_at; @@ -3688,7 +3368,8 @@ impl Node { let now = control_post.timestamp_ms; // Clean up blob storage local-side. Blobs in remote holders become - // orphans and get evicted naturally via LRU. + // orphans and get evicted naturally via LRU — BlobDeleteNotice is + // gone in v0.6.2. let blob_cids = { let storage = self.storage.get().await; let cids = storage.delete_blobs_for_post(post_id)?; @@ -3755,17 +3436,9 @@ impl Node { .ok_or_else(|| anyhow::anyhow!("post not found"))? }; - // Posts are authored by POSTING identities (personas), never the - // network NodeId. "Is this mine?" = author ∈ all my posting identities; - // remember the matched persona so crypto below uses its (id, seed) pair. - let author_persona = { - let storage = self.storage.get().await; - storage.get_posting_identity(&post.author)? - }; - let author_persona = match author_persona { - Some(p) => p, - None => anyhow::bail!("cannot revoke: you are not the author"), - }; + if post.author != self.node_id { + anyhow::bail!("cannot revoke: you are not the author"); + } let existing_recipients = match &visibility { PostVisibility::Public => anyhow::bail!("cannot revoke access on a public post"), @@ -3791,8 +3464,8 @@ impl Node { match mode { RevocationMode::SyncAccessList => { let new_wrapped = crypto::rewrap_visibility( - &author_persona.secret_seed, - &author_persona.node_id, + &self.default_posting_secret, + &self.node_id, existing_recipients, &new_recipient_ids, )?; @@ -3806,7 +3479,12 @@ impl Node { // Propagate via a signed control-visibility post rather than a // direct push. Only the target's author can make such a post. - let author_secret = author_persona.secret_seed; + let author_secret = { + let s = self.storage.get().await; + s.get_posting_identity(&post.author)? + .map(|pi| pi.secret_seed) + .ok_or_else(|| anyhow::anyhow!("missing posting secret for post author"))? + }; let control_post = crate::control::build_visibility_control_post( &post.author, &author_secret, @@ -3836,8 +3514,8 @@ impl Node { RevocationMode::ReEncrypt => { let (new_content, new_wrapped) = crypto::re_encrypt_post( &post.content, - &author_persona.secret_seed, - &author_persona.node_id, + &self.default_posting_secret, + &self.node_id, existing_recipients, &new_recipient_ids, )?; @@ -3846,9 +3524,7 @@ impl Node { }; let new_post = Post { - // Keep the ORIGINAL persona as author — the replacement - // must not migrate content to the default persona. - author: post.author, + author: self.default_posting_id, content: new_content, attachments: post.attachments.clone(), timestamp_ms: post.timestamp_ms, @@ -3862,7 +3538,7 @@ impl Node { storage.store_post_with_visibility(&new_post_id, &new_post, &new_vis)?; } - // delete_post propagates the deletion as a signed control post. + // delete_post already pushes the DeleteRecord. // Replacement post propagates via the CDN to remaining recipients. self.delete_post(post_id).await?; @@ -3882,15 +3558,9 @@ impl Node { revoked: &NodeId, mode: RevocationMode, ) -> anyhow::Result { - // Posts are authored by posting identities — query every persona, - // not the network NodeId (which never authors posts). let posts = { let storage = self.storage.get().await; - let mut all = Vec::new(); - for persona in storage.list_posting_identities()? { - all.extend(storage.find_posts_by_circle_intent(circle_name, &persona.node_id)?); - } - all + storage.find_posts_by_circle_intent(circle_name, &self.node_id)? }; let mut count = 0; @@ -4078,7 +3748,7 @@ impl Node { { let on_cooldown = { let storage = self.storage.get().await; - storage.is_relay_cooldown(&peer_id, RELAY_COOLDOWN_MS).unwrap_or(false) + storage.is_relay_cooldown(&peer_id, 300_000).unwrap_or(false) }; if !on_cooldown { @@ -4098,7 +3768,7 @@ impl Node { ); let intro_result = tokio::time::timeout( - std::time::Duration::from_secs(RELAY_INTRO_TIMEOUT_SECS), + std::time::Duration::from_secs(15), self.network.send_relay_introduce_standalone(relay_peer, &peer_id, *ttl), ).await; @@ -4249,7 +3919,7 @@ impl Node { let storage = self.storage.get().await; let _ = storage.update_follow_last_sync(&peer_id, 0); } - let stats = self.network.conn_handle().content_sync_from_peer(&peer_id).await?; + let stats = self.network.conn_handle().pull_from_peer(&peer_id).await?; // Also fetch engagement data (reactions, comments) for posts we hold let engagement = self.network.conn_handle().fetch_engagement_from_peer(&peer_id).await.unwrap_or(0); info!( @@ -4269,7 +3939,7 @@ impl Node { pub async fn sync_with_addr(&self, addr: iroh::EndpointAddr) -> anyhow::Result<()> { let peer_id = *addr.id.as_bytes(); self.network.connect_to_peer(peer_id, addr).await?; - let stats = self.network.conn_handle().content_sync_from_peer(&peer_id).await?; + let stats = self.network.conn_handle().pull_from_peer(&peer_id).await?; info!( peer = hex::encode(peer_id), posts = stats.posts_received, @@ -4280,7 +3950,7 @@ impl Node { /// Pull from all connected peers pub async fn sync_all(&self) -> anyhow::Result<()> { - let stats = self.network.content_sync_all().await?; + let stats = self.network.pull_from_all().await?; info!( "Pull complete: {} posts from {} peers", stats.posts_received, stats.peers_pulled @@ -4313,13 +3983,8 @@ impl Node { self.bootstrap_anchors.lock().await.clone() } - /// This device's slot/depth budget. - pub fn device_profile(&self) -> DeviceProfile { - self.profile - } - - /// Get connection info for display: (node_id, slot, connected_at) - pub async fn list_connections(&self) -> Vec<(NodeId, MeshSlot, u64)> { + /// Get connection info for display: (node_id, slot_kind, connected_at) + pub async fn list_connections(&self) -> Vec<(NodeId, PeerSlotKind, u64)> { self.network.connection_info().await } @@ -4338,73 +4003,53 @@ impl Node { tokio::spawn(async move { network.run_accept_loop().await }) } - /// Start the sync cycle — two cadences on one 60s tick. - /// - /// (1) THE PULL, i.e. the uniques-index exchange (0x40/0x41). design.html - /// §sync: a pull is not a post transfer, it is "if you want these IDs, - /// talk to me and I'll help you find them". Runs on the slow tick - /// because the pools are large and mostly static; the push-side - /// announce (0x01) already covers fast changes. - /// - /// (2) TRANSITIONAL content sync (0x46/0x47) for stale authors. Folds into - /// the update-cadence scheduler + CDN replication in Iteration D. - /// Until then it is the only carrier for non-public visibilities. - pub fn start_sync_cycle(self: &Arc) -> tokio::task::JoinHandle<()> { + /// Start pull cycle: Protocol v4 tiered pull — 60s ticks, full pull on first tick, + /// then only pull for stale authors (last_sync_ms > 4 hours old). + pub fn start_pull_cycle(self: &Arc, _interval_secs: u64) -> tokio::task::JoinHandle<()> { let node = Arc::clone(self); tokio::spawn(async move { let mut interval = tokio::time::interval(std::time::Duration::from_secs(60)); - let mut tick: u64 = 0; + let mut is_first_tick = true; loop { interval.tick().await; - tick += 1; - if tick == 1 { - // Startup: full content sync + engagement fetch, then - // prefetch blobs for what arrived. - let _ = node.network.content_sync_all().await; + if is_first_tick { + // Full pull on startup + let _ = node.network.pull_from_all().await; + is_first_tick = false; + // Prefetch after initial sync let peers = node.network.conn_handle().connected_peers().await; for peer_id in peers { node.prefetch_blobs_from_peer(&peer_id).await; } - let n = node.network.uniques_pull_all().await; - tracing::debug!(peers = n, "Startup uniques-index exchange"); continue; } - // (1) Uniques-index exchange every 5 minutes. - if tick % 5 == 0 { - let n = node.network.uniques_pull_all().await; - if n > 0 { - tracing::debug!(peers = n, "Uniques-index exchange"); - } - } - - // (2) Tiered content sync: only when some author is stale. + // Tiered: only pull for stale authors (4-hour default) let stale_authors = { let storage = node.storage.get().await; storage.get_stale_follows(4 * 3600 * 1000).unwrap_or_default() }; + if stale_authors.is_empty() { continue; // Most ticks skip — no stale authors } - // Every connected peer, not just `peers.first()`: one arbitrary - // peer is very unlikely to hold a given stale author's posts, - // so the tiered tick silently did almost nothing. + // Find a connected peer and pull let peers = node.network.conn_handle().connected_peers().await; - for peer_id in &peers { - match node.network.conn_handle().content_sync_from_peer(peer_id).await { - Ok(stats) if stats.posts_received > 0 => { - tracing::debug!( - peer = hex::encode(peer_id), - posts = stats.posts_received, - "Tiered content sync complete" - ); - node.prefetch_blobs_from_peer(peer_id).await; + if let Some(peer_id) = peers.first() { + match node.network.conn_handle().pull_from_peer(peer_id).await { + Ok(stats) => { + if stats.posts_received > 0 { + tracing::debug!( + posts = stats.posts_received, + "Tiered pull complete" + ); + node.prefetch_blobs_from_peer(peer_id).await; + } } - Ok(_) => {} - Err(e) => tracing::debug!(error = %e, "Tiered content sync failed"), + Err(e) => tracing::debug!(error = %e, "Tiered pull failed"), } } } @@ -4436,7 +4081,7 @@ impl Node { } } } else { - match network.broadcast_uniques().await { + match network.broadcast_diff().await { Ok(count) => { if count > 0 { tracing::debug!(count, "Broadcast routing diff"); @@ -4486,12 +4131,8 @@ impl Node { }) } - /// Start recovery loop: triggered when the mesh drops below 2 peers. - /// - /// Recovery is deliberately NOT stochastic (round-4 ruling). A node with - /// fewer than 2 mesh peers cannot function, so it always acts immediately; - /// only *growth* rolls dice. Anchors are gathered pool-first, then the - /// bootstrap cache. + /// Start recovery loop: triggered when mesh drops below 2 connections. + /// Immediately reconnects to anchors and requests referrals. pub fn start_recovery_loop(&self) -> tokio::task::JoinHandle<()> { let network = Arc::clone(&self.network); let storage = Arc::clone(&self.storage); @@ -4505,30 +4146,85 @@ impl Node { network.set_recovery_tx(tx).await; while rx.recv().await.is_some() { tracing::info!("Recovery triggered: reconnecting to anchors"); - log_evt(ActivityLevel::Warn, ActivityCategory::Recovery, "Recovery triggered: mesh below 2".into(), None); + log_evt(ActivityLevel::Warn, ActivityCategory::Recovery, "Recovery triggered: mesh empty".into(), None); // Debounce: wait briefly for more disconnects to settle tokio::time::sleep(std::time::Duration::from_secs(2)).await; // Drain any queued signals while rx.try_recv().is_ok() {} - let anchors = gather_anchor_candidates(&storage, &network, node_id, 8).await; - let mut connected = 0usize; - for (anchor_nid, anchor_addrs) in &anchors { - // ENTRY class: always served, by definition of the ruling. - // There is no separate registration — the request enrols us. - connected += run_convection( - &network, - *anchor_nid, - anchor_addrs, - crate::protocol::ConvectionClass::Entry, - node_id, - ).await; - if network.conn_handle().mesh_count().await >= 2 { - break; + // Gather anchors: known_anchors table, then anchor peers fallback + let anchors: Vec<(crate::types::NodeId, Vec)> = { + let s = storage.get().await; + let known = s.list_known_anchors().unwrap_or_default(); + if !known.is_empty() { + known + } else { + s.list_anchor_peers().unwrap_or_default() + .into_iter() + .map(|r| (r.node_id, r.addresses)) + .collect() + } + }; + + for (anchor_nid, anchor_addrs) in &anchors { + if *anchor_nid == node_id { continue; } + // Connect to anchor (mesh or session fallback) + if !network.is_peer_connected_or_session(anchor_nid).await { + let endpoint_id = match iroh::EndpointId::from_bytes(anchor_nid) { + Ok(eid) => eid, + Err(_) => continue, + }; + let mut addr = iroh::EndpointAddr::from(endpoint_id); + for sa in anchor_addrs { + addr = addr.with_ip_addr(*sa); + } + match network.connect_to_anchor(*anchor_nid, addr).await { + Ok(()) => { + log_evt(ActivityLevel::Info, ActivityCategory::Recovery, "Connected to anchor".into(), Some(*anchor_nid)); + } + Err(e) => { + tracing::debug!(error = %e, "Recovery: anchor connect failed"); + log_evt(ActivityLevel::Warn, ActivityCategory::Recovery, format!("Anchor connect failed: {}", e), Some(*anchor_nid)); + continue; + } + } + } + // Register with anchor + let _ = network.send_anchor_register(anchor_nid).await; + // Request referrals + match network.request_anchor_referrals(anchor_nid).await { + Ok(referrals) => { + 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)) = crate::parse_connect_string(&connect_str) { + match network.connect_to_peer(rid, raddr).await { + Ok(()) => { + tracing::info!(peer = hex::encode(rid), "Recovery: connected to referred peer"); + log_evt(ActivityLevel::Info, ActivityCategory::Recovery, "Connected to referred peer".into(), Some(rid)); + } + Err(_) => { + match network.connect_via_introduction(rid, *anchor_nid).await { + Ok(()) => { + tracing::info!(peer = hex::encode(rid), "Recovery: connected via hole punch"); + log_evt(ActivityLevel::Info, ActivityCategory::Recovery, "Connected via hole punch".into(), Some(rid)); + } + Err(e) => { + tracing::debug!(error = %e, peer = hex::encode(rid), "Recovery: hole punch failed"); + log_evt(ActivityLevel::Warn, ActivityCategory::Recovery, format!("Hole punch failed: {}", e), Some(rid)); + } + } + } + } + } + } + } + } + Err(e) => tracing::debug!(error = %e, "Recovery: referral request failed"), } - } - if connected > 0 { - log_evt(ActivityLevel::Info, ActivityCategory::Recovery, format!("Convection produced {} connections", connected), None); } let conn_count = network.connection_count().await; tracing::info!(connections = conn_count, "Recovery complete"); @@ -4537,117 +4233,6 @@ impl Node { }) } - /// Run one convection exchange against a specific anchor, on demand. - /// - /// Diagnostics + integration testing: the automatic paths pick the anchor - /// themselves (recovery pool-first, the stochastic arm at random), which is - /// correct but untestable. Returns `(peers_connected, refused, elapsed_ms)` - /// — the elapsed time is the point of the cheap-refusal contract. - pub async fn convection_request(&self, anchor: NodeId) -> anyhow::Result<(usize, bool, u128)> { - let started = std::time::Instant::now(); - let mesh = self.network.conn_handle().mesh_count().await; - let class = crate::protocol::ConvectionClass::for_mesh_count(mesh); - let addrs: Vec = { - let s = self.storage.get().await; - s.get_peer_record(&anchor).ok().flatten().map(|r| r.addresses).unwrap_or_default() - }; - if !self.network.is_peer_connected_or_session(&anchor).await { - let eid = iroh::EndpointId::from_bytes(&anchor)?; - let mut ea = iroh::EndpointAddr::from(eid); - for sa in &addrs { - ea = ea.with_ip_addr(*sa); - } - self.network.connect_to_anchor(anchor, ea).await?; - } - let response = self.network.request_convection(&anchor, class).await?; - let refused = response.refused; - let connected = if refused { - 0 - } else { - self.network.act_on_convection(&anchor, &response).await - }; - Ok((connected, refused, started.elapsed().as_millis())) - } - - /// Run the uniques-index exchange (the v0.8 "pull") against every mesh peer. - pub async fn uniques_pull(&self) -> usize { - self.network.uniques_pull_all().await - } - - /// Start the convection loop: the "ask a random known anchor" arm of the - /// per-disconnect stochastic action (round-4/5). - /// - /// The dice are rolled inside `disconnect_peer` under the conn_mgr lock - /// (pure state read + RNG); this loop is where the resulting network I/O - /// happens, so nothing blocks a teardown. It also carries the anchor - /// self-verification probe, which lost its home when the register cycle - /// retired. - pub fn start_convection_loop(&self) -> tokio::task::JoinHandle<()> { - let network = Arc::clone(&self.network); - let storage = Arc::clone(&self.storage); - let node_id = self.node_id; - let alog = Arc::clone(&self.activity_log); - let timer = Arc::clone(&self.last_convection_ms); - let (tx, mut rx) = tokio::sync::mpsc::channel::<()>(1); - tokio::spawn(async move { - let log_evt = |level: ActivityLevel, cat: ActivityCategory, msg: String, peer: Option| { - if let Ok(mut log) = alog.try_lock() { log.log(level, cat, msg, peer); } - }; - network.conn_handle().set_convection_tx(tx).await; - // Slow maintenance tick: the anchor self-verification probe used to - // hang off the register cycle. - let mut probe_tick = tokio::time::interval(std::time::Duration::from_secs(600)); - probe_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); - loop { - tokio::select! { - signal = rx.recv() => { - if signal.is_none() { break; } - // Coalesce a burst of disconnects into one action. - while rx.try_recv().is_ok() {} - - let mesh = network.conn_handle().mesh_count().await; - let class = crate::protocol::ConvectionClass::for_mesh_count(mesh); - let mut anchors = gather_anchor_candidates(&storage, &network, node_id, 12).await; - if anchors.is_empty() { - // No anchor to ask — fall through to the mesh arm - // rather than doing nothing. - network.notify_growth().await; - continue; - } - // "a RANDOM known anchor" — not the best-ranked one. - // Ranking anchors was a scarcity artifact; spreading - // load is what keeps convection windows fresh. - use rand::seq::SliceRandom; - anchors.shuffle(&mut rand::rng()); - let (anchor_nid, anchor_addrs) = anchors.remove(0); - timer.store( - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_millis() as u64, - AtomicOrdering::Relaxed, - ); - let n = run_convection(&network, anchor_nid, &anchor_addrs, class, node_id).await; - if n > 0 { - log_evt(ActivityLevel::Info, ActivityCategory::Anchor, format!("Convection: {} new peers", n), Some(anchor_nid)); - } else { - // Nothing came back — let the mesh arm try. - network.notify_growth().await; - } - } - _ = probe_tick.tick() => { - if network.conn_handle().probe_due().await { - log_evt(ActivityLevel::Info, ActivityCategory::Anchor, "Initiating anchor self-verification probe".into(), None); - if let Err(e) = network.conn_handle().initiate_anchor_probe().await { - tracing::debug!(error = %e, "Anchor probe error"); - } - } - } - } - } - }) - } - /// Start social checkin cycle: every interval_secs, refresh stale social routes. /// Uses ephemeral connections if not persistently connected. pub fn start_social_checkin_cycle(&self, interval_secs: u64) -> tokio::task::JoinHandle<()> { @@ -4693,6 +4278,165 @@ impl Node { }) } + /// Register with all connected anchor peers. Returns count registered. + pub async fn register_with_anchors(&self) -> usize { + let conns = self.network.connection_info().await; + let mut count = 0; + for (nid, _, _) in &conns { + if self.network.is_anchor_peer(nid).await { + match self.network.send_anchor_register(nid).await { + Ok(()) => { + count += 1; + info!(anchor = hex::encode(nid), "Registered with anchor"); + } + Err(e) => debug!(error = %e, anchor = hex::encode(nid), "Anchor register failed"), + } + } + } + count + } + + /// Start anchor register cycle: periodically re-register with anchors and request referrals + /// when connection count is low. + pub fn start_anchor_register_cycle(&self, interval_secs: u64) -> tokio::task::JoinHandle<()> { + let network = Arc::clone(&self.network); + let storage = Arc::clone(&self.storage); + let node_id = self.node_id; + let alog = Arc::clone(&self.activity_log); + let timer = Arc::clone(&self.last_anchor_register_ms); + tokio::spawn(async move { + let log_evt = |level: ActivityLevel, cat: ActivityCategory, msg: String, peer: Option| { + if let Ok(mut log) = alog.try_lock() { log.log(level, cat, msg, peer); } + }; + let mut interval = + tokio::time::interval(std::time::Duration::from_secs(interval_secs)); + loop { + interval.tick().await; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; + timer.store(now, AtomicOrdering::Relaxed); + + // Re-register with connected anchors (mesh + session) + let conns = network.connection_info().await; + let session_peers = network.session_peer_ids().await; + let mut registered_anchors = std::collections::HashSet::new(); + // Mesh-connected anchors + for (nid, _, _) in &conns { + if network.is_anchor_peer(nid).await { + match network.send_anchor_register(nid).await { + Ok(()) => { + log_evt(ActivityLevel::Info, ActivityCategory::Anchor, "Re-registered with anchor".into(), Some(*nid)); + registered_anchors.insert(*nid); + } + Err(e) => { + tracing::debug!(error = %e, "Anchor re-register failed"); + log_evt(ActivityLevel::Warn, ActivityCategory::Anchor, format!("Re-register failed: {}", e), Some(*nid)); + } + } + } + } + // Session-connected anchors (e.g. anchor with full mesh) + for nid in &session_peers { + if registered_anchors.contains(nid) { continue; } + if network.is_anchor_peer(nid).await { + match network.send_anchor_register(nid).await { + Ok(()) => { + log_evt(ActivityLevel::Info, ActivityCategory::Anchor, "Re-registered with anchor (session)".into(), Some(*nid)); + } + Err(e) => { + tracing::debug!(error = %e, "Anchor session re-register failed"); + } + } + } + } + + // If few connections, try requesting referrals from known anchors + let conn_count = network.connection_count().await; + if conn_count < 10 { + log_evt(ActivityLevel::Info, ActivityCategory::Anchor, format!("Low connections ({}), requesting referrals", conn_count), None); + let known = { + let s = storage.get().await; + s.list_known_anchors().unwrap_or_default() + }; + for (anchor_nid, anchor_addrs) in known { + if anchor_nid == node_id { + continue; + } + // Connect if not already connected (mesh or session) + if !network.is_peer_connected_or_session(&anchor_nid).await { + let endpoint_id = match iroh::EndpointId::from_bytes(&anchor_nid) { + Ok(eid) => eid, + Err(_) => continue, + }; + let mut addr = iroh::EndpointAddr::from(endpoint_id); + for sa in &anchor_addrs { + addr = addr.with_ip_addr(*sa); + } + if let Err(e) = network.connect_to_anchor(anchor_nid, addr).await { + tracing::debug!(error = %e, "Anchor cycle: connect failed"); + continue; + } + } + match network.request_anchor_referrals(&anchor_nid).await { + Ok(referrals) => { + 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)) = crate::parse_connect_string(&connect_str) { + match network.connect_to_peer(rid, raddr).await { + Ok(()) => { + tracing::info!(peer = hex::encode(rid), "Anchor cycle: connected to referred peer"); + log_evt(ActivityLevel::Info, ActivityCategory::Anchor, "Connected to referred peer".into(), Some(rid)); + } + Err(_) => { + match network.connect_via_introduction(rid, anchor_nid).await { + Ok(()) => { + tracing::info!(peer = hex::encode(rid), "Anchor cycle: connected via hole punch"); + log_evt(ActivityLevel::Info, ActivityCategory::Anchor, "Connected via hole punch".into(), Some(rid)); + } + Err(e) => { + tracing::debug!(error = %e, peer = hex::encode(rid), "Anchor cycle: hole punch failed"); + log_evt(ActivityLevel::Warn, ActivityCategory::Anchor, format!("Hole punch failed: {}", e), Some(rid)); + } + } + } + } + } + } + } + } + Err(e) => tracing::debug!(error = %e, "Anchor cycle: referral request failed"), + } + } + } + + // Anchor self-verification probe + { + let probe_due = network.conn_handle().probe_due().await; + if probe_due { + log_evt(ActivityLevel::Info, ActivityCategory::Anchor, "Initiating anchor self-verification probe".into(), None); + match network.conn_handle().initiate_anchor_probe().await { + Ok(true) => {}, // success already logged inside + Ok(false) => {}, // failure already logged inside + Err(e) => { + tracing::debug!(error = %e, "Anchor probe error"); + } + } + } + } + } + }) + } + /// Start bootstrap connectivity check: 24 hours after startup, verify the bootstrap /// anchor is within our network knowledge (N1/N2/N3). If not, we may be in an isolated /// segment — reconnect to bootstrap and request referrals to bridge back. @@ -4717,18 +4461,20 @@ impl Node { continue; } - // Is the bootstrap anywhere in our N1-N4 horizon? N4 counts: - // it is used for search and resolution, it is only never - // re-announced. + // Check if bootstrap is in N1 (mesh), N2, or N3 let is_reachable = { let connected = node.network.is_connected(&bootstrap_nid).await; if connected { true } else { let storage = node.storage.get().await; - storage.find_any_reachable(std::slice::from_ref(&bootstrap_nid)) - .map(|r| !r.is_empty()) - .unwrap_or(false) + let in_n2 = storage.find_in_n2(&bootstrap_nid).unwrap_or_default(); + if !in_n2.is_empty() { + true + } else { + let in_n3 = storage.find_in_n3(&bootstrap_nid).unwrap_or_default(); + !in_n3.is_empty() + } } }; @@ -4746,16 +4492,26 @@ impl Node { continue; } - // ENTRY class: an isolated segment is exactly the case the - // always-served class exists for. - let n = run_convection( - &node.network, - bootstrap_nid, - &[], - crate::protocol::ConvectionClass::Entry, - node.node_id, - ).await; - tracing::info!(connected = n, "Bootstrap connectivity: convection complete"); + // Report bootstrap in our N1 for 24 hours so peers learn about it + node.network.conn_handle().add_sticky_n1(&bootstrap_nid, 24 * 60 * 60 * 1000); + + match node.network.request_anchor_referrals(&bootstrap_nid).await { + Ok(referrals) => { + tracing::info!(count = referrals.len(), "Bootstrap connectivity: got referrals"); + for referral in referrals { + if referral.node_id == node.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)) = crate::parse_connect_string(&connect_str) { + let _ = node.network.connect_to_peer(rid, raddr).await; + } + } + } + } + Err(e) => { + tracing::warn!(error = %e, "Bootstrap connectivity: referral request failed"); + } + } } }) } @@ -4841,9 +4597,8 @@ impl Node { if nid == self.node_id { continue; } - // Temp referral slots are never advertised as part of our - // neighborhood. - if !kind.is_mesh() { + // Prefer social peers + if kind != PeerSlotKind::Local && result.len() >= 10 { continue; } let addrs: Vec = storage.get_peer_record(&nid) @@ -4871,16 +4626,13 @@ impl Node { // ---- Blob Eviction ---- /// Compute priority score for a blob. Higher score = keep longer. - /// `own_author_ids` = ALL of this node's posting identities (blob authors - /// are posting ids, never the network NodeId). pub fn compute_blob_priority( &self, candidate: &crate::storage::EvictionCandidate, - own_author_ids: &[NodeId], follows: &[NodeId], now_ms: u64, ) -> f64 { - compute_blob_priority_standalone(candidate, own_author_ids, follows, now_ms) + compute_blob_priority_standalone(candidate, &self.node_id, follows, now_ms) } /// Delete a blob locally. BlobDeleteNotice was removed in v0.6.2; remote @@ -4914,20 +4666,17 @@ impl Node { // 1-hour staleness for replica counts let staleness_ms = 3600 * 1000; - let (candidates, follows, own_ids) = { + let (candidates, follows) = { let storage = self.storage.get().await; let candidates = storage.get_eviction_candidates(staleness_ms)?; let follows = storage.list_follows().unwrap_or_default(); - let own_ids: Vec = storage.list_posting_identities() - .unwrap_or_default() - .into_iter().map(|p| p.node_id).collect(); - (candidates, follows, own_ids) + (candidates, follows) }; // Score and sort ascending (lowest priority first) let mut scored: Vec<(f64, &crate::storage::EvictionCandidate)> = candidates .iter() - .map(|c| (self.compute_blob_priority(c, &own_ids, &follows, now), c)) + .map(|c| (self.compute_blob_priority(c, &follows, now), c)) .collect(); scored.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal)); @@ -4975,32 +4724,22 @@ impl Node { Ok(n) => info!(evicted = n, "Eviction cycle complete"), Err(e) => warn!(error = %e, "Eviction cycle failed"), } - - // v0.8 (A3): comment-TTL sweep piggybacks the storage- - // hygiene loop. Hard delete — expiry is the forgetting - // mechanism; 300s ticks are far inside 30–365d TTLs. - { - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_millis() as u64; - let s = node.storage.get().await; - match s.expire_comments(now) { - Ok(0) | Err(_) => {} - Ok(n) => info!(expired = n, "Expired comments swept"), - } - } - - // v0.8 (A3): registry auto-renew — while "Listed" is - // checked, re-sign a fresh 30d entry when the current one - // expires within 5 days (~every 25 days). - if let Err(e) = node.renew_registry_entries_if_due().await { - debug!(error = %e, "Registry auto-renew pass failed"); - } } }) } + /// No-op since v0.7.2: the `portmapper::Client` held inside the active + /// `PortMapping` auto-renews internally in its own background service. + /// Retained for API compatibility with callers in CLI and Tauri until + /// they're cleaned up. Returns `None`. + /// + /// TODO(v0.7.x): wire a watcher on `mapping.watch_external()` to clear + /// anchor mode if the external address stays `None` for more than ~5min + /// (parity with the old "3 renewal failures" behavior). + pub fn start_upnp_renewal_cycle(&self) -> Option> { + None + } + // --- HTTP Post Delivery --- /// Start the HTTP server for serving public posts to browsers. @@ -5016,6 +4755,9 @@ impl Node { } let storage = Arc::clone(&self.storage); let blob_store = Arc::clone(&self.blob_store); + let downstream_addrs = Arc::new(tokio::sync::Mutex::new( + std::collections::HashMap::<[u8; 32], Vec>::new(), + )); // Advertise HTTP capability to peers let http_addr = self.network.http_addr(); @@ -5033,7 +4775,7 @@ impl Node { info!("Starting HTTP server on TCP port {}", port); Some(tokio::spawn(async move { - if let Err(e) = crate::http::run_http_server(port, storage, blob_store).await { + if let Err(e) = crate::http::run_http_server(port, storage, blob_store, downstream_addrs).await { warn!("HTTP server stopped: {}", e); } })) @@ -5138,13 +4880,7 @@ impl Node { }; // propagate_engagement_diff targets all file_holders (flat set, max 5) // which already subsumes what used to be upstream + downstream. - network.propagate_engagement_diff( - &post_id, - &diff, - // exclude_peer is NETWORK-class (file_holders hold device - // ids) — pass the network NodeId, not a posting id. - &self.node_id, - ).await; + network.propagate_engagement_diff(&post_id, &diff, &our_node_id).await; } Ok(reaction) @@ -5173,13 +4909,7 @@ impl Node { }], timestamp_ms: now, }; - network.propagate_engagement_diff( - &post_id, - &diff, - // exclude_peer is NETWORK-class (file_holders hold device - // ids) — pass the network NodeId, not a posting id. - &self.node_id, - ).await; + network.propagate_engagement_diff(&post_id, &diff, &our_node_id).await; } Ok(()) @@ -5212,14 +4942,11 @@ impl Node { Ok(reactions) } - /// Get reaction counts grouped by emoji for a post. "Mine" = a reaction - /// from ANY of our posting identities. + /// Get reaction counts grouped by emoji for a post. pub async fn get_reaction_counts(&self, post_id: PostId) -> anyhow::Result> { + let our_node_id = self.default_posting_id; let storage = self.storage.get().await; - let our_ids: Vec = storage.list_posting_identities() - .unwrap_or_default() - .into_iter().map(|p| p.node_id).collect(); - let counts = storage.get_reaction_counts(&post_id, &our_ids)?; + let counts = storage.get_reaction_counts(&post_id, &our_node_id)?; Ok(counts) } @@ -5275,8 +5002,6 @@ impl Node { .duration_since(std::time::UNIX_EPOCH)? .as_millis() as u64; - // v0.8 (A3): TTL drawn BEFORE signing — it's inside the digest. - let expires_at_ms = crate::registry::draw_ordinary_comment_expiry(now); let signature = crate::crypto::sign_comment( &seed, &our_node_id, @@ -5284,7 +5009,6 @@ impl Node { &content, now, ref_post_id.as_ref(), - expires_at_ms, ); let comment = crate::types::InlineComment { @@ -5298,16 +5022,10 @@ impl Node { pub_x_index: None, group_sig: None, encrypted_payload: None, - expires_at_ms, }; let storage = self.storage.get().await; - // `store_own_comment`, not `store_comment`: WE authored this, so its - // author (our persona) must stay out of our own uniques announce. - storage.store_own_comment(&comment)?; - // v0.8 (A3): refresh the aggregated header so pulls serve this - // comment without waiting for a diff roundtrip. - let _ = storage.rebuild_blob_header_from_db(&post_id, &our_node_id, now); + storage.store_comment(&comment)?; drop(storage); // Propagate via BlobHeaderDiff to the target post's known holders. @@ -5319,13 +5037,7 @@ impl Node { ops: vec![crate::types::BlobHeaderDiffOp::AddComment(comment.clone())], timestamp_ms: now, }; - network.propagate_engagement_diff( - &post_id, - &diff, - // exclude_peer is NETWORK-class (file_holders hold device - // ids) — pass the network NodeId, not a posting id. - &self.node_id, - ).await; + network.propagate_engagement_diff(&post_id, &diff, &our_node_id).await; } Ok(comment) @@ -5345,7 +5057,6 @@ impl Node { let storage = self.storage.get().await; storage.edit_comment(&our_node_id, &post_id, timestamp_ms, &new_content)?; - let _ = storage.rebuild_blob_header_from_db(&post_id, &our_node_id, now); drop(storage); // Propagate via BlobHeaderDiff @@ -5362,13 +5073,7 @@ impl Node { }], timestamp_ms: now, }; - network.propagate_engagement_diff( - &post_id, - &diff, - // exclude_peer is NETWORK-class (file_holders hold device - // ids) — pass the network NodeId, not a posting id. - &self.node_id, - ).await; + network.propagate_engagement_diff(&post_id, &diff, &our_node_id).await; } Ok(()) } @@ -5386,18 +5091,8 @@ impl Node { let storage = self.storage.get().await; storage.delete_comment(&our_node_id, &post_id, timestamp_ms)?; - let _ = storage.rebuild_blob_header_from_db(&post_id, &our_node_id, now); drop(storage); - // v0.8 (A3): self-certifying delete signature — holders that - // never met this persona can verify it from the op alone. - let delete_sig = crate::crypto::sign_comment_delete( - &self.default_posting_secret, - &our_node_id, - &post_id, - timestamp_ms, - ); - // Propagate via BlobHeaderDiff { let network = &self.network; @@ -5408,17 +5103,10 @@ impl Node { author: our_node_id, post_id, timestamp_ms, - signature: delete_sig, }], timestamp_ms: now, }; - network.propagate_engagement_diff( - &post_id, - &diff, - // exclude_peer is NETWORK-class (file_holders hold device - // ids) — pass the network NodeId, not a posting id. - &self.node_id, - ).await; + network.propagate_engagement_diff(&post_id, &diff, &our_node_id).await; } Ok(()) } @@ -5453,13 +5141,7 @@ impl Node { ops: vec![crate::types::BlobHeaderDiffOp::SetPolicy(policy)], timestamp_ms: now, }; - network.propagate_engagement_diff( - &post_id, - &diff, - // exclude_peer is NETWORK-class (file_holders hold device - // ids) — pass the network NodeId, not a posting id. - &self.node_id, - ).await; + network.propagate_engagement_diff(&post_id, &diff, &our_node_id).await; } Ok(()) @@ -5524,9 +5206,7 @@ impl Node { }], timestamp_ms: now, }; - // exclude_peer is NETWORK-class — pass our device NodeId, not the - // posting-class author id. - self.network.propagate_engagement_diff(&post_id, &diff, &self.node_id).await; + self.network.propagate_engagement_diff(&post_id, &diff, &post_author).await; Ok(()) } @@ -5561,19 +5241,15 @@ impl Node { let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH)? .as_millis() as u64; - // v0.8 (A3): expiry rides the outer plaintext fields so - // non-member holders can expire the comment too. - let expires_at_ms = crate::registry::draw_ordinary_comment_expiry(now); let comment = crate::fof::build_fof_comment( &post_id, &unlock, &slot_binder_nonce, - &commenter_id, &commenter_secret, &body, None, now, expires_at_ms, + &commenter_id, &commenter_secret, &body, None, now, )?; // Store locally. { let storage = self.storage.get().await; - storage.store_own_comment(&comment)?; - let _ = storage.rebuild_blob_header_from_db(&post_id, &post_author, now); + storage.store_comment(&comment)?; } // Propagate via engagement-diff path. @@ -5583,9 +5259,7 @@ impl Node { ops: vec![crate::types::BlobHeaderDiffOp::AddComment(comment.clone())], timestamp_ms: now, }; - // exclude_peer is NETWORK-class — pass our device NodeId, not the - // posting-class author id. - self.network.propagate_engagement_diff(&post_id, &diff, &self.node_id).await; + self.network.propagate_engagement_diff(&post_id, &diff, &post_author).await; Ok(comment) } @@ -5666,9 +5340,7 @@ impl Node { }], timestamp_ms: now, }; - // exclude_peer is NETWORK-class — pass our device NodeId, not the - // posting-class author id. - self.network.propagate_engagement_diff(&post_id, &diff, &self.node_id).await; + self.network.propagate_engagement_diff(&post_id, &diff, &post_author).await; Ok(()) } @@ -5751,9 +5423,7 @@ impl Node { }], timestamp_ms: now, }; - // exclude_peer is NETWORK-class — pass our device NodeId, not the - // posting-class author id. - self.network.propagate_engagement_diff(&post_id, &diff, &self.node_id).await; + self.network.propagate_engagement_diff(&post_id, &diff, &post_author).await; Ok(()) } @@ -5792,44 +5462,34 @@ impl Node { // --- Encrypted receipt/comment slot methods --- - /// Unwrap the CEK for a post we are a participant of, returning - /// (cek, sorted_participants, our_participant_id) where - /// `our_participant_id` is the POSTING identity of ours that actually - /// matched the participant set (participants are posting ids — the - /// network NodeId never appears in them). + /// Unwrap the CEK for a post we are a participant of, returning (cek, sorted_participants). /// Returns None if this is a public post or we cannot decrypt. async fn get_post_cek_and_participants( &self, post_id: &PostId, - ) -> anyhow::Result, NodeId)>> { + ) -> anyhow::Result)>> { let storage = self.storage.get().await; let (post, visibility) = match storage.get_post_with_visibility(post_id)? { Some(pv) => pv, None => return Ok(None), }; - let personas = storage.list_posting_identities().unwrap_or_default(); drop(storage); match &visibility { + PostVisibility::Public => Ok(None), PostVisibility::Encrypted { recipients } => { - // Try every persona; remember WHICH one unwrapped the CEK. - let matched = personas.iter().find_map(|pi| { - crypto::unwrap_cek_for_recipient( - &pi.secret_seed, - &pi.node_id, - &post.author, - recipients, - ) - .ok() - .flatten() - .map(|cek| (cek, pi.node_id)) - }); - match matched { - Some((cek, our_id)) => { + let cek = crypto::unwrap_cek_for_recipient( + &self.default_posting_secret, + &self.node_id, + &post.author, + recipients, + )?; + match cek { + Some(cek) => { let mut participants: Vec = recipients.iter().map(|wk| wk.recipient).collect(); participants.sort(); participants.dedup(); - Ok(Some((cek, participants, our_id))) + Ok(Some((cek, participants))) } None => Ok(None), } @@ -5854,19 +5514,11 @@ impl Node { } participants.sort(); participants.dedup(); - // Our participant identity = whichever of our personas is - // in the set (admin/member), falling back to the default - // persona if none is listed. - let our_id = personas.iter() - .map(|p| p.node_id) - .find(|id| participants.contains(id)) - .unwrap_or(self.default_posting_id); - Ok(Some((cek, participants, our_id))) + Ok(Some((cek, participants))) } else { Ok(None) } } - PostVisibility::Public => Ok(None), // FoF Layer 3: FoFClosed posts don't use the legacy // receipt/comment slot mechanism — they use the FoF gating's // CEK_comments. This helper isn't used for FoF posts; @@ -5883,14 +5535,13 @@ impl Node { state: crate::types::ReceiptState, emoji: Option, ) -> anyhow::Result<()> { - let (cek, participants, our_participant_id) = self.get_post_cek_and_participants(&post_id).await? + let (cek, participants) = self.get_post_cek_and_participants(&post_id).await? .ok_or_else(|| anyhow::anyhow!("not a participant of this encrypted post"))?; let slot_key = crypto::derive_slot_key(&cek); - // Find our slot index (sorted participant position) — participants - // are posting ids, so search for the persona that matched the CEK. - let our_slot = participants.iter().position(|nid| nid == &our_participant_id) - .ok_or_else(|| anyhow::anyhow!("our posting id not found in participants"))?; + // Find our slot index (sorted participant position) + let our_slot = participants.iter().position(|nid| nid == &self.node_id) + .ok_or_else(|| anyhow::anyhow!("our node_id not found in participants"))?; // Build plaintext: [1 byte state][8 bytes timestamp_ms][23 bytes emoji+padding] let now = std::time::SystemTime::now() @@ -5972,7 +5623,7 @@ impl Node { post_id: PostId, content: String, ) -> anyhow::Result<()> { - let (cek, _participants, our_participant_id) = self.get_post_cek_and_participants(&post_id).await? + let (cek, _participants) = self.get_post_cek_and_participants(&post_id).await? .ok_or_else(|| anyhow::anyhow!("not a participant of this encrypted post"))?; let slot_key = crypto::derive_slot_key(&cek); @@ -5980,12 +5631,9 @@ impl Node { .duration_since(std::time::UNIX_EPOCH)? .as_millis() as u64; - // Build plaintext: [32 bytes author_posting_id][8 bytes timestamp_ms][216 bytes content+padding] - // Slot authorship is the matched POSTING identity — never the network - // NodeId (mis-attribution + a persona↔device linkage leak to all - // participants). + // Build plaintext: [32 bytes author_node_id][8 bytes timestamp_ms][216 bytes content+padding] let mut plaintext = [0u8; 256]; - plaintext[..32].copy_from_slice(&our_participant_id); + plaintext[..32].copy_from_slice(&self.node_id); plaintext[32..40].copy_from_slice(&now.to_le_bytes()); let content_bytes = content.as_bytes(); let copy_len = content_bytes.len().min(216); @@ -6088,7 +5736,7 @@ impl Node { &self, post_id: PostId, ) -> anyhow::Result> { - let (cek, participants, _our_participant_id) = self.get_post_cek_and_participants(&post_id).await? + let (cek, participants) = self.get_post_cek_and_participants(&post_id).await? .ok_or_else(|| anyhow::anyhow!("not a participant of this encrypted post"))?; let slot_key = crypto::derive_slot_key(&cek); @@ -6150,7 +5798,7 @@ impl Node { &self, post_id: PostId, ) -> anyhow::Result> { - let (cek, _participants, _our_participant_id) = self.get_post_cek_and_participants(&post_id).await? + let (cek, _participants) = self.get_post_cek_and_participants(&post_id).await? .ok_or_else(|| anyhow::anyhow!("not a participant of this encrypted post"))?; let slot_key = crypto::derive_slot_key(&cek); @@ -6202,678 +5850,6 @@ impl Node { } } -/// v0.8 (A3): a received greeting (or reply), unsealed for the inbox. -/// The `(comment_author, post_id, timestamp_ms)` triple is the comment -/// key used by `reply_to_greeting` / `dismiss_greeting`. -#[derive(Debug, Clone)] -pub struct GreetingRecord { - /// Throwaway outer comment identity (comment key part 1). - pub comment_author: NodeId, - /// The bio/return-path post the comment sits on (comment key part 2). - pub post_id: PostId, - /// Comment timestamp (comment key part 3). - pub timestamp_ms: u64, - /// The sender's REAL persona id (recovered from inside the seal). - pub sender_persona: NodeId, - pub sender_name: String, - pub text: String, - /// Post whose Greeting open slot a reply goes into (inside the seal). - pub return_path: PostId, - /// Fresh per-greeting x25519 pubkey replies must be sealed to. - pub reply_pubkey: [u8; 32], -} - -// --- v0.8 (A3): registry + greetings API --- -impl Node { - /// Per-persona greeting consent toggle. Republishes the persona's bio - /// so the greeting slot appears/disappears on the wire. - /// - /// Turning consent OFF also revokes the Greeting open-slot pub_x of - /// every previously published bio: old bios never expire, and each - /// carries its own valid open slot — without revocation, holders keep - /// accepting greetings on superseded bios indefinitely (each with its - /// own 64-greeting cap). `revoke_fof_commenter` is the designated - /// global off-switch (spec §1.7): the RevocationEntry propagates on - /// the standard rails and holders cascade-purge stored greetings. - pub async fn set_greetings_open(&self, posting_id: &NodeId, open: bool) -> anyhow::Result<()> { - let (secret, display_name, bio, avatar, greeting_slots) = { - let s = self.storage.get().await; - s.set_setting( - &greetings_open_setting_key(posting_id), - if open { "1" } else { "0" }, - )?; - let identity = s.get_posting_identity(posting_id)? - .ok_or_else(|| anyhow::anyhow!("persona not on this device"))?; - let profile = s.get_profile(posting_id)?; - // When closing: collect every prior post by this persona that - // declares a Greeting open slot, so its pub_x can be revoked. - let mut slots: Vec<(PostId, u32)> = Vec::new(); - if !open { - for (post_id, post) in s.list_gated_posts_by_author(posting_id)? { - if let Some(decl) = post - .fof_gating - .as_ref() - .and_then(|g| g.open_slot.as_ref()) - { - if decl.kind == crate::types::OpenSlotKind::Greeting { - slots.push((post_id, decl.slot_index)); - } - } - } - } - ( - identity.secret_seed, - profile.as_ref().map(|p| p.display_name.clone()).unwrap_or_default(), - profile.as_ref().map(|p| p.bio.clone()).unwrap_or_default(), - profile.as_ref().and_then(|p| p.avatar_cid), - slots, - ) - }; - // Republish the bio with the new consent state. - self.publish_profile_post_as(posting_id, &secret, &display_name, &bio, avatar).await?; - // Off-switch: revoke the greeting slot on every prior bio so - // holders stop accepting (and purge) greetings on them. - for (post_id, slot_index) in greeting_slots { - if let Err(e) = self - .revoke_fof_commenter(post_id, slot_index, GREETING_CONSENT_REVOKE_REASON) - .await - { - warn!( - post = hex::encode(post_id), - slot = slot_index, - error = %e, - "Failed to revoke greeting slot on prior bio" - ); - } - } - Ok(()) - } - - /// Current greeting-consent state (unset = ON, the pre-checked default). - pub async fn get_greetings_open(&self, posting_id: &NodeId) -> anyhow::Result { - let s = self.storage.get().await; - Ok(greetings_open_setting(&s, posting_id)) - } - - /// Best-effort network fetch of a post we don't hold: content-search - /// worm by post id, then PostFetch from the reported holders, stored - /// through the standard receive path. - async fn fetch_post_best_effort(&self, post_id: &PostId) -> anyhow::Result> { - let search = self - .network - .content_search(&[0u8; 32], Some(*post_id), None) - .await - .ok() - .flatten(); - if let Some(result) = search { - let holders: Vec = [result.post_holder, Some(result.node_id)] - .into_iter() - .flatten() - .collect(); - for holder in holders { - let _ = self.connect_by_node_id(holder).await; - if let Ok(Some(sp)) = self.network.post_fetch(&holder, post_id).await { - let s = self.storage.get().await; - let _ = crate::control::receive_post( - &s, &sp.id, &sp.post, &sp.visibility, sp.intent.as_ref(), - ); - return Ok(s.get_post(post_id)?); - } - } - } - Ok(None) - } - - /// Shared greeting/reply sender: seal `text` to `recipient_x25519_pub` - /// and drop it into `target_post_id`'s Greeting open slot under a - /// freshly-minted throwaway outer identity. The sealed body carries - /// the ACTING persona's identity + next-hop return path (that - /// persona's bio post) + a fresh reply key. `acting_persona` must be - /// a local posting identity — passing the wrong persona here would - /// disclose an unrelated persona's real posting key inside the seal, - /// silently linking personas the architecture keeps unlinkable. - async fn send_sealed_via_open_slot( - &self, - acting_persona: &NodeId, - target_post_id: PostId, - recipient_x25519_pub: [u8; 32], - text: &str, - ) -> anyhow::Result<()> { - if text.chars().count() > 600 { - anyhow::bail!("greeting text over 600 chars"); - } - - // Load the target post (fetch if absent), require a Greeting slot. - let target_post = { - let s = self.storage.get().await; - s.get_post(&target_post_id)? - }; - let target_post = match target_post { - Some(p) => p, - None => self - .fetch_post_best_effort(&target_post_id) - .await? - .ok_or_else(|| anyhow::anyhow!("target post not held and not fetchable"))?, - }; - let decl = target_post - .fof_gating - .as_ref() - .and_then(|g| g.open_slot.as_ref()) - .ok_or_else(|| anyhow::anyhow!("post declares no open slot"))? - .clone(); - if decl.kind != crate::types::OpenSlotKind::Greeting { - anyhow::bail!("post's open slot is not a Greeting slot"); - } - let slot_binder_nonce = target_post.fof_gating.as_ref().unwrap().slot_binder_nonce; - - // The acting persona's return path + display name + fresh reply - // keypair. Everything inside the seal is per-persona: identity, - // name, and return-path bio must all belong to `acting_persona`. - let (return_path, sender_name) = { - let s = self.storage.get().await; - // Refuse to seal anything if the persona isn't local — a - // wrong id here would misattribute the message. - s.get_posting_identity(acting_persona)? - .ok_or_else(|| anyhow::anyhow!("acting persona not on this device"))?; - let rp = s - .get_latest_profile_post_id_by_author(acting_persona)? - .ok_or_else(|| anyhow::anyhow!( - "no bio post to use as return path — set a profile first (`name `)" - ))?; - let name = s - .get_profile(acting_persona)? - .map(|p| p.display_name) - .unwrap_or_default(); - (rp, name) - }; - let (reply_priv, reply_pub) = crypto::generate_x25519_keypair(); - { - let s = self.storage.get().await; - s.store_greeting_reply_key(&reply_pub, &reply_priv, &return_path)?; - } - - // Sealed body: real (acting) persona + return path + fresh reply key. - let body = crate::types::GreetingBody { - v: 1, - sender_persona: hex::encode(acting_persona), - sender_name: sender_name.chars().take(64).collect(), - text: text.to_string(), - return_path: hex::encode(return_path), - reply_pubkey: hex::encode(reply_pub), - }; - let plaintext = serde_json::to_vec(&body)?; - let sealed = crypto::seal_greeting_body( - &recipient_x25519_pub, - &target_post_id, - &plaintext, - decl.body_bucket as usize, - )?; - let sealed_b64 = { - use base64::Engine; - base64::engine::general_purpose::STANDARD.encode(&sealed) - }; - - // Throwaway outer identity — minted per greeting, never reused. - // It exists in the network only through this comment and vanishes - // when the comment expires (§20 identity hygiene). - let throwaway_key = iroh::SecretKey::generate(&mut rand::rng()); - let throwaway_seed: [u8; 32] = throwaway_key.to_bytes(); - let throwaway_id: NodeId = *throwaway_key.public().as_bytes(); - - let unlock = crate::fof::derive_open_slot_unlock(&target_post, &throwaway_id) - .ok_or_else(|| anyhow::anyhow!("open slot did not unlock (revoked or malformed)"))?; - - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH)? - .as_millis() as u64; - let expires_at_ms = crate::registry::draw_ordinary_comment_expiry(now); - let comment = crate::fof::build_fof_comment( - &target_post_id, - &unlock, - &slot_binder_nonce, - &throwaway_id, - &throwaway_seed, - &sealed_b64, - None, - now, - expires_at_ms, - )?; - - { - let s = self.storage.get().await; - // Greeting: the author is a per-greeting THROWAWAY id. We are the - // first node in the network that could announce it, at bounce 1, - // the moment it is created — first-announcer identifies the - // greeter. `store_own_comment` keeps it out of our announce. - s.store_own_comment(&comment)?; - let _ = s.rebuild_blob_header_from_db(&target_post_id, &target_post.author, now); - } - - // Propagate on the existing engagement rail. - let diff = crate::protocol::BlobHeaderDiffPayload { - post_id: target_post_id, - author: target_post.author, - ops: vec![crate::types::BlobHeaderDiffOp::AddComment(comment)], - timestamp_ms: now, - }; - self.network.propagate_engagement_diff(&target_post_id, &diff, &self.node_id).await; - Ok(()) - } - - /// Send a sealed first-contact greeting to a bio post's author. - /// Messaging-first: no vouch is involved anywhere in this flow. - pub async fn send_greeting(&self, bio_post_id: PostId, text: String) -> anyhow::Result<()> { - // Recipient key: the bio author's posting key, converted to x25519. - let author = { - let s = self.storage.get().await; - s.get_post(&bio_post_id)?.map(|p| p.author) - }; - let author = match author { - Some(a) => a, - None => self - .fetch_post_best_effort(&bio_post_id) - .await? - .map(|p| p.author) - .ok_or_else(|| anyhow::anyhow!("bio post not held and not fetchable"))?, - }; - let recipient = crypto::ed25519_pubkey_to_x25519_public(&author)?; - // Outbound first-contact greetings act as the default persona. - let acting = self.default_posting_id; - self.send_sealed_via_open_slot(&acting, bio_post_id, recipient, &text).await - } - - /// Reply to a received greeting: sealed to the greeting's fresh - /// `reply_pubkey` (never a long-term key), dropped into the sender's - /// declared `return_path` post through its Greeting open slot, under - /// a fresh throwaway outer identity. Each reply carries OUR next-hop - /// return path + fresh reply key. - pub async fn reply_to_greeting( - &self, - comment_author: NodeId, - post_id: PostId, - timestamp_ms: u64, - text: String, - ) -> anyhow::Result<()> { - let greeting = self - .list_greetings() - .await? - .into_iter() - .find(|g| { - g.comment_author == comment_author - && g.post_id == post_id - && g.timestamp_ms == timestamp_ms - }) - .ok_or_else(|| anyhow::anyhow!("greeting not found (expired or dismissed?)"))?; - // Act as the persona the greeting was ADDRESSED TO — the author - // of the bio post it arrived on. Hardcoding the default persona - // here would leak the default persona's real posting key + bio - // into the seal, silently linking two personas (and answering as - // someone the counterparty never greeted). - let acting = { - let s = self.storage.get().await; - let bio_author = s - .get_post(&greeting.post_id)? - .map(|p| p.author) - .ok_or_else(|| anyhow::anyhow!("greeting's bio post no longer held"))?; - s.get_posting_identity(&bio_author)? - .ok_or_else(|| anyhow::anyhow!( - "persona the greeting was addressed to is not on this device" - ))? - .node_id - }; - self.send_sealed_via_open_slot(&acting, greeting.return_path, greeting.reply_pubkey, &text) - .await - } - - /// Unseal + list greetings (and replies) on all of our personas' bio - /// posts. Original greetings open with the persona's long-term key; - /// replies open with the stored per-greeting reply private keys. - /// Dismissed rows are skipped. - pub async fn list_greetings(&self) -> anyhow::Result> { - use base64::Engine; - let s = self.storage.get().await; - let personas = s.list_posting_identities()?; - let reply_keys = s.list_greeting_reply_keys()?; - let mut out: Vec = Vec::new(); - - for persona in &personas { - let persona_priv = crypto::ed25519_seed_to_x25519_private(&persona.secret_seed); - for (post_id, post) in s.list_gated_posts_by_author(&persona.node_id)? { - let Some(gating) = post.fof_gating.as_ref() else { continue }; - let Some(decl) = gating.open_slot.as_ref() else { continue }; - if decl.kind != crate::types::OpenSlotKind::Greeting { - continue; - } - for c in s.get_comments(&post_id)? { - if c.pub_x_index != Some(decl.slot_index) { - continue; - } - if s.is_greeting_dismissed(&c.author, &post_id, c.timestamp_ms)? { - continue; - } - // Outer layer: the CEK is public (derivable open slot). - let Some(unlock) = crate::fof::derive_open_slot_unlock(&post, &c.author) - else { continue }; - let Ok(payload) = crate::fof::decrypt_fof_comment_payload( - &c, &unlock.cek, &gating.slot_binder_nonce, - ) else { continue }; - let Ok(sealed) = - base64::engine::general_purpose::STANDARD.decode(payload.body.as_bytes()) - else { continue }; - // Inner seal: long-term persona key (original - // greetings) or a stored fresh reply key (replies). - let plain = crypto::open_greeting_body(&persona_priv, &post_id, &sealed) - .or_else(|| { - reply_keys.iter().find_map(|(privkey, _rp)| { - crypto::open_greeting_body(privkey, &post_id, &sealed) - }) - }); - let Some(plain) = plain else { continue }; - let Ok(body) = serde_json::from_slice::(&plain) - else { continue }; - let Ok(sender_persona) = crate::parse_node_id_hex(&body.sender_persona) - else { continue }; - let Ok(return_path) = crate::parse_node_id_hex(&body.return_path) - else { continue }; - let Ok(reply_pubkey) = crate::parse_node_id_hex(&body.reply_pubkey) - else { continue }; - out.push(GreetingRecord { - comment_author: c.author, - post_id, - timestamp_ms: c.timestamp_ms, - sender_persona, - sender_name: body.sender_name, - text: body.text, - return_path, - reply_pubkey, - }); - } - } - } - out.sort_by_key(|g| std::cmp::Reverse(g.timestamp_ms)); - Ok(out) - } - - /// Dismiss a greeting (local only — nothing propagates). - pub async fn dismiss_greeting( - &self, - comment_author: NodeId, - post_id: PostId, - timestamp_ms: u64, - ) -> anyhow::Result<()> { - let s = self.storage.get().await; - s.add_greeting_dismissal(&comment_author, &post_id, timestamp_ms) - } - - /// Register a persona in the network registry: a plaintext, - /// self-certifying entry {name, keywords} signed by the persona's - /// REAL posting key, fixed 30-day TTL, newest-wins per persona. - /// Re-running renews. Sets the "Listed" flag for auto-renew. - pub async fn register_persona( - &self, - posting_id: &NodeId, - name: &str, - keywords: &[String], - ) -> anyhow::Result<()> { - let entry = crate::registry::RegistrationEntry { - v: 1, - name: name.to_string(), - keywords: keywords.to_vec(), - }; - let entry_json = serde_json::to_string(&entry)?; - // Enforce shape limits locally before signing anything. - crate::registry::parse_registration(&entry_json)?; - - let (secret_seed, registry_post) = { - let s = self.storage.get().await; - let identity = s.get_posting_identity(posting_id)? - .ok_or_else(|| anyhow::anyhow!("persona not on this device"))?; - let _ = crate::registry::materialize_registry_post(&s); - let post = s.get_post(&crate::registry::REGISTRY_POST_ID)? - .ok_or_else(|| anyhow::anyhow!("registry post missing after materialization"))?; - (identity.secret_seed, post) - }; - - let unlock = crate::fof::derive_open_slot_unlock(®istry_post, posting_id) - .ok_or_else(|| anyhow::anyhow!("registry open slot did not unlock"))?; - - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH)? - .as_millis() as u64; - let expires_at_ms = now + crate::registry::REGISTRATION_TTL_MS; - - // group_sig over (content_bytes || post_id || pub_x_index_le) — - // the plaintext-open-slot form of the standard scheme. - let group_sig = { - use ed25519_dalek::{Signer, SigningKey}; - let signer = SigningKey::from_bytes(&unlock.priv_x_seed); - let mut to_sign = Vec::with_capacity(entry_json.len() + 32 + 4); - to_sign.extend_from_slice(entry_json.as_bytes()); - to_sign.extend_from_slice(&crate::registry::REGISTRY_POST_ID); - to_sign.extend_from_slice(&unlock.slot_index.to_le_bytes()); - signer.sign(&to_sign).to_bytes().to_vec() - }; - - // The standard comment signature IS the self-certification: the - // entry names its persona in `author`, verified against that key. - let signature = crypto::sign_comment( - &secret_seed, - posting_id, - &crate::registry::REGISTRY_POST_ID, - &entry_json, - now, - None, - expires_at_ms, - ); - let comment = crate::types::InlineComment { - author: *posting_id, - post_id: crate::registry::REGISTRY_POST_ID, - content: entry_json, - timestamp_ms: now, - signature, - deleted_at: None, - ref_post_id: None, - pub_x_index: Some(unlock.slot_index), - group_sig: Some(group_sig), - encrypted_payload: None, - expires_at_ms, - }; - - { - let s = self.storage.get().await; - // Newest-wins locally (deletes our older entries). - let _ = s.upsert_registry_entry_newest_wins( - &crate::registry::REGISTRY_POST_ID, posting_id, now, - ); - s.store_own_comment(&comment)?; - let _ = s.rebuild_blob_header_from_db( - &crate::registry::REGISTRY_POST_ID, ®istry_post.author, now, - ); - let id_hex = hex::encode(posting_id); - s.set_setting(&format!("registry_listed.{}", id_hex), "1")?; - s.set_setting(&format!("registry_name.{}", id_hex), name)?; - s.set_setting(&format!("registry_keywords.{}", id_hex), &keywords.join(","))?; - } - - let diff = crate::protocol::BlobHeaderDiffPayload { - post_id: crate::registry::REGISTRY_POST_ID, - author: registry_post.author, - ops: vec![crate::types::BlobHeaderDiffOp::AddComment(comment)], - timestamp_ms: now, - }; - self.network - .propagate_engagement_diff(&crate::registry::REGISTRY_POST_ID, &diff, &self.node_id) - .await; - Ok(()) - } - - /// Remove a persona's registry entry via a self-certifying signed - /// DeleteComment (honored by holders that never met the persona). - /// Clears the "Listed" flag. - pub async fn unregister_persona(&self, posting_id: &NodeId) -> anyhow::Result<()> { - let (secret_seed, newest) = { - let s = self.storage.get().await; - let identity = s.get_posting_identity(posting_id)? - .ok_or_else(|| anyhow::anyhow!("persona not on this device"))?; - let newest = s.get_newest_registry_entry( - &crate::registry::REGISTRY_POST_ID, posting_id, - )?; - s.set_setting(&format!("registry_listed.{}", hex::encode(posting_id)), "0")?; - (identity.secret_seed, newest) - }; - let Some((entry_ts, _exp)) = newest else { - return Ok(()); // nothing listed — flag cleared, done - }; - - let delete_sig = crypto::sign_comment_delete( - &secret_seed, - posting_id, - &crate::registry::REGISTRY_POST_ID, - entry_ts, - ); - { - let s = self.storage.get().await; - let _ = s.delete_comment(posting_id, &crate::registry::REGISTRY_POST_ID, entry_ts); - let now_ms = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH)? - .as_millis() as u64; - let _ = s.rebuild_blob_header_from_db( - &crate::registry::REGISTRY_POST_ID, &crate::DEFAULT_ANCHOR_POSTING_ID, now_ms, - ); - } - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH)? - .as_millis() as u64; - let diff = crate::protocol::BlobHeaderDiffPayload { - post_id: crate::registry::REGISTRY_POST_ID, - author: crate::DEFAULT_ANCHOR_POSTING_ID, - ops: vec![crate::types::BlobHeaderDiffOp::DeleteComment { - author: *posting_id, - post_id: crate::registry::REGISTRY_POST_ID, - timestamp_ms: entry_ts, - signature: delete_sig, - }], - timestamp_ms: now, - }; - self.network - .propagate_engagement_diff(&crate::registry::REGISTRY_POST_ID, &diff, &self.node_id) - .await; - Ok(()) - } - - /// Search the registry: refresh the chain from up to 3 connected - /// peers (BlobHeaderRequest via the existing engagement-fetch rail), - /// then query locally. Search cost lands on the searcher (design §27). - pub async fn search_registry( - &self, - query: &str, - ) -> anyhow::Result> { - // Mark the registry post due so the engagement fetch includes it. - { - let s = self.storage.get().await; - let _ = crate::registry::materialize_registry_post(&s); - let _ = s.update_post_last_check(&crate::registry::REGISTRY_POST_ID, 0); - } - let peers = self.list_connections().await; - for (peer, _slot, _ts) in peers.into_iter().take(3) { - let _ = self.network.conn_handle().fetch_engagement_from_peer(&peer).await; - } - let s = self.storage.get().await; - crate::registry::search_entries(&s, query) - } - - /// Auto-renew (round 8, DECIDED): while the "Listed" flag is set, - /// re-sign a fresh 30d entry when the current one expires within 5 - /// days (~every 25 days). Piggybacked on the eviction cycle. - pub async fn renew_registry_entries_if_due(&self) -> anyhow::Result { - const RENEW_WINDOW_MS: u64 = 5 * 24 * 3600 * 1000; - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH)? - .as_millis() as u64; - - let due: Vec<(NodeId, String, Vec)> = { - let s = self.storage.get().await; - let mut due = Vec::new(); - for persona in s.list_posting_identities()? { - let id_hex = hex::encode(persona.node_id); - let listed = s - .get_setting(&format!("registry_listed.{}", id_hex))? - .map(|v| v == "1") - .unwrap_or(false); - if !listed { - continue; - } - let newest = s.get_newest_registry_entry( - &crate::registry::REGISTRY_POST_ID, - &persona.node_id, - )?; - let needs_renew = match newest { - Some((_ts, exp)) => exp <= now + RENEW_WINDOW_MS, - None => true, - }; - if !needs_renew { - continue; - } - let name = s - .get_setting(&format!("registry_name.{}", id_hex))? - .unwrap_or_else(|| persona.display_name.clone()); - let keywords: Vec = s - .get_setting(&format!("registry_keywords.{}", id_hex))? - .unwrap_or_default() - .split(',') - .filter(|k| !k.is_empty()) - .map(|k| k.to_string()) - .collect(); - due.push((persona.node_id, name, keywords)); - } - due - }; - - let mut renewed = 0usize; - for (persona_id, name, keywords) in due { - if self.register_persona(&persona_id, &name, &keywords).await.is_ok() { - renewed += 1; - } - } - Ok(renewed) - } - - /// One-shot genesis publish of the registry post (`--publish-registry`). - /// Refuses unless the default posting identity is the bootstrap - /// anchor's (mirrors `publish_announcement`). Debug builds may bypass - /// via `ITSGOIN_TEST_ALLOW_REGISTRY_GENESIS=1` for multi-node tests. - pub async fn publish_registry_genesis(&self) -> anyhow::Result { - #[allow(unused_mut)] - let mut allowed = self.default_posting_id == crate::DEFAULT_ANCHOR_POSTING_ID; - #[cfg(debug_assertions)] - { - if std::env::var("ITSGOIN_TEST_ALLOW_REGISTRY_GENESIS").as_deref() == Ok("1") { - allowed = true; - } - } - if !allowed { - anyhow::bail!( - "refusing to publish registry genesis: default posting identity is not the bootstrap anchor" - ); - } - { - let s = self.storage.get().await; - let _ = crate::registry::materialize_registry_post(&s)?; - } - self.update_neighbor_manifests_as( - &self.default_posting_id, - &self.default_posting_secret, - &crate::registry::REGISTRY_POST_ID, - crate::registry::REGISTRY_GENESIS_TIMESTAMP_MS, - ).await; - info!( - post_id = hex::encode(crate::registry::REGISTRY_POST_ID), - "Registry genesis published" - ); - Ok(crate::registry::REGISTRY_POST_ID) - } -} - pub struct NodeStats { pub post_count: usize, pub peer_count: usize, @@ -6884,7 +5860,7 @@ pub struct NodeStats { /// score = pin_boost + (relationship × heart_recency × freshness / (peer_copies + 1)) pub fn compute_blob_priority_standalone( candidate: &crate::storage::EvictionCandidate, - own_author_ids: &[NodeId], + our_node_id: &NodeId, follows: &[NodeId], now_ms: u64, ) -> f64 { @@ -6901,8 +5877,7 @@ pub fn compute_blob_priority_standalone( }; // v0.6.2: audience removed. Relationship is author-of-ours vs followed vs other. - // Authors are posting identities — check against ALL of our personas. - let relationship = if own_author_ids.contains(&candidate.author) { + let relationship = if candidate.author == *our_node_id { 5.0 } else if follows.contains(&candidate.author) { 2.0 @@ -6926,8 +5901,7 @@ pub fn compute_blob_priority_standalone( impl Node { /// Start the active replication cycle: periodically ask peers to hold our - /// under-replicated recent content. All devices initiate — phones need - /// their content replicated before they go to sleep. + /// under-replicated recent content. Only Available/Persistent devices initiate. pub fn start_replication_cycle(self: &Arc, interval_secs: u64) -> tokio::task::JoinHandle<()> { let node = Arc::clone(self); tokio::spawn(async move { @@ -6973,19 +5947,13 @@ impl Node { // Single lock: get under-replicated posts AND peer roles/pressure let (under_replicated, suitable_peers) = { let storage = self.storage.get().await; - // Own posts are authored by posting identities (personas), never - // the network NodeId — union recent posts across all personas. - let personas = storage.list_posting_identities().unwrap_or_default(); - let mut recent_ids: Vec = Vec::new(); - for persona in &personas { - match storage.get_own_recent_post_ids(&persona.node_id, since_ms) { - Ok(ids) => recent_ids.extend(ids), - Err(e) => { - debug!(error = %e, "Replication: failed to get own recent posts"); - return; - } + let recent_ids = match storage.get_own_recent_post_ids(&self.node_id, since_ms) { + Ok(ids) => ids, + Err(e) => { + debug!(error = %e, "Replication: failed to get own recent posts"); + return; } - } + }; // Filter to under-replicated (< 2 holders) let mut needs_replication = Vec::new(); @@ -7104,7 +6072,7 @@ mod tests { let candidate = make_candidate(our_id, true, now - 86400_000, now, 0); let score = compute_blob_priority_standalone( - &candidate, &[our_id], &[], now, + &candidate, &our_id, &[], now, ); assert!(score > 1000.0, "own pinned should score >1000, got {}", score); } @@ -7118,7 +6086,7 @@ mod tests { let follow_candidate = make_candidate(follow_id, false, now - 86400_000, now, 0); let follow_score = compute_blob_priority_standalone( - &follow_candidate, &[our_id], &[follow_id], now, + &follow_candidate, &our_id, &[follow_id], now, ); let stranger_candidate = make_candidate( @@ -7128,7 +6096,7 @@ mod tests { 5, ); let stranger_score = compute_blob_priority_standalone( - &stranger_candidate, &[our_id], &[], now, + &stranger_candidate, &our_id, &[], now, ); assert!(follow_score > stranger_score, @@ -7149,7 +6117,7 @@ mod tests { 10, ); let score = compute_blob_priority_standalone( - &candidate, &[our_id], &[], now, + &candidate, &our_id, &[], now, ); assert!(score < 0.01, "stranger stale should score near 0, got {}", score); @@ -7166,34 +6134,11 @@ mod tests { let follow = make_candidate(follow_id, false, now - 86400_000, now, 0); let stranger = make_candidate(stranger_id, false, now - 30 * 86400_000, now - 30 * 86400_000, 10); - let own_score = compute_blob_priority_standalone(&own, &[our_id], &[follow_id], now); - let follow_score = compute_blob_priority_standalone(&follow, &[our_id], &[follow_id], now); - let stranger_score = compute_blob_priority_standalone(&stranger, &[our_id], &[follow_id], now); + let own_score = compute_blob_priority_standalone(&own, &our_id, &[follow_id], now); + let follow_score = compute_blob_priority_standalone(&follow, &our_id, &[follow_id], now); + let stranger_score = compute_blob_priority_standalone(&stranger, &our_id, &[follow_id], now); assert!(own_score > follow_score, "own ({}) > follow ({})", own_score, follow_score); assert!(follow_score > stranger_score, "follow ({}) > stranger ({})", follow_score, stranger_score); } - - /// A1 (bug 4): blobs authored by ANY of our posting identities get the - /// own-content 5.0 tier — including non-default personas — and the - /// network NodeId never matches (posting authors only). - #[test] - fn second_persona_blob_scores_as_own() { - let persona1 = make_node_id(1); - let persona2 = make_node_id(2); - let network_id = make_node_id(9); - let now = 10_000_000_000u64; - - let by_second = make_candidate(persona2, false, now - 86400_000, now, 0); - let by_network = make_candidate(network_id, false, now - 86400_000, now, 0); - - let own_ids = [persona1, persona2]; - let second_score = compute_blob_priority_standalone(&by_second, &own_ids, &[], now); - let network_score = compute_blob_priority_standalone(&by_network, &own_ids, &[], now); - // Identical candidates → the only difference is the relationship - // tier: 5.0 (own persona) vs 0.1 (stranger). Ratio must reflect it. - assert!(second_score > network_score * 10.0, - "persona2 blob ({}) must be own-tier vs network-authored ({})", - second_score, network_score); - } } diff --git a/crates/core/src/profile.rs b/crates/core/src/profile.rs index 2617e25..7d584f8 100644 --- a/crates/core/src/profile.rs +++ b/crates/core/src/profile.rs @@ -68,6 +68,7 @@ pub fn apply_profile_post_if_applicable( updated_at: content.timestamp_ms, anchors: vec![], recent_peers: vec![], + preferred_peers: vec![], public_visible: true, avatar_cid: content.avatar_cid, }; @@ -174,9 +175,6 @@ pub fn scan_vouch_grants_for_all_personas( /// batch distributing the persona's current `V_me` to vouched personas. /// `bio_epoch` is a monotonic per-persona counter that lets receivers /// short-circuit re-scanning unchanged bios. -/// v0.8 (A3): `fof_gating` carries the bio's comment gating — including -/// the Greeting open slot when the persona's `greetings_open` consent is -/// on. `None` publishes a bio that accepts no greetings. pub fn build_profile_post( author: &NodeId, author_secret: &[u8; 32], @@ -185,7 +183,6 @@ pub fn build_profile_post( avatar_cid: Option<[u8; 32]>, vouch_grants: Option, bio_epoch: u32, - fof_gating: Option, ) -> Post { let timestamp_ms = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -206,8 +203,8 @@ pub fn build_profile_post( content: serde_json::to_string(&content).unwrap_or_default(), attachments: vec![], timestamp_ms, - fof_gating, - supersedes_post_id: None, + fof_gating: None, + supersedes_post_id: None, } } @@ -348,7 +345,7 @@ mod tests { let s = temp_storage(); let (sec, pub_id) = make_keypair(11); - let post = build_profile_post(&pub_id, &sec, "Alice", "hello world", None, None, 0, None); + let post = build_profile_post(&pub_id, &sec, "Alice", "hello world", None, None, 0); apply_profile_post_if_applicable(&s, &post, Some(&VisibilityIntent::Profile)).unwrap(); let stored = s.get_profile(&pub_id).unwrap().expect("profile stored"); @@ -363,7 +360,7 @@ mod tests { let (sec_b, _pub_b) = make_keypair(2); // Build a post claiming `pub_a` but signing with `sec_b`. - let post = build_profile_post(&pub_a, &sec_b, "Impostor", "", None, None, 0, None); + let post = build_profile_post(&pub_a, &sec_b, "Impostor", "", None, None, 0); let res = apply_profile_post_if_applicable(&s, &post, Some(&VisibilityIntent::Profile)); assert!(res.is_err()); assert!(s.get_profile(&pub_a).unwrap().is_none()); @@ -375,7 +372,7 @@ mod tests { let (sec, pub_id) = make_keypair(3); // Seed with a newer profile. - let mut newer = build_profile_post(&pub_id, &sec, "NewName", "", None, None, 0, None); + let mut newer = build_profile_post(&pub_id, &sec, "NewName", "", None, None, 0); // Hack the timestamp to make it clearly newer. let mut content: ProfilePostContent = serde_json::from_str(&newer.content).unwrap(); content.timestamp_ms = 10_000; @@ -385,7 +382,7 @@ mod tests { apply_profile_post_if_applicable(&s, &newer, Some(&VisibilityIntent::Profile)).unwrap(); // Apply an older profile — should be ignored. - let mut older = build_profile_post(&pub_id, &sec, "OldName", "", None, None, 0, None); + let mut older = build_profile_post(&pub_id, &sec, "OldName", "", None, None, 0); let mut content_o: ProfilePostContent = serde_json::from_str(&older.content).unwrap(); content_o.timestamp_ms = 5_000; content_o.signature = crypto::sign_profile(&sec, &content_o.display_name, &content_o.bio, &content_o.avatar_cid, content_o.timestamp_ms); diff --git a/crates/core/src/protocol.rs b/crates/core/src/protocol.rs index 1e354b8..0221c3c 100644 --- a/crates/core/src/protocol.rs +++ b/crates/core/src/protocol.rs @@ -1,15 +1,12 @@ use serde::{Deserialize, Serialize}; use crate::types::{ - BlobHeaderDiffOp, CdnManifest, DeleteRecord, GroupEpoch, GroupId, NodeId, - PeerWithAddress, Post, PostId, PostVisibility, PublicProfile, WormId, + BlobHeaderDiffOp, CdnManifest, DeleteRecord, GroupEpoch, GroupId, GroupMemberKey, NodeId, + PeerWithAddress, Post, PostId, PostVisibility, PublicProfile, VisibilityUpdate, WormId, }; -/// Wire-protocol ALPN. One protocol version per build, no negotiation — -/// mismatched versions are refused at the QUIC handshake. v0.8 = itsgoin/4: -/// clean break from <=v0.7.x (zero-users ruling, design.html Appendix D -/// item 3). -pub const ALPN: &[u8] = b"itsgoin/4"; +/// Single ALPN for Discovery Protocol v3 (N1/N2/N3 architecture) +pub const ALPN_V2: &[u8] = b"itsgoin/3"; /// A post bundled with its visibility metadata for sync #[derive(Debug, Serialize, Deserialize)] @@ -18,8 +15,9 @@ pub struct SyncPost { pub post: Post, pub visibility: PostVisibility, /// Optional originator's intent, so receivers can filter control posts - /// out of the feed and process their ControlOp payload. None = regular - /// post. + /// out of the feed and process their ControlOp payload. Absent on + /// pre-v0.6.2 senders; receivers treat as "unknown"/regular post. + #[serde(default)] pub intent: Option, } @@ -27,29 +25,23 @@ pub struct SyncPost { #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[repr(u8)] pub enum MessageType { - UniquesAnnounce = 0x01, + NodeListUpdate = 0x01, InitialExchange = 0x02, AddressRequest = 0x03, AddressResponse = 0x04, RefuseRedirect = 0x05, - /// The uniques-index exchange (design.html §sync). A pull is no longer a - /// post transfer: it is "if you want these IDs, talk to me and I'll help - /// you find them". Symmetric — request carries our pools, response carries - /// theirs, one round trip updates both sides. - UniquesPullRequest = 0x40, - UniquesPullResponse = 0x41, - /// TRANSITIONAL (Iteration D folds this into the update-cadence scheduler + - /// CDN replication). Content still has to move somewhere while - /// PostFetchRequest (0xD4) serves Public posts only: DMs, group posts, - /// Friends and FoFClosed posts have no other carrier. Splitting it off - /// 0x40/0x41 keeps §sync honest — a *pull* is not a post transfer, because - /// post transfer is not a pull. - ContentSyncRequest = 0x46, - ContentSyncResponse = 0x47, + PullSyncRequest = 0x40, + PullSyncResponse = 0x41, + // 0x42 (PostNotification), 0x43 (PostPush), 0x44 (AudienceRequest), + // 0x45 (AudienceResponse) retired in v0.6.2: persona-signed direct pushes + // are gone. Public posts propagate via the CDN; encrypted posts via pull. ProfileUpdate = 0x50, + DeleteRecord = 0x51, + VisibilityUpdate = 0x52, WormQuery = 0x60, WormResponse = 0x61, SocialAddressUpdate = 0x70, + SocialDisconnectNotice = 0x71, SocialCheckin = 0x72, // 0x80-0x81 reserved BlobRequest = 0x90, @@ -57,15 +49,19 @@ pub enum MessageType { ManifestRefreshRequest = 0x92, ManifestRefreshResponse = 0x93, ManifestPush = 0x94, + // 0x95 (BlobDeleteNotice) retired in v0.6.2 — remote holders evict via LRU. + // 0xA0 (GroupKeyDistribute) retired in v0.6.2 — group seeds now travel + // as encrypted posts via the CDN. See `group_key_distribution` module. + GroupKeyRequest = 0xA1, + GroupKeyResponse = 0xA2, RelayIntroduce = 0xB0, RelayIntroduceResult = 0xB1, SessionRelay = 0xB2, + MeshPrefer = 0xB3, CircleProfileUpdate = 0xB4, - // 0xC0 retired in v0.8 — anchors keep a rolling window of recent callers - // fed by the request itself; there is no registration message and no - // registration database. - ConvectionRequest = 0xC1, - ConvectionResponse = 0xC2, + AnchorRegister = 0xC0, + AnchorReferralRequest = 0xC1, + AnchorReferralResponse = 0xC2, AnchorProbeRequest = 0xC3, AnchorProbeResult = 0xC4, PortScanHeartbeat = 0xC5, @@ -87,31 +83,36 @@ pub enum MessageType { impl MessageType { pub fn from_byte(b: u8) -> Option { match b { - 0x01 => Some(Self::UniquesAnnounce), + 0x01 => Some(Self::NodeListUpdate), 0x02 => Some(Self::InitialExchange), 0x03 => Some(Self::AddressRequest), 0x04 => Some(Self::AddressResponse), 0x05 => Some(Self::RefuseRedirect), - 0x40 => Some(Self::UniquesPullRequest), - 0x41 => Some(Self::UniquesPullResponse), - 0x46 => Some(Self::ContentSyncRequest), - 0x47 => Some(Self::ContentSyncResponse), + 0x40 => Some(Self::PullSyncRequest), + 0x41 => Some(Self::PullSyncResponse), 0x50 => Some(Self::ProfileUpdate), + 0x51 => Some(Self::DeleteRecord), + 0x52 => Some(Self::VisibilityUpdate), 0x60 => Some(Self::WormQuery), 0x61 => Some(Self::WormResponse), 0x70 => Some(Self::SocialAddressUpdate), + 0x71 => Some(Self::SocialDisconnectNotice), 0x72 => Some(Self::SocialCheckin), 0x90 => Some(Self::BlobRequest), 0x91 => Some(Self::BlobResponse), 0x92 => Some(Self::ManifestRefreshRequest), 0x93 => Some(Self::ManifestRefreshResponse), 0x94 => Some(Self::ManifestPush), + 0xA1 => Some(Self::GroupKeyRequest), + 0xA2 => Some(Self::GroupKeyResponse), 0xB0 => Some(Self::RelayIntroduce), 0xB1 => Some(Self::RelayIntroduceResult), 0xB2 => Some(Self::SessionRelay), + 0xB3 => Some(Self::MeshPrefer), 0xB4 => Some(Self::CircleProfileUpdate), - 0xC1 => Some(Self::ConvectionRequest), - 0xC2 => Some(Self::ConvectionResponse), + 0xC0 => Some(Self::AnchorRegister), + 0xC1 => Some(Self::AnchorReferralRequest), + 0xC2 => Some(Self::AnchorReferralResponse), 0xC3 => Some(Self::AnchorProbeRequest), 0xC4 => Some(Self::AnchorProbeResult), 0xC5 => Some(Self::PortScanHeartbeat), @@ -139,152 +140,13 @@ impl MessageType { // --- Payload structs --- -/// A packed set of 32-byte IDs: base64 of the raw concatenated bytes. -/// -/// `NodeId = [u8; 32]` serialised through serde_json becomes a JSON array of 32 -/// decimal numbers (~100-130 bytes/ID — a 3-4x blowup). The uniques pools can -/// carry thousands of IDs, so they ride packed instead. -pub type PackedIds = String; - -/// Encode a set of IDs into a packed base64 blob. -pub fn pack_ids(ids: &[NodeId]) -> PackedIds { - use base64::Engine; - let mut raw = Vec::with_capacity(ids.len() * 32); - for id in ids { - raw.extend_from_slice(id); - } - base64::engine::general_purpose::STANDARD.encode(raw) -} - -/// Decode a packed base64 blob back into IDs. Trailing partial IDs are dropped. -pub fn unpack_ids(packed: &str) -> Vec { - use base64::Engine; - let raw = match base64::engine::general_purpose::STANDARD.decode(packed) { - Ok(r) => r, - Err(_) => return Vec::new(), - }; - raw.chunks_exact(32) - .map(|c| { - let mut id = [0u8; 32]; - id.copy_from_slice(c); - id - }) - .collect() -} - -/// How many 32-byte IDs a packed base64 blob holds, WITHOUT decoding it. -/// -/// Standard base64 emits 4 chars per 3 input bytes (padded), so decoded length -/// is `chars / 4 * 3` minus padding; integer division by 32 absorbs the padding -/// slack for any non-empty blob. Exact for every blob `pack_ids` produces. -pub fn packed_id_count(packed: &str) -> usize { - (packed.len() / 4) * 3 / 32 -} - -/// The ONLY entry type in a uniques pool that carries an address. -/// -/// design.html §layers: anchor entries carry addresses so the pools double as -/// the anchor directory; every other entry is a bare ID (there is no point -/// sharing an address that needs an introduction anyway). Addresses MUST be -/// filtered through `is_publicly_routable()` before they go on the wire. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AnchorEntry { - /// hex node id (anchors are always node-class) - pub i: String, - /// e.g. ["1.2.3.4:4433"] - pub a: Vec, -} - -/// One slice of a uniques pool. Node-class and author-class IDs live in -/// separate arrays so a receiver structurally cannot mistake a persona ID for -/// a connectable node ID. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct UniquesSlice { - /// Connectable network IDs, bare. - #[serde(default)] - pub nodes: PackedIds, - /// Posting/persona/throwaway IDs, bare. CDN-resolvable only — never a - /// connect target. - #[serde(default)] - pub authors: PackedIds, - /// Address-bearing anchor entries (node-class by definition). - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub anchors: Vec, -} - -impl UniquesSlice { - pub fn is_empty(&self) -> bool { - self.nodes.is_empty() && self.authors.is_empty() && self.anchors.is_empty() - } - /// Number of IDs carried (for logging / budget checks). - /// - /// `pack_ids` concatenates ALL IDs and base64-encodes the blob ONCE, so the - /// encoded length is `4 * ceil(32n/3)` characters — about 42.67 per ID, not - /// 44 (44 is the encoding of exactly ONE 32-byte ID). Counting `len()/44` - /// undercounts by ~3% at scale and by 33% for a 3-ID blob. Since this backs - /// budget checks, it is computed from the DECODED byte length instead. - pub fn len(&self) -> usize { - packed_id_count(&self.nodes) + packed_id_count(&self.authors) + self.anchors.len() - } -} - -/// 0x01 — the two-pool uniques announce (design.html §layers). -/// -/// POOL 1 (`fwd`) is FORWARDABLE and indexed by OUR bounce: -/// fwd[0] = N0 — ourselves (and our own anchor address, if we are an anchor) -/// fwd[1] = N1 — our own uniques: mesh peers, social directs, CDN file -/// authors, CDN file-holder peers, merged so the receiver -/// cannot tell which source an entry came from -/// fwd[2] = N2 — what our N1 reported to us -/// The receiver stores `fwd[i]` at bounce `i + 1` (their N1..N3), tagged to us -/// as reporter, and MAY re-announce it shifted one deeper. -/// -/// POOL 2 (`term`) is TERMINAL: our N3, deduplicated against every fwd slice. -/// The receiver stores it as their N4 and USES it for search / address -/// resolution, but NEVER re-announces it. The structural separation — a -/// distinct field, not a flag in a merged list — is what makes "never -/// re-announce" un-forgettable at the announce builder: the builder simply -/// never reads bounce-4 rows. There is no N5 anywhere. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct UniquesAnnouncePayload { - pub seq: u64, - /// Always true in v0.8 — the announce is a full snapshot that replaces this - /// reporter's rows. (Incremental diffing over four layers with anchor - /// metadata was not worth the divergence risk; the sender skips the send - /// entirely when nothing changed, which recovers most of the saving.) - pub full: bool, - /// Pool 1, forwardable. Always length 3 (bounces 0, 1, 2). - pub fwd: Vec, - /// Pool 2, terminal. Empty when the recipient advertised `depth < 4`. - #[serde(default)] - pub term: UniquesSlice, - /// Deepest bounce the SENDER retains (4 desktop, 3 mobile). Lets the peer - /// skip building/sending the terminal pool for us — the single biggest - /// bandwidth saving on a cellular link. - pub depth: u8, -} - -impl UniquesAnnouncePayload { - pub fn empty(seq: u64, depth: u8) -> Self { - Self { - seq, - full: true, - fwd: vec![UniquesSlice::default(), UniquesSlice::default(), UniquesSlice::default()], - term: UniquesSlice::default(), - depth, - } - } -} - -/// Initial exchange: uniques announce + profile + deletes + post_ids + peer addresses +/// Initial exchange: N1/N2 node lists + profile + deletes + post_ids + peer addresses #[derive(Debug, Serialize, Deserialize)] pub struct InitialExchangePayload { - /// Our uniques pools (full snapshot). `None` means "this connection carries - /// no knowledge" — i.e. the sender placed us in a temporary referral slot. - /// Explicit on the wire rather than a pair of empty vectors so the receiver - /// can tell "nothing to share" from "not sharing". - #[serde(default)] - pub uniques: Option, + /// Our connections + social contacts NodeIds (no addresses) + pub n1_node_ids: Vec, + /// Our deduplicated N2 NodeIds (no addresses) + pub n2_node_ids: Vec, /// Our profile pub profile: Option, /// Our delete records @@ -292,57 +154,68 @@ pub struct InitialExchangePayload { /// Our post IDs (for replica tracking) pub post_ids: Vec, /// Our N+10:Addresses (connected peers with addresses) for social routing + #[serde(default)] pub peer_addresses: Vec, /// If sender is an anchor, their stable advertised address (e.g. "174.127.120.52:4433") + #[serde(default)] pub anchor_addr: Option, /// What the sender sees as the receiver's address (STUN-like observed addr) + #[serde(default)] pub your_observed_addr: Option, /// Sender's NAT type ("public", "easy", "hard", "unknown") + #[serde(default)] pub nat_type: Option, /// Sender's NAT mapping behavior ("eim", "edm", "unknown") + #[serde(default)] pub nat_mapping: Option, /// Sender's NAT filtering behavior ("open", "port_restricted", "unknown") + #[serde(default)] pub nat_filtering: Option, /// Whether the sender is running an HTTP server for direct browser access + #[serde(default)] pub http_capable: bool, /// External HTTP address if known (e.g. "1.2.3.4:4433") + #[serde(default)] pub http_addr: Option, /// CDN replication device role: "intermittent", "available", "persistent" + #[serde(default)] pub device_role: Option, /// CDN cache pressure: 0-255 availability score (255 = lots of capacity) + #[serde(default)] pub cache_pressure: Option, /// Set by anchor when it detects this NodeId is already connected from a different address #[serde(default, skip_serializing_if = "Option::is_none")] pub duplicate_active: Option, } -/// 0x40/0x41 — the uniques-index exchange, both directions. -/// -/// The requester sends its pools; the responder answers with its own. One -/// round trip refreshes both indexes, which is what makes an index *exchange* -/// rather than a fetch. Content does not ride this path. +/// Incremental N1/N2 changes #[derive(Debug, Serialize, Deserialize)] -pub struct UniquesPullPayload { - /// The sender's two pools. `None` = "I carry no knowledge on this link" - /// (temp referral slot), mirroring `InitialExchangePayload.uniques`. - #[serde(default)] - pub uniques: Option, +pub struct NodeListUpdatePayload { + pub seq: u64, + pub n1_added: Vec, + pub n1_removed: Vec, + pub n2_added: Vec, + pub n2_removed: Vec, } -/// 0x46 — TRANSITIONAL content sync request. Identical in shape to the v0.7 -/// pull request; only the opcode moved. See `MessageType::ContentSyncRequest`. +/// Pull-based post sync request #[derive(Debug, Serialize, Deserialize)] -pub struct ContentSyncRequestPayload { +pub struct PullSyncRequestPayload { /// Our follows (for the responder to filter) pub follows: Vec, - /// Per-author last-sync timestamps (Vec of tuples for serde compat) + /// Post IDs we already have (backward compat — empty for v4 senders) + #[serde(default)] + pub have_post_ids: Vec, + /// Protocol v4: per-author timestamps (Vec of tuples for serde compat) + #[serde(default)] pub since_ms: Vec<(NodeId, u64)>, } -/// 0x47 — TRANSITIONAL content sync response. +/// Pull-based post sync response #[derive(Debug, Serialize, Deserialize)] -pub struct ContentSyncResponsePayload { +pub struct PullSyncResponsePayload { pub posts: Vec, + pub visibility_updates: Vec, } /// Profile update (pushed via uni-stream) @@ -351,6 +224,18 @@ pub struct ProfileUpdatePayload { pub profiles: Vec, } +/// Delete record (pushed via uni-stream) +#[derive(Debug, Serialize, Deserialize)] +pub struct DeleteRecordPayload { + pub records: Vec, +} + +/// Visibility update (pushed via uni-stream) +#[derive(Debug, Serialize, Deserialize)] +pub struct VisibilityUpdatePayload { + pub updates: Vec, +} + /// Address resolution request (bi-stream: ask reporter for a hop-2 peer's address) #[derive(Debug, Serialize, Deserialize)] pub struct AddressRequestPayload { @@ -363,8 +248,10 @@ pub struct AddressResponsePayload { pub target: NodeId, pub address: Option, /// Set when the target is known-disconnected (requester registered as watcher) + #[serde(default)] pub disconnected_at: Option, /// Target's N+10:Addresses if known + #[serde(default)] pub peer_addresses: Vec, } @@ -381,12 +268,15 @@ pub struct WormQueryPayload { pub worm_id: WormId, pub target: NodeId, /// Additional IDs to search for (up to 10 recent_peers of target) + #[serde(default)] pub needle_peers: Vec, pub ttl: u8, pub visited: Vec, /// Optional: also search for a specific post by ID + #[serde(default)] pub post_id: Option, /// Optional: also search for a specific blob by CID + #[serde(default)] pub blob_id: Option<[u8; 32]>, } @@ -396,15 +286,19 @@ pub struct WormResponsePayload { pub worm_id: WormId, pub found: bool, /// Which needle was actually found (target or one of its recent_peers) + #[serde(default)] pub found_id: Option, pub addresses: Vec, pub reporter: Option, pub hop: Option, /// One random wide-peer referral: (node_id, address) for bloom round + #[serde(default)] pub wide_referral: Option<(NodeId, String)>, /// Node that holds the requested post (may differ from found_id) + #[serde(default)] pub post_holder: Option, /// Node that holds the requested blob + #[serde(default)] pub blob_holder: Option, } @@ -418,6 +312,12 @@ pub struct SocialAddressUpdatePayload { pub peer_addresses: Vec, } +/// Disconnect notice: "peer X disconnected" +#[derive(Debug, Serialize, Deserialize)] +pub struct SocialDisconnectNoticePayload { + pub node_id: NodeId, +} + /// Lightweight keepalive checkin (bidirectional) #[derive(Debug, Serialize, Deserialize)] pub struct SocialCheckinPayload { @@ -433,6 +333,7 @@ pub struct SocialCheckinPayload { pub struct BlobRequestPayload { pub cid: [u8; 32], /// Requester's addresses so the host can record downstream + #[serde(default)] pub requester_addresses: Vec, } @@ -442,12 +343,16 @@ pub struct BlobResponsePayload { pub cid: [u8; 32], pub found: bool, /// Base64-encoded blob bytes (empty if not found) + #[serde(default)] pub data_b64: String, /// Author manifest + host info (if available) + #[serde(default)] pub manifest: Option, /// Whether host accepted requester as downstream + #[serde(default)] pub cdn_registered: bool, /// If not registered (host full), try these peers + #[serde(default)] pub cdn_redirect_peers: Vec, } @@ -485,6 +390,23 @@ pub struct ManifestPushEntry { // encrypted posts (`VisibilityIntent::GroupKeyDistribute`). See // `crate::group_key_distribution` and `types::GroupKeyDistributionContent`. +/// Member requests current group key (bi-stream request) +#[derive(Debug, Serialize, Deserialize)] +pub struct GroupKeyRequestPayload { + pub group_id: GroupId, + pub known_epoch: GroupEpoch, +} + +/// Admin responds with wrapped key (bi-stream response) +#[derive(Debug, Serialize, Deserialize)] +pub struct GroupKeyResponsePayload { + pub group_id: GroupId, + pub epoch: GroupEpoch, + pub group_public_key: [u8; 32], + pub admin: NodeId, + pub member_key: Option, +} + // --- Relay introduction payloads --- /// Relay introduction identifier for deduplication @@ -531,6 +453,18 @@ pub struct SessionRelayPayload { pub target: NodeId, } +/// Mesh preference negotiation (bi-stream: request + response) +#[derive(Debug, Serialize, Deserialize)] +pub struct MeshPreferPayload { + /// true = "I want us to be preferred peers" (request) + pub requesting: bool, + /// true = "I agree to be preferred peers" (response only) + pub accepted: bool, + /// Reason for rejection (response only, when accepted=false) + #[serde(default)] + pub reject_reason: Option, +} + /// Circle profile update: encrypted profile variant for a circle (uni-stream push) #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CircleProfileUpdatePayload { @@ -545,63 +479,33 @@ pub struct CircleProfileUpdatePayload { pub updated_at: u64, } -// --- Anchor convection payloads (design.html §anchors) --- +// --- Anchor referral payloads --- -/// The one request-class bit (round-5 ruling). -/// -/// ENTRY = bootstrap or recovery, i.e. the caller has fewer than 2 mesh -/// connections. Always served — a node that cannot get in cannot come back. -/// TOP_UP = growth with a working mesh. Served capacity-permitting and refused -/// CHEAPLY (a single response message, no timeout burned). Top-ups are the -/// convection medium — they are what keeps referral windows fresh — so they -/// are not throttled aggressively; the refusal doubles as the adaptive load -/// signal the caller feeds back into its action weights. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum ConvectionClass { - Entry, - TopUp, -} - -impl ConvectionClass { - /// Entry class is decided purely by "can this node function right now". - pub fn for_mesh_count(mesh_count: usize) -> Self { - if mesh_count < 2 { Self::Entry } else { Self::TopUp } - } - pub fn is_entry(self) -> bool { - matches!(self, Self::Entry) - } -} - -/// 0xC1 — "I'm joining / I need peers". The request itself is what enrols the -/// caller in the anchor's rolling window; there is no separate registration. +/// Node registers its address with an anchor (uni-stream) #[derive(Debug, Serialize, Deserialize)] -pub struct ConvectionRequestPayload { - pub requester: NodeId, - pub requester_addresses: Vec, - pub class: ConvectionClass, -} - -/// 0xC2 — up to 2 recent prior callers, or a cheap refusal. -#[derive(Debug, Serialize, Deserialize)] -pub struct ConvectionResponsePayload { - pub referrals: Vec, - /// Cheap refusal of a TOP_UP request: one message, immediately, so the - /// caller never burns a timeout. Never set for ENTRY-class requests. - #[serde(default)] - pub refused: bool, -} - -/// A single referral out of the anchor's rolling caller window. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ConvectionReferral { +pub struct AnchorRegisterPayload { + pub node_id: NodeId, + pub addresses: Vec, +} + +/// Node requests peer referrals from an anchor (bi-stream request) +#[derive(Debug, Serialize, Deserialize)] +pub struct AnchorReferralRequestPayload { + pub requester: NodeId, + pub requester_addresses: Vec, +} + +/// Anchor responds with peer referrals (bi-stream response) +#[derive(Debug, Serialize, Deserialize)] +pub struct AnchorReferralResponsePayload { + pub referrals: Vec, +} + +/// A single peer referral from an anchor +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AnchorReferral { pub node_id: NodeId, pub addresses: Vec, - /// The anchor had BOTH ends live and has pushed a RelayIntroduce toward - /// this peer naming the caller as requester — so the peer is already - /// punching outward. Dial straight through instead of paying for a second - /// introduction round trip. - #[serde(default)] - pub introduced: bool, } // --- Anchor probe payloads --- @@ -629,9 +533,7 @@ pub struct AnchorProbeResultPayload { pub observed_addr: Option, } -/// Port scan heartbeat during scanning hole punch (informational). -/// Reserved wire surface for the EDM scanner (`edm_port_scan_disabled_v0_7_3` -/// in connection.rs) — no sender/handler until the raw-UDP scan refactor. +/// Port scan heartbeat during scanning hole punch (informational) #[derive(Debug, Serialize, Deserialize)] pub struct PortScanHeartbeatPayload { pub peer: NodeId, @@ -682,6 +584,7 @@ pub struct BlobHeaderResponsePayload { /// True if the sender has a newer header than requested pub updated: bool, /// JSON-serialized BlobHeader (if updated) + #[serde(default)] pub header_json: Option, } @@ -822,31 +725,36 @@ mod tests { #[test] fn message_type_roundtrip() { let types = [ - MessageType::UniquesAnnounce, + MessageType::NodeListUpdate, MessageType::InitialExchange, MessageType::AddressRequest, MessageType::AddressResponse, MessageType::RefuseRedirect, - MessageType::UniquesPullRequest, - MessageType::UniquesPullResponse, - MessageType::ContentSyncRequest, - MessageType::ContentSyncResponse, + MessageType::PullSyncRequest, + MessageType::PullSyncResponse, MessageType::ProfileUpdate, + MessageType::DeleteRecord, + MessageType::VisibilityUpdate, MessageType::WormQuery, MessageType::WormResponse, MessageType::SocialAddressUpdate, + MessageType::SocialDisconnectNotice, MessageType::SocialCheckin, MessageType::BlobRequest, MessageType::BlobResponse, MessageType::ManifestRefreshRequest, MessageType::ManifestRefreshResponse, MessageType::ManifestPush, + MessageType::GroupKeyRequest, + MessageType::GroupKeyResponse, MessageType::RelayIntroduce, MessageType::RelayIntroduceResult, MessageType::SessionRelay, + MessageType::MeshPrefer, MessageType::CircleProfileUpdate, - MessageType::ConvectionRequest, - MessageType::ConvectionResponse, + MessageType::AnchorRegister, + MessageType::AnchorReferralRequest, + MessageType::AnchorReferralResponse, MessageType::AnchorProbeRequest, MessageType::AnchorProbeResult, MessageType::PortScanHeartbeat, @@ -933,6 +841,43 @@ mod tests { assert_eq!(decoded2.reject_reason.unwrap(), "target not reachable"); } + #[test] + fn mesh_prefer_payload_roundtrip() { + // Request + let request = MeshPreferPayload { + requesting: true, + accepted: false, + reject_reason: None, + }; + let json = serde_json::to_string(&request).unwrap(); + let decoded: MeshPreferPayload = serde_json::from_str(&json).unwrap(); + assert!(decoded.requesting); + assert!(!decoded.accepted); + assert!(decoded.reject_reason.is_none()); + + // Accepted response + let accept = MeshPreferPayload { + requesting: false, + accepted: true, + reject_reason: None, + }; + let json2 = serde_json::to_string(&accept).unwrap(); + let decoded2: MeshPreferPayload = serde_json::from_str(&json2).unwrap(); + assert!(!decoded2.requesting); + assert!(decoded2.accepted); + + // Rejected response + let reject = MeshPreferPayload { + requesting: false, + accepted: false, + reject_reason: Some("slots full".to_string()), + }; + let json3 = serde_json::to_string(&reject).unwrap(); + let decoded3: MeshPreferPayload = serde_json::from_str(&json3).unwrap(); + assert!(!decoded3.accepted); + assert_eq!(decoded3.reject_reason.unwrap(), "slots full"); + } + #[test] fn session_relay_payload_roundtrip() { let payload = SessionRelayPayload { @@ -968,105 +913,56 @@ mod tests { } #[test] - fn convection_request_carries_the_class_bit() { - for class in [ConvectionClass::Entry, ConvectionClass::TopUp] { - let payload = ConvectionRequestPayload { - requester: [2u8; 32], - requester_addresses: vec!["10.0.0.2:4433".to_string()], - class, - }; - let json = serde_json::to_string(&payload).unwrap(); - let decoded: ConvectionRequestPayload = serde_json::from_str(&json).unwrap(); - assert_eq!(decoded.requester, [2u8; 32]); - assert_eq!(decoded.requester_addresses, vec!["10.0.0.2:4433"]); - assert_eq!(decoded.class, class); - } - // The class is derived purely from "can this node function right now". - assert!(ConvectionClass::for_mesh_count(0).is_entry()); - assert!(ConvectionClass::for_mesh_count(1).is_entry()); - assert!(!ConvectionClass::for_mesh_count(2).is_entry()); - assert!(!ConvectionClass::for_mesh_count(20).is_entry()); + fn anchor_register_payload_roundtrip() { + let payload = AnchorRegisterPayload { + node_id: [1u8; 32], + addresses: vec!["192.168.1.5:4433".to_string(), "10.0.0.1:4433".to_string()], + }; + let json = serde_json::to_string(&payload).unwrap(); + let decoded: AnchorRegisterPayload = serde_json::from_str(&json).unwrap(); + assert_eq!(decoded.node_id, [1u8; 32]); + assert_eq!(decoded.addresses.len(), 2); + assert_eq!(decoded.addresses[0], "192.168.1.5:4433"); } #[test] - fn convection_response_roundtrip_including_cheap_refusal() { - let payload = ConvectionResponsePayload { + fn anchor_referral_request_payload_roundtrip() { + let payload = AnchorReferralRequestPayload { + requester: [2u8; 32], + requester_addresses: vec!["10.0.0.2:4433".to_string()], + }; + let json = serde_json::to_string(&payload).unwrap(); + let decoded: AnchorReferralRequestPayload = serde_json::from_str(&json).unwrap(); + assert_eq!(decoded.requester, [2u8; 32]); + assert_eq!(decoded.requester_addresses, vec!["10.0.0.2:4433"]); + } + + #[test] + fn anchor_referral_response_payload_roundtrip() { + let payload = AnchorReferralResponsePayload { referrals: vec![ - ConvectionReferral { + AnchorReferral { node_id: [3u8; 32], addresses: vec!["10.0.0.3:4433".to_string()], - introduced: false, }, - ConvectionReferral { + AnchorReferral { node_id: [4u8; 32], - addresses: vec!["10.0.0.4:4433".to_string()], - introduced: true, + addresses: vec!["10.0.0.4:4433".to_string(), "192.168.1.4:4433".to_string()], }, ], - refused: false, }; let json = serde_json::to_string(&payload).unwrap(); - let decoded: ConvectionResponsePayload = serde_json::from_str(&json).unwrap(); + let decoded: AnchorReferralResponsePayload = serde_json::from_str(&json).unwrap(); assert_eq!(decoded.referrals.len(), 2); - assert!(!decoded.referrals[0].introduced); - assert!(decoded.referrals[1].introduced); - assert!(!decoded.refused); + assert_eq!(decoded.referrals[0].node_id, [3u8; 32]); + assert_eq!(decoded.referrals[0].addresses, vec!["10.0.0.3:4433"]); + assert_eq!(decoded.referrals[1].node_id, [4u8; 32]); + assert_eq!(decoded.referrals[1].addresses.len(), 2); - // A refusal is one small message with no referrals — the caller reads - // it immediately instead of burning a timeout. - let refusal = ConvectionResponsePayload { referrals: vec![], refused: true }; - let json2 = serde_json::to_string(&refusal).unwrap(); - assert!(json2.len() < 64, "refusal must stay tiny: {}", json2); - let decoded2: ConvectionResponsePayload = serde_json::from_str(&json2).unwrap(); - assert!(decoded2.refused); + // Empty referrals + let empty = AnchorReferralResponsePayload { referrals: vec![] }; + let json2 = serde_json::to_string(&empty).unwrap(); + let decoded2: AnchorReferralResponsePayload = serde_json::from_str(&json2).unwrap(); assert!(decoded2.referrals.is_empty()); } - - #[test] - fn uniques_pull_is_an_index_exchange_not_a_post_transfer() { - let payload = UniquesPullPayload { - uniques: Some(UniquesAnnouncePayload::empty(7, 4)), - }; - let json = serde_json::to_string(&payload).unwrap(); - let decoded: UniquesPullPayload = serde_json::from_str(&json).unwrap(); - let u = decoded.uniques.unwrap(); - assert_eq!(u.seq, 7); - assert_eq!(u.depth, 4); - assert_eq!(u.fwd.len(), 3); - // No post field exists on this payload at all — content rides 0x46/0x47. - assert!(!json.contains("posts")); - - // A temp referral slot carries no knowledge in either direction. - let none = UniquesPullPayload { uniques: None }; - let json2 = serde_json::to_string(&none).unwrap(); - let decoded2: UniquesPullPayload = serde_json::from_str(&json2).unwrap(); - assert!(decoded2.uniques.is_none()); - } - - /// `len()` backs budget checks, so it must be EXACT — not the old - /// `bytes / 44` approximation, which assumed one base64 encoding per ID - /// when `pack_ids` encodes the whole concatenated blob once. - #[test] - fn uniques_slice_len_counts_packed_ids_exactly() { - for n in [0usize, 1, 2, 3, 7, 100, 1000] { - let ids: Vec = (0..n) - .map(|i| { - let mut id = [0u8; 32]; - id[0] = (i % 251) as u8; - id[1] = (i / 251) as u8; - id - }) - .collect(); - let packed = pack_ids(&ids); - assert_eq!(packed_id_count(&packed), n, "packed_id_count for n={}", n); - assert_eq!(unpack_ids(&packed).len(), n); - - let slice = UniquesSlice { - nodes: packed.clone(), - authors: packed.clone(), - anchors: vec![AnchorEntry { i: "aa".into(), a: vec!["1.2.3.4:1".into()] }], - }; - assert_eq!(slice.len(), n * 2 + 1, "UniquesSlice::len for n={}", n); - } - } } diff --git a/crates/core/src/registry.rs b/crates/core/src/registry.rs deleted file mode 100644 index d5557f9..0000000 --- a/crates/core/src/registry.rs +++ /dev/null @@ -1,474 +0,0 @@ -//! v0.8 (A3): the network registry post — a well-known "registrations -//! here" post whose comment chain is the opt-in, searchable linkage of a -//! public persona name/keywords to a posting-identity author ID -//! (design.html §27 `id="discovery"`). -//! -//! - The post's canonical bytes are FROZEN in [`REGISTRY_POST_JSON`]; -//! its id is [`REGISTRY_POST_ID`] = BLAKE3(bytes). The constant is the -//! authority — never re-serialize the `Post` struct at runtime to -//! derive the id (serde field order = declaration order; any future -//! field would silently change the bytes). -//! - Every v0.8 node self-materializes the post at startup -//! ([`materialize_registry_post`]); only the comment-chain pull is -//! network work. -//! - Registration = the SAME open-slot comment implementation as -//! greetings (round-6 ruling), aimed at this post: plaintext JSON -//! entry, self-certified by the standard comment signature under the -//! persona's REAL posting key, fixed 30-day TTL, one active entry per -//! persona (newest wins). -//! - Sharding: `shard(seed, k)` derivation exists from day one via the -//! content string embedding the seed + k. k=1 forever in A3; adding -//! shard k+1 is a new content string = threshold change, not a -//! migration. - -use anyhow::{bail, Result}; -use serde::{Deserialize, Serialize}; - -use crate::storage::Storage; -use crate::types::{NodeId, Post, PostId}; - -/// Shard seed + index (design ruling: derivable shard identity). -pub const REGISTRY_SHARD_SEED: &str = "itsgoin-registry-v1"; -pub const REGISTRY_SHARD_K: u32 = 1; - -/// Fixed genesis timestamp baked into the canonical bytes -/// (2026-07-30T00:00:00Z). -pub const REGISTRY_GENESIS_TIMESTAMP_MS: u64 = 1_785_369_600_000; - -/// Single padded bucket for registration entries (plaintext JSON). -pub const REGISTRY_BODY_BUCKET: u16 = 512; - -/// Registrations live exactly 30 days; re-sign to stay listed. -pub const REGISTRATION_TTL_MS: u64 = 30 * 24 * 3600 * 1000; -/// Holder-side slack when checking the fixed TTL (clock skew). -pub const REGISTRATION_TTL_SLACK_MS: u64 = 5 * 60 * 1000; - -/// Entry shape limits (holder-enforced). -pub const MAX_REGISTRY_NAME_CHARS: usize = 64; -pub const MAX_REGISTRY_KEYWORDS: usize = 8; -pub const MAX_REGISTRY_KEYWORD_CHARS: usize = 32; - -/// Ordinary comment TTL window: rand(30..=365 days), drawn BEFORE -/// signing (the expiry is inside the signed digest). -pub const ORDINARY_TTL_MIN_MS: u64 = 30 * 24 * 3600 * 1000; -pub const ORDINARY_TTL_MAX_MS: u64 = 365 * 24 * 3600 * 1000; - -/// Draw the expiry for an ordinary comment: `now + rand(30..=365 days)`. -/// The randomness serves identity hygiene — throwaway commenter IDs -/// vanish at unpredictable times. -/// -/// Debug/test override: `ITSGOIN_TEST_TTL_SECS=` makes every ordinary -/// comment expire in n seconds (integration-test hook; debug builds only). -pub fn draw_ordinary_comment_expiry(now_ms: u64) -> u64 { - #[cfg(debug_assertions)] - { - if let Ok(secs) = std::env::var("ITSGOIN_TEST_TTL_SECS") { - if let Ok(secs) = secs.parse::() { - return now_ms + secs * 1000; - } - } - } - use rand::Rng; - let ttl = rand::rng().random_range(ORDINARY_TTL_MIN_MS..=ORDINARY_TTL_MAX_MS); - now_ms + ttl -} - -/// The frozen canonical serialized bytes of the registry post. Generated -/// once (see `build_canonical_registry_post` + the `frozen_bytes_*` -/// tests) and pasted here as a literal. DO NOT regenerate at runtime. -pub const REGISTRY_POST_JSON: &str = include_str!("registry_post_v1.json"); - -/// `BLAKE3(REGISTRY_POST_JSON)` — the registry post's id. Asserted -/// against the frozen bytes in CI (`frozen_bytes_triple_assertion`). -pub const REGISTRY_POST_ID: PostId = [ - 0x95, 0x28, 0x55, 0x78, 0x0d, 0xaa, 0x7c, 0xcc, - 0x1c, 0xe1, 0x2d, 0x18, 0x10, 0x3f, 0x77, 0x1d, - 0x6b, 0xe0, 0xd8, 0x81, 0x33, 0x18, 0x7b, 0xbf, - 0xec, 0x64, 0x60, 0xda, 0x24, 0x21, 0x90, 0xb4, -]; - -/// Deterministic builder for the canonical registry post. All seal -/// inputs are blake3-derived from the shard seed + k, so the output -/// bytes are reproducible bit-for-bit — this is what generated (and what -/// CI re-checks against) [`REGISTRY_POST_JSON`]. -pub fn build_canonical_registry_post() -> Result { - use crate::types::{FoFCommentGating, OpenSlotDecl, OpenSlotKind, WrapSlot}; - use ed25519_dalek::SigningKey; - - let shard_input = format!("{}/k={}", REGISTRY_SHARD_SEED, REGISTRY_SHARD_K); - let derive = |ctx: &str| -> [u8; 32] { blake3::derive_key(ctx, shard_input.as_bytes()) }; - - let slot_binder_nonce = derive("itsgoin/registry/v1/slot-binder"); - let cek = derive("itsgoin/registry/v1/cek"); - let priv_x_seed = derive("itsgoin/registry/v1/priv-x-seed"); - let pub_x = SigningKey::from_bytes(&priv_x_seed).verifying_key().to_bytes(); - - let v_open = crate::crypto::derive_open_slot_vx( - &crate::DEFAULT_ANCHOR_POSTING_ID, - &slot_binder_nonce, - ); - let sealed = crate::crypto::seal_wrap_slot(&v_open, &slot_binder_nonce, &cek, &priv_x_seed)?; - - let gating = FoFCommentGating { - slot_binder_nonce, - pub_post_set: vec![pub_x], - wrap_slots: vec![WrapSlot { - prefilter_tag: sealed.prefilter_tag, - read_ciphertext: sealed.read_ciphertext, - sign_ciphertext: sealed.sign_ciphertext, - }], - revocation_list: vec![], - open_slot: Some(OpenSlotDecl { - slot_index: 0, - kind: OpenSlotKind::Registry, - body_bucket: REGISTRY_BODY_BUCKET, - }), - }; - - Ok(Post { - author: crate::DEFAULT_ANCHOR_POSTING_ID, - content: format!( - "ItsGoin Network Registry — shard({}, k={}). Register here with a signed \ - public comment {{name, keywords}} authored by your persona's posting key. \ - Entries are self-certifying, expire after 30 days, and are newest-wins per \ - persona. Delete = signed DeleteComment.", - REGISTRY_SHARD_SEED, REGISTRY_SHARD_K, - ), - attachments: vec![], - timestamp_ms: REGISTRY_GENESIS_TIMESTAMP_MS, - fof_gating: Some(gating), - supersedes_post_id: None, - }) -} - -/// Parse the frozen constant into a `Post`. `verify_post_id` passes by -/// construction everywhere. -pub fn registry_post() -> Post { - serde_json::from_str(REGISTRY_POST_JSON) - .expect("frozen REGISTRY_POST_JSON must deserialize — build is broken otherwise") -} - -/// Self-materialize the registry post (store-if-absent, Public). Every -/// v0.8 node can therefore hold/serve the chain; no fetch needed. -/// Returns `true` if the post was newly stored. -pub fn materialize_registry_post(storage: &Storage) -> Result { - let post = registry_post(); - debug_assert!(crate::content::verify_post_id(®ISTRY_POST_ID, &post)); - storage.store_post_with_intent( - ®ISTRY_POST_ID, - &post, - &crate::types::PostVisibility::Public, - &crate::types::VisibilityIntent::Public, - ) -} - -/// A registration entry — plaintext JSON in the comment `content`. -/// Public by intent: no encryption, no anonymity. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct RegistrationEntry { - pub v: u32, - pub name: String, - #[serde(default)] - pub keywords: Vec, -} - -/// Parse + shape-validate a registration entry from comment content. -pub fn parse_registration(content: &str) -> Result { - if content.len() > REGISTRY_BODY_BUCKET as usize { - bail!("registration entry exceeds body bucket ({} bytes)", content.len()); - } - let entry: RegistrationEntry = serde_json::from_str(content) - .map_err(|e| anyhow::anyhow!("registration entry is not valid JSON: {}", e))?; - if entry.v != 1 { - bail!("unsupported registration version {}", entry.v); - } - if entry.name.is_empty() || entry.name.chars().count() > MAX_REGISTRY_NAME_CHARS { - bail!("registration name empty or over {} chars", MAX_REGISTRY_NAME_CHARS); - } - if entry.keywords.len() > MAX_REGISTRY_KEYWORDS { - bail!("more than {} keywords", MAX_REGISTRY_KEYWORDS); - } - for kw in &entry.keywords { - if kw.is_empty() || kw.chars().count() > MAX_REGISTRY_KEYWORD_CHARS { - bail!("keyword empty or over {} chars", MAX_REGISTRY_KEYWORD_CHARS); - } - } - Ok(entry) -} - -/// Fixed-30d TTL check for registrations: `expires_at_ms − timestamp_ms` -/// must equal 30 days within ±5 minutes of slack. -pub fn registration_ttl_ok(timestamp_ms: u64, expires_at_ms: u64) -> bool { - let Some(ttl) = expires_at_ms.checked_sub(timestamp_ms) else { return false; }; - ttl.abs_diff(REGISTRATION_TTL_MS) <= REGISTRATION_TTL_SLACK_MS -} - -/// One search hit from the local registry chain. -#[derive(Debug, Clone)] -pub struct RegistryMatch { - pub author: NodeId, - pub name: String, - pub keywords: Vec, - pub timestamp_ms: u64, - pub expires_at_ms: u64, -} - -/// Case-insensitive substring search over name + keywords of the -/// locally-held, unexpired, newest-wins registry entries. Search cost -/// lands on the searcher (design §27) — LOCAL query only; chain refresh -/// is the caller's job (`Node::search_registry`). -/// -/// An empty query returns every live entry (browse mode). -pub fn search_entries(storage: &Storage, query: &str) -> Result> { - let comments = storage.get_comments(®ISTRY_POST_ID)?; - let needle = query.trim().to_lowercase(); - - // Newest-wins per author (storage enforces at ingest; dedupe - // defensively for locally-authored duplicates). - let mut newest: std::collections::HashMap = - std::collections::HashMap::new(); - for c in &comments { - let Ok(entry) = parse_registration(&c.content) else { continue; }; - let m = RegistryMatch { - author: c.author, - name: entry.name, - keywords: entry.keywords, - timestamp_ms: c.timestamp_ms, - expires_at_ms: c.expires_at_ms, - }; - match newest.get(&c.author) { - Some(existing) if existing.timestamp_ms >= m.timestamp_ms => {} - _ => { - newest.insert(c.author, m); - } - } - } - - let mut hits: Vec = newest - .into_values() - .filter(|m| { - if needle.is_empty() { - return true; - } - m.name.to_lowercase().contains(&needle) - || m.keywords.iter().any(|k| k.to_lowercase().contains(&needle)) - }) - .collect(); - hits.sort_by(|a, b| b.timestamp_ms.cmp(&a.timestamp_ms)); - Ok(hits) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::types::InlineComment; - - fn temp_storage() -> Storage { - Storage::open(":memory:").unwrap() - } - - /// Generator (dev-time): rebuild the canonical bytes and print them. - /// Run with `cargo test -p itsgoin-core registry_generator -- --ignored --nocapture` - /// when (and only when) the canonical definition intentionally changes, - /// then paste the output into `registry_post_v1.json` + REGISTRY_POST_ID. - #[test] - #[ignore] - fn registry_generator() { - let post = build_canonical_registry_post().unwrap(); - let json = serde_json::to_string(&post).unwrap(); - let id = blake3::hash(json.as_bytes()); - println!("JSON:\n{}", json); - println!("ID: {:?}", id.as_bytes()); - println!("ID hex: {}", hex::encode(id.as_bytes())); - } - - /// CI: the frozen literal, its BLAKE3, the shipped constant, and the - /// deterministic builder must all agree — forever. - #[test] - fn frozen_bytes_triple_assertion() { - // 1. blake3(frozen bytes) == REGISTRY_POST_ID. - let hash = blake3::hash(REGISTRY_POST_JSON.as_bytes()); - assert_eq!( - hash.as_bytes(), - ®ISTRY_POST_ID, - "REGISTRY_POST_ID must equal blake3 of the frozen bytes", - ); - - // 2. compute_post_id(parse(frozen bytes)) == REGISTRY_POST_ID — - // i.e., re-serialization is currently byte-identical, so - // verify_post_id passes everywhere. - let post = registry_post(); - assert_eq!( - crate::content::compute_post_id(&post), - REGISTRY_POST_ID, - "round-trip serialization must reproduce the frozen bytes", - ); - - // 3. The deterministic builder still reproduces the constant - // (all seal inputs derived — no drift in crypto derivations). - let rebuilt = build_canonical_registry_post().unwrap(); - assert_eq!( - serde_json::to_string(&rebuilt).unwrap(), - REGISTRY_POST_JSON, - "deterministic builder must reproduce the frozen bytes", - ); - - // Sanity: canonical fields. - assert_eq!(post.author, crate::DEFAULT_ANCHOR_POSTING_ID); - assert_eq!(post.timestamp_ms, REGISTRY_GENESIS_TIMESTAMP_MS); - let gating = post.fof_gating.as_ref().expect("gating"); - let decl = gating.open_slot.as_ref().expect("open slot"); - assert_eq!(decl.slot_index, 0); - assert_eq!(decl.kind, crate::types::OpenSlotKind::Registry); - assert_eq!(decl.body_bucket, REGISTRY_BODY_BUCKET); - assert_eq!(gating.wrap_slots.len(), 1); - } - - /// The registry open slot must actually open under the derivable key - /// and yield the signing seed matching pub_post_set[0]. - #[test] - fn registry_open_slot_derivable() { - use ed25519_dalek::SigningKey; - let post = registry_post(); - let unlock = crate::fof::derive_open_slot_unlock(&post, &[0u8; 32]) - .expect("registry open slot must open under the derived key"); - assert_eq!(unlock.slot_index, 0); - let derived_pub = SigningKey::from_bytes(&unlock.priv_x_seed).verifying_key().to_bytes(); - assert_eq!(post.fof_gating.unwrap().pub_post_set[0], derived_pub); - } - - #[test] - fn materialize_is_idempotent() { - let s = temp_storage(); - assert!(materialize_registry_post(&s).unwrap(), "first store inserts"); - assert!(!materialize_registry_post(&s).unwrap(), "second store no-ops"); - let (post, vis) = s.get_post_with_visibility(®ISTRY_POST_ID).unwrap().unwrap(); - assert_eq!(post.author, crate::DEFAULT_ANCHOR_POSTING_ID); - assert!(matches!(vis, crate::types::PostVisibility::Public)); - } - - #[test] - fn registration_shape_limits() { - // Good. - parse_registration(r#"{"v":1,"name":"Alice","keywords":["rust","p2p"]}"#).unwrap(); - // No keywords is fine. - parse_registration(r#"{"v":1,"name":"Bob"}"#).unwrap(); - // Wrong version. - assert!(parse_registration(r#"{"v":2,"name":"X"}"#).is_err()); - // Empty name. - assert!(parse_registration(r#"{"v":1,"name":""}"#).is_err()); - // Name too long. - let long_name = "x".repeat(65); - assert!(parse_registration(&format!(r#"{{"v":1,"name":"{}"}}"#, long_name)).is_err()); - // Too many keywords. - let kws: Vec = (0..9).map(|i| format!("k{}", i)).collect(); - let json = serde_json::json!({"v":1,"name":"A","keywords":kws}).to_string(); - assert!(parse_registration(&json).is_err()); - // Keyword too long. - let json = serde_json::json!({"v":1,"name":"A","keywords":["x".repeat(33)]}).to_string(); - assert!(parse_registration(&json).is_err()); - // Not JSON. - assert!(parse_registration("hello").is_err()); - } - - #[test] - fn registration_ttl_fixed_30d() { - let ts = 1_000_000u64; - // Exact 30d. - assert!(registration_ttl_ok(ts, ts + REGISTRATION_TTL_MS)); - // Inside slack. - assert!(registration_ttl_ok(ts, ts + REGISTRATION_TTL_MS + REGISTRATION_TTL_SLACK_MS)); - assert!(registration_ttl_ok(ts, ts + REGISTRATION_TTL_MS - REGISTRATION_TTL_SLACK_MS)); - // Outside slack. - assert!(!registration_ttl_ok(ts, ts + REGISTRATION_TTL_MS + REGISTRATION_TTL_SLACK_MS + 1)); - assert!(!registration_ttl_ok(ts, ts + REGISTRATION_TTL_MS - REGISTRATION_TTL_SLACK_MS - 1)); - // Degenerate. - assert!(!registration_ttl_ok(ts, ts)); - assert!(!registration_ttl_ok(ts, 0)); - } - - fn reg_comment(author: NodeId, ts: u64, name: &str, kws: &[&str]) -> InlineComment { - InlineComment { - author, - post_id: REGISTRY_POST_ID, - content: serde_json::json!({"v":1,"name":name,"keywords":kws}).to_string(), - timestamp_ms: ts, - signature: vec![0u8; 64], - deleted_at: None, - ref_post_id: None, - pub_x_index: Some(0), - group_sig: None, - encrypted_payload: None, - expires_at_ms: ts + REGISTRATION_TTL_MS, - } - } - - fn test_now() -> u64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_millis() as u64 - } - - #[test] - fn newest_wins_upsert() { - let s = temp_storage(); - let author = [7u8; 32]; - let t0 = test_now(); - - // First entry at t0. - assert!(s.upsert_registry_entry_newest_wins(®ISTRY_POST_ID, &author, t0).unwrap()); - s.store_comment(®_comment(author, t0, "Old", &["a"])).unwrap(); - - // Newer entry at t0+1000: accepted, older row hard-deleted. - assert!(s.upsert_registry_entry_newest_wins(®ISTRY_POST_ID, &author, t0 + 1000).unwrap()); - s.store_comment(®_comment(author, t0 + 1000, "New", &["b"])).unwrap(); - let comments = s.get_comments(®ISTRY_POST_ID).unwrap(); - assert_eq!(comments.len(), 1, "older row hard-deleted"); - assert_eq!(comments[0].timestamp_ms, t0 + 1000); - - // Older-than-stored entry: rejected, nothing changes. - assert!(!s.upsert_registry_entry_newest_wins(®ISTRY_POST_ID, &author, t0 + 500).unwrap()); - assert_eq!(s.get_comments(®ISTRY_POST_ID).unwrap().len(), 1); - - // Same-timestamp re-ingest: idempotent accept. - assert!(s.upsert_registry_entry_newest_wins(®ISTRY_POST_ID, &author, t0 + 1000).unwrap()); - } - - #[test] - fn search_matches_name_and_keywords() { - let s = temp_storage(); - let alice = [1u8; 32]; - let bob = [2u8; 32]; - let t0 = test_now(); - s.store_comment(®_comment(alice, t0, "Alice", &["rust", "p2p"])).unwrap(); - s.store_comment(®_comment(bob, t0 + 1, "Bob", &["gardening"])).unwrap(); - - // Keyword hit (case-insensitive). - let hits = search_entries(&s, "RUST").unwrap(); - assert_eq!(hits.len(), 1); - assert_eq!(hits[0].author, alice); - assert_eq!(hits[0].name, "Alice"); - - // Name substring hit. - let hits = search_entries(&s, "bo").unwrap(); - assert_eq!(hits.len(), 1); - assert_eq!(hits[0].author, bob); - - // Empty query = browse all. - assert_eq!(search_entries(&s, "").unwrap().len(), 2); - - // No hit. - assert!(search_entries(&s, "zzz").unwrap().is_empty()); - } - - #[test] - fn search_skips_expired_entries() { - let s = temp_storage(); - let alice = [1u8; 32]; - let mut c = reg_comment(alice, 1000, "Alice", &["rust"]); - c.expires_at_ms = 2000; // long past - s.store_comment(&c).unwrap(); - assert!(search_entries(&s, "rust").unwrap().is_empty(), "expired entries filtered"); - } -} diff --git a/crates/core/src/registry_post_v1.json b/crates/core/src/registry_post_v1.json deleted file mode 100644 index f0b1e96..0000000 --- a/crates/core/src/registry_post_v1.json +++ /dev/null @@ -1 +0,0 @@ -{"author":[23,175,20,25,86,174,11,80,220,28,185,36,140,173,245,252,163,113,234,45,133,49,172,154,221,60,3,202,255,198,20,65],"content":"ItsGoin Network Registry — shard(itsgoin-registry-v1, k=1). Register here with a signed public comment {name, keywords} authored by your persona's posting key. Entries are self-certifying, expire after 30 days, and are newest-wins per persona. Delete = signed DeleteComment.","attachments":[],"timestamp_ms":1785369600000,"fof_gating":{"slot_binder_nonce":[194,138,144,176,11,137,185,140,117,108,61,163,60,83,166,44,35,228,74,53,76,175,67,116,175,175,49,54,140,206,82,147],"pub_post_set":[[247,219,138,10,17,157,246,133,230,232,228,97,55,195,255,125,108,212,226,71,102,202,26,31,78,233,76,47,236,66,71,144]],"wrap_slots":[{"prefilter_tag":[95,128],"read_ciphertext":[216,233,169,245,192,77,2,116,19,155,45,0,222,219,59,237,100,86,247,42,2,101,141,2,131,40,39,236,29,158,172,66,8,45,135,137,219,4,159,255,148,101,61,120,3,253,1,220],"sign_ciphertext":[42,203,235,211,8,208,10,246,77,37,158,207,8,214,152,49,15,110,220,5,190,177,105,164,146,117,190,211,12,13,32,36,109,105,156,33,42,133,118,213,248,33,74,182,178,235,176,204]}],"revocation_list":[],"open_slot":{"slot_index":0,"kind":"Registry","body_bucket":512}}} \ No newline at end of file diff --git a/crates/core/src/storage.rs b/crates/core/src/storage.rs index ffa7e61..9cd95cb 100644 --- a/crates/core/src/storage.rs +++ b/crates/core/src/storage.rs @@ -7,9 +7,9 @@ use crate::types::{ Attachment, Circle, CircleProfile, CommentPolicy, DeleteRecord, FollowVisibility, GossipPeerInfo, GroupEpoch, GroupId, GroupKeyRecord, GroupMemberKey, InlineComment, ManifestEntry, NodeId, PeerRecord, - IdClass, PeerWithAddress, Post, PostId, PostVisibility, PostingIdentity, - PublicProfile, ReachEntry, Reaction, ReachMethod, SocialRelation, SocialRouteEntry, - SocialStatus, ThreadMeta, UniquesPools, VisibilityIntent, + PeerSlotKind, PeerWithAddress, Post, PostId, PostVisibility, PostingIdentity, + PublicProfile, Reaction, ReachMethod, SocialRelation, SocialRouteEntry, SocialStatus, + ThreadMeta, VisibilityIntent, }; /// Direction for file_holders entries: whether we sent the file to this peer, @@ -92,39 +92,11 @@ impl StoragePool { /// Current schema version. Bump this when making schema or data changes /// that require migration. Old databases with a lower version will be migrated. /// If the gap is too large (major version mismatch), the DB is reset instead. -const SCHEMA_VERSION: u32 = 3; +const SCHEMA_VERSION: u32 = 2; /// Minimum schema version we can migrate from. Anything older gets a full reset. const MIN_MIGRATABLE_VERSION: u32 = 1; -/// How recently we must have been in contact with an anchor-flagged peer for -/// its address to ride our own bounce-1 slice. `peers.is_anchor` is a one-way -/// latch; this is what bounds it in time. -const ANCHOR_ENRICH_MAX_AGE_MS: i64 = 24 * 60 * 60 * 1000; // 24h - -// ---- v0.8 §layers size budget for the uniques pools ---- -// -// design.html §layers publishes N2 <= 400 and N3 <= 8,000 as the design -// ceiling. These are the ENFORCED numbers, set generously above the design -// budget so a large-but-honest peer degrades gracefully (truncation) rather -// than being refused, while a hostile peer cannot make us write six figures of -// rows per announce. - -/// Max entries we will BUILD into one shallow announce slice (fwd[1], fwd[2]). -pub const UNIQUES_BUILD_CAP_SHALLOW: usize = 4_000; -/// Max entries we will BUILD into the terminal slice (our N3, budget 8,000). -pub const UNIQUES_BUILD_CAP_TERM: usize = 12_000; - -/// Max entries we will ACCEPT at each bounce from one reporter — 2x our own -/// build caps, so an honest peer is never truncated and a hostile one is. -pub fn uniques_accept_cap(bounce: u8) -> usize { - match bounce { - 0 | 1 | 2 => 8_000, - 3 => 8_000, - _ => 24_000, - } -} - impl Storage { pub fn open(path: impl AsRef) -> anyhow::Result { let conn = Connection::open(path.as_ref())?; @@ -246,25 +218,24 @@ impl Storage { target_id BLOB PRIMARY KEY, failed_at INTEGER NOT NULL ); - -- v0.8 (Iteration C): reachable_n2 + reachable_n3 collapse into one - -- bounce-parametrised table so N4 does not need a third near-identical - -- copy of 12 methods. bounce ∈ {2,3,4}; bounce 1 (our own uniques) is - -- computed from mesh_peers/social_routes/CDN, never stored here. - -- addresses is non-empty ONLY when is_anchor = 1 (design.html §layers). - CREATE TABLE IF NOT EXISTS reachable ( - reporter_node_id BLOB NOT NULL, - reachable_id BLOB NOT NULL, - bounce INTEGER NOT NULL, - id_class INTEGER NOT NULL DEFAULT 0, - is_anchor INTEGER NOT NULL DEFAULT 0, - addresses TEXT NOT NULL DEFAULT '[]', - updated_at INTEGER NOT NULL, - PRIMARY KEY (reporter_node_id, reachable_id) + CREATE TABLE IF NOT EXISTS reachable_n2 ( + reporter_node_id BLOB NOT NULL, + reachable_node_id BLOB NOT NULL, + updated_at INTEGER NOT NULL, + PRIMARY KEY (reporter_node_id, reachable_node_id) ); - CREATE INDEX IF NOT EXISTS idx_reachable_id ON reachable(reachable_id, bounce); - CREATE INDEX IF NOT EXISTS idx_reachable_anchor ON reachable(is_anchor, bounce); + CREATE INDEX IF NOT EXISTS idx_n2_reachable ON reachable_n2(reachable_node_id); + CREATE TABLE IF NOT EXISTS reachable_n3 ( + reporter_node_id BLOB NOT NULL, + reachable_node_id BLOB NOT NULL, + updated_at INTEGER NOT NULL, + PRIMARY KEY (reporter_node_id, reachable_node_id) + ); + CREATE INDEX IF NOT EXISTS idx_n3_reachable ON reachable_n3(reachable_node_id); CREATE TABLE IF NOT EXISTS mesh_peers ( node_id BLOB NOT NULL PRIMARY KEY, + slot_kind TEXT NOT NULL, + priority INTEGER NOT NULL DEFAULT 0, connected_at INTEGER NOT NULL, last_diff_seq INTEGER NOT NULL DEFAULT 0 ); @@ -337,6 +308,10 @@ impl Storage { target_id BLOB PRIMARY KEY, failed_at INTEGER NOT NULL ); + CREATE TABLE IF NOT EXISTS preferred_peers ( + node_id BLOB PRIMARY KEY, + agreed_at INTEGER NOT NULL + ); CREATE TABLE IF NOT EXISTS circle_profiles ( author BLOB NOT NULL, circle_name TEXT NOT NULL, @@ -391,34 +366,9 @@ impl Storage { group_sig BLOB, encrypted_payload BLOB, deleted_at INTEGER, - -- v0.8 (A3): absolute expiry (ms). NULL/0 = legacy no-TTL. - expires_at INTEGER, - -- v0.8 (C): 1 = WE authored this comment locally. Locally - -- authored comment authors (our personas, our per-greeting - -- throwaway IDs) must never enter our own uniques announce — - -- announcing them at bounce 1 next to our node ID is a - -- persona-to-device linkage (design.html §21). - origin_local INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (author, post_id, timestamp_ms) ); CREATE INDEX IF NOT EXISTS idx_comments_post ON comments(post_id); - -- v0.8 (A3): local-only greeting inbox dismissals. - CREATE TABLE IF NOT EXISTS greeting_dismissals ( - comment_author BLOB NOT NULL, - post_id BLOB NOT NULL, - timestamp_ms INTEGER NOT NULL, - dismissed_at INTEGER NOT NULL, - PRIMARY KEY (comment_author, post_id, timestamp_ms) - ); - -- v0.8 (A3): private halves of per-greeting reply x25519 keys, - -- keyed by the reply pubkey we told the recipient to seal to. - -- return_post_id = our return-path post (own bio) at mint time. - CREATE TABLE IF NOT EXISTS greeting_reply_keys ( - reply_pubkey BLOB PRIMARY KEY, - reply_privkey BLOB NOT NULL, - return_post_id BLOB NOT NULL, - created_at INTEGER NOT NULL - ); CREATE TABLE IF NOT EXISTS comment_policies ( post_id BLOB PRIMARY KEY, policy_json TEXT NOT NULL @@ -706,34 +656,6 @@ impl Storage { "ALTER TABLE routing_peers RENAME TO mesh_peers;" ); - // v0.8 Iteration C: mesh_peers loses slot_kind (Local/Wide collapsed into - // one pool) and priority (the preferred-tier rank, dead since Iteration B). - // init_tables() uses CREATE TABLE IF NOT EXISTS, so an existing DB keeps - // the old 5-column table and never sees the new DDL — the drop+create has - // to happen HERE (migrate runs after init_tables, before migrate_data). - // Safe to drop outright: node.rs clears the whole table on every open. - let has_slot_kind = self.conn.prepare( - "SELECT COUNT(*) FROM pragma_table_info('mesh_peers') WHERE name='slot_kind'" - )?.query_row([], |row| row.get::<_, i64>(0))?; - if has_slot_kind > 0 { - self.conn.execute_batch( - "DROP TABLE IF EXISTS mesh_peers; - CREATE TABLE mesh_peers ( - node_id BLOB NOT NULL PRIMARY KEY, - connected_at INTEGER NOT NULL, - last_diff_seq INTEGER NOT NULL DEFAULT 0 - );" - )?; - } - - // v0.8 Iteration C: reachable_n2/reachable_n3 → one `reachable` table - // parametrised by bounce (2/3/4). The N-layer index is rebuilt from - // scratch on every handshake, so there is nothing worth carrying over. - let _ = self.conn.execute_batch( - "DROP TABLE IF EXISTS reachable_n2; - DROP TABLE IF EXISTS reachable_n3;" - ); - // Add post_id/author/created_at/last_accessed_at columns to blobs if missing // (blobs are recoverable from filesystem + posts, so drop-and-recreate is safe) let has_blob_post_id = self.conn.prepare( @@ -766,10 +688,25 @@ impl Storage { )?; } - // v0.8: preferred-peer tier removed — drop the agreement table. - // (The unused profiles.preferred_peers / social_routes.preferred_tree - // columns are left in place on old dirs; no code reads them.) - self.conn.execute_batch("DROP TABLE IF EXISTS preferred_peers;")?; + // Add preferred_peers column to profiles if missing (Preferred Mesh Peers migration) + let has_preferred_peers = self.conn.prepare( + "SELECT COUNT(*) FROM pragma_table_info('profiles') WHERE name='preferred_peers'" + )?.query_row([], |row| row.get::<_, i64>(0))?; + if has_preferred_peers == 0 { + self.conn.execute_batch( + "ALTER TABLE profiles ADD COLUMN preferred_peers TEXT NOT NULL DEFAULT '[]';" + )?; + } + + // Add preferred_tree column to social_routes if missing (Preferred Tree migration) + let has_pref_tree = self.conn.prepare( + "SELECT COUNT(*) FROM pragma_table_info('social_routes') WHERE name='preferred_tree'" + )?.query_row([], |row| row.get::<_, i64>(0))?; + if has_pref_tree == 0 { + self.conn.execute_batch( + "ALTER TABLE social_routes ADD COLUMN preferred_tree TEXT NOT NULL DEFAULT '[]';" + )?; + } // Add public_visible column to profiles if missing (Phase D-4 migration) let has_public_visible = self.conn.prepare( @@ -863,29 +800,6 @@ impl Storage { )?; } - // v0.8 (A3): add expires_at for comment TTL (rand 30–365d ordinary, - // fixed 30d registry entries). NULL/0 = legacy comment without TTL. - let has_expires_at = self.conn.prepare( - "SELECT COUNT(*) FROM pragma_table_info('comments') WHERE name='expires_at'" - )?.query_row([], |row| row.get::<_, i64>(0))?; - if has_expires_at == 0 { - self.conn.execute_batch( - "ALTER TABLE comments ADD COLUMN expires_at INTEGER DEFAULT NULL;" - )?; - } - - // v0.8 (C): mark locally-authored comments so `build_own_uniques` can - // exclude their authors. Our own personas and per-greeting throwaway - // IDs must never ride our N1 slice — see §21 persona/device split. - let has_origin_local = self.conn.prepare( - "SELECT COUNT(*) FROM pragma_table_info('comments') WHERE name='origin_local'" - )?.query_row([], |row| row.get::<_, i64>(0))?; - if has_origin_local == 0 { - self.conn.execute_batch( - "ALTER TABLE comments ADD COLUMN origin_local INTEGER NOT NULL DEFAULT 0;" - )?; - } - // v0.6.2: add canonical_root_post_id to group_keys. When set, the // record is a group (many-way, anchored at a public root post); // when NULL it's a traditional circle (one-way, admin-only). @@ -907,6 +821,65 @@ impl Storage { "CREATE INDEX IF NOT EXISTS idx_group_keys_root ON group_keys(canonical_root_post_id);" )?; + // v0.6.2 import bugfix: the import pipeline up through f b0e293 + // filed EVERY encrypted post as VisibilityIntent::Friends, including + // DMs. The Messages tab in the UI only shows posts whose intent is + // `Direct` (or `unknown` with the right visibility shape), so DMs + // imported from an "everything" bundle silently disappeared. + // + // This one-time migration reclassifies short-recipient encrypted + // posts filed as Friends back to Direct, using the same small-list + // heuristic as the import code (recipients <= 3 → Direct). A guard + // flag in the settings kv ensures this sweep runs once per DB. + let imported_dm_fixup_done = self.get_setting("mig_import_dm_fixup_v1")?.is_some(); + if !imported_dm_fixup_done { + let mut fixed = 0i64; + let mut stmt = self.conn.prepare( + "SELECT id, visibility FROM posts + WHERE visibility_intent = '\"Friends\"' + AND visibility LIKE '%Encrypted%'" + )?; + let rows: Vec<(Vec, String)> = stmt + .query_map([], |row| Ok((row.get::<_, Vec>(0)?, row.get::<_, String>(1)?)))? + .filter_map(|r| r.ok()) + .collect(); + drop(stmt); + for (id_bytes, vis_json) in rows { + // Parse out the recipients array from the Encrypted + // visibility. Short recipient list (≤ 3) = likely DM. + let vis: Result = serde_json::from_str(&vis_json); + let should_fix = match vis { + Ok(PostVisibility::Encrypted { recipients }) => recipients.len() <= 3, + _ => false, + }; + if !should_fix { continue; } + // Build a Direct-intent value with the recipient list recovered + // from the visibility — semantically equivalent to what the + // user's original send-side intent would have been. + let vis_parsed: PostVisibility = match serde_json::from_str(&vis_json) { + Ok(v) => v, + Err(_) => continue, + }; + let recipients: Vec = match vis_parsed { + PostVisibility::Encrypted { recipients } => { + recipients.iter().map(|wk| wk.recipient).collect() + } + _ => continue, + }; + let intent = crate::types::VisibilityIntent::Direct(recipients); + let intent_json = serde_json::to_string(&intent).unwrap_or_default(); + let n = self.conn.execute( + "UPDATE posts SET visibility_intent = ?1 WHERE id = ?2", + params![intent_json, id_bytes], + )?; + fixed += n as i64; + } + self.set_setting("mig_import_dm_fixup_v1", &fixed.to_string())?; + if fixed > 0 { + tracing::info!(count = fixed, "Migrated imported DMs from Friends-intent to Direct-intent"); + } + } + // Add device_role column to peers if missing (Active CDN replication) let has_device_role = self.conn.prepare( "SELECT COUNT(*) FROM pragma_table_info('peers') WHERE name='device_role'" @@ -939,6 +912,22 @@ impl Storage { )?; } + // 0.6.1-beta: seed file_holders from legacy upstream/downstream tables + // before they're dropped. Idempotent — only fires on an empty + // file_holders table. + self.seed_file_holders_from_legacy()?; + + // 0.6.1-beta: drop legacy directional tables — replaced by file_holders. + self.conn.execute_batch( + "DROP TABLE IF EXISTS blob_upstream; + DROP TABLE IF EXISTS blob_downstream; + DROP TABLE IF EXISTS post_upstream; + DROP TABLE IF EXISTS post_downstream;", + )?; + + // 0.6.2-beta: seed post_recipients index from existing encrypted posts. + self.seed_post_recipients_from_posts()?; + // FoF Layer 2: add comment columns for pub_x_index / group_sig / // encrypted_payload. Old DBs have NULL → deserializes to None. let has_comment_pub_x = self.conn.prepare( @@ -1260,29 +1249,20 @@ impl Storage { } /// Batch: reaction counts for multiple posts at once - /// `our_ids` = ALL of the local node's posting identities (reactors are - /// posting ids — the network NodeId never reacts). - pub fn get_reaction_counts_batch(&self, post_ids: &[PostId], our_ids: &[NodeId]) -> anyhow::Result>> { + pub fn get_reaction_counts_batch(&self, post_ids: &[PostId], our_node_id: &NodeId) -> anyhow::Result>> { use std::collections::HashMap; let mut result: HashMap> = HashMap::new(); if post_ids.is_empty() { return Ok(result); } let placeholders: String = (0..post_ids.len()).map(|i| format!("?{}", i + 1)).collect::>().join(","); - let mine_placeholders: String = if our_ids.is_empty() { - "NULL".to_string() - } else { - (0..our_ids.len()).map(|i| format!("?{}", post_ids.len() + 1 + i)).collect::>().join(",") - }; let sql = format!( - "SELECT post_id, emoji, COUNT(*) as cnt, SUM(CASE WHEN reactor IN ({}) THEN 1 ELSE 0 END) as my_count + "SELECT post_id, emoji, COUNT(*) as cnt, SUM(CASE WHEN reactor = ?{} THEN 1 ELSE 0 END) as my_count FROM reactions WHERE post_id IN ({}) AND deleted_at IS NULL GROUP BY post_id, emoji ORDER BY cnt DESC", - mine_placeholders, placeholders + post_ids.len() + 1, placeholders ); let mut stmt = self.conn.prepare(&sql)?; let mut params: Vec> = post_ids.iter().map(|id| Box::new(id.to_vec()) as Box).collect(); - for oid in our_ids { - params.push(Box::new(oid.to_vec())); - } + params.push(Box::new(our_node_id.to_vec())); let param_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect(); let rows = stmt.query_map(param_refs.as_slice(), |row| { let pid: Vec = row.get(0)?; @@ -1565,7 +1545,7 @@ impl Storage { pub fn list_discoverable_profiles(&self, self_id: &NodeId) -> anyhow::Result> { let mut stmt = self.conn.prepare( "SELECT p.node_id, p.display_name, p.bio, p.updated_at, - p.anchors, p.recent_peers, + p.anchors, p.recent_peers, p.preferred_peers, p.public_visible, p.avatar_cid FROM profiles p WHERE p.display_name != '' @@ -1584,8 +1564,9 @@ impl Storage { let updated_at = row.get::<_, i64>(3)? as u64; let anchors = parse_anchors_json(&row.get::<_, String>(4).unwrap_or_default()); let recent_peers = parse_anchors_json(&row.get::<_, String>(5).unwrap_or_default()); - let public_visible: i64 = row.get(6).unwrap_or(1); - let avatar_cid: Option> = row.get(7).ok(); + let preferred_peers = parse_anchors_json(&row.get::<_, String>(6).unwrap_or_default()); + let public_visible: i64 = row.get(7).unwrap_or(1); + let avatar_cid: Option> = row.get(8).ok(); let avatar_cid = avatar_cid.and_then(|v| if v.len() == 32 { let mut arr = [0u8; 32]; arr.copy_from_slice(&v); Some(arr) } else { None }); @@ -1596,6 +1577,7 @@ impl Storage { updated_at, anchors, recent_peers, + preferred_peers, public_visible: public_visible != 0, avatar_cid, }); @@ -1897,10 +1879,8 @@ impl Storage { /// Get a random N2 stranger (node in reachable_n2 but not in our connections) /// Returns (witness_node_id, reporter_node_id) for anchor probe pub fn random_n2_stranger(&self, our_connections: &std::collections::HashSet) -> anyhow::Result> { - // id_class = 0: an author/persona ID cannot act as a probe witness. let mut stmt = self.conn.prepare( - "SELECT reachable_id, reporter_node_id FROM reachable - WHERE bounce = 2 AND id_class = 0 ORDER BY RANDOM() LIMIT 10" + "SELECT reachable_node_id, reporter_node_id FROM reachable_n2 ORDER BY RANDOM() LIMIT 10" )?; let rows = stmt.query_map([], |row| { let rn: Vec = row.get(0)?; @@ -1990,9 +1970,12 @@ impl Storage { let recent_peers_json = serde_json::to_string( &profile.recent_peers.iter().map(hex::encode).collect::>() )?; + let preferred_peers_json = serde_json::to_string( + &profile.preferred_peers.iter().map(hex::encode).collect::>() + )?; let avatar_cid_slice = profile.avatar_cid.as_ref().map(|c| c.as_slice()); self.conn.execute( - "INSERT OR REPLACE INTO profiles (node_id, display_name, bio, updated_at, anchors, recent_peers, public_visible, avatar_cid) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + "INSERT OR REPLACE INTO profiles (node_id, display_name, bio, updated_at, anchors, recent_peers, preferred_peers, public_visible, avatar_cid) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", params![ profile.node_id.as_slice(), profile.display_name, @@ -2000,6 +1983,7 @@ impl Storage { profile.updated_at as i64, anchors_json, recent_peers_json, + preferred_peers_json, profile.public_visible as i64, avatar_cid_slice, ], @@ -2138,14 +2122,15 @@ impl Storage { /// Get a profile by node ID pub fn get_profile(&self, node_id: &NodeId) -> anyhow::Result> { let mut stmt = self.conn.prepare( - "SELECT node_id, display_name, bio, updated_at, anchors, recent_peers, public_visible, avatar_cid FROM profiles WHERE node_id = ?1", + "SELECT node_id, display_name, bio, updated_at, anchors, recent_peers, preferred_peers, public_visible, avatar_cid FROM profiles WHERE node_id = ?1", )?; let mut rows = stmt.query(params![node_id.as_slice()])?; if let Some(row) = rows.next()? { let anchors = parse_anchors_json(&row.get::<_, String>(4)?); let recent_peers = parse_anchors_json(&row.get::<_, String>(5).unwrap_or_else(|_| "[]".to_string())); - let public_visible = row.get::<_, i64>(6).unwrap_or(1) != 0; - let avatar_cid = row.get::<_, Option>>(7).unwrap_or(None) + let preferred_peers = parse_anchors_json(&row.get::<_, String>(6).unwrap_or_else(|_| "[]".to_string())); + let public_visible = row.get::<_, i64>(7).unwrap_or(1) != 0; + let avatar_cid = row.get::<_, Option>>(8).unwrap_or(None) .and_then(|b| <[u8; 32]>::try_from(b.as_slice()).ok()); Ok(Some(PublicProfile { node_id: blob_to_nodeid(row.get(0)?)?, @@ -2154,6 +2139,7 @@ impl Storage { updated_at: row.get::<_, i64>(3)? as u64, anchors, recent_peers, + preferred_peers, public_visible, avatar_cid, })) @@ -2166,7 +2152,7 @@ impl Storage { pub fn list_profiles(&self) -> anyhow::Result> { let mut stmt = self .conn - .prepare("SELECT node_id, display_name, bio, updated_at, anchors, recent_peers, public_visible, avatar_cid FROM profiles")?; + .prepare("SELECT node_id, display_name, bio, updated_at, anchors, recent_peers, preferred_peers, public_visible, avatar_cid FROM profiles")?; let rows = stmt.query_map([], |row| { let node_id_bytes: Vec = row.get(0)?; let display_name: String = row.get(1)?; @@ -2174,13 +2160,14 @@ impl Storage { let updated_at: i64 = row.get(3)?; let anchors_json: String = row.get(4)?; let recent_peers_json: String = row.get::<_, String>(5).unwrap_or_else(|_| "[]".to_string()); - let public_visible: i64 = row.get::<_, i64>(6).unwrap_or(1); - let avatar_cid_bytes: Option> = row.get::<_, Option>>(7).unwrap_or(None); - Ok((node_id_bytes, display_name, bio, updated_at, anchors_json, recent_peers_json, public_visible, avatar_cid_bytes)) + let preferred_peers_json: String = row.get::<_, String>(6).unwrap_or_else(|_| "[]".to_string()); + let public_visible: i64 = row.get::<_, i64>(7).unwrap_or(1); + let avatar_cid_bytes: Option> = row.get::<_, Option>>(8).unwrap_or(None); + Ok((node_id_bytes, display_name, bio, updated_at, anchors_json, recent_peers_json, preferred_peers_json, public_visible, avatar_cid_bytes)) })?; let mut profiles = Vec::new(); for row in rows { - let (node_id_bytes, display_name, bio, updated_at, anchors_json, recent_peers_json, public_visible, avatar_cid_bytes) = row?; + let (node_id_bytes, display_name, bio, updated_at, anchors_json, recent_peers_json, preferred_peers_json, public_visible, avatar_cid_bytes) = row?; let avatar_cid = avatar_cid_bytes.and_then(|b| <[u8; 32]>::try_from(b.as_slice()).ok()); profiles.push(PublicProfile { node_id: blob_to_nodeid(node_id_bytes)?, @@ -2189,6 +2176,7 @@ impl Storage { updated_at: updated_at as u64, anchors: parse_anchors_json(&anchors_json), recent_peers: parse_anchors_json(&recent_peers_json), + preferred_peers: parse_anchors_json(&preferred_peers_json), public_visible: public_visible != 0, avatar_cid, }); @@ -2220,84 +2208,9 @@ impl Storage { Ok(records) } - /// Anchor-flagged peers we have actually been in contact with recently. - /// - /// `build_own_uniques` uses this (never the unbounded `list_anchor_peers`) - /// so a stale one-way `is_anchor` latch cannot keep re-advertising an - /// address we have not touched in months. - pub fn list_anchor_peers_seen_since(&self, cutoff_ms: i64) -> anyhow::Result> { - let mut stmt = self.conn.prepare( - "SELECT node_id, addresses, last_seen, introduced_by, is_anchor, first_seen - FROM peers WHERE is_anchor = 1 AND last_seen >= ?1 ORDER BY last_seen DESC", - )?; - let mut records = Vec::new(); - let mut rows = stmt.query(params![cutoff_ms])?; - while let Some(row) = rows.next()? { - records.push(row_to_peer_record(row)?); - } - Ok(records) - } + // ---- Known anchors (persistent anchor cache for NAT traversal) ---- - /// Merge addresses into a peer row WITHOUT dropping what is already there. - /// - /// `upsert_peer` REPLACES the address list, which is right when we have - /// just connected on those addresses and wrong for anything a peer merely - /// claims: a claimed address that replaced a working one is bugs-fixed #5 - /// (stale/wrong addresses block reconnection), remotely triggerable. Union - /// semantics keep every address we have ever had for the node, newest - /// first, capped so the row cannot be inflated without bound. - pub fn add_peer_addresses( - &self, - node_id: &NodeId, - addresses: &[SocketAddr], - ) -> anyhow::Result<()> { - const MAX_PEER_ADDRESSES: usize = 8; - if addresses.is_empty() { - return Ok(()); - } - // EXISTING FIRST, claims appended. `upsert_peer` writes the addresses we - // actually connected on, so position 0 stays the proven one — callers - // that take `.first()` keep working, and no volume of claims can push - // a working address out under the cap (bugs-fixed #5). - let mut merged: Vec = self - .get_peer_record(node_id)? - .map(|r| r.addresses.iter().map(|a| a.to_string()).collect()) - .unwrap_or_default(); - for a in addresses { - let s = a.to_string(); - if !merged.contains(&s) { - merged.push(s); - } - } - merged.truncate(MAX_PEER_ADDRESSES); - let now = now_ms(); - let addrs_json = serde_json::to_string(&merged)?; - self.conn.execute( - "INSERT INTO peers (node_id, addresses, last_seen, introduced_by, first_seen) - VALUES (?1, ?2, ?3, NULL, ?3) - ON CONFLICT(node_id) DO UPDATE SET addresses = ?2, last_seen = ?3", - params![node_id.as_slice(), addrs_json, now], - )?; - Ok(()) - } - - // ---- Known anchors — the BOOTSTRAP CACHE ---- - // - // v0.8 (round-4 ruling): this table is demoted. The uniques pools are the - // anchor directory (anchor entries are the only address-bearing rows, so - // `list_pool_anchors` supplies anchors in bulk and for free); `known_anchors` - // exists only to survive a cold start with an empty index. `success_count` - // ranking was a v0.7.x scarcity artifact — under pool-mined abundance, - // "the anchor that answered most often" is not a better anchor, it is just - // the one we hammered. Ordering is now by recency, which at least means - // "most likely to still be at that address". - - /// Cap on the bootstrap cache. Raised from 5 because `apply_uniques_- - /// to_storage` now mirrors every received anchor entry here — at 5 the - /// table would thrash on every announce. - pub const KNOWN_ANCHOR_CACHE_CAP: usize = 32; - - /// Upsert a known anchor. Auto-prunes to `KNOWN_ANCHOR_CACHE_CAP`. + /// Upsert a known anchor. Increments success_count on conflict. Auto-prunes to 5. pub fn upsert_known_anchor(&self, node_id: &NodeId, addresses: &[SocketAddr]) -> anyhow::Result<()> { let addr_json = serde_json::to_string( &addresses.iter().map(|a| a.to_string()).collect::>(), @@ -2310,19 +2223,18 @@ impl Storage { addresses = ?2, last_seen_ms = ?3, success_count = success_count + 1", params![node_id.as_slice(), addr_json, now], )?; - self.prune_known_anchors(Self::KNOWN_ANCHOR_CACHE_CAP)?; + self.prune_known_anchors(5)?; Ok(()) } - /// List the bootstrap cache, freshest first. (Not success_count — see the - /// section comment above.) + /// List known anchors, ordered by success_count descending. pub fn list_known_anchors(&self) -> anyhow::Result)>> { let mut stmt = self.conn.prepare( "SELECT node_id, addresses FROM known_anchors - ORDER BY last_seen_ms DESC LIMIT ?1", + ORDER BY success_count DESC LIMIT 5", )?; let mut result = Vec::new(); - let mut rows = stmt.query(params![Self::KNOWN_ANCHOR_CACHE_CAP as i64])?; + let mut rows = stmt.query([])?; while let Some(row) = rows.next()? { let node_id = blob_to_nodeid(row.get(0)?)?; let addr_json: String = row.get(1)?; @@ -2363,9 +2275,7 @@ impl Storage { Ok(()) } - /// Prune the bootstrap cache to at most `max` entries, dropping the - /// stalest first. (The 3-day stale-anchor self-prune on failed probes — - /// `delete_known_anchor` via `maybe_prune_stale_anchor` — is unchanged.) + /// Prune known anchors to keep at most `max` entries (by highest success_count). pub fn prune_known_anchors(&self, max: usize) -> anyhow::Result { let count: i64 = self.conn.query_row( "SELECT COUNT(*) FROM known_anchors", @@ -2379,7 +2289,7 @@ impl Storage { self.conn.execute( "DELETE FROM known_anchors WHERE node_id IN ( SELECT node_id FROM known_anchors - ORDER BY last_seen_ms ASC + ORDER BY success_count ASC, last_seen_ms ASC LIMIT ?1 )", params![excess as i64], @@ -2647,18 +2557,15 @@ impl Storage { } } - /// Resolve display info for a peer: check circle profiles any of the - /// viewer's posting identities belongs to, then fall back to public - /// profile. `viewers` = ALL of the local node's posting identities - /// (circle_members holds posting ids, never the network NodeId). + /// Resolve display info for a peer: check circle profiles the viewer belongs to, + /// then fall back to public profile. /// Returns (display_name, bio, avatar_cid). pub fn resolve_display_for_peer( &self, author: &NodeId, - viewers: &[NodeId], + viewer: &NodeId, ) -> anyhow::Result<(String, String, Option<[u8; 32]>)> { - // Find circles where a viewer persona is a member and author has a - // circle profile; take the freshest match across all personas. + // Find circles where viewer is a member and author has a circle profile let mut stmt = self.conn.prepare( "SELECT cp.display_name, cp.bio, cp.avatar_cid, cp.updated_at FROM circle_profiles cp @@ -2667,26 +2574,17 @@ impl Storage { ORDER BY cp.updated_at DESC LIMIT 1", )?; - let mut best: Option<(String, String, Option<[u8; 32]>, u64)> = None; - for viewer in viewers { - let mut rows = stmt.query(params![author.as_slice(), viewer.as_slice()])?; - if let Some(row) = rows.next()? { - let dn: String = row.get(0)?; - // Only use circle profile if it has actual content - if !dn.is_empty() { - let bio: String = row.get(1)?; - let avatar_cid = row.get::<_, Option>>(2).unwrap_or(None) - .and_then(|b| <[u8; 32]>::try_from(b.as_slice()).ok()); - let updated_at = row.get::<_, i64>(3).unwrap_or(0) as u64; - if best.as_ref().map(|b| updated_at > b.3).unwrap_or(true) { - best = Some((dn, bio, avatar_cid, updated_at)); - } - } + let mut rows = stmt.query(params![author.as_slice(), viewer.as_slice()])?; + if let Some(row) = rows.next()? { + let dn: String = row.get(0)?; + // Only use circle profile if it has actual content + if !dn.is_empty() { + let bio: String = row.get(1)?; + let avatar_cid = row.get::<_, Option>>(2).unwrap_or(None) + .and_then(|b| <[u8; 32]>::try_from(b.as_slice()).ok()); + return Ok((dn, bio, avatar_cid)); } } - if let Some((dn, bio, avatar_cid, _)) = best { - return Ok((dn, bio, avatar_cid)); - } // Fall back to public profile if let Some(profile) = self.get_profile(author)? { @@ -3520,562 +3418,276 @@ impl Storage { Ok(count > 0) } - // ---- Reach: the bounce-parametrised uniques index ---- + // ---- Reach: N2/N3 ---- - /// Replace a reporter's ENTIRE contribution at one bounce. - /// - /// Dedup is free at the store: the composite PK `(reporter, reachable_id)` - /// gives per-reporter set-merge, and the conflict clause keeps the - /// SHALLOWEST bounce we have ever heard from this reporter — so an ID can - /// never occupy two depths from one reporter (the ambiguity the old - /// n2/n3-table pair papered over by sorting). - /// - /// DELETE + inserts run in ONE transaction. Thousands of individually - /// committed inserts (an announce is sized in thousands of rows, once per - /// peer per cycle across ~20 peers) is the dominant cost of the whole - /// uniques exchange; one commit makes it a rounding error. - pub fn set_reach( - &self, - reporter: &NodeId, - bounce: u8, - entries: &[ReachEntry], - ) -> anyhow::Result<()> { - let tx = self.conn.unchecked_transaction()?; + /// Replace a peer's entire N1 set in reachable_n2 (their N1 share → our N2). + pub fn set_peer_n1(&self, reporter: &NodeId, node_ids: &[NodeId]) -> anyhow::Result<()> { + let now = now_ms(); self.conn.execute( - "DELETE FROM reachable WHERE reporter_node_id = ?1 AND bounce = ?2", - params![reporter.as_slice(), bounce as i64], + "DELETE FROM reachable_n2 WHERE reporter_node_id = ?1", + params![reporter.as_slice()], )?; - self.add_reach_inner(reporter, bounce, entries)?; - tx.commit()?; + let mut stmt = self.conn.prepare( + "INSERT OR REPLACE INTO reachable_n2 (reporter_node_id, reachable_node_id, updated_at) VALUES (?1, ?2, ?3)", + )?; + for nid in node_ids { + stmt.execute(params![reporter.as_slice(), nid.as_slice(), now])?; + } Ok(()) } - /// Merge entries into a reporter's contribution at one bounce. - pub fn add_reach( - &self, - reporter: &NodeId, - bounce: u8, - entries: &[ReachEntry], - ) -> anyhow::Result<()> { - if entries.is_empty() { - return Ok(()); - } - let tx = self.conn.unchecked_transaction()?; - self.add_reach_inner(reporter, bounce, entries)?; - tx.commit()?; - Ok(()) - } - - fn add_reach_inner( - &self, - reporter: &NodeId, - bounce: u8, - entries: &[ReachEntry], - ) -> anyhow::Result<()> { - if entries.is_empty() { - return Ok(()); - } + /// Add NodeIds to a peer's N1 set in reachable_n2. + pub fn add_peer_n1(&self, reporter: &NodeId, node_ids: &[NodeId]) -> anyhow::Result<()> { let now = now_ms(); let mut stmt = self.conn.prepare( - "INSERT INTO reachable - (reporter_node_id, reachable_id, bounce, id_class, is_anchor, addresses, updated_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) - ON CONFLICT(reporter_node_id, reachable_id) DO UPDATE SET - bounce = MIN(bounce, excluded.bounce), - id_class = excluded.id_class, - is_anchor = MAX(is_anchor, excluded.is_anchor), - addresses = CASE WHEN excluded.is_anchor = 1 THEN excluded.addresses ELSE addresses END, - updated_at = excluded.updated_at", + "INSERT OR REPLACE INTO reachable_n2 (reporter_node_id, reachable_node_id, updated_at) VALUES (?1, ?2, ?3)", )?; - for e in entries { - let addrs_json = if e.is_anchor && !e.addresses.is_empty() { - serde_json::to_string(&e.addresses).unwrap_or_else(|_| "[]".into()) - } else { - "[]".to_string() - }; - stmt.execute(params![ - reporter.as_slice(), - e.id.as_slice(), - bounce as i64, - e.id_class.as_i64(), - if e.is_anchor { 1i64 } else { 0i64 }, - addrs_json, - now, - ])?; + for nid in node_ids { + stmt.execute(params![reporter.as_slice(), nid.as_slice(), now])?; } Ok(()) } - /// Remove all entries a reporter contributed (on disconnect). - pub fn clear_peer_reach(&self, reporter: &NodeId) -> anyhow::Result { + /// Remove NodeIds from a peer's N1 set in reachable_n2. + pub fn remove_peer_n1(&self, reporter: &NodeId, node_ids: &[NodeId]) -> anyhow::Result<()> { + let mut stmt = self.conn.prepare( + "DELETE FROM reachable_n2 WHERE reporter_node_id = ?1 AND reachable_node_id = ?2", + )?; + for nid in node_ids { + stmt.execute(params![reporter.as_slice(), nid.as_slice()])?; + } + Ok(()) + } + + /// Remove all N2 entries from a specific reporter (on disconnect). + pub fn clear_peer_n2(&self, reporter: &NodeId) -> anyhow::Result { let deleted = self.conn.execute( - "DELETE FROM reachable WHERE reporter_node_id = ?1", + "DELETE FROM reachable_n2 WHERE reporter_node_id = ?1", params![reporter.as_slice()], )?; Ok(deleted) } - /// Which reporters place this ID at exactly this bounce? - pub fn find_reporters_at(&self, id: &NodeId, bounce: u8) -> anyhow::Result> { + /// Replace a peer's N2-reported entries in reachable_n3 (their N2 share → our N3). + pub fn set_peer_n2(&self, reporter: &NodeId, node_ids: &[NodeId]) -> anyhow::Result<()> { + let now = now_ms(); + self.conn.execute( + "DELETE FROM reachable_n3 WHERE reporter_node_id = ?1", + params![reporter.as_slice()], + )?; let mut stmt = self.conn.prepare( - "SELECT reporter_node_id FROM reachable WHERE reachable_id = ?1 AND bounce = ?2", + "INSERT OR REPLACE INTO reachable_n3 (reporter_node_id, reachable_node_id, updated_at) VALUES (?1, ?2, ?3)", + )?; + for nid in node_ids { + stmt.execute(params![reporter.as_slice(), nid.as_slice(), now])?; + } + Ok(()) + } + + /// Add to N3 from a peer's N2 changes. + pub fn add_peer_n2(&self, reporter: &NodeId, node_ids: &[NodeId]) -> anyhow::Result<()> { + let now = now_ms(); + let mut stmt = self.conn.prepare( + "INSERT OR REPLACE INTO reachable_n3 (reporter_node_id, reachable_node_id, updated_at) VALUES (?1, ?2, ?3)", + )?; + for nid in node_ids { + stmt.execute(params![reporter.as_slice(), nid.as_slice(), now])?; + } + Ok(()) + } + + /// Remove from N3. + pub fn remove_peer_n2(&self, reporter: &NodeId, node_ids: &[NodeId]) -> anyhow::Result<()> { + let mut stmt = self.conn.prepare( + "DELETE FROM reachable_n3 WHERE reporter_node_id = ?1 AND reachable_node_id = ?2", + )?; + for nid in node_ids { + stmt.execute(params![reporter.as_slice(), nid.as_slice()])?; + } + Ok(()) + } + + /// Remove all N3 entries from a specific reporter. + pub fn clear_peer_n3(&self, reporter: &NodeId) -> anyhow::Result { + let deleted = self.conn.execute( + "DELETE FROM reachable_n3 WHERE reporter_node_id = ?1", + params![reporter.as_slice()], + )?; + Ok(deleted) + } + + /// Which reporters have this node in N2? + pub fn find_in_n2(&self, node_id: &NodeId) -> anyhow::Result> { + let mut stmt = self.conn.prepare( + "SELECT reporter_node_id FROM reachable_n2 WHERE reachable_node_id = ?1", )?; let mut result = Vec::new(); - let mut rows = stmt.query(params![id.as_slice(), bounce as i64])?; + let mut rows = stmt.query(params![node_id.as_slice()])?; while let Some(row) = rows.next()? { result.push(blob_to_nodeid(row.get(0)?)?); } Ok(result) } - /// Which reporters have this ID in our N2 (bounce 2)? - pub fn find_in_n2(&self, node_id: &NodeId) -> anyhow::Result> { - self.find_reporters_at(node_id, 2) - } - - /// Which reporters have this ID in our N3 (bounce 3)? + /// Which reporters have this node in N3? pub fn find_in_n3(&self, node_id: &NodeId) -> anyhow::Result> { - self.find_reporters_at(node_id, 3) + let mut stmt = self.conn.prepare( + "SELECT reporter_node_id FROM reachable_n3 WHERE reachable_node_id = ?1", + )?; + let mut result = Vec::new(); + let mut rows = stmt.query(params![node_id.as_slice()])?; + while let Some(row) = rows.next()? { + result.push(blob_to_nodeid(row.get(0)?)?); + } + Ok(result) } - /// Which reporters have this ID in our N4 (bounce 4)? - /// - /// N4 is USED (search, address resolution) but NEVER re-announced — - /// `build_uniques_pools` simply does not read bounce-4 rows. - pub fn find_in_n4(&self, node_id: &NodeId) -> anyhow::Result> { - self.find_reporters_at(node_id, 4) - } - - /// Batch lookup across the whole stored horizon (bounces 2..4). - /// Returns Vec<(target, reporter, bounce)> sorted shallowest-first. - /// Node-class only — an author ID is not a routing target. - pub fn find_any_reachable(&self, ids: &[NodeId]) -> anyhow::Result> { + /// Batch lookup: find any of the given node IDs in N2 or N3. + /// Returns Vec<(target, reporter, level)> where level is 2 or 3, sorted by level ASC. + pub fn find_any_in_n2_n3(&self, ids: &[NodeId]) -> anyhow::Result> { if ids.is_empty() { return Ok(vec![]); } - let mut stmt = self.conn.prepare( - "SELECT reporter_node_id, bounce FROM reachable - WHERE reachable_id = ?1 AND id_class = 0 ORDER BY bounce ASC", - )?; let mut results = Vec::new(); + // Check N2 first (closer) for id in ids { - let mut rows = stmt.query(params![id.as_slice()])?; - while let Some(row) = rows.next()? { - let reporter = blob_to_nodeid(row.get(0)?)?; - let bounce: i64 = row.get(1)?; - results.push((*id, reporter, bounce as u8)); + let reporters = self.find_in_n2(id)?; + for r in reporters { + results.push((*id, r, 2u8)); } } - results.sort_by_key(|&(_, _, bounce)| bounce); + // Then N3 + for id in ids { + let reporters = self.find_in_n3(id)?; + for r in reporters { + results.push((*id, r, 3u8)); + } + } + results.sort_by_key(|&(_, _, level)| level); Ok(results) } - /// Everything a reporter contributed at one bounce. - pub fn list_reach_for_reporter( - &self, - reporter: &NodeId, - bounce: u8, - ) -> anyhow::Result> { + /// All NodeIds this peer can reach (from N2 table). + pub fn list_n2_for_reporter(&self, reporter: &NodeId) -> anyhow::Result> { let mut stmt = self.conn.prepare( - "SELECT reachable_id FROM reachable WHERE reporter_node_id = ?1 AND bounce = ?2", + "SELECT reachable_node_id FROM reachable_n2 WHERE reporter_node_id = ?1", )?; let mut result = Vec::new(); - let mut rows = stmt.query(params![reporter.as_slice(), bounce as i64])?; + let mut rows = stmt.query(params![reporter.as_slice()])?; while let Some(row) = rows.next()? { result.push(blob_to_nodeid(row.get(0)?)?); } Ok(result) } - /// Distinct IDs stored at a bounce, with their class/anchor metadata. - /// - /// One row per ID is selected EXPLICITLY (anchor rows first, then freshest) - /// rather than relying on SQLite's bare-column-with-MAX() quirk to keep - /// `id_class`, `is_anchor` and `addresses` from the same physical row. A - /// future query rewrite or a second aggregate would silently break that - /// pairing, and the thing it pairs is "is this ID address-bearing" — the - /// invariant the whole announce format rests on. - /// - /// `limit` bounds the result deterministically so the announce builder - /// cannot emit an unbounded slice; ordering is stable across cycles - /// (anchors, then freshest) so the announce digest does not thrash. - pub fn list_reach_entries_at(&self, bounce: u8) -> anyhow::Result> { - self.list_reach_entries_at_limited(bounce, usize::MAX) - } - - pub fn list_reach_entries_at_limited( - &self, - bounce: u8, - limit: usize, - ) -> anyhow::Result> { - let mut stmt = self.conn.prepare( - "SELECT reachable_id, id_class, is_anchor, addresses, updated_at - FROM reachable WHERE bounce = ?1 - ORDER BY reachable_id ASC, is_anchor DESC, updated_at DESC", - )?; - let mut result: Vec = Vec::new(); - let mut last: Option = None; - let mut rows = stmt.query(params![bounce as i64])?; - while let Some(row) = rows.next()? { - let id = blob_to_nodeid(row.get(0)?)?; - if last == Some(id) { - continue; // keep only the winning row per ID + /// Build N1 share: merge mesh peers (connections) + social contacts NodeIds (deduplicated). + pub fn build_n1_share(&self) -> anyhow::Result> { + let mut ids = std::collections::HashSet::new(); + // Add mesh peers (connections) + let mesh_peers = self.list_mesh_peers()?; + for (nid, _, _) in mesh_peers { + ids.insert(nid); + } + // Add only ONLINE social routes (not disconnected) + let routes = self.list_social_routes()?; + for route in routes { + if route.status == crate::types::SocialStatus::Online { + ids.insert(route.node_id); } - last = Some(id); - let id_class = IdClass::from_i64(row.get::<_, i64>(1)?); - let is_anchor = row.get::<_, i64>(2)? != 0; - let addrs_json: String = row.get(3)?; - let addresses: Vec = serde_json::from_str(&addrs_json).unwrap_or_default(); - result.push(ReachEntry { id, id_class, is_anchor, addresses }); } - if result.len() > limit { - // Anchors first (they are the anchor directory), then the rest — - // deterministic, so trimming does not thrash the digest. - result.sort_by(|a, b| b.is_anchor.cmp(&a.is_anchor).then(a.id.cmp(&b.id))); - result.truncate(limit); - } - Ok(result) + Ok(ids.into_iter().collect()) } - /// Distinct IDs stored at a bounce (bare). - pub fn list_reach_ids_at(&self, bounce: u8) -> anyhow::Result> { + /// Build N2 share (reach): deduplicated unique NodeIds from all N2 entries. + pub fn build_n2_share(&self) -> anyhow::Result> { let mut stmt = self.conn.prepare( - "SELECT DISTINCT reachable_id FROM reachable WHERE bounce = ?1", + "SELECT DISTINCT reachable_node_id FROM reachable_n2", )?; let mut result = Vec::new(); - let mut rows = stmt.query(params![bounce as i64])?; + let mut rows = stmt.query([])?; while let Some(row) = rows.next()? { result.push(blob_to_nodeid(row.get(0)?)?); } Ok(result) } - /// Deepest bounce present in the index (0 when empty). Test/diagnostic - /// helper: nothing may ever be stored past bounce 4. - pub fn max_stored_bounce(&self) -> anyhow::Result { - Ok(self.conn.query_row( - "SELECT ifnull(MAX(bounce), 0) FROM reachable", - [], - |row| row.get(0), - )?) - } - - /// Count of distinct IDs stored at a bounce. - pub fn count_distinct_at(&self, bounce: u8) -> anyhow::Result { + /// Count distinct reachable NodeIds in the N2 table. + pub fn count_distinct_n2(&self) -> anyhow::Result { let count: i64 = self.conn.query_row( - "SELECT COUNT(DISTINCT reachable_id) FROM reachable WHERE bounce = ?1", - params![bounce as i64], + "SELECT COUNT(DISTINCT reachable_node_id) FROM reachable_n2", + [], |row| row.get(0), )?; Ok(count as usize) } - /// Anchor entries mined from the uniques pools, shallowest and freshest first. - /// - /// Round-4 ruling: because anchor entries are the only address-bearing rows, - /// the pools double as the anchor directory. `known_anchors` demotes to a - /// bootstrap cache. SEAM FOR WRITER 2 (convection): this is the query the - /// stochastic growth trigger should draw a "random known anchor" from. - pub fn list_pool_anchors(&self, limit: usize) -> anyhow::Result)>> { + /// Count distinct reachable NodeIds in the N3 table. + pub fn count_distinct_n3(&self) -> anyhow::Result { + let count: i64 = self.conn.query_row( + "SELECT COUNT(DISTINCT reachable_node_id) FROM reachable_n3", + [], + |row| row.get(0), + )?; + Ok(count as usize) + } + + /// List distinct reachable NodeIds in the N3 table. + pub fn list_distinct_n3(&self) -> anyhow::Result> { let mut stmt = self.conn.prepare( - "SELECT reachable_id, addresses, MIN(bounce) FROM reachable - WHERE is_anchor = 1 AND id_class = 0 - GROUP BY reachable_id - ORDER BY MIN(bounce) ASC, MAX(updated_at) DESC - LIMIT ?1", + "SELECT DISTINCT reachable_node_id FROM reachable_n3", )?; let mut result = Vec::new(); - let mut rows = stmt.query(params![limit as i64])?; + let mut rows = stmt.query([])?; while let Some(row) = rows.next()? { - let id = blob_to_nodeid(row.get(0)?)?; - let addrs_json: String = row.get(1)?; - let addrs: Vec = serde_json::from_str(&addrs_json).unwrap_or_default(); - if !addrs.is_empty() { - result.push((id, addrs)); - } + result.push(blob_to_nodeid(row.get(0)?)?); } Ok(result) } - /// Anchor-flagged share of the distinct node-class IDs we know about — a - /// free local statistic (no census) because anchor entries self-identify by - /// carrying an address. SEAM FOR WRITER 2: round-5 adaptive action-weight - /// prior for the stochastic convection trigger. - pub fn anchor_density(&self) -> anyhow::Result { - let (anchors, total): (i64, i64) = self.conn.query_row( - "SELECT COUNT(DISTINCT CASE WHEN is_anchor = 1 THEN reachable_id END), - COUNT(DISTINCT reachable_id) - FROM reachable WHERE id_class = 0", - [], - |row| Ok((row.get(0)?, row.get(1)?)), - )?; - if total == 0 { - return Ok(0.0); - } - Ok(anchors as f64 / total as f64) - } - - /// Our own bounce-1 uniques: every ID we have a live path to, merged from - /// all four sources BEFORE announcing so a receiver cannot tell which source - /// an entry came from. - /// - /// * mesh peers (node class) — temp referral slots are excluded - /// structurally: they are never - /// written to `mesh_peers` - /// * social directs (node class) — ALL of them, not just Online: - /// the ruling is "everyone's - /// directs"; a held route is a - /// findable ID either way - /// * CDN file-holder peers (node class) - /// * CDN file authors (AUTHOR class) — posting identities. No address, - /// no device linkage. Kept in a - /// separate class so they never - /// enter the connect/hole-punch - /// paths. - /// * comment authors (AUTHOR class) — OTHER people's throwaway/ - /// greeting IDs stay in - /// deliberately (anonymity noise, - /// round-1 ruling); ours are - /// excluded, see below. - /// - /// NEVER included: - /// * our own posting identities (all personas, not just the default) and - /// any author ID on a locally-authored comment (per-greeting throwaway - /// IDs). Our node ID and — when we are an anchor — our address ride - /// `fwd[0]` of the very same announce, so emitting our personas at - /// bounce 1 would hand every mesh peer a signed-by-transport - /// persona→device mapping. Worse, our own personas are the invariant - /// across every announce we ever send while everything else churns, so - /// the set is trivially separable by diffing two announces. design.html - /// §21: "Posting identities never map to device addresses on the wire." - /// They remain discoverable through everyone ELSE who holds our - /// content — we simply must not be the reporter. - /// * anchors we have merely HEARD about. A third-party anchor report is - /// an index row at its true bounce, not a bounce-1 unique of ours; - /// re-emitting it at depth 1 would reset its horizon on every hop and - /// let a terminal (N4) entry propagate forever. - pub fn build_own_uniques(&self) -> anyhow::Result> { - let mut nodes: std::collections::HashSet = std::collections::HashSet::new(); - let mut authors: std::collections::HashSet = std::collections::HashSet::new(); - - for nid in self.list_mesh_peers()? { - nodes.insert(nid); - } - for route in self.list_social_routes()? { - nodes.insert(route.node_id); - } - { - let mut stmt = self.conn.prepare("SELECT DISTINCT peer_id FROM file_holders")?; - let mut rows = stmt.query([])?; - while let Some(row) = rows.next()? { - if let Ok(nid) = blob_to_nodeid(row.get(0)?) { - nodes.insert(nid); - } - } - } - { - let mut stmt = self.conn.prepare("SELECT DISTINCT node_id FROM post_replicas")?; - let mut rows = stmt.query([])?; - while let Some(row) = rows.next()? { - if let Ok(nid) = blob_to_nodeid(row.get(0)?) { - nodes.insert(nid); - } - } - } - { - let mut stmt = self.conn.prepare("SELECT DISTINCT author FROM cdn_manifests")?; - let mut rows = stmt.query([])?; - while let Some(row) = rows.next()? { - if let Ok(nid) = blob_to_nodeid(row.get(0)?) { - authors.insert(nid); - } - } - } - { - // origin_local = 0: comments that reached us from the network. - // Ours are filtered at the store, not here, so nobody has to - // remember the rule at each of the four local authoring sites. - let mut stmt = self.conn - .prepare("SELECT DISTINCT author FROM comments WHERE origin_local = 0")?; - let mut rows = stmt.query([])?; - while let Some(row) = rows.next()? { - if let Ok(nid) = blob_to_nodeid(row.get(0)?) { - authors.insert(nid); - } - } - } - - // Our own personas — ALL of them, not just the default posting ID — - // never ride our own announce. See the doc comment above. - for pi in self.list_posting_identities()? { - authors.remove(&pi.node_id); - nodes.remove(&pi.node_id); - } - - // A node-class ID wins over an author-class one: it is the strictly - // more useful classification and cannot mislead a connect attempt. - for n in &nodes { - authors.remove(n); - } - - // Anchor enrichment, NOT anchor import. An ID already in `nodes` (we - // have a live path to it) that we have PROVEN is an anchor by - // connecting to it carries its address; nothing new is added to the - // set. `is_anchor` is only ever set from a completed handshake or a - // connected peer's own self-declaration — never from a third party's - // announce — so a bounce-4 hearsay anchor can never surface here at - // bounce 1. - let anchor_ids: std::collections::HashSet = self - .list_anchor_peers_seen_since(now_ms() - ANCHOR_ENRICH_MAX_AGE_MS)? - .into_iter() - .map(|r| r.node_id) - .filter(|id| nodes.contains(id)) - .collect(); - - let mut out: Vec = Vec::with_capacity(nodes.len() + authors.len()); - for id in nodes { - let addresses = if anchor_ids.contains(&id) { - self.get_peer_record(&id) - .ok() - .flatten() - .map(|r| { - r.addresses - .iter() - .filter(|a| crate::network::is_publicly_routable(a)) - .map(|a| a.to_string()) - .collect::>() - }) - .unwrap_or_default() - } else { - Vec::new() - }; - let is_anchor = !addresses.is_empty(); - out.push(ReachEntry { id, id_class: IdClass::Node, is_anchor, addresses }); - } - for id in authors { - out.push(ReachEntry { - id, - id_class: IdClass::Author, - is_anchor: false, - addresses: Vec::new(), - }); - } - Ok(out) - } - - /// Build the two announce pools (design.html §layers). - /// - /// Slices are built shallow-to-deep against one running set of already- - /// emitted IDs, so an ID announced at a shallower layer is never repeated - /// deeper — dedup at the ANNOUNCE side, complementing the store-side - /// `MIN(bounce)` upsert. Duplication counts are never transmitted (that - /// would leak topology); only membership is used and the set is discarded. - /// - /// The terminal pool reads bounce 3. Bounce 4 (our N4) is NEVER read here — - /// that omission is the whole "terminal, never re-announced" rule, and it - /// is enforced structurally rather than by a filter someone can forget. - pub fn build_uniques_pools( - &self, - our_node_id: &NodeId, - our_anchor_addr: Option<&str>, - ) -> anyhow::Result { - let mut seen: std::collections::HashSet = std::collections::HashSet::new(); - - // fwd[0] = N0: ourselves. The only place our own anchor address rides. - let self_entry = ReachEntry { - id: *our_node_id, - id_class: IdClass::Node, - is_anchor: our_anchor_addr.is_some(), - addresses: our_anchor_addr.map(|a| vec![a.to_string()]).unwrap_or_default(), - }; - seen.insert(*our_node_id); - let fwd0 = vec![self_entry]; - - let take = |entries: Vec, - seen: &mut std::collections::HashSet| - -> Vec { - let mut out = Vec::new(); - for e in entries { - if seen.insert(e.id) { - out.push(e); - } - } - out - }; - - // Each slice is capped (§layers size budget). The trim is deterministic - // — anchors first, then by ID — so a stable index produces a stable - // digest and the announce is skipped rather than resent every cycle. - let mut fwd1 = take(self.build_own_uniques()?, &mut seen); - if fwd1.len() > UNIQUES_BUILD_CAP_SHALLOW { - fwd1.sort_by(|a, b| b.is_anchor.cmp(&a.is_anchor).then(a.id.cmp(&b.id))); - fwd1.truncate(UNIQUES_BUILD_CAP_SHALLOW); - } - let fwd2 = take( - self.list_reach_entries_at_limited(2, UNIQUES_BUILD_CAP_SHALLOW)?, - &mut seen, - ); - let term = take( - self.list_reach_entries_at_limited(3, UNIQUES_BUILD_CAP_TERM)?, - &mut seen, - ); - - Ok(UniquesPools { fwd: vec![fwd0, fwd1, fwd2], term }) - } - - /// Count distinct reachable IDs at bounce 2 (our N2). - pub fn count_distinct_n2(&self) -> anyhow::Result { - self.count_distinct_at(2) - } - - /// Count distinct reachable IDs at bounce 3 (our N3). - pub fn count_distinct_n3(&self) -> anyhow::Result { - self.count_distinct_at(3) - } - - /// Count distinct reachable IDs at bounce 4 (our N4 — the terminal layer). - pub fn count_distinct_n4(&self) -> anyhow::Result { - self.count_distinct_at(4) - } - - /// List distinct reachable IDs at bounce 3. - pub fn list_distinct_n3(&self) -> anyhow::Result> { - self.list_reach_ids_at(3) - } - - /// List distinct reachable IDs at bounce 4. - pub fn list_distinct_n4(&self) -> anyhow::Result> { - self.list_reach_ids_at(4) - } - - /// Diversity score: how many IDs does this reporter contribute at bounce 2 + /// Diversity score: how many unique NodeIds does this reporter contribute /// that no other reporter provides? pub fn count_unique_n2_for_reporter( &self, reporter: &NodeId, - _exclude_reporters: &[NodeId], + exclude_reporters: &[NodeId], ) -> anyhow::Result { + // Get this reporter's N2 set let reporter_set: std::collections::HashSet = - self.list_reach_for_reporter(reporter, 2)?.into_iter().collect(); + self.list_n2_for_reporter(reporter)?.into_iter().collect(); + if reporter_set.is_empty() { return Ok(0); } + + // Get all other reporters' N2 sets (excluding specified reporters) + let exclude_set: std::collections::HashSet = + exclude_reporters.iter().copied().collect(); let mut other_nodes = std::collections::HashSet::new(); + let mut stmt = self.conn.prepare( - "SELECT reachable_id FROM reachable WHERE reporter_node_id != ?1 AND bounce = 2", + "SELECT reachable_node_id FROM reachable_n2 WHERE reporter_node_id != ?1", )?; let mut rows = stmt.query(params![reporter.as_slice()])?; while let Some(row) = rows.next()? { - if let Ok(nid) = blob_to_nodeid(row.get(0)?) { + let rn: Vec = row.get(0)?; + // Check if the reporter of this entry is excluded + // (simplified: we just exclude the reporter itself) + if let Ok(nid) = blob_to_nodeid(rn) { other_nodes.insert(nid); } } - Ok(reporter_set.difference(&other_nodes).count()) + + let unique = reporter_set.difference(&other_nodes).count(); + let _ = exclude_set; // used for future filtering + Ok(unique) } - /// Clear the whole uniques index (startup sweep after unclean shutdown). - pub fn clear_all_reach(&self) -> anyhow::Result { - Ok(self.conn.execute("DELETE FROM reachable", [])?) + /// Remove stale N2/N3 entries. + /// Clear ALL N2/N3 entries (startup sweep after unclean shutdown). + pub fn clear_all_n2_n3(&self) -> anyhow::Result { + let d1 = self.conn.execute("DELETE FROM reachable_n2", [])?; + let d2 = self.conn.execute("DELETE FROM reachable_n3", [])?; + Ok(d1 + d2) } /// Clear ALL mesh_peers entries (no connections exist at startup). @@ -4084,56 +3696,39 @@ impl Storage { Ok(deleted) } - /// Age out stale uniques rows — same discipline at every bounce, N4 included. - pub fn prune_reach(&self, max_age_ms: u64) -> anyhow::Result { + pub fn prune_n2_n3(&self, max_age_ms: u64) -> anyhow::Result { let cutoff = now_ms() - max_age_ms as i64; - Ok(self.conn.execute( - "DELETE FROM reachable WHERE updated_at < ?1", + let d1 = self.conn.execute( + "DELETE FROM reachable_n2 WHERE updated_at < ?1", params![cutoff], - )?) + )?; + let d2 = self.conn.execute( + "DELETE FROM reachable_n3 WHERE updated_at < ?1", + params![cutoff], + )?; + Ok(d1 + d2) } - /// How many candidates the SQL shortlist returns. Large enough that the - /// Rust-side exclusions (self, already-connected, likely-unreachable) can - /// never empty it in practice, small enough that the growth loop — which - /// uses exactly ONE candidate per pass and is woken on every announce that - /// stores rows — is not a full scan + in-memory sort of six figures of - /// rows. `idx_reachable_id(reachable_id, bounce)` supports the grouping. - pub const GROWTH_SHORTLIST: usize = 64; - - /// Score growth candidates for the growth loop's diversity selection. - /// - /// Returns (node_id, reporter_count, shallowest_bounce) over the whole - /// stored horizon, already ORDERED and LIMITED in SQL: shallowest first - /// (cheapest to resolve and to reach), then fewest reporters (most unique - /// neighborhood = highest diversity value). - /// - /// `shallowest_bounce` is returned GRADED rather than as a boolean: a - /// bounce-4 ID seen by one reporter must not outrank a genuine bounce-2 ID - /// seen by three, which is exactly what `1.0/reporter_count + 0.3` did when - /// the horizon widened past N2. - /// - /// Author-class IDs are EXCLUDED: a persona ID has no address and can never - /// answer a connect, so scoring it would burn hole-punch timeouts and mark - /// phantom peers unreachable. - pub fn score_n2_candidates_batch(&self) -> anyhow::Result> { + /// Score all N2 candidates for growth loop diversity selection. + /// Returns (node_id, reporter_count, in_n3) for each unique N2 candidate. + /// Lower reporter_count = more unique neighborhood = higher diversity value. + pub fn score_n2_candidates_batch(&self) -> anyhow::Result> { let mut stmt = self.conn.prepare( - "SELECT reachable_id, - COUNT(DISTINCT reporter_node_id) AS reporter_count, - MIN(bounce) AS shallowest - FROM reachable - WHERE id_class = 0 - GROUP BY reachable_id - ORDER BY shallowest ASC, reporter_count ASC - LIMIT ?1", + "SELECT n2.reachable_node_id, + COUNT(DISTINCT n2.reporter_node_id) as reporter_count, + CASE WHEN n3.reachable_node_id IS NOT NULL THEN 1 ELSE 0 END as in_n3 + FROM reachable_n2 n2 + LEFT JOIN (SELECT DISTINCT reachable_node_id FROM reachable_n3) n3 + ON n2.reachable_node_id = n3.reachable_node_id + GROUP BY n2.reachable_node_id", )?; let mut results = Vec::new(); - let mut rows = stmt.query(params![Self::GROWTH_SHORTLIST as i64])?; + let mut rows = stmt.query([])?; while let Some(row) = rows.next()? { let nid = blob_to_nodeid(row.get(0)?)?; let reporter_count: usize = row.get::<_, i64>(1)? as usize; - let shallowest: i64 = row.get(2)?; - results.push((nid, reporter_count, shallowest.clamp(0, 255) as u8)); + let in_n3: bool = row.get::<_, i64>(2)? != 0; + results.push((nid, reporter_count, in_n3)); } Ok(results) } @@ -4150,20 +3745,24 @@ impl Storage { // ---- Mesh Peers ---- - /// Record a MESH-slot peer. - /// - /// Temporary referral slots must NEVER be written here. `build_own_uniques` - /// reads this table to construct our announce, so simply not writing the row - /// is what keeps temp peers out of the uniques exchange — at the store - /// layer, with no filtering anyone can forget. (`get_lateral_blob_sources` - /// also treats membership here as "reachable", a second reason.) - pub fn add_mesh_peer(&self, node_id: &NodeId) -> anyhow::Result<()> { + /// Add a mesh peer connection record. + pub fn add_mesh_peer( + &self, + node_id: &NodeId, + slot_kind: PeerSlotKind, + priority: i32, + ) -> anyhow::Result<()> { let now = now_ms(); self.conn.execute( - "INSERT INTO mesh_peers (node_id, connected_at, last_diff_seq) - VALUES (?1, ?2, 0) - ON CONFLICT(node_id) DO UPDATE SET connected_at = ?2", - params![node_id.as_slice(), now], + "INSERT INTO mesh_peers (node_id, slot_kind, priority, connected_at, last_diff_seq) + VALUES (?1, ?2, ?3, ?4, 0) + ON CONFLICT(node_id) DO UPDATE SET slot_kind = ?2, priority = ?3, connected_at = ?4", + params![ + node_id.as_slice(), + slot_kind.to_string(), + priority, + now, + ], )?; Ok(()) } @@ -4177,10 +3776,66 @@ impl Storage { Ok(()) } - /// List all mesh-slot peers, most recently connected first. - pub fn list_mesh_peers(&self) -> anyhow::Result> { + /// List all mesh peers: (node_id, slot_kind_str, priority). + pub fn list_mesh_peers(&self) -> anyhow::Result> { let mut stmt = self.conn.prepare( - "SELECT node_id FROM mesh_peers ORDER BY connected_at DESC", + "SELECT node_id, slot_kind, priority FROM mesh_peers ORDER BY connected_at DESC", + )?; + let mut result = Vec::new(); + let mut rows = stmt.query([])?; + while let Some(row) = rows.next()? { + let node_id = blob_to_nodeid(row.get(0)?)?; + let slot_kind: String = row.get(1)?; + let priority: i32 = row.get(2)?; + result.push((node_id, slot_kind, priority)); + } + Ok(result) + } + + /// Count mesh peers of a given slot kind. + pub fn count_mesh_peers_by_kind(&self, slot_kind: PeerSlotKind) -> anyhow::Result { + let count: i64 = self.conn.query_row( + "SELECT COUNT(*) FROM mesh_peers WHERE slot_kind = ?1", + params![slot_kind.to_string()], + |row| row.get(0), + )?; + Ok(count as usize) + } + + /// Update last_diff_seq for a mesh peer. + pub fn update_mesh_peer_seq(&self, node_id: &NodeId, seq: u64) -> anyhow::Result<()> { + self.conn.execute( + "UPDATE mesh_peers SET last_diff_seq = ?1 WHERE node_id = ?2", + params![seq as i64, node_id.as_slice()], + )?; + Ok(()) + } + + // ---- Preferred Peers ---- + + /// Add a bilateral preferred peer agreement. + pub fn add_preferred_peer(&self, node_id: &NodeId) -> anyhow::Result<()> { + let now = now_ms(); + self.conn.execute( + "INSERT OR REPLACE INTO preferred_peers (node_id, agreed_at) VALUES (?1, ?2)", + params![node_id.as_slice(), now], + )?; + Ok(()) + } + + /// Remove a preferred peer agreement. + pub fn remove_preferred_peer(&self, node_id: &NodeId) -> anyhow::Result<()> { + self.conn.execute( + "DELETE FROM preferred_peers WHERE node_id = ?1", + params![node_id.as_slice()], + )?; + Ok(()) + } + + /// List all preferred peers. + pub fn list_preferred_peers(&self) -> anyhow::Result> { + let mut stmt = self.conn.prepare( + "SELECT node_id FROM preferred_peers ORDER BY agreed_at DESC", )?; let mut result = Vec::new(); let mut rows = stmt.query([])?; @@ -4190,11 +3845,67 @@ impl Storage { Ok(result) } - /// Update last_diff_seq for a mesh peer. - pub fn update_mesh_peer_seq(&self, node_id: &NodeId, seq: u64) -> anyhow::Result<()> { + /// Check if a peer is a preferred peer. + pub fn is_preferred_peer(&self, node_id: &NodeId) -> anyhow::Result { + let count: i64 = self.conn.query_row( + "SELECT COUNT(*) FROM preferred_peers WHERE node_id = ?1", + params![node_id.as_slice()], + |row| row.get(0), + )?; + Ok(count > 0) + } + + /// Count preferred peers. + pub fn count_preferred_peers(&self) -> anyhow::Result { + let count: i64 = self.conn.query_row( + "SELECT COUNT(*) FROM preferred_peers", + [], + |row| row.get(0), + )?; + Ok(count as usize) + } + + // ---- Preferred Tree ---- + + /// Build 2-layer preferred peer tree from stored profiles. + /// Layer 0: target. Layer 1: target's preferred_peers. Layer 2: each L1 peer's preferred_peers. + /// Returns ~100 unique NodeIds. + pub fn build_preferred_tree_for(&self, target: &NodeId) -> anyhow::Result> { + let mut tree = std::collections::HashSet::new(); + + // Layer 0: target itself + tree.insert(*target); + + // Layer 1: target's preferred peers from their profile + let l1_peers = match self.get_profile(target)? { + Some(profile) => profile.preferred_peers, + None => return Ok(tree.into_iter().collect()), + }; + + for pp in &l1_peers { + tree.insert(*pp); + } + + // Layer 2: each L1 peer's preferred peers + for pp in &l1_peers { + if let Some(profile) = self.get_profile(pp)? { + for pp2 in &profile.preferred_peers { + tree.insert(*pp2); + } + } + } + + Ok(tree.into_iter().collect()) + } + + /// Update the preferred_tree JSON for a social route. + pub fn update_social_route_preferred_tree(&self, node_id: &NodeId, tree: &[NodeId]) -> anyhow::Result<()> { + let json = serde_json::to_string( + &tree.iter().map(hex::encode).collect::>() + )?; self.conn.execute( - "UPDATE mesh_peers SET last_diff_seq = ?1 WHERE node_id = ?2", - params![seq as i64, node_id.as_slice()], + "UPDATE social_routes SET preferred_tree = ?1 WHERE node_id = ?2", + params![json, node_id.as_slice()], )?; Ok(()) } @@ -4207,14 +3918,17 @@ impl Storage { &entry.addresses.iter().map(|a| a.to_string()).collect::>() )?; let peer_addrs_json = serde_json::to_string(&entry.peer_addresses)?; + let pref_tree_json = serde_json::to_string( + &entry.preferred_tree.iter().map(hex::encode).collect::>() + )?; self.conn.execute( - "INSERT INTO social_routes (node_id, addresses, peer_addresses, relation, status, last_connected_ms, last_seen_ms, reach_method) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) + "INSERT INTO social_routes (node_id, addresses, peer_addresses, relation, status, last_connected_ms, last_seen_ms, reach_method, preferred_tree) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) ON CONFLICT(node_id) DO UPDATE SET addresses = ?2, peer_addresses = ?3, relation = ?4, status = ?5, last_connected_ms = MAX(social_routes.last_connected_ms, ?6), last_seen_ms = MAX(social_routes.last_seen_ms, ?7), - reach_method = ?8", + reach_method = ?8, preferred_tree = ?9", params![ entry.node_id.as_slice(), addrs_json, @@ -4224,6 +3938,7 @@ impl Storage { entry.last_connected_ms as i64, entry.last_seen_ms as i64, entry.reach_method.to_string(), + pref_tree_json, ], )?; Ok(()) @@ -4232,7 +3947,7 @@ impl Storage { /// Get a single social route entry. pub fn get_social_route(&self, node_id: &NodeId) -> anyhow::Result> { let mut stmt = self.conn.prepare( - "SELECT node_id, addresses, peer_addresses, relation, status, last_connected_ms, last_seen_ms, reach_method + "SELECT node_id, addresses, peer_addresses, relation, status, last_connected_ms, last_seen_ms, reach_method, preferred_tree FROM social_routes WHERE node_id = ?1", )?; let mut rows = stmt.query(params![node_id.as_slice()])?; @@ -4313,7 +4028,7 @@ impl Storage { /// List all social routes, sorted by last_seen DESC. pub fn list_social_routes(&self) -> anyhow::Result> { let mut stmt = self.conn.prepare( - "SELECT node_id, addresses, peer_addresses, relation, status, last_connected_ms, last_seen_ms, reach_method + "SELECT node_id, addresses, peer_addresses, relation, status, last_connected_ms, last_seen_ms, reach_method, preferred_tree FROM social_routes ORDER BY last_seen_ms DESC", )?; let mut entries = Vec::new(); @@ -4328,7 +4043,7 @@ impl Storage { pub fn list_stale_social_routes(&self, max_age_ms: u64) -> anyhow::Result> { let cutoff = now_ms() - max_age_ms as i64; let mut stmt = self.conn.prepare( - "SELECT node_id, addresses, peer_addresses, relation, status, last_connected_ms, last_seen_ms, reach_method + "SELECT node_id, addresses, peer_addresses, relation, status, last_connected_ms, last_seen_ms, reach_method, preferred_tree FROM social_routes WHERE last_seen_ms < ?1 ORDER BY last_seen_ms ASC", )?; let mut entries = Vec::new(); @@ -4371,6 +4086,9 @@ impl Storage { // Build peer_addresses from the contact's profile recent_peers let peer_addresses = self.build_peer_addresses_for(&nid)?; + // Build preferred peer tree + let preferred_tree = self.build_preferred_tree_for(&nid).unwrap_or_default(); + // Only insert if not already present (don't overwrite runtime state) if !self.has_social_route(&nid)? { self.upsert_social_route(&SocialRouteEntry { @@ -4382,8 +4100,12 @@ impl Storage { last_connected_ms: 0, last_seen_ms: now, reach_method: ReachMethod::Direct, + preferred_tree, })?; count += 1; + } else { + // Update the preferred tree for existing routes + self.update_social_route_preferred_tree(&nid, &preferred_tree)?; } } @@ -4391,31 +4113,14 @@ impl Storage { } /// Build peer_addresses for a contact from their profile's recent_peers. - /// - /// PUBLICLY ROUTABLE ONLY. design.html §21 sanctions this payload class on - /// the reasoning that these are "network endpoints an observer at that - /// vantage could already see us talking to" — that holds for public - /// addresses and not for a peer's LAN address (192.168.x, 10.x picked up - /// from an earlier exchange), which discloses local topology to someone who - /// could not otherwise observe it. A peer left with no routable address is - /// dropped rather than sent as a bare, useless entry. pub fn build_peer_addresses_for(&self, node_id: &NodeId) -> anyhow::Result> { let recent_peers = self.get_recent_peers(node_id)?; let mut result = Vec::new(); for rp in recent_peers.iter().take(10) { let addrs: Vec = self .get_peer_record(rp)? - .map(|r| { - r.addresses - .iter() - .filter(|a| crate::connection::convection_addr_ok(a)) - .map(|a| a.to_string()) - .collect() - }) + .map(|r| r.addresses.iter().map(|a| a.to_string()).collect()) .unwrap_or_default(); - if addrs.is_empty() { - continue; - } result.push(PeerWithAddress { n: hex::encode(rp), a: addrs, @@ -4803,36 +4508,6 @@ impl Storage { Ok(result) } - /// List ALL stored CDN manifests: (cid, manifest_json). Used by the v0.8 - /// startup migration to re-sign own manifests / purge stale foreign ones. - pub fn list_all_cdn_manifests(&self) -> anyhow::Result> { - let mut stmt = self.conn.prepare( - "SELECT cid, manifest_json FROM cdn_manifests" - )?; - let rows = stmt.query_map([], |row| { - let cid_bytes: Vec = row.get(0)?; - let json: String = row.get(1)?; - Ok((cid_bytes, json)) - })?; - let mut result = Vec::new(); - for row in rows { - let (cid_bytes, json) = row?; - let cid: [u8; 32] = cid_bytes.try_into() - .map_err(|_| anyhow::anyhow!("invalid cid in cdn_manifests"))?; - result.push((cid, json)); - } - Ok(result) - } - - /// Delete a single CDN manifest row by CID (file_holders untouched). - pub fn delete_cdn_manifest(&self, cid: &[u8; 32]) -> anyhow::Result<()> { - self.conn.execute( - "DELETE FROM cdn_manifests WHERE cid = ?1", - params![cid.as_slice()], - )?; - Ok(()) - } - /// Get CIDs of manifests older than a cutoff. Callers look up holders /// via file_holders to pick a refresh source. pub fn get_stale_manifest_cids(&self, older_than_ms: u64) -> anyhow::Result> { @@ -5024,6 +4699,36 @@ impl Storage { Ok(out) } + /// Seed the post_recipients index from existing encrypted posts. + /// One-time idempotent migration for users upgrading from pre-0.6.2. + pub fn seed_post_recipients_from_posts(&self) -> anyhow::Result<()> { + let existing: i64 = self.conn.prepare("SELECT COUNT(*) FROM post_recipients")? + .query_row([], |row| row.get(0))?; + if existing > 0 { + return Ok(()); + } + // Scan all posts, parse visibility, index recipients. + let mut stmt = self.conn.prepare("SELECT id, visibility FROM posts")?; + let rows = stmt.query_map([], |row| { + let id_bytes: Vec = row.get(0)?; + let vis_json: String = row.get(1)?; + Ok((id_bytes, vis_json)) + })?; + let entries: Vec<([u8; 32], PostVisibility)> = rows + .filter_map(|r| r.ok()) + .filter_map(|(id_bytes, vis_json)| { + let pid = <[u8; 32]>::try_from(id_bytes.as_slice()).ok()?; + let vis: PostVisibility = serde_json::from_str(&vis_json).ok()?; + Some((pid, vis)) + }) + .collect(); + drop(stmt); + for (pid, vis) in entries { + self.index_post_recipients(&pid, &vis)?; + } + Ok(()) + } + // --- Posting identities (multi-persona plumbing) --- pub fn upsert_posting_identity(&self, id: &PostingIdentity) -> anyhow::Result<()> { @@ -6054,6 +5759,55 @@ impl Storage { Ok(()) } + /// One-time migration: seed file_holders from the legacy upstream/downstream + /// tables so a user upgrading from pre-0.6.1 doesn't start with empty holder + /// sets. Idempotent — inserts use ON CONFLICT DO NOTHING semantics via the + /// PRIMARY KEY. Skips tables that don't exist on fresh installs. + pub fn seed_file_holders_from_legacy(&self) -> anyhow::Result<()> { + // Skip if file_holders already populated (idempotent re-run protection). + let existing: i64 = self.conn.prepare("SELECT COUNT(*) FROM file_holders")? + .query_row([], |row| row.get(0))?; + if existing > 0 { + return Ok(()); + } + let now = now_ms() as i64; + let table_exists = |name: &str| -> anyhow::Result { + let count: i64 = self.conn.prepare( + "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1", + )?.query_row(params![name], |row| row.get(0))?; + Ok(count > 0) + }; + if table_exists("post_upstream")? { + self.conn.execute( + "INSERT OR IGNORE INTO file_holders (file_id, peer_id, peer_addresses, last_interaction_ms, direction) + SELECT post_id, peer_node_id, '[]', ?1, 'received' FROM post_upstream", + params![now], + )?; + } + if table_exists("post_downstream")? { + self.conn.execute( + "INSERT OR IGNORE INTO file_holders (file_id, peer_id, peer_addresses, last_interaction_ms, direction) + SELECT post_id, peer_node_id, '[]', ?1, 'sent' FROM post_downstream", + params![now], + )?; + } + if table_exists("blob_upstream")? { + self.conn.execute( + "INSERT OR IGNORE INTO file_holders (file_id, peer_id, peer_addresses, last_interaction_ms, direction) + SELECT cid, source_node_id, source_addresses, ?1, 'received' FROM blob_upstream", + params![now], + )?; + } + if table_exists("blob_downstream")? { + self.conn.execute( + "INSERT OR IGNORE INTO file_holders (file_id, peer_id, peer_addresses, last_interaction_ms, direction) + SELECT cid, peer_node_id, peer_addresses, ?1, 'sent' FROM blob_downstream", + params![now], + )?; + } + Ok(()) + } + // --- Engagement: reactions --- /// Store a reaction (upsert by reactor+post_id+emoji). @@ -6154,27 +5908,13 @@ impl Storage { } /// Get reaction counts grouped by emoji for a post (excludes tombstoned reactions). - /// `my_ids` = ALL of the local node's posting identities (reactors are - /// posting ids — the network NodeId never reacts). - pub fn get_reaction_counts(&self, post_id: &PostId, my_ids: &[NodeId]) -> anyhow::Result> { - let mine_placeholders: String = if my_ids.is_empty() { - "NULL".to_string() - } else { - (0..my_ids.len()).map(|i| format!("?{}", i + 2)).collect::>().join(",") - }; - let sql = format!( + pub fn get_reaction_counts(&self, post_id: &PostId, my_node_id: &NodeId) -> anyhow::Result> { + let mut stmt = self.conn.prepare( "SELECT emoji, COUNT(*) as cnt, - SUM(CASE WHEN reactor IN ({}) THEN 1 ELSE 0 END) as my_count - FROM reactions WHERE post_id = ?1 AND deleted_at IS NULL GROUP BY emoji ORDER BY cnt DESC", - mine_placeholders - ); - let mut stmt = self.conn.prepare(&sql)?; - let mut params: Vec> = vec![Box::new(post_id.to_vec())]; - for id in my_ids { - params.push(Box::new(id.to_vec())); - } - let param_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect(); - let rows = stmt.query_map(param_refs.as_slice(), |row| { + SUM(CASE WHEN reactor = ?2 THEN 1 ELSE 0 END) as my_count + FROM reactions WHERE post_id = ?1 AND deleted_at IS NULL GROUP BY emoji ORDER BY cnt DESC" + )?; + let rows = stmt.query_map(params![post_id.as_slice(), my_node_id.as_slice()], |row| { let emoji: String = row.get(0)?; let count: i64 = row.get(1)?; let my_count: i64 = row.get(2)?; @@ -6195,18 +5935,15 @@ impl Storage { self.conn.execute( "INSERT INTO comments (author, post_id, content, timestamp_ms, signature, deleted_at, - ref_post_id, pub_x_index, group_sig, encrypted_payload, expires_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11) + ref_post_id, pub_x_index, group_sig, encrypted_payload) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10) ON CONFLICT(author, post_id, timestamp_ms) DO UPDATE SET content = CASE WHEN excluded.deleted_at IS NOT NULL THEN content ELSE excluded.content END, - signature = CASE WHEN excluded.deleted_at IS NOT NULL THEN signature ELSE excluded.signature END, deleted_at = CASE WHEN excluded.deleted_at IS NOT NULL THEN excluded.deleted_at ELSE deleted_at END, ref_post_id = COALESCE(excluded.ref_post_id, ref_post_id), pub_x_index = COALESCE(excluded.pub_x_index, pub_x_index), group_sig = COALESCE(excluded.group_sig, group_sig), - encrypted_payload = COALESCE(excluded.encrypted_payload, encrypted_payload), - expires_at = CASE WHEN excluded.expires_at IS NOT NULL AND excluded.expires_at > 0 - THEN excluded.expires_at ELSE expires_at END", + encrypted_payload = COALESCE(excluded.encrypted_payload, encrypted_payload)", params![ comment.author.as_slice(), comment.post_id.as_slice(), @@ -6218,26 +5955,6 @@ impl Storage { comment.pub_x_index.map(|i| i as i64), comment.group_sig.as_ref().map(|b| b.as_slice()), comment.encrypted_payload.as_ref().map(|b| b.as_slice()), - if comment.expires_at_ms > 0 { Some(comment.expires_at_ms as i64) } else { None }, - ], - )?; - Ok(()) - } - - /// Store a comment WE authored. Identical to `store_comment` except it - /// marks the row `origin_local = 1`, which keeps its author ID (our - /// persona, or a per-greeting throwaway) out of `build_own_uniques` — see - /// design.html §21: we must never be the node that announces our own - /// posting identities at bounce 1 beside our node ID and address. - pub fn store_own_comment(&self, comment: &InlineComment) -> anyhow::Result<()> { - self.store_comment(comment)?; - self.conn.execute( - "UPDATE comments SET origin_local = 1 - WHERE author = ?1 AND post_id = ?2 AND timestamp_ms = ?3", - params![ - comment.author.as_slice(), - comment.post_id.as_slice(), - comment.timestamp_ms as i64, ], )?; Ok(()) @@ -6261,18 +5978,14 @@ impl Storage { Ok(updated > 0) } - /// Get live (non-tombstoned, unexpired) comments for a post. Used for - /// UI display. The expiry filter is belt-and-suspenders between - /// `expire_comments` sweeps. + /// Get live (non-tombstoned) comments for a post. Used for UI display. pub fn get_comments(&self, post_id: &PostId) -> anyhow::Result> { let mut stmt = self.conn.prepare( "SELECT author, post_id, content, timestamp_ms, signature, ref_post_id, - pub_x_index, group_sig, encrypted_payload, expires_at - FROM comments WHERE post_id = ?1 AND deleted_at IS NULL - AND (expires_at IS NULL OR expires_at = 0 OR expires_at > ?2) - ORDER BY timestamp_ms ASC" + pub_x_index, group_sig, encrypted_payload + FROM comments WHERE post_id = ?1 AND deleted_at IS NULL ORDER BY timestamp_ms ASC" )?; - let rows = stmt.query_map(params![post_id.as_slice(), now_ms()], |row| { + let rows = stmt.query_map(params![post_id.as_slice()], |row| { let author: Vec = row.get(0)?; let pid: Vec = row.get(1)?; let content: String = row.get(2)?; @@ -6282,12 +5995,11 @@ impl Storage { let pxi: Option = row.get(6)?; let gsig: Option> = row.get(7)?; let epl: Option> = row.get(8)?; - let exp: Option = row.get(9)?; - Ok((author, pid, content, ts, sig, ref_post, pxi, gsig, epl, exp)) + Ok((author, pid, content, ts, sig, ref_post, pxi, gsig, epl)) })?; let mut result = Vec::new(); for row in rows { - let (author_bytes, pid_bytes, content, ts, sig, ref_post, pxi, gsig, epl, exp) = row?; + let (author_bytes, pid_bytes, content, ts, sig, ref_post, pxi, gsig, epl) = row?; let author = blob_to_nodeid(author_bytes)?; let post_id = blob_to_postid(pid_bytes)?; let ref_post_id = match ref_post { @@ -6305,30 +6017,19 @@ impl Storage { pub_x_index: pxi.map(|v| v as u32), group_sig: gsig, encrypted_payload: epl, - expires_at_ms: exp.unwrap_or(0) as u64, }); } Ok(result) } - /// Get ALL comments for a post, including tombstoned ones (but never - /// expired ones — expiry is the forgetting mechanism and expired - /// comments must not be re-served). Used for header rebuild so - /// tombstones propagate through pull-based sync. - /// - /// v0.8 (A3): also carries the FoF fields (pub_x_index / group_sig / - /// encrypted_payload) + expires_at — without them, FoF/greeting - /// comments served via full-header sync would lose their group_sig - /// and be dropped by the receive gate. + /// Get ALL comments for a post, including tombstoned ones. Used for header rebuild + /// so tombstones propagate through pull-based sync. pub fn get_comments_with_tombstones(&self, post_id: &PostId) -> anyhow::Result> { let mut stmt = self.conn.prepare( - "SELECT author, post_id, content, timestamp_ms, signature, deleted_at, ref_post_id, - pub_x_index, group_sig, encrypted_payload, expires_at - FROM comments WHERE post_id = ?1 - AND (expires_at IS NULL OR expires_at = 0 OR expires_at > ?2) - ORDER BY timestamp_ms ASC" + "SELECT author, post_id, content, timestamp_ms, signature, deleted_at, ref_post_id + FROM comments WHERE post_id = ?1 ORDER BY timestamp_ms ASC" )?; - let rows = stmt.query_map(params![post_id.as_slice(), now_ms()], |row| { + let rows = stmt.query_map(params![post_id.as_slice()], |row| { let author: Vec = row.get(0)?; let pid: Vec = row.get(1)?; let content: String = row.get(2)?; @@ -6336,15 +6037,11 @@ impl Storage { let sig: Vec = row.get(4)?; let del: Option = row.get(5)?; let ref_post: Option> = row.get(6)?; - let pxi: Option = row.get(7)?; - let gsig: Option> = row.get(8)?; - let epl: Option> = row.get(9)?; - let exp: Option = row.get(10)?; - Ok((author, pid, content, ts, sig, del, ref_post, pxi, gsig, epl, exp)) + Ok((author, pid, content, ts, sig, del, ref_post)) })?; let mut result = Vec::new(); for row in rows { - let (author_bytes, pid_bytes, content, ts, sig, del, ref_post, pxi, gsig, epl, exp) = row?; + let (author_bytes, pid_bytes, content, ts, sig, del, ref_post) = row?; let author = blob_to_nodeid(author_bytes)?; let post_id = blob_to_postid(pid_bytes)?; let ref_post_id = match ref_post { @@ -6359,319 +6056,22 @@ impl Storage { signature: sig, deleted_at: del.map(|v| v as u64), ref_post_id, - pub_x_index: pxi.map(|v| v as u32), - group_sig: gsig, - encrypted_payload: epl, - expires_at_ms: exp.unwrap_or(0) as u64, + pub_x_index: None, + group_sig: None, + encrypted_payload: None, }); } Ok(result) } - /// Get comment count for a post (excludes tombstoned + expired comments). + /// Get comment count for a post (excludes tombstoned comments). pub fn get_comment_count(&self, post_id: &PostId) -> anyhow::Result { let count: i64 = self.conn.prepare( - "SELECT COUNT(*) FROM comments WHERE post_id = ?1 AND deleted_at IS NULL - AND (expires_at IS NULL OR expires_at = 0 OR expires_at > ?2)" - )?.query_row(params![post_id.as_slice(), now_ms()], |row| row.get(0))?; + "SELECT COUNT(*) FROM comments WHERE post_id = ?1 AND deleted_at IS NULL" + )?.query_row(params![post_id.as_slice()], |row| row.get(0))?; Ok(count as u64) } - /// v0.8 (A3): rebuild the post's aggregated blob header from vetted - /// DB state (reactions + comments + policy), preserving slot fields - /// from any previously-stored header JSON. Used after local comment - /// creation (so pulls serve our own engagement) and after pull-path - /// ingestion (so we never re-serve peer-supplied comments our accept - /// gate rejected). - pub fn rebuild_blob_header_from_db( - &self, - post_id: &PostId, - fallback_author: &NodeId, - now: u64, - ) -> anyhow::Result<()> { - let reactions = self.get_reactions_with_tombstones(post_id).unwrap_or_default(); - let comments = self.get_comments_with_tombstones(post_id).unwrap_or_default(); - let policy = self.get_comment_policy(post_id).ok().flatten().unwrap_or_default(); - let (existing_json, _) = self.get_blob_header(post_id).ok().flatten() - .unwrap_or((String::new(), 0)); - let mut header: crate::types::BlobHeader = serde_json::from_str(&existing_json) - .unwrap_or_else(|_| crate::types::BlobHeader { - post_id: *post_id, - author: *fallback_author, - reactions: vec![], - comments: vec![], - policy: CommentPolicy::default(), - updated_at: 0, - thread_splits: vec![], - receipt_slots: vec![], - comment_slots: vec![], - prior_author: None, - }); - header.post_id = *post_id; - header.reactions = reactions; - header.comments = comments; - header.policy = policy; - header.updated_at = now; - // Trust the locally-stored post for authorship when available. - let author = self.get_post(post_id).ok().flatten() - .map(|p| p.author) - .unwrap_or(*fallback_author); - header.author = author; - let json = serde_json::to_string(&header)?; - self.store_blob_header(post_id, &author, &json, now) - } - - // --- v0.8 (A3): comment TTL sweep + open-slot helpers --- - - /// Hard-DELETE (not tombstone) all comments whose expiry has passed. - /// Expiry is the forgetting mechanism — tombstones would keep - /// throwaway IDs alive. Returns the number of rows deleted. - pub fn expire_comments(&self, now_ms_val: u64) -> anyhow::Result { - let deleted = self.conn.execute( - "DELETE FROM comments - WHERE expires_at IS NOT NULL AND expires_at > 0 AND expires_at <= ?1", - params![now_ms_val as i64], - )?; - Ok(deleted) - } - - /// Newest-wins registry rule: one active entry per persona (`author`) - /// on the registry post. Returns `true` if the incoming row (at - /// `incoming_ts`) is the newest for this author — in which case any - /// strictly-older rows are HARD-deleted — and `false` when a newer - /// entry is already stored (caller drops the incoming one). - pub fn upsert_registry_entry_newest_wins( - &self, - post_id: &PostId, - author: &NodeId, - incoming_ts: u64, - ) -> anyhow::Result { - if !self.registry_entry_is_newest(post_id, author, incoming_ts)? { - return Ok(false); - } - self.prune_older_registry_entries(post_id, author, incoming_ts)?; - Ok(true) - } - - /// READ-ONLY newest-wins check: is `incoming_ts` at least as new as - /// every stored entry for this persona? Used by the accept gate, - /// which must stay side-effect free — the destructive prune happens - /// in the storing callers AFTER a successful store_comment. - pub fn registry_entry_is_newest( - &self, - post_id: &PostId, - author: &NodeId, - incoming_ts: u64, - ) -> anyhow::Result { - let newest: Option = self.conn.query_row( - "SELECT MAX(timestamp_ms) FROM comments WHERE post_id = ?1 AND author = ?2", - params![post_id.as_slice(), author.as_slice()], - |row| row.get(0), - )?; - if let Some(newest) = newest { - if (newest as u64) > incoming_ts { - return Ok(false); - } - } - Ok(true) - } - - /// Hard-delete this persona's registry entries older than `newest_ts`. - /// Call only after the newest entry has been successfully stored, so - /// a store failure never leaves the persona entry-less. - pub fn prune_older_registry_entries( - &self, - post_id: &PostId, - author: &NodeId, - newest_ts: u64, - ) -> anyhow::Result { - let n = self.conn.execute( - "DELETE FROM comments WHERE post_id = ?1 AND author = ?2 AND timestamp_ms < ?3", - params![post_id.as_slice(), author.as_slice(), newest_ts as i64], - )?; - Ok(n) - } - - /// Count live (unexpired, non-tombstoned) comments on a post's open - /// slot — the per-bio greeting flood cap input. - pub fn count_unexpired_open_slot_comments( - &self, - post_id: &PostId, - pub_x_index: u32, - ) -> anyhow::Result { - let count: i64 = self.conn.query_row( - "SELECT COUNT(*) FROM comments - WHERE post_id = ?1 AND pub_x_index = ?2 AND deleted_at IS NULL - AND (expires_at IS NULL OR expires_at = 0 OR expires_at > ?3)", - params![post_id.as_slice(), pub_x_index as i64, now_ms()], - |row| row.get(0), - )?; - Ok(count as u64) - } - - /// Local-only: mark a greeting as dismissed in the inbox. - pub fn add_greeting_dismissal( - &self, - comment_author: &NodeId, - post_id: &PostId, - timestamp_ms: u64, - ) -> anyhow::Result<()> { - self.conn.execute( - "INSERT OR REPLACE INTO greeting_dismissals - (comment_author, post_id, timestamp_ms, dismissed_at) - VALUES (?1, ?2, ?3, ?4)", - params![comment_author.as_slice(), post_id.as_slice(), timestamp_ms as i64, now_ms()], - )?; - Ok(()) - } - - /// Local-only: was this greeting dismissed? - pub fn is_greeting_dismissed( - &self, - comment_author: &NodeId, - post_id: &PostId, - timestamp_ms: u64, - ) -> anyhow::Result { - let count: i64 = self.conn.query_row( - "SELECT COUNT(*) FROM greeting_dismissals - WHERE comment_author = ?1 AND post_id = ?2 AND timestamp_ms = ?3", - params![comment_author.as_slice(), post_id.as_slice(), timestamp_ms as i64], - |row| row.get(0), - )?; - Ok(count > 0) - } - - /// Persist the private half of a per-greeting reply x25519 keypair. - pub fn store_greeting_reply_key( - &self, - reply_pubkey: &[u8; 32], - reply_privkey: &[u8; 32], - return_post_id: &PostId, - ) -> anyhow::Result<()> { - self.conn.execute( - "INSERT OR REPLACE INTO greeting_reply_keys - (reply_pubkey, reply_privkey, return_post_id, created_at) - VALUES (?1, ?2, ?3, ?4)", - params![ - reply_pubkey.as_slice(), - reply_privkey.as_slice(), - return_post_id.as_slice(), - now_ms(), - ], - )?; - Ok(()) - } - - /// List all stored reply private keys (privkey, return_post_id). - /// Used to unseal replies arriving on our return-path posts. - pub fn list_greeting_reply_keys(&self) -> anyhow::Result> { - let mut stmt = self.conn.prepare( - "SELECT reply_privkey, return_post_id FROM greeting_reply_keys ORDER BY created_at DESC" - )?; - let rows = stmt.query_map([], |row| { - let privkey: Vec = row.get(0)?; - let post: Vec = row.get(1)?; - Ok((privkey, post)) - })?; - let mut result = Vec::new(); - for row in rows { - let (privkey, post) = row?; - let pk: [u8; 32] = privkey.as_slice().try_into() - .map_err(|_| anyhow::anyhow!("invalid reply privkey length"))?; - result.push((pk, blob_to_postid(post)?)); - } - Ok(result) - } - - /// All FoF-gated posts authored by `author` (fof_gating_json set), - /// reconstructed with their gating. Used by the greeting inbox to - /// scan own bio posts (current + previous revisions) for open-slot - /// comments. - pub fn list_gated_posts_by_author( - &self, - author: &NodeId, - ) -> anyhow::Result> { - let mut stmt = self.conn.prepare( - "SELECT id, author, content, attachments, timestamp_ms, fof_gating_json - FROM posts WHERE author = ?1 AND fof_gating_json IS NOT NULL - ORDER BY timestamp_ms DESC", - )?; - let rows = stmt.query_map(params![author.as_slice()], |row| { - let id: Vec = row.get(0)?; - let author: Vec = row.get(1)?; - let content: String = row.get(2)?; - let attachments: String = row.get(3)?; - let ts: i64 = row.get(4)?; - let fof_json: Option = row.get(5)?; - Ok((id, author, content, attachments, ts, fof_json)) - })?; - let mut result = Vec::new(); - for row in rows { - let (id, author_bytes, content, attachments_json, ts, fof_json) = row?; - let attachments: Vec = - serde_json::from_str(&attachments_json).unwrap_or_default(); - let fof_gating = fof_json - .and_then(|s| serde_json::from_str::(&s).ok()); - result.push(( - blob_to_postid(id)?, - Post { - author: blob_to_nodeid(author_bytes)?, - content, - attachments, - timestamp_ms: ts as u64, - fof_gating, - supersedes_post_id: None, - }, - )); - } - Ok(result) - } - - /// Newest live registry entry for `author`: - /// `(timestamp_ms, expires_at_ms)`. Used by unregister + auto-renew. - pub fn get_newest_registry_entry( - &self, - registry_post_id: &PostId, - author: &NodeId, - ) -> anyhow::Result> { - let result = self.conn.query_row( - "SELECT timestamp_ms, expires_at FROM comments - WHERE post_id = ?1 AND author = ?2 AND deleted_at IS NULL - ORDER BY timestamp_ms DESC LIMIT 1", - params![registry_post_id.as_slice(), author.as_slice()], - |row| { - let ts: i64 = row.get(0)?; - let exp: Option = row.get(1)?; - Ok((ts as u64, exp.unwrap_or(0) as u64)) - }, - ); - match result { - Ok(pair) => Ok(Some(pair)), - Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), - Err(e) => Err(e.into()), - } - } - - /// Latest `VisibilityIntent::Profile` post authored by `author` — - /// the persona's current bio post (the v1 greeting return path). - pub fn get_latest_profile_post_id_by_author( - &self, - author: &NodeId, - ) -> anyhow::Result> { - let result = self.conn.query_row( - "SELECT id FROM posts - WHERE author = ?1 AND visibility_intent = '\"Profile\"' - ORDER BY timestamp_ms DESC LIMIT 1", - params![author.as_slice()], - |row| row.get::<_, Vec>(0), - ); - match result { - Ok(bytes) => Ok(Some(blob_to_postid(bytes)?)), - Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), - Err(e) => Err(e.into()), - } - } - // --- Engagement: comment policies --- /// Store or update a comment policy for a post. @@ -6843,6 +6243,8 @@ fn row_to_social_route(row: &rusqlite::Row) -> anyhow::Result let last_seen_ms = row.get::<_, i64>(6)? as u64; let method_str: String = row.get(7)?; let reach_method: ReachMethod = method_str.parse().unwrap_or(ReachMethod::Direct); + let pref_tree_json: String = row.get::<_, String>(8).unwrap_or_else(|_| "[]".to_string()); + let preferred_tree = parse_anchors_json(&pref_tree_json); Ok(SocialRouteEntry { node_id, @@ -6853,6 +6255,7 @@ fn row_to_social_route(row: &rusqlite::Row) -> anyhow::Result last_connected_ms, last_seen_ms, reach_method, + preferred_tree, }) } @@ -7021,12 +6424,8 @@ mod tests { assert!(s.is_relay_cooldown(&target, 1).unwrap()); } - fn nodes(ids: &[NodeId]) -> Vec { - ids.iter().map(|id| ReachEntry::node(*id)).collect() - } - #[test] - fn reach_crud() { + fn n2_n3_crud() { let s = temp_storage(); let reporter_a = make_node_id(1); let reporter_b = make_node_id(2); @@ -7034,285 +6433,69 @@ mod tests { let node_y = make_node_id(11); let node_z = make_node_id(12); - s.set_reach(&reporter_a, 2, &nodes(&[node_x, node_y])).unwrap(); - assert_eq!(s.find_in_n2(&node_x).unwrap(), vec![reporter_a]); + // Set reporter_a's N1 (their connections) → our N2 + s.set_peer_n1(&reporter_a, &[node_x, node_y]).unwrap(); + let found = s.find_in_n2(&node_x).unwrap(); + assert_eq!(found, vec![reporter_a]); - s.set_reach(&reporter_b, 2, &nodes(&[node_y, node_z])).unwrap(); - assert_eq!(s.find_in_n2(&node_y).unwrap().len(), 2); + // Set reporter_b's N1 → our N2 + s.set_peer_n1(&reporter_b, &[node_y, node_z]).unwrap(); + let found = s.find_in_n2(&node_y).unwrap(); + assert_eq!(found.len(), 2); // Both reporters have node_y - assert_eq!(s.list_reach_ids_at(2).unwrap().len(), 3); + // Build N2 share (deduplicated) + let n2_share = s.build_n2_share().unwrap(); + assert_eq!(n2_share.len(), 3); // node_x, node_y, node_z - // Disconnect cleanup wipes every bounce for that reporter. - s.set_reach(&reporter_a, 4, &nodes(&[node_z])).unwrap(); - let cleared = s.clear_peer_reach(&reporter_a).unwrap(); - assert_eq!(cleared, 3); - assert!(s.find_in_n2(&node_x).unwrap().is_empty()); - assert!(s.find_in_n4(&node_z).unwrap().is_empty()); + // Clear reporter_a's N2 contributions + let cleared = s.clear_peer_n2(&reporter_a).unwrap(); + assert_eq!(cleared, 2); + let found = s.find_in_n2(&node_x).unwrap(); + assert!(found.is_empty()); - // N3 + N4 land at their own bounces, reporter-tagged. - s.set_reach(&reporter_a, 3, &nodes(&[node_z])).unwrap(); - assert_eq!(s.find_in_n3(&node_z).unwrap(), vec![reporter_a]); - s.set_reach(&reporter_b, 4, &nodes(&[node_x])).unwrap(); - assert_eq!(s.find_in_n4(&node_x).unwrap(), vec![reporter_b]); - assert_eq!(s.count_distinct_n4().unwrap(), 1); + // N3 operations + s.set_peer_n2(&reporter_a, &[node_z]).unwrap(); + let found = s.find_in_n3(&node_z).unwrap(); + assert_eq!(found, vec![reporter_a]); + + s.clear_peer_n3(&reporter_a).unwrap(); + let found = s.find_in_n3(&node_z).unwrap(); + assert!(found.is_empty()); } - /// Store-side dedup: one reporter cannot place one ID at two depths — the - /// shallowest sighting wins. #[test] - fn reach_store_dedup_keeps_shallowest_bounce() { + fn n1_share_build() { let s = temp_storage(); - let reporter = make_node_id(1); - let target = make_node_id(10); + let peer_a = make_node_id(1); + let follow_b = make_node_id(2); + let addr: std::net::SocketAddr = "10.0.0.1:4433".parse().unwrap(); - s.set_reach(&reporter, 4, &nodes(&[target])).unwrap(); - assert_eq!(s.find_in_n4(&target).unwrap(), vec![reporter]); - - // Same reporter now reports it shallower. - s.add_reach(&reporter, 2, &nodes(&[target])).unwrap(); - assert_eq!(s.find_in_n2(&target).unwrap(), vec![reporter]); - assert!(s.find_in_n4(&target).unwrap().is_empty(), "must not occupy two depths"); - - // A deeper re-report does not push it back down. - s.add_reach(&reporter, 3, &nodes(&[target])).unwrap(); - assert_eq!(s.find_in_n2(&target).unwrap(), vec![reporter]); - } - - /// §layers privacy invariant: addresses exist ONLY on anchor entries. - #[test] - fn reach_addresses_only_on_anchors() { - let s = temp_storage(); - let reporter = make_node_id(1); - let plain = make_node_id(10); - let anchor = make_node_id(11); - - // A non-anchor entry that arrives carrying addresses must be blanked. - let sneaky = ReachEntry { - id: plain, - id_class: IdClass::Node, - is_anchor: false, - addresses: vec!["1.2.3.4:4433".to_string()], - }; - s.set_reach( - &reporter, - 2, - &[sneaky, ReachEntry::anchor(anchor, vec!["5.6.7.8:4433".to_string()])], - ) - .unwrap(); - - let leaked: i64 = s - .conn - .query_row( - "SELECT COUNT(*) FROM reachable WHERE is_anchor = 0 AND addresses != '[]'", - [], - |r| r.get(0), - ) - .unwrap(); - assert_eq!(leaked, 0, "non-anchor entries must never carry an address"); - - let mined = s.list_pool_anchors(10).unwrap(); - assert_eq!(mined.len(), 1); - assert_eq!(mined[0].0, anchor); - assert_eq!(mined[0].1, vec!["5.6.7.8:4433".to_string()]); - - // Anchor density is a free local statistic (2 distinct node IDs, 1 anchor). - assert!((s.anchor_density().unwrap() - 0.5).abs() < 1e-9); - } - - /// Author-class IDs must never be offered as connect targets. - #[test] - fn author_class_excluded_from_growth_candidates() { - let s = temp_storage(); - let reporter = make_node_id(1); - let node = make_node_id(10); - let persona = make_node_id(11); - - s.set_reach(&reporter, 2, &[ReachEntry::node(node), ReachEntry::author(persona)]) - .unwrap(); - - let scored = s.score_n2_candidates_batch().unwrap(); - let ids: Vec = scored.iter().map(|(id, _, _)| *id).collect(); - assert!(ids.contains(&node)); - assert!(!ids.contains(&persona), "persona IDs are not connectable"); - - // ...but they ARE findable, which is the whole point of carrying them. - assert!(s.list_reach_ids_at(2).unwrap().contains(&persona)); - } - - /// The announce builder must never read bounce-4 rows: N4 is terminal. - #[test] - fn n4_never_appears_in_a_built_announce() { - let s = temp_storage(); - let us = make_node_id(0); - let reporter = make_node_id(1); - let n2_id = make_node_id(10); - let n3_id = make_node_id(11); - let n4_id = make_node_id(12); - - s.set_reach(&reporter, 2, &nodes(&[n2_id])).unwrap(); - s.set_reach(&reporter, 3, &nodes(&[n3_id])).unwrap(); - s.set_reach(&reporter, 4, &nodes(&[n4_id])).unwrap(); - - let pools = s.build_uniques_pools(&us, None).unwrap(); - let all: Vec = pools - .fwd - .iter() - .flatten() - .chain(pools.term.iter()) - .map(|e| e.id) - .collect(); - - assert!(all.contains(&n2_id), "our N2 rides pool 1"); - assert!(all.contains(&n3_id), "our N3 rides pool 2 (terminal)"); - assert!(!all.contains(&n4_id), "N4 must NEVER be re-announced"); - - // And it is in the right pool. - assert!(pools.fwd[2].iter().any(|e| e.id == n2_id)); - assert!(pools.term.iter().any(|e| e.id == n3_id)); - } - - /// Announce-side dedup: an ID announced shallow is not repeated deeper. - #[test] - fn announce_dedup_across_pool_slices() { - let s = temp_storage(); - let us = make_node_id(0); - let reporter = make_node_id(1); - let dup = make_node_id(10); - - // Same ID known at bounce 2 AND bounce 3 (via different reporters). - s.set_reach(&reporter, 2, &nodes(&[dup])).unwrap(); - s.set_reach(&make_node_id(2), 3, &nodes(&[dup])).unwrap(); - - let pools = s.build_uniques_pools(&us, None).unwrap(); - let count = pools - .fwd - .iter() - .flatten() - .chain(pools.term.iter()) - .filter(|e| e.id == dup) - .count(); - assert_eq!(count, 1, "an ID must appear in exactly one pool slice"); - assert!(pools.fwd[2].iter().any(|e| e.id == dup), "shallowest slice wins"); - - // Our own node id is never repeated deeper either. - let self_count = pools - .fwd - .iter() - .flatten() - .chain(pools.term.iter()) - .filter(|e| e.id == us) - .count(); - assert_eq!(self_count, 1); - } - - /// Our own uniques merge every source, and anchor addresses only attach to - /// peers actually flagged as anchors. - #[test] - fn own_uniques_merge_sources_and_flag_anchors() { - let s = temp_storage(); - let mesh = make_node_id(1); - let direct = make_node_id(2); - let anchor = make_node_id(3); - let addr: std::net::SocketAddr = "203.0.113.7:4433".parse().unwrap(); - - s.add_mesh_peer(&mesh).unwrap(); + // Add a mesh peer + s.add_mesh_peer(&peer_a, PeerSlotKind::Local, 0).unwrap(); + // Add a follow with social route + s.add_follow(&follow_b).unwrap(); s.upsert_social_route(&SocialRouteEntry { - node_id: direct, - addresses: vec![], + node_id: follow_b, + addresses: vec![addr], peer_addresses: vec![], relation: SocialRelation::Follow, - // Deliberately Disconnected: the ruling is "everyone's directs". status: SocialStatus::Disconnected, last_connected_ms: 0, last_seen_ms: 1000, reach_method: ReachMethod::Direct, - }) - .unwrap(); - // An anchor we have a LIVE PATH to (here: a social direct) carries its - // address. Anchor flagging ENRICHES a unique; it never imports one. - s.upsert_peer(&direct, &[addr], None).unwrap(); - s.set_peer_anchor(&direct, true).unwrap(); + preferred_tree: vec![], + }).unwrap(); - // An anchor-flagged peers row with no live path — e.g. one we merely - // heard about — must NOT become a bounce-1 unique of ours. - s.upsert_peer(&anchor, &[addr], None).unwrap(); - s.set_peer_anchor(&anchor, true).unwrap(); + // Disconnected routes should NOT be in N1 share + let n1 = s.build_n1_share().unwrap(); + assert!(n1.contains(&peer_a)); + assert!(!n1.contains(&follow_b), "Disconnected social route should not be in N1"); - let own = s.build_own_uniques().unwrap(); - let ids: Vec = own.iter().map(|e| e.id).collect(); - assert!(ids.contains(&mesh)); - assert!(ids.contains(&direct), "held routes count even when offline"); - assert!( - !ids.contains(&anchor), - "an anchor we merely have a row for is not a live path of ours" - ); - - let anchor_entry = own.iter().find(|e| e.id == direct).unwrap(); - assert!(anchor_entry.is_anchor); - assert_eq!(anchor_entry.addresses, vec![addr.to_string()]); - - let mesh_entry = own.iter().find(|e| e.id == mesh).unwrap(); - assert!(!mesh_entry.is_anchor); - assert!(mesh_entry.addresses.is_empty(), "non-anchors are bare IDs"); - } - - /// design.html §21: "Posting identities never map to device addresses on - /// the wire." Our node ID (and our anchor address) ride `fwd[0]` of the - /// same announce, so a persona of ours in `fwd[1]` IS that mapping. - #[test] - fn own_personas_and_local_comment_authors_never_ride_our_announce() { - let s = temp_storage(); - let us = make_node_id(0); - // Plural personas per the A1 rule — not just the default posting id. - let persona = add_persona(&s, 66); - let persona2 = add_persona(&s, 67); - let throwaway = make_node_id(77); - let foreign_author = make_node_id(88); - - // CDN manifests we authored (node.rs stores these with OUR posting id). - s.store_cdn_manifest(&make_post_id(1), "{}", &persona, 1).unwrap(); - s.store_cdn_manifest(&make_post_id(2), "{}", &persona2, 1).unwrap(); - - let mk = |author: NodeId, ts: u64| crate::types::InlineComment { - author, - post_id: [9u8; 32], - content: "hi".into(), - timestamp_ms: ts, - signature: vec![1, 2, 3], - ref_post_id: None, - deleted_at: None, - pub_x_index: None, - group_sig: None, - encrypted_payload: None, - expires_at_ms: 0, - }; - // Our greeting: a per-greeting throwaway ID we minted. We would be the - // FIRST node in the network to announce it, at bounce 1, the instant it - // was created — first-announcer identifies the greeter. - s.store_own_comment(&mk(throwaway, 100)).unwrap(); - // Someone else's comment reached us from the network — that author is - // legitimate uniques material (anonymity noise, round-1 ruling). - s.store_comment(&mk(foreign_author, 200)).unwrap(); - - let own = s.build_own_uniques().unwrap(); - let ids: Vec = own.iter().map(|e| e.id).collect(); - assert!(!ids.contains(&persona), "our own persona must never be in our N1"); - assert!(!ids.contains(&persona2), "every persona, not just the default"); - assert!(!ids.contains(&throwaway), "our greeting throwaway must never be in our N1"); - assert!(ids.contains(&foreign_author), "other people's authors still count"); - - // ...and it holds through the pool builder, which is what goes on the wire. - let pools = s.build_uniques_pools(&us, None).unwrap(); - let all: Vec = pools - .fwd - .iter() - .flatten() - .chain(pools.term.iter()) - .map(|e| e.id) - .collect(); - assert!(!all.contains(&persona)); - assert!(!all.contains(&persona2)); - assert!(!all.contains(&throwaway)); + // Set to Online — now it should be included + s.set_social_route_status(&follow_b, SocialStatus::Online).unwrap(); + let n1 = s.build_n1_share().unwrap(); + assert!(n1.contains(&peer_a)); + assert!(n1.contains(&follow_b), "Online social route should be in N1"); } #[test] @@ -7324,9 +6507,9 @@ mod tests { let shared_node = make_node_id(11); // reporter_a has unique_node + shared_node - s.set_reach(&reporter_a, 2, &nodes(&[unique_node, shared_node])).unwrap(); + s.set_peer_n1(&reporter_a, &[unique_node, shared_node]).unwrap(); // reporter_b only has shared_node - s.set_reach(&reporter_b, 2, &nodes(&[shared_node])).unwrap(); + s.set_peer_n1(&reporter_b, &[shared_node]).unwrap(); // reporter_a contributes 1 unique node (unique_node) let unique = s.count_unique_n2_for_reporter(&reporter_a, &[]).unwrap(); @@ -7337,40 +6520,41 @@ mod tests { assert_eq!(unique, 0); } - /// N4 is USED for search even though it is never re-announced. #[test] - fn find_any_reachable_spans_the_whole_horizon() { + fn find_any_in_n2_n3() { let s = temp_storage(); let reporter = make_node_id(1); let node_n2 = make_node_id(10); let node_n3 = make_node_id(11); - let node_n4 = make_node_id(12); - let node_nowhere = make_node_id(13); + let node_nowhere = make_node_id(12); - s.set_reach(&reporter, 2, &nodes(&[node_n2])).unwrap(); - s.set_reach(&make_node_id(2), 3, &nodes(&[node_n3])).unwrap(); - s.set_reach(&make_node_id(3), 4, &nodes(&[node_n4])).unwrap(); + s.set_peer_n1(&reporter, &[node_n2]).unwrap(); + s.set_peer_n2(&reporter, &[node_n3]).unwrap(); - let results = s - .find_any_reachable(&[node_n2, node_n3, node_n4, node_nowhere]) - .unwrap(); - assert_eq!(results.len(), 3); - assert_eq!(results[0].2, 2); - assert_eq!(results[1].2, 3); - assert_eq!(results[2].2, 4); + let results = s.find_any_in_n2_n3(&[node_n2, node_n3, node_nowhere]).unwrap(); + assert_eq!(results.len(), 2); + assert_eq!(results[0].2, 2); // N2 first + assert_eq!(results[1].2, 3); // N3 second } #[test] fn mesh_peers_crud() { + use crate::types::PeerSlotKind; let s = temp_storage(); let nid = make_node_id(1); - s.add_mesh_peer(&nid).unwrap(); + s.add_mesh_peer(&nid, PeerSlotKind::Local, 4).unwrap(); let peers = s.list_mesh_peers().unwrap(); - assert_eq!(peers, vec![nid]); + assert_eq!(peers.len(), 1); + assert_eq!(peers[0].0, nid); + assert_eq!(peers[0].1, "local"); + assert_eq!(peers[0].2, 4); + + assert_eq!(s.count_mesh_peers_by_kind(PeerSlotKind::Local).unwrap(), 1); + assert_eq!(s.count_mesh_peers_by_kind(PeerSlotKind::Wide).unwrap(), 0); s.remove_mesh_peer(&nid).unwrap(); - assert!(s.list_mesh_peers().unwrap().is_empty()); + assert_eq!(s.count_mesh_peers_by_kind(PeerSlotKind::Local).unwrap(), 0); } // ---- Social routes tests ---- @@ -7394,6 +6578,7 @@ mod tests { last_connected_ms: 1000, last_seen_ms: 2000, reach_method: ReachMethod::Direct, + preferred_tree: vec![], }; s.upsert_social_route(&entry).unwrap(); @@ -7453,6 +6638,7 @@ mod tests { last_connected_ms: 0, last_seen_ms: 0, reach_method: ReachMethod::Direct, + preferred_tree: vec![], }; s.upsert_social_route(&entry).unwrap(); @@ -7794,21 +6980,235 @@ mod tests { assert_eq!(got_pubkey, expected_pubkey); } + // ---- Preferred peers tests ---- + #[test] - fn slot_counts() { - // v0.8: ONE mesh pool. Local/Wide collapsed. - assert_eq!(DeviceProfile::Desktop.mesh_slots(), 20); - assert_eq!(DeviceProfile::Mobile.mesh_slots(), 15); + fn preferred_peers_crud() { + let s = temp_storage(); + let peer_a = make_node_id(1); + let peer_b = make_node_id(2); - // Temp referral slots sit ABOVE the mesh cap, +4..+10 (round-3 ruling). - for p in [DeviceProfile::Desktop, DeviceProfile::Mobile] { - let t = p.temp_referral_slots(); - assert!((4..=10).contains(&t), "temp referral band out of range: {t}"); - } + assert_eq!(s.count_preferred_peers().unwrap(), 0); + assert!(!s.is_preferred_peer(&peer_a).unwrap()); - // Device-tiered depth: mobile caps the stored horizon at 3 bounces. - assert_eq!(DeviceProfile::Desktop.max_bounce(), 4); - assert_eq!(DeviceProfile::Mobile.max_bounce(), 3); + s.add_preferred_peer(&peer_a).unwrap(); + s.add_preferred_peer(&peer_b).unwrap(); + + assert_eq!(s.count_preferred_peers().unwrap(), 2); + assert!(s.is_preferred_peer(&peer_a).unwrap()); + assert!(s.is_preferred_peer(&peer_b).unwrap()); + + let list = s.list_preferred_peers().unwrap(); + assert_eq!(list.len(), 2); + assert!(list.contains(&peer_a)); + assert!(list.contains(&peer_b)); + + s.remove_preferred_peer(&peer_a).unwrap(); + assert!(!s.is_preferred_peer(&peer_a).unwrap()); + assert_eq!(s.count_preferred_peers().unwrap(), 1); + } + + #[test] + fn preferred_peers_idempotent() { + let s = temp_storage(); + let peer = make_node_id(1); + + s.add_preferred_peer(&peer).unwrap(); + s.add_preferred_peer(&peer).unwrap(); // no error on duplicate + assert_eq!(s.count_preferred_peers().unwrap(), 1); + } + + #[test] + fn profile_stores_preferred_peers() { + let s = temp_storage(); + let nid = make_node_id(1); + let pref_a = make_node_id(10); + let pref_b = make_node_id(11); + + let profile = PublicProfile { + node_id: nid, + display_name: "test".to_string(), + bio: "".to_string(), + updated_at: 1000, + anchors: vec![], + recent_peers: vec![], + preferred_peers: vec![pref_a, pref_b], + public_visible: true, + avatar_cid: None, + }; + s.store_profile(&profile).unwrap(); + + let got = s.get_profile(&nid).unwrap().unwrap(); + assert_eq!(got.preferred_peers.len(), 2); + assert!(got.preferred_peers.contains(&pref_a)); + assert!(got.preferred_peers.contains(&pref_b)); + assert!(got.public_visible); + assert!(got.avatar_cid.is_none()); + } + + #[test] + fn preferred_slot_counts() { + assert_eq!(DeviceProfile::Desktop.preferred_slots(), 10); + assert_eq!(DeviceProfile::Desktop.local_slots(), 71); + assert_eq!(DeviceProfile::Mobile.preferred_slots(), 3); + assert_eq!(DeviceProfile::Mobile.local_slots(), 7); + // Total unchanged + assert_eq!( + DeviceProfile::Desktop.preferred_slots() + DeviceProfile::Desktop.local_slots() + DeviceProfile::Desktop.wide_slots(), + 101 + ); + assert_eq!( + DeviceProfile::Mobile.preferred_slots() + DeviceProfile::Mobile.local_slots() + DeviceProfile::Mobile.wide_slots(), + 15 + ); + } + + #[test] + fn peer_slot_kind_preferred_roundtrip() { + let kind: PeerSlotKind = "preferred".parse().unwrap(); + assert_eq!(kind, PeerSlotKind::Preferred); + assert_eq!(kind.to_string(), "preferred"); + } + + // ---- Preferred tree tests ---- + + #[test] + fn build_preferred_tree_empty() { + let s = temp_storage(); + let target = make_node_id(1); + // No profile stored — tree should just contain the target + let tree = s.build_preferred_tree_for(&target).unwrap(); + assert_eq!(tree.len(), 1); + assert!(tree.contains(&target)); + } + + #[test] + fn build_preferred_tree_two_layers() { + let s = temp_storage(); + let target = make_node_id(1); + let l1_a = make_node_id(10); + let l1_b = make_node_id(11); + let l2_a1 = make_node_id(20); + let l2_a2 = make_node_id(21); + let l2_b1 = make_node_id(30); + + // Target's profile with 2 preferred peers + s.store_profile(&PublicProfile { + node_id: target, + display_name: "target".to_string(), + bio: "".to_string(), + updated_at: 1000, + anchors: vec![], + recent_peers: vec![], + preferred_peers: vec![l1_a, l1_b], + public_visible: true, + avatar_cid: None, + }).unwrap(); + + // L1 peer A's profile with 2 preferred peers + s.store_profile(&PublicProfile { + node_id: l1_a, + display_name: "l1a".to_string(), + bio: "".to_string(), + updated_at: 1000, + anchors: vec![], + recent_peers: vec![], + preferred_peers: vec![l2_a1, l2_a2], + public_visible: true, + avatar_cid: None, + }).unwrap(); + + // L1 peer B's profile with 1 preferred peer + s.store_profile(&PublicProfile { + node_id: l1_b, + display_name: "l1b".to_string(), + bio: "".to_string(), + updated_at: 1000, + anchors: vec![], + recent_peers: vec![], + preferred_peers: vec![l2_b1], + public_visible: true, + avatar_cid: None, + }).unwrap(); + + let tree = s.build_preferred_tree_for(&target).unwrap(); + // Should contain: target, l1_a, l1_b, l2_a1, l2_a2, l2_b1 = 6 unique nodes + assert_eq!(tree.len(), 6); + assert!(tree.contains(&target)); + assert!(tree.contains(&l1_a)); + assert!(tree.contains(&l1_b)); + assert!(tree.contains(&l2_a1)); + assert!(tree.contains(&l2_a2)); + assert!(tree.contains(&l2_b1)); + } + + #[test] + fn build_preferred_tree_deduplicates() { + let s = temp_storage(); + let target = make_node_id(1); + let shared = make_node_id(10); + let l1_a = make_node_id(11); + + // Target's preferred peers include shared + s.store_profile(&PublicProfile { + node_id: target, + display_name: "target".to_string(), + bio: "".to_string(), + updated_at: 1000, + anchors: vec![], + recent_peers: vec![], + preferred_peers: vec![l1_a, shared], + public_visible: true, + avatar_cid: None, + }).unwrap(); + + // L1 peer's preferred peers also include shared + s.store_profile(&PublicProfile { + node_id: l1_a, + display_name: "l1a".to_string(), + bio: "".to_string(), + updated_at: 1000, + anchors: vec![], + recent_peers: vec![], + preferred_peers: vec![shared, target], + public_visible: true, + avatar_cid: None, + }).unwrap(); + + let tree = s.build_preferred_tree_for(&target).unwrap(); + // Should contain: target, l1_a, shared = 3 unique nodes (no duplicates) + assert_eq!(tree.len(), 3); + } + + #[test] + fn social_route_preferred_tree_roundtrip() { + use crate::types::{ReachMethod, SocialRelation, SocialRouteEntry, SocialStatus}; + let s = temp_storage(); + let nid = make_node_id(1); + let tree_node = make_node_id(10); + + let entry = SocialRouteEntry { + node_id: nid, + addresses: vec![], + peer_addresses: vec![], + relation: SocialRelation::Follow, + status: SocialStatus::Online, + last_connected_ms: 0, + last_seen_ms: 1000, + reach_method: ReachMethod::Direct, + preferred_tree: vec![tree_node], + }; + s.upsert_social_route(&entry).unwrap(); + + let got = s.get_social_route(&nid).unwrap().unwrap(); + assert_eq!(got.preferred_tree.len(), 1); + assert!(got.preferred_tree.contains(&tree_node)); + + // Update preferred tree + let new_tree = vec![make_node_id(20), make_node_id(21)]; + s.update_social_route_preferred_tree(&nid, &new_tree).unwrap(); + let got2 = s.get_social_route(&nid).unwrap().unwrap(); + assert_eq!(got2.preferred_tree.len(), 2); } // ---- Circle Profile tests ---- @@ -7872,6 +7272,7 @@ mod tests { updated_at: 1000, anchors: vec![], recent_peers: vec![], + preferred_peers: vec![], public_visible: true, avatar_cid: None, }).unwrap(); @@ -7892,13 +7293,13 @@ mod tests { s.set_circle_profile(&cp).unwrap(); // Viewer in circle sees circle profile - let (dn, bio, avatar) = s.resolve_display_for_peer(&author, &[viewer]).unwrap(); + let (dn, bio, avatar) = s.resolve_display_for_peer(&author, &viewer).unwrap(); assert_eq!(dn, "Alice (CF)"); assert_eq!(bio, "Circle bio"); assert_eq!(avatar, Some([99u8; 32])); // Stranger sees public profile - let (dn2, bio2, avatar2) = s.resolve_display_for_peer(&author, &[stranger]).unwrap(); + let (dn2, bio2, avatar2) = s.resolve_display_for_peer(&author, &stranger).unwrap(); assert_eq!(dn2, "Alice Public"); assert_eq!(bio2, "Public bio"); assert!(avatar2.is_none()); @@ -7918,12 +7319,13 @@ mod tests { updated_at: 1000, anchors: vec![], recent_peers: vec![], + preferred_peers: vec![], public_visible: false, avatar_cid: None, }).unwrap(); // Stranger sees nothing - let (dn, bio, avatar) = s.resolve_display_for_peer(&author, &[stranger]).unwrap(); + let (dn, bio, avatar) = s.resolve_display_for_peer(&author, &stranger).unwrap(); assert!(dn.is_empty()); assert!(bio.is_empty()); assert!(avatar.is_none()); @@ -7942,6 +7344,7 @@ mod tests { updated_at: 1000, anchors: vec![], recent_peers: vec![], + preferred_peers: vec![], public_visible: true, avatar_cid: None, }).unwrap(); @@ -7964,33 +7367,29 @@ mod tests { s.upsert_known_anchor(&a2, &[addr]).unwrap(); s.upsert_known_anchor(&a3, &[addr]).unwrap(); - // Re-contacting a1 refreshes it. Ordering is by RECENCY, not by - // success_count: under pool-mined anchor abundance "the anchor that - // answered most often" is not a better anchor, just the one we - // hammered (round-4 ruling). - std::thread::sleep(std::time::Duration::from_millis(2)); + // a1 gets extra success bumps + s.upsert_known_anchor(&a1, &[addr]).unwrap(); s.upsert_known_anchor(&a1, &[addr]).unwrap(); let anchors = s.list_known_anchors().unwrap(); assert_eq!(anchors.len(), 3); - assert_eq!(anchors[0].0, a1, "freshest anchor first"); + // a1 should be first (highest success_count = 3) + assert_eq!(anchors[0].0, a1); } #[test] - fn known_anchors_prune_to_the_bootstrap_cache_cap() { + fn known_anchors_prune() { let s = temp_storage(); let addr: SocketAddr = "10.0.0.1:4433".parse().unwrap(); - // The old cap was 5. `apply_uniques_to_storage` now mirrors every - // received anchor entry into this table, so at 5 it would thrash on - // every announce. - for i in 0..(Storage::KNOWN_ANCHOR_CACHE_CAP as u8 + 8) { + for i in 0..10u8 { let nid = make_node_id(i + 1); s.upsert_known_anchor(&nid, &[addr]).unwrap(); } + // Auto-prune should keep only 5 let anchors = s.list_known_anchors().unwrap(); - assert_eq!(anchors.len(), Storage::KNOWN_ANCHOR_CACHE_CAP); + assert_eq!(anchors.len(), 5); } #[test] @@ -8089,7 +7488,7 @@ mod tests { let reactions = s.get_reactions(&post_id).unwrap(); assert_eq!(reactions.len(), 3); - let counts = s.get_reaction_counts(&post_id, &[me]).unwrap(); + let counts = s.get_reaction_counts(&post_id, &me).unwrap(); assert_eq!(counts.len(), 2); // 👍 and ❤️ // 👍 has 2 reactions, one from me let thumbs = counts.iter().find(|(e, _, _)| e == "👍").unwrap(); @@ -8121,7 +7520,6 @@ mod tests { pub_x_index: None, group_sig: None, encrypted_payload: None, - expires_at_ms: 0, }).unwrap(); s.store_comment(&InlineComment { @@ -8135,7 +7533,6 @@ mod tests { pub_x_index: None, group_sig: None, encrypted_payload: None, - expires_at_ms: 0, }).unwrap(); let comments = s.get_comments(&post_id).unwrap(); @@ -8164,7 +7561,6 @@ mod tests { pub_x_index: None, group_sig: None, encrypted_payload: None, - expires_at_ms: 0, }).unwrap(); let live = s.get_comments(&post_id).unwrap(); @@ -8253,287 +7649,4 @@ mod tests { assert!(s.get_thread_parent(&parent).unwrap().is_none()); } - - // --- A1 ID-class fixes: multi-persona coverage --- - - fn make_post(author: NodeId, ts: u64) -> Post { - Post { - author, - content: format!("post at {ts}"), - attachments: vec![], - timestamp_ms: ts, - fof_gating: None, - supersedes_post_id: None, - } - } - - fn add_persona(s: &Storage, byte: u8) -> NodeId { - let id = make_node_id(byte); - s.upsert_posting_identity(&PostingIdentity { - node_id: id, - secret_seed: [byte; 32], - display_name: String::new(), - created_at: byte as u64, - }).unwrap(); - id - } - - /// Replication (bug A1-3): own posts are found by unioning over ALL - /// posting identities; a network-NodeId query finds nothing. - #[test] - fn own_recent_posts_found_across_personas_not_network_id() { - let s = temp_storage(); - let network_id = make_node_id(9); - let persona1 = add_persona(&s, 1); - let persona2 = add_persona(&s, 2); - - s.store_post(&make_post_id(11), &make_post(persona1, 1000)).unwrap(); - s.store_post(&make_post_id(12), &make_post(persona2, 2000)).unwrap(); - - // The old bug: querying by the network id → always empty. - assert!(s.get_own_recent_post_ids(&network_id, 0).unwrap().is_empty()); - - // The fix pattern: union across all posting identities. - let mut found = Vec::new(); - for pi in s.list_posting_identities().unwrap() { - found.extend(s.get_own_recent_post_ids(&pi.node_id, 0).unwrap()); - } - assert_eq!(found.len(), 2, "posts from BOTH personas must be found"); - } - - /// Reaction "mine" flag (caller-side bug): reactors are posting ids; - /// the flag must light up for a reaction from ANY persona and never for - /// the network id. - #[test] - fn reaction_mine_flag_multi_persona() { - let s = temp_storage(); - let network_id = make_node_id(9); - let persona1 = make_node_id(1); - let persona2 = make_node_id(2); - let post_id = make_post_id(21); - - s.store_reaction(&Reaction { - reactor: persona2, - emoji: "👍".to_string(), - post_id, - timestamp_ms: 1000, - encrypted_payload: None, - deleted_at: None, - signature: vec![], - }).unwrap(); - - // Old bug shape: network id as "me" → never mine. - let counts = s.get_reaction_counts(&post_id, &[network_id]).unwrap(); - assert!(!counts[0].2, "network id must never own a reaction"); - - // Fix: all personas → reaction by the SECOND persona is mine. - let counts = s.get_reaction_counts(&post_id, &[persona1, persona2]).unwrap(); - assert!(counts[0].2, "reaction by any persona must count as mine"); - - // Batch variant behaves identically. - let batch = s.get_reaction_counts_batch(&[post_id], &[persona1, persona2]).unwrap(); - assert!(batch.get(&post_id).unwrap()[0].2); - let batch = s.get_reaction_counts_batch(&[post_id], &[network_id]).unwrap(); - assert!(!batch.get(&post_id).unwrap()[0].2); - } - - /// Circle display resolution (bug A1-11): the viewer join is against - /// posting-class circle members — a second persona's membership must - /// resolve, the network id must not. - #[test] - fn resolve_display_for_second_persona_viewer() { - let s = temp_storage(); - let network_id = make_node_id(9); - let persona1 = make_node_id(1); - let persona2 = make_node_id(2); - let author = make_node_id(5); - - s.create_circle("friends").unwrap(); - // Only our SECOND persona is a member of the author's circle mirror. - s.add_circle_member("friends", &persona2).unwrap(); - s.set_circle_profile(&CircleProfile { - author, - circle_name: "friends".to_string(), - display_name: "Circle Name".to_string(), - bio: "circle bio".to_string(), - avatar_cid: None, - updated_at: 1000, - }).unwrap(); - - // Old bug shape: network id as viewer → no circle match. - let (dn, _, _) = s.resolve_display_for_peer(&author, &[network_id]).unwrap(); - assert_eq!(dn, "", "network-id viewer must not resolve circle profiles"); - - // Fix: all personas as viewers → second persona matches. - let (dn, bio, _) = s.resolve_display_for_peer(&author, &[persona1, persona2]).unwrap(); - assert_eq!(dn, "Circle Name"); - assert_eq!(bio, "circle bio"); - } - - /// Circle revocation (bug A1-9): posts stored under per-persona authors - /// are found by per-persona circle-intent queries, not by the network id. - #[test] - fn circle_intent_posts_found_per_persona() { - let s = temp_storage(); - let network_id = make_node_id(9); - let persona1 = add_persona(&s, 1); - let persona2 = add_persona(&s, 2); - - let vis = PostVisibility::Encrypted { recipients: vec![] }; - s.store_post_with_intent( - &make_post_id(31), - &make_post(persona1, 1000), - &vis, - &VisibilityIntent::Circle("crew".to_string()), - ).unwrap(); - s.store_post_with_intent( - &make_post_id(32), - &make_post(persona2, 2000), - &vis, - &VisibilityIntent::Circle("crew".to_string()), - ).unwrap(); - - // Old bug shape: query by network id → nothing. - assert!(s.find_posts_by_circle_intent("crew", &network_id).unwrap().is_empty()); - - // Fix pattern: union across personas finds both posts. - let mut found = 0; - for pi in s.list_posting_identities().unwrap() { - found += s.find_posts_by_circle_intent("crew", &pi.node_id).unwrap().len(); - } - assert_eq!(found, 2); - } - - // --- v0.8 (A3): comment TTL + greeting storage --- - - fn ttl_comment(author: NodeId, post_id: PostId, ts: u64, expires: u64) -> InlineComment { - InlineComment { - author, - post_id, - content: "hello".to_string(), - timestamp_ms: ts, - signature: vec![0u8; 64], - deleted_at: None, - ref_post_id: None, - pub_x_index: None, - group_sig: None, - encrypted_payload: None, - expires_at_ms: expires, - } - } - - fn test_now_ms() -> u64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_millis() as u64 - } - - /// The expires_at migration must be idempotent across re-opens of a - /// file-backed DB (pragma-guarded ALTER). - #[test] - fn expires_at_migration_idempotent() { - let dir = std::env::temp_dir().join(format!("itsgoin-a3-mig-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let db_path = dir.join("test.db"); - let db_str = db_path.to_str().unwrap().to_string(); - { - let s = Storage::open(&db_str).unwrap(); - let now = test_now_ms(); - s.store_comment(&ttl_comment(make_node_id(1), make_post_id(1), now, now + 60_000)).unwrap(); - } - // Re-open: migration runs again, must not fail or lose data. - { - let s = Storage::open(&db_str).unwrap(); - let comments = s.get_comments(&make_post_id(1)).unwrap(); - assert_eq!(comments.len(), 1); - assert!(comments[0].expires_at_ms > 0, "expiry survived re-open"); - } - let _ = std::fs::remove_dir_all(&dir); - } - - /// expire_comments hard-deletes only due rows; legacy (NULL/0) rows - /// are never touched. - #[test] - fn expire_comments_deletes_only_due_rows() { - let s = temp_storage(); - let post_id = make_post_id(2); - let now = test_now_ms(); - - s.store_comment(&ttl_comment(make_node_id(1), post_id, 1000, now - 1)).unwrap(); // due - s.store_comment(&ttl_comment(make_node_id(2), post_id, 1001, now + 60_000)).unwrap(); // live - s.store_comment(&ttl_comment(make_node_id(3), post_id, 1002, 0)).unwrap(); // legacy no-TTL - - assert_eq!(s.expire_comments(now).unwrap(), 1, "only the due row deleted"); - // Hard delete: gone even from the with-tombstones view. - let all = s.get_comments_with_tombstones(&post_id).unwrap(); - assert_eq!(all.len(), 2); - assert!(all.iter().all(|c| c.author != make_node_id(1))); - // Idempotent. - assert_eq!(s.expire_comments(now).unwrap(), 0); - } - - /// Read filters exclude expired-but-unswept rows (belt-and-suspenders - /// between sweeps). - #[test] - fn read_filters_exclude_expired_unswept() { - let s = temp_storage(); - let post_id = make_post_id(3); - let now = test_now_ms(); - - s.store_comment(&ttl_comment(make_node_id(1), post_id, 1000, now - 1)).unwrap(); // expired, unswept - s.store_comment(&ttl_comment(make_node_id(2), post_id, 1001, now + 60_000)).unwrap(); - - assert_eq!(s.get_comments(&post_id).unwrap().len(), 1); - assert_eq!(s.get_comments_with_tombstones(&post_id).unwrap().len(), 1); - assert_eq!(s.get_comment_count(&post_id).unwrap(), 1); - } - - #[test] - fn open_slot_comment_count_helper() { - let s = temp_storage(); - let post_id = make_post_id(4); - let now = test_now_ms(); - - for i in 0..3u8 { - let mut c = ttl_comment(make_node_id(10 + i), post_id, 1000 + i as u64, now + 60_000); - c.pub_x_index = Some(5); - s.store_comment(&c).unwrap(); - } - // One on a different slot; one expired on slot 5. - let mut other = ttl_comment(make_node_id(20), post_id, 2000, now + 60_000); - other.pub_x_index = Some(6); - s.store_comment(&other).unwrap(); - let mut expired = ttl_comment(make_node_id(21), post_id, 2001, now - 1); - expired.pub_x_index = Some(5); - s.store_comment(&expired).unwrap(); - - assert_eq!(s.count_unexpired_open_slot_comments(&post_id, 5).unwrap(), 3); - assert_eq!(s.count_unexpired_open_slot_comments(&post_id, 6).unwrap(), 1); - assert_eq!(s.count_unexpired_open_slot_comments(&post_id, 7).unwrap(), 0); - } - - #[test] - fn greeting_dismissals_and_reply_keys() { - let s = temp_storage(); - let author = make_node_id(1); - let post_id = make_post_id(5); - - assert!(!s.is_greeting_dismissed(&author, &post_id, 1000).unwrap()); - s.add_greeting_dismissal(&author, &post_id, 1000).unwrap(); - assert!(s.is_greeting_dismissed(&author, &post_id, 1000).unwrap()); - // Different timestamp = different greeting, not dismissed. - assert!(!s.is_greeting_dismissed(&author, &post_id, 1001).unwrap()); - // Idempotent re-dismiss. - s.add_greeting_dismissal(&author, &post_id, 1000).unwrap(); - - // Reply keys roundtrip. - let privkey = [7u8; 32]; - let pubkey = [8u8; 32]; - s.store_greeting_reply_key(&pubkey, &privkey, &post_id).unwrap(); - let keys = s.list_greeting_reply_keys().unwrap(); - assert_eq!(keys.len(), 1); - assert_eq!(keys[0].0, privkey); - assert_eq!(keys[0].1, post_id); - } } diff --git a/crates/core/src/types.rs b/crates/core/src/types.rs index faba589..aca1ea1 100644 --- a/crates/core/src/types.rs +++ b/crates/core/src/types.rs @@ -68,22 +68,7 @@ pub struct Attachment { pub size_bytes: u64, } -/// Public profile — plaintext, synced to all peers. -/// -/// v0.8 keying (dual-purpose struct — the `node_id` decides which class): -/// - NETWORK-id rows carry TOPOLOGY ONLY (anchors, recent_peers); -/// persona fields stay empty. They travel via -/// ProfileUpdate / InitialExchange, always through -/// `sanitized_for_network_broadcast()`. -/// - POSTING-id rows carry PERSONA fields (display_name, bio, avatar_cid, -/// public_visible); topology fields stay empty. They travel ONLY as -/// signed profile posts (`ProfilePostContent`), which have no topology -/// fields at all — so posting identities never advertise device -/// neighborhoods on the wire. -/// The topology NodeIds on network-id rows are the designed 11-needle -/// findability mechanism (device-to-device, no posting-id linkage) — an -/// accepted tradeoff, not a leak; see design.html v0.8 location-anonymity -/// ruling before reflagging in audits. +/// Public profile — plaintext, synced to all peers #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct PublicProfile { pub node_id: NodeId, @@ -99,6 +84,9 @@ pub struct PublicProfile { /// Up to 10 currently-connected peer NodeIds (for 11-needle worm search) #[serde(default)] pub recent_peers: Vec, + /// Bilateral preferred peer NodeIds (stable relay hubs) + #[serde(default)] + pub preferred_peers: Vec, /// Whether display_name/bio are visible to non-circle peers #[serde(default = "default_true")] pub public_visible: bool, @@ -110,7 +98,7 @@ pub struct PublicProfile { impl PublicProfile { /// Return a copy with persona-level display data (display_name, bio, /// avatar_cid) stripped, leaving only the routing metadata (anchors, - /// recent_peers). v0.6.1 broadcasts the profile under + /// recent_peers, preferred_peers). v0.6.1 broadcasts the profile under /// the network NodeId; attaching a human-readable name to that key would /// correlate the network endpoint to a specific person. Persona display /// data will travel via signed posts from v0.6.2 onward. @@ -462,6 +450,14 @@ pub struct DeleteRecord { pub signature: Vec, } +/// An update to a post's visibility (new wrapped keys after revocation) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VisibilityUpdate { + pub post_id: PostId, + pub author: NodeId, + pub visibility: PostVisibility, +} + /// How to handle revoking a recipient's access to past encrypted posts #[derive(Debug, Clone, Copy)] pub enum RevocationMode { @@ -649,67 +645,33 @@ impl NatProfile { } /// Device profile — determines connection slot budget -/// -/// v0.8 (Iteration C): the Local/Wide split is gone. There is ONE mesh pool -/// (`mesh_slots`) plus a strictly-additional band of temporary referral slots -/// (`temp_referral_slots`) that live ABOVE the mesh cap and carry no knowledge -/// exchange. See design.html §connections. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum DeviceProfile { - /// Desktop: 20 mesh + up to 10 temp referral + /// Desktop: 81 local + 20 wide = 101 mesh connections Desktop, - /// Mobile: 15 mesh + up to 4 temp referral + /// Mobile: 10 local + 5 wide = 15 mesh connections Mobile, } impl DeviceProfile { - /// The single mesh pool size (v0.8 narrow mesh, deep knowledge). - /// - /// `ITSGOIN_TEST_MESH_SLOTS` overrides it so integration tests can reach - /// the temp-referral boundary with a handful of nodes instead of 21. Test - /// gate only — same pattern as `ITSGOIN_TEST_ALLOW_REGISTRY_GENESIS`. - /// - /// Read ONCE into a `OnceLock`: this is called from `ConnectionManager` - /// construction and from every path that wants a fresh cap, and an env - /// lookup per call is a syscall-shaped cost on a hot path (and lets the - /// mesh cap change under a running node, which nothing expects). - pub fn mesh_slots(&self) -> usize { - static OVERRIDE: std::sync::OnceLock> = std::sync::OnceLock::new(); - let over = *OVERRIDE.get_or_init(|| { - std::env::var("ITSGOIN_TEST_MESH_SLOTS") - .ok() - .and_then(|v| v.parse::().ok()) - .filter(|n| *n > 0) - }); - if let Some(n) = over { - return n; - } - match self { - DeviceProfile::Desktop => 20, - DeviceProfile::Mobile => 15, - } - } - - /// Temporary referral slots, allocated ABOVE `mesh_slots`. These exist only - /// to facilitate introductions: they carry no uniques exchange, are never - /// persisted to `mesh_peers`, and either graduate into a freed mesh slot or - /// expire. Ruling: +4..+10 above the cap. - pub fn temp_referral_slots(&self) -> usize { + pub fn preferred_slots(&self) -> usize { match self { DeviceProfile::Desktop => 10, - DeviceProfile::Mobile => 4, + DeviceProfile::Mobile => 3, } } - /// Deepest bounce this device stores in the `reachable` index. - /// Desktop keeps the full 4-bounce horizon; mobile caps at 3 (it drops the - /// terminal pool on receipt and advertises `depth = 3` so senders skip - /// building it). Alternative considered and NOT implemented: Bloom-compress - /// N4 on mobile (see `apply_uniques_announce` doc comment). - pub fn max_bounce(&self) -> u8 { + pub fn local_slots(&self) -> usize { match self { - DeviceProfile::Desktop => 4, - DeviceProfile::Mobile => 3, + DeviceProfile::Desktop => 71, + DeviceProfile::Mobile => 7, + } + } + + pub fn wide_slots(&self) -> usize { + match self { + DeviceProfile::Desktop => 20, + DeviceProfile::Mobile => 5, } } @@ -749,101 +711,37 @@ impl std::fmt::Display for SessionReachMethod { } } -/// Which class of slot a live connection occupies. -/// -/// v0.8: `Mesh` is the real pool (capped at `DeviceProfile::mesh_slots`) and is -/// the ONLY class that participates in the uniques announce. `TempReferral` -/// slots sit above the cap, exist purely to broker introductions, and are never -/// written to `mesh_peers` — which is what structurally keeps them out of our -/// own announcements. They graduate into a freed mesh slot or expire. -/// -/// NOT on the wire — purely local accounting. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum MeshSlot { - Mesh, - TempReferral { expires_at_ms: u64 }, +/// Slot kind for mesh connections +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum PeerSlotKind { + /// Bilateral preferred connections (Desktop: 10, Mobile: 3) + Preferred, + /// Diverse local connections (Desktop: 71, Mobile: 7) + Local, + /// Bloom-sourced random distant connections (20 slots) + Wide, } -impl MeshSlot { - pub fn is_mesh(&self) -> bool { - matches!(self, MeshSlot::Mesh) - } - pub fn is_temp(&self) -> bool { - !self.is_mesh() - } -} - -impl std::fmt::Display for MeshSlot { +impl std::fmt::Display for PeerSlotKind { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - MeshSlot::Mesh => write!(f, "mesh"), - MeshSlot::TempReferral { .. } => write!(f, "temp"), + PeerSlotKind::Preferred => write!(f, "preferred"), + PeerSlotKind::Local => write!(f, "local"), + PeerSlotKind::Wide => write!(f, "wide"), } } } -/// Class of an ID carried in a uniques pool. -/// -/// A `Node` ID is a network identity we may connect to / hole-punch / relay to. -/// An `Author` ID is a posting identity (persona, throwaway, greeting ID). It -/// has NO address and NO device linkage (Iteration A stripped -/// `AuthorManifest.author_addresses`), so it must NEVER enter the address -/// resolution cascade or the growth-candidate scorer — it resolves only through -/// CDN holder search. The pools keep the two classes in separate arrays so a -/// receiver structurally cannot confuse them. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum IdClass { - Node = 0, - Author = 1, -} - -impl IdClass { - pub fn as_i64(self) -> i64 { - self as i64 +impl std::str::FromStr for PeerSlotKind { + type Err = anyhow::Error; + fn from_str(s: &str) -> Result { + match s { + "preferred" => Ok(PeerSlotKind::Preferred), + "local" | "social" => Ok(PeerSlotKind::Local), + "wide" => Ok(PeerSlotKind::Wide), + _ => Err(anyhow::anyhow!("unknown slot kind: {}", s)), + } } - pub fn from_i64(v: i64) -> Self { - if v == 1 { IdClass::Author } else { IdClass::Node } - } -} - -/// One entry in the uniques index / announce pools. -/// -/// `addresses` is non-empty ONLY when `is_anchor` — that is the §layers privacy -/// invariant, enforced at the store (`add_reach` blanks the column otherwise) -/// and at the wire builder (only `AnchorEntry` has an address field at all). -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ReachEntry { - pub id: NodeId, - pub id_class: IdClass, - pub is_anchor: bool, - pub addresses: Vec, -} - -impl ReachEntry { - /// A bare node-class entry (no address). - pub fn node(id: NodeId) -> Self { - Self { id, id_class: IdClass::Node, is_anchor: false, addresses: Vec::new() } - } - /// A bare author-class entry (no address, never a connect target). - pub fn author(id: NodeId) -> Self { - Self { id, id_class: IdClass::Author, is_anchor: false, addresses: Vec::new() } - } - /// An anchor entry — the only kind that carries an address. - pub fn anchor(id: NodeId, addresses: Vec) -> Self { - Self { id, id_class: IdClass::Node, is_anchor: true, addresses } - } -} - -/// The two announce pools, pre-wire. -/// -/// `fwd` is always length 3 (our N0, N1, N2) and is FORWARDABLE — the receiver -/// stores `fwd[i]` at bounce `i + 1` and may re-announce it shifted. -/// `term` is our N3 and is TERMINAL — the receiver stores it at bounce 4, uses -/// it, and never re-announces it. -#[derive(Debug, Clone, Default)] -pub struct UniquesPools { - pub fwd: Vec>, - pub term: Vec, } // --- Social Routing Cache --- @@ -958,6 +856,8 @@ pub struct AuthorManifest { /// The post this manifest is anchored to pub post_id: PostId, pub author: NodeId, + /// Author's N+10:A (ip:port strings) + pub author_addresses: Vec, /// Original post creation time (ms) pub created_at: u64, /// When manifest was last updated (ms) @@ -970,16 +870,20 @@ pub struct AuthorManifest { pub signature: Vec, } -/// CDN manifest traveling with blobs (author-signed part + serving host). -/// v0.8: ALL device-address fields removed (host_addresses, source, -/// source_addresses, downstream_count) — posting-identity manifests must not -/// carry device addresses (location anonymity). Holder resolution goes via -/// file_holders / worm lookup / redirect peers instead. +/// CDN manifest traveling with blobs (author-signed part + host metadata) #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CdnManifest { pub author_manifest: AuthorManifest, - /// Serving host's NodeId (the QUIC-authenticated device serving the blob) + /// Serving host's NodeId pub host: NodeId, + /// Serving host's N+10:A + pub host_addresses: Vec, + /// Who the host got it from + pub source: NodeId, + /// Source's N+10:A + pub source_addresses: Vec, + /// How many downstream this host has + pub downstream_count: u32, } /// Cached routing info for a social contact @@ -993,6 +897,8 @@ pub struct SocialRouteEntry { pub last_connected_ms: u64, pub last_seen_ms: u64, pub reach_method: ReachMethod, + /// 2-layer preferred peer tree (~100 nodes) for fast relay candidate search + pub preferred_tree: Vec, } // --- Engagement System --- @@ -1072,38 +978,6 @@ pub struct InlineComment { /// Non-FoF observers see only ciphertext + sigs — body is opaque. #[serde(default, skip_serializing_if = "Option::is_none")] pub encrypted_payload: Option>, - /// v0.8 (A3): absolute expiry, ms since epoch. Fixed at creation and - /// covered by the comment signature (digest v2) — no silent extension - /// is possible. Ordinary comments: created_at + rand(30..=365 days). - /// Registry entries: created_at + exactly 30 days. `0` means "no TTL" - /// (pre-v0.8 comment); v0.8 receivers REJECT `0` at ingest. - #[serde(default)] - pub expires_at_ms: u64, -} - -/// v0.8 (A3): plaintext JSON inside a sealed greeting blob (see -/// `crypto::seal_greeting_body`). Only the recipient (bio author for an -/// original greeting; holder of the per-greeting `reply_pubkey` private -/// half for a reply) can read this. -/// -/// Messaging-first (rounds 8–9): `return_path` names the post whose -/// Greeting open slot the recipient replies into (v1: the sender's own -/// bio post), and `reply_pubkey` is a FRESH per-greeting x25519 key — -/// replies are sealed to it, never to a long-term key. No vouch is -/// involved anywhere in this flow. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct GreetingBody { - pub v: u32, - /// hex32 — the sender's REAL posting pubkey (inside the seal only). - pub sender_persona: String, - /// <= 64 chars. - pub sender_name: String, - /// <= 600 chars. - pub text: String, - /// hex32 post id — where the sender watches for a reply. - pub return_path: String, - /// hex32 fresh x25519 pubkey — seal replies to THIS. - pub reply_pubkey: String, } /// Permission level for comments on a post @@ -1202,34 +1076,6 @@ pub struct RevocationEntry { pub author_sig: Vec, } -/// v0.8 (A3): what an open slot on a post is FOR. One mechanism — a wrap -/// slot whose V_x is derivable by anyone — two features aimed at -/// different parent posts (round-6 ruling). -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] -pub enum OpenSlotKind { - /// Sealed first-contact greetings on a bio post. - Greeting, - /// Plaintext self-certifying registration entries on the registry post. - Registry, -} - -/// v0.8 (A3): author-signed declaration that one of the post's wrap -/// slots is OPEN — its V_x is derivable from the post alone via -/// `crypto::derive_open_slot_vx(author, slot_binder_nonce)`. Covered by -/// the PostId hash (lives inside `FoFCommentGating`), so it is immutable -/// per publish; a bio republish (new slot_binder_nonce) is the only way -/// to change it. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct OpenSlotDecl { - /// Index into `wrap_slots`; its pub_x is a normal member of - /// `pub_post_set`. Observable-by-design: greetings are identifiable - /// as a class, never by sender. - pub slot_index: u32, - pub kind: OpenSlotKind, - /// Padded plaintext bucket size in bytes (single bucket, design §27). - pub body_bucket: u16, -} - /// FoF Layer 2: the author-published gating block embedded in a /// FoF-comment-policy post. Carries the wrap slots + the matching /// pub_post_set + the slot_binder_nonce. The `revocation_list` is @@ -1251,10 +1097,6 @@ pub struct FoFCommentGating { /// arrive; the on-wire t=0 snapshot is empty. #[serde(default)] pub revocation_list: Vec, - /// v0.8 (A3): optional open-slot declaration (greeting / registry). - /// `None` on ordinary FoF-gated posts. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub open_slot: Option, } /// Author-controlled engagement policy for a post @@ -1286,19 +1128,7 @@ pub enum BlobHeaderDiffOp { RemoveReaction { reactor: NodeId, emoji: String, post_id: PostId }, AddComment(InlineComment), EditComment { author: NodeId, post_id: PostId, timestamp_ms: u64, new_content: String }, - /// v0.8 (A3): self-certifying delete. `signature` is - /// `crypto::sign_comment_delete` by the comment author's posting key - /// over (author || post_id || timestamp_ms). Receivers accept when the - /// signature verifies OR when the diff sender is the parent post's - /// author (moderation override). Empty signature = legacy shape, - /// only the sender-override path can accept it. - DeleteComment { - author: NodeId, - post_id: PostId, - timestamp_ms: u64, - #[serde(default)] - signature: Vec, - }, + DeleteComment { author: NodeId, post_id: PostId, timestamp_ms: u64 }, SetPolicy(CommentPolicy), ThreadSplit { new_post_id: PostId }, /// Write an encrypted receipt slot (64 bytes encrypted data) diff --git a/crates/core/src/upnp.rs b/crates/core/src/upnp.rs index 379647b..643a985 100644 --- a/crates/core/src/upnp.rs +++ b/crates/core/src/upnp.rs @@ -69,7 +69,7 @@ use std::net::SocketAddr; use std::num::NonZeroU16; use std::time::Duration; -use tracing::{debug, info}; +use tracing::{debug, info, warn}; /// An active port mapping. While this value is held, the underlying /// `portmapper::Client` keeps the lease alive in a background task. diff --git a/crates/core/src/web.rs b/crates/core/src/web.rs index e1efe0e..c946abb 100644 --- a/crates/core/src/web.rs +++ b/crates/core/src/web.rs @@ -3,9 +3,8 @@ //! renders HTML, serves blobs. No permanent storage of fetched content. //! //! Routes (behind Apache reverse proxy): -//! GET /p/ → render post HTML (fetched on-demand; anchor -//! resolves holders itself) -//! GET /b/ → serve blob (images/videos) +//! GET /p// → render post HTML (fetched on-demand) +//! GET /b/ → serve blob (images/videos) use std::net::SocketAddr; use std::sync::Arc; @@ -90,7 +89,7 @@ fn extract_header<'a>(buf: &'a [u8], name: &str) -> Option<&'a str> { None } -/// Handle GET /p/ +/// Handle GET /p// /// /// Three-tier serving: /// 1. Redirect to a CDN holder with a public/punchable HTTP server @@ -113,11 +112,26 @@ async fn serve_post(stream: &mut TcpStream, path: &str, node: &Arc, browse _ => return, }; + // Parse optional author_id (after the slash) + let author_id: Option = if rest.len() > 65 { + let author_hex = &rest[65..]; + if author_hex.len() == 64 && author_hex.chars().all(|c| c.is_ascii_hexdigit()) { + hex::decode(author_hex).ok().and_then(|b| b.try_into().ok()) + } else { + None + } + } else { + None + }; + // Single lock: gather holders, local post, AND author name if local let (holders, local_post, local_author_name) = { let store = node.storage.get().await; let mut holders = Vec::new(); + if let Some(author) = author_id { + holders.push(author); + } if let Ok(file_holders) = store.get_file_holders(&post_id) { for (peer, _addrs) in file_holders { if !holders.contains(&peer) { @@ -163,9 +177,8 @@ async fn serve_post(stream: &mut TcpStream, path: &str, node: &Arc, browse } } - // Fetch via content search + PostFetch (no author hint — holders resolved - // from file_holders / worm search) - let author: NodeId = [0u8; 32]; + // Fetch via content search + PostFetch + let author = author_id.unwrap_or([0u8; 32]); info!("Web: proxying post {} via QUIC (no redirect candidate found)", post_hex); let search_result = tokio::time::timeout( diff --git a/crates/tauri-app/Cargo.toml b/crates/tauri-app/Cargo.toml index d2de386..7e9e5a2 100644 --- a/crates/tauri-app/Cargo.toml +++ b/crates/tauri-app/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "itsgoin-desktop" -version = "0.8.0-alpha" +version = "0.7.3" edition = "2021" [lib] diff --git a/crates/tauri-app/src/lib.rs b/crates/tauri-app/src/lib.rs index a6440b5..5d9dfdd 100644 --- a/crates/tauri-app/src/lib.rs +++ b/crates/tauri-app/src/lib.rs @@ -6,7 +6,7 @@ use tracing::info; use itsgoin_core::identity::IdentityManager; use itsgoin_core::node::Node; -use itsgoin_core::types::{NodeId, Post, PostId, PostVisibility, VisibilityIntent}; +use itsgoin_core::types::{NodeId, PeerSlotKind, Post, PostId, PostVisibility, VisibilityIntent}; /// The active Node, swappable on identity switch. type AppNode = Arc>>; @@ -145,8 +145,6 @@ struct BadgeCountsDto { unread_messages: usize, new_reacts: usize, new_comments: usize, - /// v0.8 (A3): undismissed greetings in the inbox (Messages badge adds this). - greetings: usize, } #[derive(Serialize)] @@ -281,11 +279,7 @@ async fn post_to_dto( // Engagement data let reaction_counts = { let storage = node.storage.get().await; - // Reactors are posting ids — "reacted_by_me" = any of our personas. - let our_ids: Vec = storage.list_posting_identities() - .unwrap_or_default() - .into_iter().map(|p| p.node_id).collect(); - storage.get_reaction_counts(id, &our_ids).unwrap_or_default() + storage.get_reaction_counts(id, &node.node_id).unwrap_or_default() .into_iter() .map(|(emoji, count, reacted_by_me)| ReactionCountDto { emoji, count, reacted_by_me }) .collect() @@ -561,13 +555,9 @@ async fn list_posting_identities( async fn create_posting_identity( state: State<'_, AppNode>, display_name: String, - greetings_open: Option, ) -> Result { let node = get_node(&state).await; - let id = node - .create_posting_identity(display_name, greetings_open) - .await - .map_err(|e| e.to_string())?; + let id = node.create_posting_identity(display_name).await.map_err(|e| e.to_string())?; Ok(PostingIdentityDto { node_id: hex::encode(id.node_id), display_name: id.display_name, @@ -873,12 +863,10 @@ async fn post_to_dto_batch( // Batch queries — 3 queries total instead of 4 × N let (reaction_map, comment_map, intent_map, posting_identities) = { let storage = node.storage.get().await; - let identities = storage.list_posting_identities().unwrap_or_default(); - // Reactors are posting ids — "reacted_by_me" = any of our personas. - let our_ids: Vec = identities.iter().map(|p| p.node_id).collect(); - let reactions = storage.get_reaction_counts_batch(&post_ids, &our_ids).unwrap_or_default(); + let reactions = storage.get_reaction_counts_batch(&post_ids, &node.node_id).unwrap_or_default(); let comments = storage.get_comment_counts_batch(&post_ids).unwrap_or_default(); let intents = storage.get_post_intents_batch(&post_ids).unwrap_or_default(); + let identities = storage.list_posting_identities().unwrap_or_default(); (reactions, comments, intents, identities) }; // Map posting-id -> display-name so we can tag author=persona posts. @@ -1363,7 +1351,7 @@ async fn list_peers(state: State<'_, AppNode>) -> Result, String> { .into_iter() .map(|(nid, _, _)| nid) .collect(); - let (social_ids, n2_ids, n3_ids, n4_ids) = { + let (social_ids, n2_ids, n3_ids) = { let storage = node.storage.get().await; let social: std::collections::HashSet<_> = storage .list_social_routes() @@ -1372,7 +1360,7 @@ async fn list_peers(state: State<'_, AppNode>) -> Result, String> { .map(|r| r.node_id) .collect(); let n2: std::collections::HashSet<_> = storage - .list_reach_ids_at(2) + .build_n2_share() .unwrap_or_default() .into_iter() .collect(); @@ -1381,12 +1369,7 @@ async fn list_peers(state: State<'_, AppNode>) -> Result, String> { .unwrap_or_default() .into_iter() .collect(); - let n4: std::collections::HashSet<_> = storage - .list_distinct_n4() - .unwrap_or_default() - .into_iter() - .collect(); - (social, n2, n3, n4) + (social, n2, n3) }; let mut dtos = Vec::with_capacity(records.len()); @@ -1411,8 +1394,6 @@ async fn list_peers(state: State<'_, AppNode>) -> Result, String> { "n2" } else if n3_ids.contains(&rec.node_id) { "n3" - } else if n4_ids.contains(&rec.node_id) { - "n4" } else { "known" }; @@ -1748,264 +1729,6 @@ async fn set_session_relay_enabled(state: State<'_, AppNode>, enabled: bool) -> Ok(()) } -// --- v0.8 (A3): registry search + greetings --- - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct RegistryEntryDto { - author_id: String, - display_name: String, - keywords: Vec, - updated_at_ms: u64, - expires_at_ms: u64, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct RegistryStatusDto { - listed: bool, - keywords: Vec, - expires_at_ms: u64, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct GreetingDto { - /// Comment key part 1 (throwaway outer identity) — opaque to the UI, - /// passed back verbatim for reply/dismiss. - comment_author: String, - /// Comment key part 2. - post_id: String, - /// Comment key part 3. - timestamp_ms: u64, - /// The sender's REAL persona (recovered from inside the seal). - sender_persona_id: String, - sender_name: String, - content: String, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct GreetingSlotDto { - bio_post_id: String, - accepts_greetings: bool, -} - -/// Register the persona in the network registry (30d entry, auto-renews -/// while listed — the core eviction cycle re-signs ~every 25 days). -#[tauri::command] -async fn register_persona( - state: State<'_, AppNode>, - posting_id_hex: String, - name: String, - keywords: Vec, -) -> Result<(), String> { - let node = get_node(&state).await; - let posting_id = parse_node_id(&posting_id_hex)?; - node.register_persona(&posting_id, &name, &keywords) - .await - .map_err(|e| e.to_string()) -} - -/// Remove the persona's registry entry (self-certifying signed delete). -#[tauri::command] -async fn unregister_persona( - state: State<'_, AppNode>, - posting_id_hex: String, -) -> Result<(), String> { - let node = get_node(&state).await; - let posting_id = parse_node_id(&posting_id_hex)?; - node.unregister_persona(&posting_id).await.map_err(|e| e.to_string()) -} - -/// Current "Listed" state for the consent panel: listed flag + the -/// keywords used at last registration + entry expiry (0 if none held). -#[tauri::command] -async fn get_registry_status( - state: State<'_, AppNode>, - posting_id_hex: String, -) -> Result { - let node = get_node(&state).await; - let posting_id = parse_node_id(&posting_id_hex)?; - let storage = node.storage.get().await; - let listed = storage - .get_setting(&format!("registry_listed.{}", posting_id_hex)) - .ok() - .flatten() - .map(|v| v == "1") - .unwrap_or(false); - let keywords = storage - .get_setting(&format!("registry_keywords.{}", posting_id_hex)) - .ok() - .flatten() - .map(|v| { - v.split(',') - .map(|k| k.trim().to_string()) - .filter(|k| !k.is_empty()) - .collect() - }) - .unwrap_or_default(); - let expires_at_ms = storage - .get_newest_registry_entry(&itsgoin_core::registry::REGISTRY_POST_ID, &posting_id) - .ok() - .flatten() - .map(|(_ts, exp)| exp) - .unwrap_or(0); - Ok(RegistryStatusDto { listed, keywords, expires_at_ms }) -} - -/// Search the registry: refreshes the comment chain from peers, then -/// queries locally (search cost lands on the searcher, design §27). -#[tauri::command] -async fn search_registry( - state: State<'_, AppNode>, - query: String, -) -> Result, String> { - let node = get_node(&state).await; - let matches = node.search_registry(&query).await.map_err(|e| e.to_string())?; - Ok(matches - .into_iter() - .map(|m| RegistryEntryDto { - author_id: hex::encode(m.author), - display_name: m.name, - keywords: m.keywords, - updated_at_ms: m.timestamp_ms, - expires_at_ms: m.expires_at_ms, - }) - .collect()) -} - -/// Locally-held greeting-slot info for an author's latest bio post. -async fn greeting_slot_local(node: &Node, author: &NodeId) -> Option { - let storage = node.storage.get().await; - let bio_post_id = storage - .get_latest_profile_post_id_by_author(author) - .ok() - .flatten()?; - let post = storage.get_post(&bio_post_id).ok().flatten()?; - let accepts = post - .fof_gating - .as_ref() - .and_then(|g| g.open_slot.as_ref()) - .map(|d| d.kind == itsgoin_core::types::OpenSlotKind::Greeting) - .unwrap_or(false); - Some(GreetingSlotDto { - bio_post_id: hex::encode(bio_post_id), - accepts_greetings: accepts, - }) -} - -/// Does this author's bio declare a Greeting open slot? Drives the -/// "Say hi" button in the bio modal. On-demand (A3 §3.2): if the bio -/// isn't local yet (e.g. a registry search result), worm-locate the -/// author and sync their posts once before answering. -#[tauri::command] -async fn get_greeting_slot( - state: State<'_, AppNode>, - node_id_hex: String, -) -> Result, String> { - let node = get_node(&state).await; - let nid = parse_node_id(&node_id_hex)?; - if let Some(dto) = greeting_slot_local(&node, &nid).await { - return Ok(Some(dto)); - } - // Fetch-on-demand: existing worm lookup + one-shot sync, then retry. - if let Ok(Some(wr)) = node.worm_lookup(&nid).await { - let _ = node.sync_with(wr.node_id).await; - return Ok(greeting_slot_local(&node, &nid).await); - } - Ok(None) -} - -/// Send a sealed first-contact greeting to a bio post's author. -/// Messaging-first: no vouch is involved anywhere in this flow. -#[tauri::command] -async fn send_greeting( - state: State<'_, AppNode>, - bio_post_id_hex: String, - content: String, -) -> Result<(), String> { - let node = get_node(&state).await; - let bio_post_id = hex_to_postid(&bio_post_id_hex)?; - node.send_greeting(bio_post_id, content).await.map_err(|e| e.to_string()) -} - -/// Reply to a received greeting — sealed to the greeting's fresh reply -/// key and dropped into its declared return path (never a long-term key). -#[tauri::command] -async fn reply_to_greeting( - state: State<'_, AppNode>, - comment_author_hex: String, - post_id_hex: String, - timestamp_ms: u64, - content: String, -) -> Result<(), String> { - let node = get_node(&state).await; - let comment_author = parse_node_id(&comment_author_hex)?; - let post_id = hex_to_postid(&post_id_hex)?; - node.reply_to_greeting(comment_author, post_id, timestamp_ms, content) - .await - .map_err(|e| e.to_string()) -} - -/// Unsealed greetings (and replies) across all personas' bio posts, -/// newest first. Return-path/reply-key internals stay inside core. -#[tauri::command] -async fn list_greetings(state: State<'_, AppNode>) -> Result, String> { - let node = get_node(&state).await; - let records = node.list_greetings().await.map_err(|e| e.to_string())?; - Ok(records - .into_iter() - .map(|g| GreetingDto { - comment_author: hex::encode(g.comment_author), - post_id: hex::encode(g.post_id), - timestamp_ms: g.timestamp_ms, - sender_persona_id: hex::encode(g.sender_persona), - sender_name: g.sender_name, - content: g.text, - }) - .collect()) -} - -/// Dismiss a greeting (local only — nothing propagates). -#[tauri::command] -async fn dismiss_greeting( - state: State<'_, AppNode>, - comment_author_hex: String, - post_id_hex: String, - timestamp_ms: u64, -) -> Result<(), String> { - let node = get_node(&state).await; - let comment_author = parse_node_id(&comment_author_hex)?; - let post_id = hex_to_postid(&post_id_hex)?; - node.dismiss_greeting(comment_author, post_id, timestamp_ms) - .await - .map_err(|e| e.to_string()) -} - -/// Per-persona greeting consent (republishes the bio on change). -#[tauri::command] -async fn set_greetings_open( - state: State<'_, AppNode>, - posting_id_hex: String, - open: bool, -) -> Result<(), String> { - let node = get_node(&state).await; - let posting_id = parse_node_id(&posting_id_hex)?; - node.set_greetings_open(&posting_id, open).await.map_err(|e| e.to_string()) -} - -/// Current greeting-consent state (unset = ON, the pre-checked default). -#[tauri::command] -async fn get_greetings_open( - state: State<'_, AppNode>, - posting_id_hex: String, -) -> Result { - let node = get_node(&state).await; - let posting_id = parse_node_id(&posting_id_hex)?; - node.get_greetings_open(&posting_id).await.map_err(|e| e.to_string()) -} - /// Open a URL in the user's default system browser. /// Desktop: spawns the platform opener (xdg-open / open / cmd start). /// Only https:// URLs are accepted to avoid being a generic command exec. @@ -2073,12 +1796,12 @@ async fn list_connections(state: State<'_, AppNode>) -> Result u64 { - let mut count = storage.get_comment_count(post_id).unwrap_or(0); - if let Some(decl) = post.fof_gating.as_ref().and_then(|g| g.open_slot.as_ref()) { - if matches!(decl.kind, itsgoin_core::types::OpenSlotKind::Greeting) { - let greetings = storage - .count_unexpired_open_slot_comments(post_id, decl.slot_index) - .unwrap_or(0); - count = count.saturating_sub(greetings); - } - } - count -} - #[tauri::command] async fn get_badge_counts( state: State<'_, AppNode>, @@ -2370,17 +2071,11 @@ async fn get_badge_counts( let node = get_node(&state).await; let storage = node.storage.get().await; - // "Own post" checks below are posting-class: posts are authored by our - // posting identities (personas), never the network NodeId. - let own_ids: Vec = storage.list_posting_identities() - .unwrap_or_default() - .into_iter().map(|p| p.node_id).collect(); - // Feed badge: count non-DM posts from others newer than last_feed_view_ms let feed_posts = storage.get_feed().map_err(|e| e.to_string())?; let new_feed = feed_posts.iter() .filter(|(id, p, _vis)| { - !own_ids.contains(&p.author) + p.author != node.node_id && p.timestamp_ms > last_feed_view_ms && !matches!( storage.get_post_intent(id).ok().flatten(), @@ -2393,20 +2088,18 @@ async fn get_badge_counts( let all_posts = storage.list_posts_reverse_chron().map_err(|e| e.to_string())?; let mut new_engagement = 0usize; for (id, post, _vis) in &all_posts { - if !own_ids.contains(&post.author) { continue; } + if post.author != node.node_id { continue; } // Skip DMs if matches!( storage.get_post_intent(id).ok().flatten(), Some(VisibilityIntent::Direct(_)) ) { continue; } - let total_reacts: u64 = storage.get_reaction_counts(id, &own_ids) + let total_reacts: u64 = storage.get_reaction_counts(id, &node.node_id) .unwrap_or_default() .iter() .map(|(_, count, _)| *count) .sum(); - // Greeting open-slot comments are excluded — they have their own - // dedicated Greetings badge (see non_greeting_comment_count). - let total_comments = non_greeting_comment_count(&storage, id, post); + let total_comments = storage.get_comment_count(id).unwrap_or(0); if total_reacts > 0 || total_comments > 0 { let (seen_r, seen_c) = storage.get_seen_engagement(id).unwrap_or((0, 0)); if total_reacts > seen_r as u64 || total_comments > seen_c as u64 { @@ -2421,14 +2114,14 @@ async fn get_badge_counts( matches!( storage.get_post_intent(id).ok().flatten(), Some(VisibilityIntent::Direct(_)) - ) || (!own_ids.contains(&p.author) && matches!( + ) || (p.author != node.node_id && matches!( storage.get_post_with_visibility(id).ok().flatten(), Some((_, PostVisibility::Encrypted { .. })) )) }); let mut seen_partners = std::collections::HashSet::new(); for (_id, post, _vis) in dm_posts { - let partner = if own_ids.contains(&post.author) { + let partner = if post.author == node.node_id { // sent DM — skip for unread count continue; } else { @@ -2446,22 +2139,16 @@ async fn get_badge_counts( let mut new_reacts = 0usize; let mut new_comments = 0usize; for (id, post, _vis) in &all_posts { - if !own_ids.contains(&post.author) { continue; } - let total_reacts: u64 = storage.get_reaction_counts(id, &own_ids) + if post.author != node.node_id { continue; } + let total_reacts: u64 = storage.get_reaction_counts(id, &node.node_id) .unwrap_or_default().iter().map(|(_, c, _)| *c).sum(); - // Greeting open-slot comments excluded here too (dedicated badge). - let total_comments = non_greeting_comment_count(&storage, id, post); + let total_comments = storage.get_comment_count(id).unwrap_or(0); let (seen_r, seen_c) = storage.get_seen_engagement(id).unwrap_or((0, 0)); if total_reacts > seen_r as u64 { new_reacts += (total_reacts - seen_r as u64) as usize; } if total_comments > seen_c as u64 { new_comments += (total_comments - seen_c as u64) as usize; } } - // v0.8 (A3): greetings inbox size. Release the storage slot first — - // list_greetings takes its own guard internally. - drop(storage); - let greetings = node.list_greetings().await.map(|g| g.len()).unwrap_or(0); - - Ok(BadgeCountsDto { new_feed, new_engagement, unread_messages, new_reacts, new_comments, greetings }) + Ok(BadgeCountsDto { new_feed, new_engagement, unread_messages, new_reacts, new_comments }) } #[tauri::command] @@ -2500,13 +2187,12 @@ async fn sync_from_peer(state: State<'_, AppNode>, node_id_hex: String) -> Resul #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct NetworkSummaryDto { - mesh_count: usize, - temp_count: usize, - mesh_cap: usize, + preferred_count: usize, + local_count: usize, + wide_count: usize, total_connections: usize, n2_distinct: usize, n3_distinct: usize, - n4_distinct: usize, has_public_v6: bool, has_public_v4: bool, has_upnp: bool, @@ -2516,24 +2202,29 @@ struct NetworkSummaryDto { async fn get_network_summary(state: State<'_, AppNode>) -> Result { let node = get_node(&state).await; let conns = node.list_connections().await; - let mesh = conns.iter().filter(|(_, slot, _)| slot.is_mesh()).count(); - let temp = conns.len() - mesh; - let (n2, n3, n4) = { + let mut preferred = 0usize; + let mut local = 0usize; + let mut wide = 0usize; + for (_nid, slot_kind, _at) in &conns { + match slot_kind { + PeerSlotKind::Preferred => preferred += 1, + PeerSlotKind::Local => local += 1, + PeerSlotKind::Wide => wide += 1, + } + } + let (n2, n3) = { let storage = node.storage.get().await; - ( - storage.count_distinct_n2().unwrap_or(0), - storage.count_distinct_n3().unwrap_or(0), - storage.count_distinct_n4().unwrap_or(0), - ) + let n2 = storage.count_distinct_n2().unwrap_or(0); + let n3 = storage.count_distinct_n3().unwrap_or(0); + (n2, n3) }; Ok(NetworkSummaryDto { - mesh_count: mesh, - temp_count: temp, - mesh_cap: node.device_profile().mesh_slots(), + preferred_count: preferred, + local_count: local, + wide_count: wide, 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(), @@ -2755,14 +2446,15 @@ struct ActivityLogDto { events: Vec, rebalance_last_ms: u64, rebalance_interval_secs: u64, - convection_last_ms: u64, + anchor_register_last_ms: u64, + anchor_register_interval_secs: u64, } #[tauri::command] async fn get_activity_log(state: State<'_, AppNode>) -> Result { let node = get_node(&state).await; let events = node.get_activity_log(200); - let (rebalance_last, convection_last) = node.timer_state(); + let (rebalance_last, anchor_last) = node.timer_state(); let dto_events: Vec = events.into_iter().map(|e| { ActivityEventDto { timestamp_ms: e.timestamp_ms, @@ -2776,7 +2468,8 @@ async fn get_activity_log(state: State<'_, AppNode>) -> Result) -> Result async fn request_referrals(state: State<'_, AppNode>) -> Result { let node = get_node(&state).await; let node_id = node.node_id; - 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. + // Try known_anchors table first (populated by anchor register cycle), + // fall back to anchor peers from the peers table (is_anchor = true) let anchors: Vec<(NodeId, Vec)> = { let storage = node.storage.get().await; - let mut out: Vec<(NodeId, Vec)> = Vec::new(); - let mut seen: std::collections::HashSet = std::collections::HashSet::new(); - for (nid, addrs) in storage.list_pool_anchors(16).unwrap_or_default() { - let socks: Vec = addrs.iter().filter_map(|a| a.parse().ok()).collect(); - if !socks.is_empty() && seen.insert(nid) { - out.push((nid, socks)); - } + 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() } - 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 connected = 0usize; - let mut asked = 0usize; - let mut refused = 0usize; + let mut total = 0usize; + let mut reachable = 0usize; for (anchor_nid, anchor_addrs) in &anchors { if *anchor_nid == node_id { continue; } - if !node.network.is_peer_connected_or_session(anchor_nid).await { + // Connect to anchor if not already connected + if !node.network.is_peer_connected(anchor_nid).await { let endpoint_id = match itsgoin_core::EndpointId::from_bytes(anchor_nid) { Ok(eid) => eid, Err(_) => continue, @@ -2841,26 +2518,40 @@ async fn request_referrals(state: State<'_, AppNode>) -> Result for sa in anchor_addrs { addr = addr.with_ip_addr(*sa); } - if node.network.connect_to_anchor(*anchor_nid, addr).await.is_err() { + if let Err(_) = node.network.connect_to_peer(*anchor_nid, addr).await { continue; } } - 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; + 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; + } + } } } Err(_) => {} } } - Ok(format!( - "Convection: {} new peers from {} anchors ({} refused)", - connected, asked, refused - )) + Ok(format!("Got {} referrals from {} anchors", total, reachable)) } #[tauri::command] @@ -3204,13 +2895,14 @@ async fn switch_identity( // Start background tasks on the new node new_node.start_accept_loop(); - new_node.start_sync_cycle(); + new_node.start_pull_cycle(300); 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_convection_loop(); + new_node.start_anchor_register_cycle(600); + new_node.start_upnp_renewal_cycle(); new_node.start_upnp_tcp_renewal_cycle(); new_node.start_http_server(); new_node.start_bootstrap_connectivity_check(); @@ -3302,8 +2994,6 @@ async fn export_data( } } }; - // node_id here is only used for the export file name; the post filter - // inside export_data now iterates all posting identities itself. let result = itsgoin_core::export::export_data( &node.data_dir, &node.storage, @@ -3328,13 +3018,14 @@ 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_sync_cycle(); + node.start_pull_cycle(300); 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_convection_loop(); + node.start_anchor_register_cycle(600); + node.start_upnp_renewal_cycle(); node.start_upnp_tcp_renewal_cycle(); node.start_http_server(); node.start_bootstrap_connectivity_check(); @@ -3392,13 +3083,11 @@ async fn import_public_posts( zip_path: String, ) -> Result { let node = get_node(&state).await; - // Imported posts are re-authored as US — that must be a POSTING identity - // (the network NodeId never authors posts). let result = itsgoin_core::import::import_public_posts( std::path::Path::new(&zip_path), &node.storage, &node.blob_store, - &node.default_posting_id, + &node.node_id, ).await.map_err(|e| e.to_string())?; Ok(result.message) } @@ -3452,14 +3141,12 @@ async fn import_merge_with_key( key_hex: String, ) -> Result { let node = get_node(&state).await; - // Merged content is authored under a POSTING identity, never the - // network NodeId. let result = itsgoin_core::import::merge_with_key( std::path::Path::new(&zip_path), &key_hex, &node.storage, &node.blob_store, - &node.default_posting_id, + &node.node_id, &node.secret_seed(), ).await.map_err(|e| e.to_string())?; Ok(result.message) @@ -3576,13 +3263,14 @@ pub fn run() { // Start all background networking tasks boot_node.start_accept_loop(); - boot_node.start_sync_cycle(); + boot_node.start_pull_cycle(300); 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_convection_loop(); + boot_node.start_anchor_register_cycle(600); + boot_node.start_upnp_renewal_cycle(); boot_node.start_upnp_tcp_renewal_cycle(); boot_node.start_http_server(); boot_node.start_bootstrap_connectivity_check(); @@ -3726,17 +3414,6 @@ pub fn run() { exit_app, get_session_relay_enabled, set_session_relay_enabled, - register_persona, - unregister_persona, - get_registry_status, - search_registry, - get_greeting_slot, - send_greeting, - reply_to_greeting, - list_greetings, - dismiss_greeting, - set_greetings_open, - get_greetings_open, ]) .build(tauri::generate_context!()) .expect("error while building tauri application") diff --git a/crates/tauri-app/tauri.conf.json b/crates/tauri-app/tauri.conf.json index 932a95d..8728350 100644 --- a/crates/tauri-app/tauri.conf.json +++ b/crates/tauri-app/tauri.conf.json @@ -1,6 +1,6 @@ { "productName": "itsgoin", - "version": "0.8.0-alpha", + "version": "0.7.3", "identifier": "com.itsgoin.app", "build": { "frontendDist": "../../frontend", diff --git a/deploy.sh b/deploy.sh index 17f632d..f89cdca 100755 --- a/deploy.sh +++ b/deploy.sh @@ -18,19 +18,8 @@ SSH_OPTS="-o StrictHostKeyChecking=no" KEYSTORE="itsgoin.keystore" KS_ALIAS="itsgoin" -# Captures the FULL version string including any pre-release suffix -# (e.g. 0.8.0-alpha). The old digits-and-dots-only pattern silently -# truncated at the dash, so every ${VERSION} filename below missed the -# artifacts Tauri had actually produced. -VERSION=$(grep '"version"' crates/tauri-app/tauri.conf.json | head -1 | sed 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/') -# Pre-release versions announce on their own channel so stable clients -# are not told an alpha is "the" current release. -case "$VERSION" in - *-*) ANN_CHANNEL="${VERSION#*-}" ;; - *) ANN_CHANNEL="stable" ;; -esac - -echo "=== Deploying v${VERSION} (announce channel: ${ANN_CHANNEL}) ===" +VERSION=$(grep '"version"' crates/tauri-app/tauri.conf.json | head -1 | sed 's/.*"\([0-9.]*\)".*/\1/') +echo "=== Deploying v${VERSION} ===" # Builds run SERIALLY — parallel cargo invocations write to the same # target/ directory, which causes intermittent failures (linuxdeploy @@ -87,11 +76,10 @@ sshpass -p "$SSH_PASS" ssh $SSH_OPTS "$SSH_HOST" " kill \$(cat ~/itsgoin-anchor.pid 2>/dev/null) 2>/dev/null sleep 1 mv ~/bin/itsgoin.new ~/bin/itsgoin && chmod +x ~/bin/itsgoin - ~/bin/itsgoin ~/itsgoin-anchor-data --publish-registry 2>&1 | tail -3 ~/bin/itsgoin ~/itsgoin-anchor-data \ --announce \ --ann-category release \ - --ann-channel ${ANN_CHANNEL} \ + --ann-channel stable \ --ann-version ${VERSION} \ --ann-url https://itsgoin.com/download.html \ --ann-title 'ItsGoin v${VERSION} available' \ diff --git a/frontend/app.js b/frontend/app.js index 88a74ca..7db32cb 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -1090,117 +1090,7 @@ function setupMyPostsMediaObserver() { mutObs.observe(myPostsList, { childList: true }); } -// v0.8 (A3): greetings inbox. Rows offer [Reply] (primary) and [Dismiss] -// ONLY — vouching is People-tab relationship management and must never be -// reachable from a Messages surface (round 9). The sender's name opens -// the ordinary bio modal, where the existing Friend flow lives. -let _greetingsCount = 0; -let _lastDmUnread = 0; -async function loadGreetings() { - const container = document.getElementById('greetings-list'); - if (!container) return; - try { - const greetings = await invoke('list_greetings'); - _greetingsCount = greetings.length; - - // Notify once per greeting (localStorage-backed seen set, tag - // prefix `greet-` mirroring `msg-`). - try { - let notified = []; - try { notified = JSON.parse(localStorage.getItem('greetNotified') || '[]'); } catch (_) {} - const notifSet = new Set(notified); - const keys = []; - for (const g of greetings) { - const key = `greet-${g.commentAuthor}-${g.timestampMs}`; - keys.push(key); - if (_notifReady && !notifSet.has(key)) { - const who = g.senderName || g.senderPersonaId.slice(0, 8); - maybeNotify(`Greeting from ${who}`, (g.content || '').slice(0, 100), key); - } - } - // Keep only keys still in the inbox (dismissed/expired drop out). - localStorage.setItem('greetNotified', JSON.stringify(keys)); - } catch (_) {} - - if (greetings.length === 0) { - container.innerHTML = `

No greetings yet

`; - } else { - container.innerHTML = greetings.map(g => { - const icon = generateIdenticon(g.senderPersonaId, 18); - const label = escapeHtml(g.senderName || g.senderPersonaId.slice(0, 12)); - return `
-
${icon} ${label} ${formatTimeAgo(g.timestampMs)}
-
${escapeHtml(g.content)}
-
- - -
- -
`; - }).join(''); - - container.querySelectorAll('.greet-bio-link').forEach(el => { - el.addEventListener('click', (e) => { - e.preventDefault(); - openBioModal(el.dataset.nodeId, el.dataset.name); - }); - }); - container.querySelectorAll('.greet-reply-btn').forEach(btn => { - btn.addEventListener('click', () => { - const box = btn.closest('.greeting-item').querySelector('.greet-reply-box'); - box.classList.toggle('hidden'); - if (!box.classList.contains('hidden')) box.querySelector('textarea').focus(); - }); - }); - container.querySelectorAll('.greet-reply-send').forEach(btn => { - btn.addEventListener('click', async () => { - const item = btn.closest('.greeting-item'); - const input = item.querySelector('.greet-reply-box textarea'); - const content = input.value.trim(); - if (!content) return; - btn.disabled = true; - try { - await invoke('reply_to_greeting', { - commentAuthorHex: item.dataset.commentAuthor, - postIdHex: item.dataset.postId, - timestampMs: Number(item.dataset.ts), - content, - }); - toast('Reply sent (sealed)'); - input.value = ''; - item.querySelector('.greet-reply-box').classList.add('hidden'); - } catch (e) { toast('Error: ' + e); } - finally { btn.disabled = false; } - }); - }); - container.querySelectorAll('.greet-dismiss-btn').forEach(btn => { - btn.addEventListener('click', async () => { - const item = btn.closest('.greeting-item'); - btn.disabled = true; - try { - await invoke('dismiss_greeting', { - commentAuthorHex: item.dataset.commentAuthor, - postIdHex: item.dataset.postId, - timestampMs: Number(item.dataset.ts), - }); - loadGreetings(); - } catch (e) { toast('Error: ' + e); btn.disabled = false; } - }); - }); - } - updateTabBadge('messages', _lastDmUnread + _greetingsCount); - } catch (e) { - container.innerHTML = `

Error: ${e}

`; - } -} - async function loadMessages(force) { - // v0.8 (A3): refresh the greetings inbox alongside DMs (own IPC call, - // fire-and-forget — it updates the shared Messages badge itself). - loadGreetings().catch(() => {}); try { const [posts, follows] = await Promise.all([ invoke('get_all_posts'), @@ -1446,9 +1336,7 @@ async function loadMessages(force) { const hasUnread = thread.posts.some(p => !p.isMe && p.timestampMs > lastReadMs); if (hasUnread) unreadCount++; } - // v0.8 (A3): the Messages badge = unread DMs + undismissed greetings. - _lastDmUnread = unreadCount; - updateTabBadge('messages', unreadCount + _greetingsCount); + updateTabBadge('messages', unreadCount); } catch (e) { conversationsList.innerHTML = `

Error: ${e}

`; } @@ -1550,7 +1438,6 @@ async function loadPeers() { else if (p.reach === 'n1') reachBadge = 'N1'; else if (p.reach === 'n2') reachBadge = 'N2'; else if (p.reach === 'n3') reachBadge = 'N3'; - else if (p.reach === 'n4') reachBadge = 'N4'; let actions = ''; if (p.nodeId === myNodeId) { @@ -1782,7 +1669,7 @@ async function openBioModal(nodeId, preloadedName) { overlay.classList.remove('hidden'); try { - // `resolve_display` returns {displayName, bio, avatarCid} for any NodeId. + // `resolve_display` returns {name, bio, avatarCid} for any NodeId. const resolved = await invoke('resolve_display', { nodeIdHex: nodeId }).catch(() => null); const follows = await invoke('list_follows').catch(() => []); const ignored = await invoke('list_ignored_peers').catch(() => []); @@ -1790,7 +1677,7 @@ async function openBioModal(nodeId, preloadedName) { const following = follows.some(f => f.nodeId === nodeId); const isIgnored = ignored.some(i => i.nodeId === nodeId); const isVouched = vouches.some(v => v.nodeId === nodeId); - const name = (resolved && resolved.displayName) || preloadedName || nodeId.slice(0, 12); + const name = (resolved && resolved.name) || preloadedName || nodeId.slice(0, 12); const bio = (resolved && resolved.bio) || ''; const icon = generateIdenticon(nodeId, 48); @@ -1799,12 +1686,12 @@ async function openBioModal(nodeId, preloadedName) {
${icon}
-
${escapeHtml(name)}
+
${escapeHtml(name)}
${nodeId}
- ${bio ? `

${escapeHtml(bio)}

` : '

No bio.

'} -
+ ${bio ? `

${escapeHtml(bio)}

` : '

No bio.

'} +
${(following && isVouched) ? `` @@ -1898,90 +1785,11 @@ async function openBioModal(nodeId, preloadedName) { } catch (e) { toast('Error: ' + e); } finally { unfriend.disabled = false; } }; - - // v0.8 (A3): greeting affordance for strangers only (no existing - // relationship). The slot check may fetch the bio on-demand, so - // the button arrives asynchronously. Established peers keep the - // ordinary Message button. - if (!following && !isVouched) { - invoke('get_greeting_slot', { nodeIdHex: nodeId }).then(async slot => { - const row = document.getElementById('bio-actions'); - if (!row || !bodyEl.contains(row)) return; // modal replaced/closed - // The slot check may have pulled the author's posts on-demand - // (registry search results start non-local) — if the first - // resolve came back empty, patch name/bio in place now. - if (!resolved || (!resolved.displayName && !resolved.bio)) { - const again = await invoke('resolve_display', { nodeIdHex: nodeId }).catch(() => null); - if (again && bodyEl.contains(row)) { - if (again.displayName) { - titleEl.textContent = again.displayName; - const nm = document.getElementById('bio-modal-name'); - if (nm) nm.textContent = again.displayName; - } - if (again.bio) { - const bp = document.getElementById('bio-modal-bio'); - if (bp) { - bp.textContent = again.bio; - bp.className = ''; - bp.style.cssText = 'font-size:0.9rem;line-height:1.45;margin:0 0 0.75rem'; - } - } - } - } - if (slot && slot.acceptsGreetings) { - const hi = document.createElement('button'); - hi.id = 'bio-say-hi'; - hi.className = 'btn btn-primary btn-sm'; - hi.textContent = 'Say hi'; - row.prepend(hi); - hi.onclick = () => openGreetingCompose(slot.bioPostId, titleEl.textContent || name); - } else { - const na = document.createElement('button'); - na.className = 'btn btn-ghost btn-sm'; - na.disabled = true; - na.textContent = 'Not accepting greetings'; - row.appendChild(na); - } - }).catch(() => {}); - } } catch (e) { bodyEl.innerHTML = `

Error: ${e}

`; } } -// v0.8 (A3): greeting compose — swaps the bio modal body for a sealed -// one-shot compose aimed at the bio post's Greeting open slot. -function openGreetingCompose(bioPostIdHex, name) { - const titleEl = document.getElementById('popover-title'); - const bodyEl = document.getElementById('popover-body'); - if (!titleEl || !bodyEl) return; - titleEl.textContent = `Say hi to ${name}`; - bodyEl.innerHTML = ` - -
- Sealed — only ${escapeHtml(name)} can read this - -
`; - const input = document.getElementById('greeting-text'); - setTimeout(() => input.focus(), 100); - const sendBtn = document.getElementById('greeting-send-btn'); - const send = async () => { - const content = input.value.trim(); - if (!content) return; - sendBtn.disabled = true; - try { - await invoke('send_greeting', { bioPostIdHex, content }); - toast('Greeting sent'); - closePopover(); - } catch (e) { - toast('Error: ' + e); - sendBtn.disabled = false; - } - }; - sendBtn.addEventListener('click', send); - input.addEventListener('keydown', (e) => { if (e.key === 'Enter' && e.ctrlKey) send(); }); -} - function openAuthorFeed(nodeId, name) { authorFilterNodeId = nodeId; authorFilterName = name || nodeId.slice(0, 12); @@ -2079,11 +1887,11 @@ async function loadNetworkSummary() { const s = await invoke('get_network_summary'); networkSummaryEl.innerHTML = `
${s.totalConnections}Connections
-
${s.meshCount}/${s.meshCap}Mesh
-
${s.tempCount}Temp referral
+
${s.preferredCount}Preferred
+
${s.localCount}Mesh
+
${s.wideCount}Non-mesh N1
${s.n2Distinct}N2 Reach
${s.n3Distinct}N3 Reach
-
${s.n4Distinct}N4 Reach
`; } catch (e) { networkSummaryEl.innerHTML = `

Could not load network summary

`; @@ -2100,9 +1908,10 @@ async function loadConnections() { connectionsList.innerHTML = conns.map(c => { const label = escapeHtml(peerLabel(c.nodeId, c.displayName)); const icon = generateIdenticon(c.nodeId, 18); - const slotClass = c.slotKind === 'temp' ? 'slot-temp' : 'slot-mesh'; - const slotLabel = c.slotKind === 'mesh' ? 'Mesh' - : c.slotKind === 'temp' ? 'Temp referral' : c.slotKind; + const slotClass = c.slotKind === 'Preferred' ? 'slot-preferred' + : c.slotKind === 'Wide' ? 'slot-wide' : 'slot-local'; + const slotLabel = c.slotKind === 'Local' ? 'Mesh' + : c.slotKind === 'Wide' ? 'Non-mesh N1' : c.slotKind; const duration = c.connectedAt ? relativeTime(c.connectedAt) : ''; return `
${icon} ${label} ${slotLabel}
@@ -2194,13 +2003,7 @@ function renderTimer(label, lastMs, intervalSecs, now) { } function escapeHtml(str) { - // Quotes MUST be escaped too: escaped output is interpolated into - // double-quoted HTML attributes (data-name="..."), and registry - // entries / greeting sender names are attacker-controlled — a name - // like `x" onclick="..."` would otherwise break out of the attribute - // and inject an inline handler (stored XSS). - return str.replace(/&/g, '&').replace(//g, '>') - .replace(/"/g, '"').replace(/'/g, '''); + return str.replace(/&/g, '&').replace(//g, '>'); } function formatTimeAgo(timestampMs) { @@ -3024,41 +2827,11 @@ async function doSyncAll() { } } -// v0.8 (A3): default persona id (hex) — the persona the Profiles lightbox -// and first-run consent act on. Falls back to the first-run auto persona. -async function getDefaultPersonaId() { - try { - const ids = await invoke('list_posting_identities'); - const def = ids.find(p => p.isDefault) || ids[0]; - if (def) return def.nodeId; - } catch (_) {} - try { - const auto = await invoke('get_first_run_auto_persona_id'); - if (auto) return auto; - } catch (_) {} - return null; -} - async function doSetupName() { // Name is optional — users who want to stay anonymous can proceed with a blank field. const name = setupName.value.trim(); setupBtn.disabled = true; try { - // v0.8 (A3): active choice at first publish — if the pre-checked - // "Accept greetings" box was unticked, record the opt-out BEFORE - // the first bio publish so no greeting slot ever ships (the - // republish inside set_display_name reads this setting). - const greetCheck = document.getElementById('setup-greetings-check'); - if (greetCheck && !greetCheck.checked) { - // The opt-out write is LOAD-BEARING (round 8: an explicit - // opt-out must never silently fail and ship the slot anyway). - // Any failure here aborts the publish; the overlay stays up. - const personaId = await getDefaultPersonaId(); - if (!personaId) { - throw new Error('could not resolve your persona to record the greeting opt-out — please try again'); - } - await invoke('set_setting', { key: 'greetings_open.' + personaId, value: '0' }); - } await invoke('set_display_name', { name }); setupOverlay.classList.add('hidden'); toast(name ? 'Welcome, ' + name + '!' : 'Welcome!'); @@ -3407,7 +3180,6 @@ document.querySelectorAll('.tab').forEach(tab => { if (!conversationsList.children.length) conversationsList.innerHTML = renderLoading(); loadMessages(true); loadDmRecipientOptions(); clearNotifications('msg-'); - clearNotifications('greet-'); } if (target === 'settings') { loadIdentities(); loadPersonas(); loadRedundancy(); loadPublicVisible(); @@ -3444,16 +3216,6 @@ $('#profile-lightbox-btn').addEventListener('click', () => { Show my profile to non-circle peers - - -
Not listed
-
@@ -3466,72 +3228,14 @@ $('#profile-lightbox-btn').addEventListener('click', () => {
`; document.body.appendChild(overlay); - // v0.8 (A3): prefill the consent panel (registry listing + greeting - // consent) for the default persona. Status line mirrors auto-renew. - // The Save button stays DISABLED until the prefill settles: a listed - // user clicking Save against the unfilled (unchecked) checkbox would - // otherwise be treated as never-listed — their unlist intent dropped - // and auto-renew silently continuing. - let _lbWasListed = false; - const _lbSaveBtn = overlay.querySelector('#lb-profile-save'); - _lbSaveBtn.disabled = true; - (async () => { - try { - const personaId = await getDefaultPersonaId(); - if (!personaId || !document.body.contains(overlay)) return; - try { - const status = await invoke('get_registry_status', { postingIdHex: personaId }); - _lbWasListed = !!status.listed; - const listedCheck = overlay.querySelector('#lb-registry-listed'); - const kwInput = overlay.querySelector('#lb-registry-keywords'); - const statusEl = overlay.querySelector('#lb-registry-status'); - if (listedCheck) listedCheck.checked = _lbWasListed; - if (kwInput && status.keywords.length) kwInput.value = status.keywords.join(', '); - if (statusEl) statusEl.textContent = _lbWasListed - ? 'Listed — auto-renews every 30 days while checked' - : 'Not listed'; - } catch (_) {} - try { - const open = await invoke('get_greetings_open', { postingIdHex: personaId }); - const greetCheck = overlay.querySelector('#lb-greetings-open'); - if (greetCheck) greetCheck.checked = !!open; - } catch (_) {} - } finally { - _lbSaveBtn.disabled = false; - } - })(); - _lbSaveBtn.addEventListener('click', async () => { + overlay.querySelector('#lb-profile-save').addEventListener('click', async () => { const name = overlay.querySelector('#lb-profile-name').value.trim(); const bio = overlay.querySelector('#lb-profile-bio').value.trim(); if (!name) { toast('Display name is required'); return; } try { - const personaId = await getDefaultPersonaId(); - // v0.8 (A3): write greeting consent BEFORE set_profile so the - // single bio republish below carries the right slot state - // (avoids a second republish via set_greetings_open). The - // write is LOAD-BEARING (round 8): if it fails, abort the - // publish rather than republishing with the wrong slot state. - const greetOpen = overlay.querySelector('#lb-greetings-open').checked; - if (!personaId) { - throw new Error('could not resolve your persona — profile not saved, please try again'); - } - await invoke('set_setting', { key: 'greetings_open.' + personaId, value: greetOpen ? '1' : '0' }); await invoke('set_profile', { name, bio }); const visible = overlay.querySelector('#lb-public-visible').checked; await invoke('set_public_visible', { visible }); - // v0.8 (A3): registry listing. Checked = (re-)register — the - // core renews automatically every ~25 days while listed. - // Unchecking sends the self-certifying signed delete. - if (personaId) { - const listed = overlay.querySelector('#lb-registry-listed').checked; - const keywords = overlay.querySelector('#lb-registry-keywords').value - .split(',').map(k => k.trim()).filter(k => k).slice(0, 8); - if (listed) { - await invoke('register_persona', { postingIdHex: personaId, name, keywords }); - } else if (_lbWasListed) { - await invoke('unregister_persona', { postingIdHex: personaId }); - } - } // Sync back to settings fields profileNameInput.value = name; profileBioInput.value = bio; @@ -3593,58 +3297,6 @@ $('#discover-toggle').addEventListener('click', () => { if (!body.classList.contains('hidden')) loadDiscoverPeople(); }); -// v0.8 (A3): registry search — pulls the registry comment chain from -// peers, then queries locally. Results show name/keywords only; the bio -// is fetched on-demand when a result is clicked (openBioModal). -async function runRegistrySearch() { - const input = $('#registry-search-input'); - const results = $('#registry-results'); - const btn = $('#registry-search-btn'); - const query = input.value.trim(); - if (!query) { results.innerHTML = ''; return; } - btn.disabled = true; - results.innerHTML = renderLoading(); - try { - const entries = await invoke('search_registry', { query }); - if (!entries || entries.length === 0) { - results.innerHTML = renderEmptyState( - 'No registry matches', - 'Entries expire after 30 days — try different keywords.' - ); - return; - } - results.innerHTML = `

Registry results

` + entries.map(e => { - const icon = generateIdenticon(e.authorId, 18); - const label = escapeHtml(e.displayName); - const kwLine = e.keywords && e.keywords.length - ? `
${e.keywords.map(k => escapeHtml(k)).join(' · ')}
` - : ''; - const ageLine = e.updatedAtMs - ? `
Listed ${formatTimeAgo(e.updatedAtMs)}
` - : ''; - return `
-
${icon} ${label}
- ${kwLine} - ${ageLine} -
`; - }).join(''); - results.querySelectorAll('.bio-link').forEach(el => { - el.addEventListener('click', (ev) => { - ev.preventDefault(); - openBioModal(el.dataset.nodeId, el.dataset.name); - }); - }); - } catch (e) { - results.innerHTML = `

Error: ${e}

`; - } finally { - btn.disabled = false; - } -} -$('#registry-search-btn').addEventListener('click', runRegistrySearch); -$('#registry-search-input').addEventListener('keydown', (e) => { - if (e.key === 'Enter') runRegistrySearch(); -}); - // "See new activity" button in Following: applies staged data and // re-renders so the user picks when to rearrange the list. { @@ -3680,7 +3332,7 @@ function openDiagnostics() {
- +
@@ -3816,11 +3468,11 @@ function openDiagnostics() { try { const r = await invoke('request_referrals'); btn.textContent = r; - setTimeout(() => { btn.textContent = 'Find Peers (Convection)'; btn.disabled = false; }, 3000); + setTimeout(() => { btn.textContent = 'Request Referrals'; btn.disabled = false; }, 3000); loadAllDiagnostics(); } catch (e) { btn.textContent = 'Failed'; - setTimeout(() => { btn.textContent = 'Find Peers (Convection)'; btn.disabled = false; }, 3000); + setTimeout(() => { btn.textContent = 'Request Referrals'; btn.disabled = false; }, 3000); } }); loadAllDiagnostics(); @@ -4325,46 +3977,15 @@ function renderPersonasList() { const deleteBtn = p.isDefault ? '' : ``; - // Round 8: greeting consent is per-persona REVOCABLE. This toggle - // is the revocation path for non-default personas (the Profiles - // lightbox only manages the default persona). Label filled async. - const greetBtn = ``; return `
${escapeHtml(label)}${defaultTag}
${p.nodeId.substring(0, 16)}...
-
${greetBtn}${setDefaultBtn}${deleteBtn}
+
${setDefaultBtn}${deleteBtn}
`; }).join(''); - list.querySelectorAll('.persona-greetings-btn').forEach(btn => { - // Fill the current consent state, then enable the toggle. - (async () => { - try { - const open = await invoke('get_greetings_open', { postingIdHex: btn.dataset.id }); - btn.dataset.open = open ? '1' : '0'; - btn.textContent = open ? 'Greetings: on' : 'Greetings: off'; - btn.disabled = false; - } catch (_) { - btn.textContent = 'Greetings: ?'; - } - })(); - btn.addEventListener('click', async () => { - const next = btn.dataset.open !== '1'; - btn.disabled = true; - try { - await invoke('set_greetings_open', { postingIdHex: btn.dataset.id, open: next }); - btn.dataset.open = next ? '1' : '0'; - btn.textContent = next ? 'Greetings: on' : 'Greetings: off'; - toast(next - ? 'Greetings enabled — bio republished with a greeting slot' - : 'Greetings disabled — slot revoked on published bios'); - } catch (e) { toast('Error: ' + e); } - finally { btn.disabled = false; } - }); - }); - list.querySelectorAll('.set-default-persona-btn').forEach(btn => { btn.addEventListener('click', async () => { btn.disabled = true; @@ -4419,10 +4040,6 @@ $('#create-persona-btn').addEventListener('click', () => {

New Persona

Peers will see this persona as a distinct author. No one can tell which personas belong to the same device.

-
@@ -4435,12 +4052,7 @@ $('#create-persona-btn').addEventListener('click', () => { const name = nameInput.value.trim(); if (!name) { toast('Enter a name'); return; } try { - // Round 8: greeting consent is an ACTIVE pre-checked choice at - // first publish. The choice rides the create call itself so the - // consent setting is written BEFORE the initial bio publish — - // an opted-out persona never ships a greeting slot at all. - const greetingsOpen = overlay.querySelector('#new-persona-greetings').checked; - await invoke('create_posting_identity', { displayName: name, greetingsOpen }); + await invoke('create_posting_identity', { displayName: name }); toast(`Persona created: ${name}`); overlay.remove(); loadPersonas(); @@ -4734,7 +4346,7 @@ async function init() { // Update tab badges from welcome screen updateTabBadge('feed', b.newFeed || 0); updateTabBadge('myposts', b.newEngagement || 0); - updateTabBadge('messages', (b.unreadMessages || 0) + (b.greetings || 0)); + updateTabBadge('messages', b.unreadMessages || 0); // Ticker + notifications only after user leaves welcome screen // (welcome page already shows these counts directly) }).catch(() => {}); @@ -4888,7 +4500,6 @@ async function init() { const badges = await invoke('get_badge_counts', { lastFeedViewMs: _lastFeedViewMs }); if (currentTab !== 'feed') updateTabBadge('feed', badges.newFeed); if (currentTab !== 'myposts') updateTabBadge('myposts', badges.newEngagement); - if (currentTab !== 'messages') updateTabBadge('messages', (badges.unreadMessages || 0) + (badges.greetings || 0)); } catch (_) {} }, 30000); diff --git a/frontend/index.html b/frontend/index.html index 3fcfc55..7848b1a 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -13,12 +13,6 @@

Welcome to ItsGoin

Pick a display name if you want one — or leave blank to stay anonymous.

- -
@@ -145,14 +139,8 @@
- -
- -
- - -
-
+ + @@ -193,15 +181,6 @@

Message Requests

- - -
-

Greetings

-

Sealed hellos from people outside your circles. Replies go back sealed — only the sender can read them.

-
-
diff --git a/frontend/style.css b/frontend/style.css index 6623d48..3f7e8e1 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -359,8 +359,9 @@ header h1 { font-size: clamp(1.4rem, 2.5vw, 2rem); color: #7fdbca; margin: 0; } /* Slot kind badges */ .slot-badge { font-size: 0.65rem; padding: 0.1rem 0.4rem; border-radius: 3px; font-family: system-ui, sans-serif; } -.slot-mesh { background: #1e2040; color: #aab; } -.slot-temp { background: #2a2a1e; color: #e2b93d; } +.slot-preferred { background: #1a3a2e; color: #7fdbca; } +.slot-local { background: #1e2040; color: #aab; } +.slot-wide { background: #2a2a1e; color: #e2b93d; } /* Reach level badges */ .reach-badge { font-size: 0.65rem; padding: 0.1rem 0.4rem; border-radius: 3px; font-family: system-ui, sans-serif; } @@ -368,7 +369,6 @@ header h1 { font-size: clamp(1.4rem, 2.5vw, 2rem); color: #7fdbca; margin: 0; } .reach-n1 { background: #1a2e3a; color: #7fc4db; } .reach-n2 { background: #1e2040; color: #aab; } .reach-n3 { background: #1e2040; color: #667; } -.reach-n4 { background: #1a1a2e; color: #556; } /* Diagnostics summary grid */ .diag-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.4rem; margin-bottom: 0.75rem; } diff --git a/scripts/a3_integration_test.sh b/scripts/a3_integration_test.sh deleted file mode 100755 index 32c887e..0000000 --- a/scripts/a3_integration_test.sh +++ /dev/null @@ -1,142 +0,0 @@ -#!/usr/bin/env bash -# v0.8 A3 integration test — registry + greetings across 3 local nodes. -# -# Spec: A3-SPEC §6 multi-node scenario. Run from the repo root after -# `cargo build -p itsgoin-cli`. Uses FIFOs to drive the interactive REPL. -# -# 1. All nodes self-materialize the same registry post ID; node1 also -# runs the --publish-registry genesis one-shot (debug gate override). -# 2. node2 registers "Alice rust p2p" → node3 `search rust` finds her. -# node2 `unregister` → node3 re-search finds nothing. -# 3. node2's bio has a greeting slot; node3 greets it; node2 sees the -# unsealed text + real sender persona; node2 replies; node3 sees the -# reply. node1 (holder) stores only ciphertext. -# 5. Newest-wins: node2 re-registers; node3 sees exactly one entry. -# 6. (Optional, ITSGOIN_TEST_TTL_SECS) comment expiry sweep. -# -# Exit code 0 = all checks passed. Logs: /tmp/itsgoin-cli{1,2,3}.log -set -u -cd "$(dirname "$0")/.." -BIN=target/debug/itsgoin -[ -x "$BIN" ] || { echo "build first: cargo build -p itsgoin-cli"; exit 2; } - -PASS=0; FAIL=0 -check() { # check - local desc="$1"; shift - if "$@" >/dev/null 2>&1; then PASS=$((PASS+1)); echo "PASS: $desc"; - else FAIL=$((FAIL+1)); echo "FAIL: $desc"; fi -} -strip_ansi() { sed 's/\x1b\[[0-9;]*m//g' "$1" | grep -v "WARN.*netlink\|WARN.*buffer_tool"; } -# Same noise filter for the tail-window checks below. The netlink/mDNS watcher -# logs in unpredictable bursts, and a burst landing between a REPL command and -# its assertion pushes the answer out of a raw `tail -N` window — a false -# failure with nothing wrong in the node. -quiet_tail() { grep -av "netlink\|buffer_tool\|iroh_quinn\|swarm_discovery" "$1" | tail -"$2"; } -export -f quiet_tail -sq() { sqlite3 "/tmp/itsgoin-test$1/itsgoin.db" "$2"; } - -cleanup() { - { exec 13>&- 14>&- 15>&-; } 2>/dev/null || true - kill $(pgrep -f 'itsgoin.*itsgoin-test') 2>/dev/null - rm -f /tmp/itsgoin-cmd{1,2,3} -} -trap cleanup EXIT - -echo "== setup ==" -kill $(pgrep -f 'itsgoin.*itsgoin-test') 2>/dev/null; sleep 1 -for i in 1 2 3; do - rm -rf /tmp/itsgoin-test$i /tmp/itsgoin-cmd$i /tmp/itsgoin-cli$i.log - mkdir -p /tmp/itsgoin-test$i - mkfifo /tmp/itsgoin-cmd$i -done - -export ITSGOIN_TEST_ALLOW_REGISTRY_GENESIS=1 - -# Genesis one-shot on node1's data dir (debug-gate override). -"$BIN" /tmp/itsgoin-test1 --bind 127.0.0.1:18411 --publish-registry > /tmp/itsgoin-genesis.log 2>&1 -check "genesis prints registry post id matching shipped constant" \ - bash -c 'g=$(grep registry_post_id /tmp/itsgoin-genesis.log | awk "{print \$2}"); - c=$(grep shipped_constant /tmp/itsgoin-genesis.log | awk "{print \$2}"); - [ -n "$g" ] && [ "$g" = "$c" ]' - -# Start the three nodes with FIFO-driven stdin. -for i in 1 2 3; do - ( exec "$BIN" /tmp/itsgoin-test$i --bind 127.0.0.1:1841$i \ - < /tmp/itsgoin-cmd$i > /tmp/itsgoin-cli$i.log 2>&1 ) & -done -# Keep FIFO write ends open for the whole run. -exec 13>/tmp/itsgoin-cmd1 14>/tmp/itsgoin-cmd2 15>/tmp/itsgoin-cmd3 -sleep 6 - -n1_id=$(strip_ansi /tmp/itsgoin-cli1.log | grep -m1 "Node ID:" | awk '{print $3}') -n2_id=$(strip_ansi /tmp/itsgoin-cli2.log | grep -m1 "Node ID:" | awk '{print $3}') -echo "node1=$n1_id node2=$n2_id" -check "all nodes self-materialized the registry post" \ - bash -c 'for i in 1 2 3; do sqlite3 /tmp/itsgoin-test$i/itsgoin.db \ - "SELECT count(*) FROM posts WHERE hex(id) = upper(\"'"$(grep shipped_constant /tmp/itsgoin-genesis.log | awk '{print $2}')"'\")" \ - | grep -q 1 || exit 1; done' - -echo "== mesh: 2,3 -> 1; 3 -> 2 ==" -echo "connect $n1_id@127.0.0.1:18411" >&14 -echo "connect $n1_id@127.0.0.1:18411" >&15 -sleep 4 -echo "connect $n2_id@127.0.0.1:18412" >&15 -sleep 4 - -echo "== step 2: register / search / unregister ==" -echo "name Alice" >&14 -sleep 3 -echo "register Alice rust p2p" >&14 -sleep 3 -echo "search rust" >&15 -sleep 8 -check "node3 search finds Alice" grep -q "Alice" /tmp/itsgoin-cli3.log - -echo "unregister" >&14 -sleep 3 -echo "search rust" >&15 -sleep 8 -check "node3 re-search finds nothing after signed delete" \ - bash -c 'quiet_tail /tmp/itsgoin-cli3.log 20 | grep -q "no registry matches"' - -echo "== step 3: greeting roundtrip ==" -# node2's bio post id (latest Profile post) from its DB. -bio2=$(sq 2 'SELECT lower(hex(id)) FROM posts WHERE visibility_intent = '"'"'"Profile"'"'"' ORDER BY timestamp_ms DESC LIMIT 1') -check "node2 has a bio post with a greeting slot" \ - bash -c '[ -n "'"$bio2"'" ] && sqlite3 /tmp/itsgoin-test2/itsgoin.db \ - "SELECT fof_gating_json FROM posts WHERE lower(hex(id))=\"'"$bio2"'\"" | grep -q Greeting' - -echo "name Bob" >&15 -sleep 3 -echo "greet $bio2 hello alice from bob" >&15 -sleep 6 -echo "greetings" >&14 -sleep 4 -check "node2 unseals the greeting text" grep -q "hello alice from bob" /tmp/itsgoin-cli2.log - -echo "reply 0 hello back bob" >&14 -sleep 6 -echo "greetings" >&15 -sleep 4 -check "node3 unseals the reply via its stored fresh reply key" \ - grep -q "hello back bob" /tmp/itsgoin-cli3.log - -# Holder-side opacity: node1 never sees plaintext greeting bodies. -check "node1 stores only ciphertext (no greeting plaintext in db)" \ - bash -c '! sqlite3 /tmp/itsgoin-test1/itsgoin.db \ - "SELECT content FROM comments" | grep -q "hello alice"' - -echo "== step 5: newest-wins ==" -echo "register Alice rust p2p" >&14 -sleep 2 -echo "register AliceV2 rust mesh" >&14 -sleep 4 -echo "search rust" >&15 -sleep 8 -check "node3 sees exactly one (newest) entry for node2's persona" \ - bash -c 'quiet_tail /tmp/itsgoin-cli3.log 6 | grep -c "rust" | grep -q "^1$" && - quiet_tail /tmp/itsgoin-cli3.log 6 | grep -q AliceV2' - -echo -echo "== results: $PASS passed, $FAIL failed ==" -[ "$FAIL" -eq 0 ] diff --git a/scripts/c_topology_test.sh b/scripts/c_topology_test.sh deleted file mode 100755 index 30a432a..0000000 --- a/scripts/c_topology_test.sh +++ /dev/null @@ -1,278 +0,0 @@ -#!/usr/bin/env bash -# v0.8 Iteration C integration test — TOPOLOGY. -# -# Run from the repo root after `cargo build -p itsgoin-cli`, AFTER -# scripts/a3_integration_test.sh (C does not replace A). Drives interactive -# REPLs over FIFOs, asserts by grepping logs and by sqlite3 against node DBs. -# -# SCENARIO 1 Two-pool uniques exchange over a chain: pool 1 shifts one bounce -# deeper per hop, pool 2 lands at N4, and N4 is USED but NEVER -# re-announced (no N5 anywhere). Plus the §layers privacy -# invariant: only anchor entries carry an address. -# SCENARIO 2 Anchor convection: the rolling window hands each caller the 2 -# most recent prior callers, produces a REAL connection, rotates -# entries out after 2 hand-outs, coordinates a RelayIntroduce when -# both ends are live, and refuses a top-up CHEAPLY. -# SCENARIO 3 Temp referral slots: allocated ABOVE the mesh cap, carrying no -# knowledge, never evicting an established mesh peer, graduating -# into a freed mesh slot. -# STANDING 0xC0 AnchorRegister is gone; no automatic session relay anywhere. -# -# Exit code 0 = all checks passed. Logs: /tmp/itsgoin-ctop{1..7}.log -set -u -cd "$(dirname "$0")/.." -BIN=target/debug/itsgoin -[ -x "$BIN" ] || { echo "build first: cargo build -p itsgoin-cli"; exit 2; } - -PASS=0; FAIL=0 -check() { # check - local desc="$1"; shift - if "$@" >/dev/null 2>&1; then PASS=$((PASS+1)); echo "PASS: $desc"; - else FAIL=$((FAIL+1)); echo "FAIL: $desc"; fi -} -strip_ansi() { sed 's/\x1b\[[0-9;]*m//g' "$1" | grep -v "WARN.*netlink\|WARN.*buffer_tool"; } -sq() { sqlite3 "/tmp/itsgoin-ctop$1/itsgoin.db" "$2"; } - -# Is `id` (hex) present in node N's uniques index at `bounce`? -at_bounce() { # at_bounce - [ "$(sq "$1" "SELECT count(*) FROM reachable WHERE lower(hex(reachable_id))='$2' AND bounce=$3")" != "0" ] -} -anywhere() { # anywhere - [ "$(sq "$1" "SELECT count(*) FROM reachable WHERE lower(hex(reachable_id))='$2'")" != "0" ] -} -# `check` runs its command via `bash -c`, which does not inherit shell functions. -export -f strip_ansi sq at_bounce anywhere - -NODES="1 2 3 4 5 6 7" -FDS="" -cleanup() { - for fd in 21 22 23 24 25 26 27; do eval "exec $fd>&-" 2>/dev/null || true; done - kill $(pgrep -f 'itsgoin.*itsgoin-ctop') 2>/dev/null - rm -f /tmp/itsgoin-ctopcmd* -} -trap cleanup EXIT - -# Test gates: -# - loopback referrals: the convection address filter is publicly-routable-only -# (bugs-fixed #8), which would make every 127.0.0.1 referral unusable. -# - mesh cap 2: reaches the temp-referral boundary with 4 nodes, not 21. -# - no growth loop: on loopback the growth loop collapses any chain into a -# full mesh in seconds, and a full mesh has no N2 at all (every peer is a -# direct). Topology here is script-controlled on purpose. -export ITSGOIN_TEST_ALLOW_LOOPBACK_REFERRALS=1 -export ITSGOIN_TEST_ALLOW_REGISTRY_GENESIS=1 -export ITSGOIN_TEST_NO_GROWTH=1 -export RUST_LOG="${RUST_LOG:-itsgoin_core=debug,info,iroh=warn,swarm_discovery=warn}" - -echo "== setup ==" -kill $(pgrep -f 'itsgoin.*itsgoin-ctop') 2>/dev/null; sleep 1 -for i in $NODES; do - rm -rf /tmp/itsgoin-ctop$i /tmp/itsgoin-ctopcmd$i /tmp/itsgoin-ctop$i.log - mkdir -p /tmp/itsgoin-ctop$i - mkfifo /tmp/itsgoin-ctopcmd$i -done - -start_node() { # start_node [extra env assignments...] - local i="$1" port="$2"; shift 2 - ( exec env "$@" "$BIN" /tmp/itsgoin-ctop$i --bind 127.0.0.1:$port \ - < /tmp/itsgoin-ctopcmd$i > /tmp/itsgoin-ctop$i.log 2>&1 ) & -} - -# Nodes 1-6: chain + convection. Node 7 joins late (fresh index). -for i in 1 2 3 4 5 6 7; do start_node $i $((18430 + i)) ITSGOIN_TEST_NO_GROWTH=1; done -exec 21>/tmp/itsgoin-ctopcmd1 22>/tmp/itsgoin-ctopcmd2 23>/tmp/itsgoin-ctopcmd3 \ - 24>/tmp/itsgoin-ctopcmd4 25>/tmp/itsgoin-ctopcmd5 26>/tmp/itsgoin-ctopcmd6 \ - 27>/tmp/itsgoin-ctopcmd7 -sleep 7 - -for i in $NODES; do - eval "n${i}=\$(strip_ansi /tmp/itsgoin-ctop$i.log | grep -m1 'Node ID:' | awk '{print \$3}')" -done -echo "n1=$n1 n2=$n2 n3=$n3 n4=$n4 n5=$n5" -[ -n "$n5" ] || { echo "FAIL: nodes did not start"; exit 1; } - -############################################################################## -echo -echo "== scenario 1: two-pool uniques exchange, N4 terminal ==" -############################################################################## -# Chain 1 - 2 - 3 - 4 - 5. Built inward so each hop's initial exchange already -# carries the deeper knowledge where possible. -echo "connect $n1@127.0.0.1:18431" >&22; sleep 3 -echo "connect $n2@127.0.0.1:18432" >&23; sleep 3 -echo "connect $n3@127.0.0.1:18433" >&24; sleep 3 -echo "connect $n4@127.0.0.1:18434" >&25; sleep 4 - -# Drive propagation explicitly: each uniques-pull round moves knowledge one -# bounce along the chain. This IS the v0.8 pull — an index exchange, no posts. -for round in 1 2 3 4 5; do - for fd in 21 22 23 24 25; do echo "uniques-pull" >&$fd; done - sleep 3 -done - -check "uniques pull is an index exchange (peers exchanged, no post transfer)" \ - bash -c 'grep -q "uniques-pull: exchanged with [1-9]" /tmp/itsgoin-ctop3.log' - -# Pool 1 shifts one bounce deeper per hop, tagged to the reporter. -check "node1 holds node3 at N2 (pool 1 shifted one bounce)" at_bounce 1 "$n3" 2 -check "node1 holds node4 at N3" at_bounce 1 "$n4" 3 -check "node1 holds node5 at N4 (terminal pool)" at_bounce 1 "$n5" 4 -check "node5 holds node1 at N4 from the other end of the chain" at_bounce 5 "$n1" 4 - -# N4 is USED — address resolution and search read it. -check "node1 can look up its N4 entry (used, not merely stored)" \ - bash -c 'grep -q "N4 via" <(echo "uniques '"$n5"'" >&21; sleep 3; tail -40 /tmp/itsgoin-ctop1.log)' - -# §layers privacy invariant: ONLY anchor entries carry an address. -check "no non-anchor index row carries an address (on any node)" \ - bash -c 'for i in 1 2 3 4 5; do - c=$(sqlite3 /tmp/itsgoin-ctop$i/itsgoin.db \ - "SELECT count(*) FROM reachable WHERE is_anchor=0 AND addresses NOT IN ('"''"',\"[]\")"); - [ "$c" = "0" ] || exit 1; done' - -# Per-reporter dedup: one row per (reporter, id) — an ID can never sit at two -# depths from the same reporter. -check "one index row per (reporter, id) — no duplicate depths" \ - bash -c 'for i in 1 2 3 4 5; do - d=$(sqlite3 /tmp/itsgoin-ctop$i/itsgoin.db \ - "SELECT count(*) FROM (SELECT reporter_node_id, reachable_id FROM reachable - GROUP BY reporter_node_id, reachable_id HAVING count(*) > 1)"); - [ "$d" = "0" ] || exit 1; done' - -# Nothing is stored past bounce 4. There is no N5 table and no N5 row. -check "stored horizon never exceeds 4 bounces" \ - bash -c 'for i in 1 2 3 4 5; do - m=$(sqlite3 /tmp/itsgoin-ctop$i/itsgoin.db "SELECT ifnull(max(bounce),0) FROM reachable"); - [ "$m" -le 4 ] || exit 1; done' - -# THE KEY ASSERTION: a fresh node joining node1 must NOT learn node5. node1 -# knows node5 only at N4, and the terminal pool is never re-announced. -echo "connect $n1@127.0.0.1:18431" >&27; sleep 4 -for round in 1 2 3; do echo "uniques-pull" >&27; echo "uniques-pull" >&21; sleep 3; done -check "N4 is never re-announced: node7 never learns node5" \ - bash -c '! /usr/bin/env bash -c "[ \"\$(sqlite3 /tmp/itsgoin-ctop7/itsgoin.db \ - \"SELECT count(*) FROM reachable WHERE lower(hex(reachable_id))=\\\"'"$n5"'\\\"\")\" != \"0\" ]"' -check "node7 DID learn node2 (the exchange itself works)" anywhere 7 "$n2" - -############################################################################## -echo -echo "== scenario 2: anchor convection ==" -############################################################################## -# Node 6 plays the anchor: directly dialable, and it is the one every caller -# asks. Callers are node1 and node5 — the two ENDS of the chain, so they have -# no path to each other and a referral that lands is unambiguous. -echo "connect $n6@127.0.0.1:18436" >&21; sleep 3 -echo "convect $n6" >&21; sleep 5 # node1 admitted; window was empty -echo "connect $n6@127.0.0.1:18436" >&25; sleep 3 -echo "convect $n6" >&25; sleep 10 # node5 is referred to node1 - -check "anchor serves referrals out of the rolling window" \ - bash -c 'grep -q "Convection: serving referrals" /tmp/itsgoin-ctop6.log' -check "the anchor admitted callers to the window without any registration msg" \ - bash -c 'grep -q "Convection: admitted caller to rolling window" /tmp/itsgoin-ctop6.log' -check "the anchor referred the PRIOR caller to the next one" \ - bash -c 'grep "Convection: referring peer" /tmp/itsgoin-ctop6.log | grep -q "'"$n1"'"' -check "a convection referral produced a real peer connection" \ - bash -c 'grep -q "Convection: connected to referred peer\|Convection: connected via introduction" \ - /tmp/itsgoin-ctop5.log' -check "node5 is now actually mesh-connected to node1 (chain ends joined)" \ - bash -c 'echo "connections" >&25; sleep 3; strip_ansi /tmp/itsgoin-ctop5.log | tail -20 | grep -q "'"${n1:0:12}"'"' - -# Both ends live on the anchor => coordinate an introduction, not a cold address. -check "anchor coordinates RelayIntroduce when both ends are live" \ - bash -c 'grep -q "Convection: coordinated introduction" /tmp/itsgoin-ctop6.log' - -# Rotation: an entry is handed to exactly 2 callers, then leaves the window. -echo "convect $n6" >&24; sleep 6 -echo "convect $n6" >&23; sleep 6 -echo "convect $n6" >&27; sleep 6 -check "each window entry is handed out at most twice, then rotates out" \ - bash -c 'n=$(grep "Convection: referring peer" /tmp/itsgoin-ctop6.log | grep -c "referred=\"\?'"$n1"'"); \ - [ "$n" -le 2 ]' -check "later callers are served the more RECENT callers" \ - bash -c 'grep "Convection: referring peer" /tmp/itsgoin-ctop6.log | tail -3 | grep -q "'"$n5"'\|'"$n4"'"' - -# CHEAP REFUSAL: node3 is an interior chain node (2 mesh peers) so its request -# is TOP-UP class. It asks node5, whose own convection window is empty — there -# is nothing fresh to circulate, so the refusal must be one message, not a -# burned timeout. -echo "connect $n5@127.0.0.1:18435" >&23; sleep 4 -echo "convect $n5" >&23; sleep 6 -check "top-up against an empty window is refused" \ - bash -c 'grep -q "convection: refused" /tmp/itsgoin-ctop3.log' -check "the refusal is CHEAP (single message, well under a timeout)" \ - bash -c 'ms=$(grep -o "convection: refused in [0-9]*ms" /tmp/itsgoin-ctop3.log | tail -1 | - grep -o "[0-9]*" | head -1); [ -n "$ms" ] && [ "$ms" -lt 3000 ]' -check "entry class is served even where top-up was refused" \ - bash -c 'grep "Convection: serving referrals" /tmp/itsgoin-ctop6.log | grep -q entry' -check "the refusing anchor lands in the caller's penalty box (refusal feedback)" \ - bash -c 'echo "convection" >&23; sleep 3; strip_ansi /tmp/itsgoin-ctop3.log | tail -20 | - grep -oE "anchor_bias [0-9.]+" | tail -1 | awk "{ exit !(\$2 < 1.0) }"' - -############################################################################## -echo -echo "== scenario 3: temp referral slots (mesh cap 2) ==" -############################################################################## -# Restart nodes 1-4 with a mesh cap of 2 so the temp-referral band is reachable. -for fd in 21 22 23 24 25 26 27; do echo "quit" >&$fd 2>/dev/null; done -sleep 3 -kill $(pgrep -f 'itsgoin.*itsgoin-ctop') 2>/dev/null; sleep 2 -for fd in 21 22 23 24 25 26 27; do eval "exec $fd>&-" 2>/dev/null || true; done - -for i in 1 2 3 4; do - rm -rf /tmp/itsgoin-ctop$i /tmp/itsgoin-ctopcmd$i /tmp/itsgoin-ctop$i.log - mkdir -p /tmp/itsgoin-ctop$i; mkfifo /tmp/itsgoin-ctopcmd$i - start_node $i $((18440 + i)) ITSGOIN_TEST_MESH_SLOTS=2 \ - ITSGOIN_TEST_ALLOW_LOOPBACK_REFERRALS=1 ITSGOIN_TEST_NO_GROWTH=1 -done -exec 21>/tmp/itsgoin-ctopcmd1 22>/tmp/itsgoin-ctopcmd2 23>/tmp/itsgoin-ctopcmd3 \ - 24>/tmp/itsgoin-ctopcmd4 -sleep 7 -for i in 1 2 3 4; do - eval "m${i}=\$(strip_ansi /tmp/itsgoin-ctop$i.log | grep -m1 'Node ID:' | awk '{print \$3}')" -done - -# Fill node1's 2 mesh slots, then send a third peer at it. -echo "connect $m1@127.0.0.1:18441" >&22; sleep 4 -echo "connect $m1@127.0.0.1:18441" >&23; sleep 4 -echo "slots" >&21; sleep 3 -check "mesh pool fills to the cap" \ - bash -c 'strip_ansi /tmp/itsgoin-ctop1.log | grep -q "mesh 2/2"' - -echo "connect $m1@127.0.0.1:18441" >&24; sleep 5 -echo "slots" >&21; sleep 3 -check "the 4th peer lands in a temp referral slot ABOVE the cap" \ - bash -c 'strip_ansi /tmp/itsgoin-ctop1.log | tail -30 | grep -q "mesh 2/2 temp-referral 1/"' -check "an established mesh peer was never evicted to make room" \ - bash -c 'strip_ansi /tmp/itsgoin-ctop1.log | tail -30 | grep -q "mesh 2/2"' -check "the temp slot carries NO knowledge (never a reporter in our index)" \ - bash -c '[ "$(sqlite3 /tmp/itsgoin-ctop1/itsgoin.db \ - "SELECT count(*) FROM reachable WHERE lower(hex(reporter_node_id))=\"'"$m4"'\"")" = "0" ]' -check "and a temp peer is never written to mesh_peers" \ - bash -c '[ "$(sqlite3 /tmp/itsgoin-ctop1/itsgoin.db \ - "SELECT count(*) FROM mesh_peers WHERE lower(hex(node_id))=\"'"$m4"'\"")" = "0" ]' - -# Free a mesh slot: the temp peer graduates into it. The wait covers the mesh -# keepalive interval (30s) — an abruptly-gone QUIC peer is noticed when the -# next keepalive write fails, not instantly. -echo "quit" >&22; sleep 45 -echo "slots" >&21; sleep 5 -check "the temp referral slot GRADUATES into the freed mesh slot" \ - bash -c 'strip_ansi /tmp/itsgoin-ctop1.log | tail -20 | grep -q "temp-referral 0/"' -check "the graduated peer now exchanges knowledge (written to mesh_peers)" \ - bash -c '[ "$(sqlite3 /tmp/itsgoin-ctop1/itsgoin.db \ - "SELECT count(*) FROM mesh_peers WHERE lower(hex(node_id))=\"'"$m4"'\"")" != "0" ]' - -############################################################################## -echo -echo "== standing assertions ==" -############################################################################## -check "0xC0 AnchorRegister is gone from the tree" \ - bash -c '! grep -rn "AnchorRegister" crates/ --include=*.rs' -check "no automatic session relay anywhere in the convection path" \ - bash -c '! grep -riE "session relay" /tmp/itsgoin-ctop*.log' -check "no registration cycle writes known_anchors (bootstrap cache only)" \ - bash -c '! grep -rn "start_anchor_register_cycle" crates/ --include=*.rs' - -echo -echo "== results: $PASS passed, $FAIL failed ==" -[ "$FAIL" -eq 0 ] diff --git a/website/design.html b/website/design.html index daa150c..bbb8b37 100644 --- a/website/design.html +++ b/website/design.html @@ -4,7 +4,7 @@ Design Document — ItsGoin - + @@ -42,9 +39,9 @@
- v0.8-design — 2026-07-29 + v0.5.3-beta — 2026-04-19

Design Document

-

This is the canonical technical reference for ItsGoin. It describes the vision, the architecture, and the target state of every subsystem — with full implementation detail. This edition describes the v0.8 target design as one coherent architecture; status badges — Implemented Rework Planned — mark where the code has and hasn't caught up. See the download page for the release changelog.

+

This is the canonical technical reference for ItsGoin. It describes the vision, the architecture, and the current state of every subsystem — with full implementation detail. See the download page for the release changelog.

@@ -52,39 +49,44 @@ Contents 1. The Vision 2. Identity & Bootstrap - 3. Anchors & Connection Convection - 4. Connections & Slot Architecture + 3. N+10 Identification + 4. Connections & Growth 5. Connection Lifecycle - 6. Network Knowledge: N1–N4 Uniques + 6. Network Knowledge Layers (N1/N2/N3) 7. Three-Layer Architecture (Mesh / Social / File) - 8. Social Routing - 9. Relay & NAT Traversal - 10. Port Mapping — UPnP-IGD + NAT-PMP + PCP - 11. LAN Discovery - 12. Worm Search - 13. Update Cadence & Keep-Alive - 14. Content Propagation - 15. Files & Storage - 16. Erasure-Coded CDN Replication - 17. Sync Protocol - 18. Encryption - 19. Friend-of-Friend Visibility - 20. Delete Propagation & Comment Expiry - 21. Social Graph Privacy & Traffic Shaping - 22. Identity Management - 23. Reciprocity (Reconsidered) - 24. Identity Architecture + 8. Anchors + 9. Referrals + 10. Relay & NAT Traversal + 11. UPnP Port Mapping + 12. LAN Discovery + 13. Worm Search + 14. Preferred Peers + 15. Social Routing + 16. Keep-Alive Sessions + 17. Content Propagation + 18. Files & Storage + 18b. Erasure-Coded CDN Replication + 19. Sync Protocol + 20. Encryption + 20a. Friend-of-Friend Visibility + 21. Delete Propagation + 22. Social Graph Privacy + 23. Multi-Device Identity + 24. Phase 2: Reciprocity 25. HTTP Post Delivery 26. Share Links - 27. Discovery & First Contact - 28. Directory Trust Layer (Future) - Appendix A: Timeout & Interval Reference + 27. Directory Service (Planned) + 28. Identity Architecture (Planned) + Appendix A: Timeout Reference Appendix B: Design Constraints Appendix C: Implementation Scorecard - Appendix D: Roadmap — v0.8 Critical Path - Appendix E: File Map + Appendix D: Roadmap + Appendix E: Features Designed But Not Built + Appendix F: File Map
+ +

1. The Vision

@@ -95,429 +97,361 @@
  • Our distributed network first, direct connections always preferred
  • Social graph and friendly UX in front, infrastructure truth in back
  • -
  • Privacy by design: public profile is minimal, private profiles are per-circle, social graph visibility is controlled, and traffic patterns are shaped so observers cannot tell where content originates
  • +
  • Privacy by design: public profile is minimal, private profiles are per-circle, social graph visibility is controlled
  • Don't break content addressing (PostId = BLAKE3(post), visibility is separate metadata)
  • Your feed is yours: reverse-chronological by default, no algorithmic ranking, user-controlled discovery
  • -
  • Narrow mesh, deep knowledge: the mesh provides width (~20 diverse random connections), network knowledge provides reach (uniques known to 4 bounces), and the CDN provides depth (content storage and distribution). See the three-layer architecture.
  • +
  • Three separate layers — Mesh (structural backbone), Social (follows/audience/DMs), File (content storage/distribution) — each with its own connections and routing
- +

2. Identity & Bootstrap

First startup

    -
  1. Identity: Load or generate ed25519 keypair from {data_dir}/identity.key. NodeId = 32-byte public key. This is the network identity only — posting personas use separate keys (see the identity architecture). A unique device identity is also generated for multi-device coordination (see multi-device).
  2. +
  3. Identity: Load or generate ed25519 keypair from {data_dir}/identity.key. NodeId = 32-byte public key. A unique device identity is also generated for multi-device coordination (see Section 23).
  4. Storage: Open SQLite database (distsoc.db), auto-migrate schema.
  5. Blob store: Create {data_dir}/blobs/ with 256 hex-prefix shards (00/ through ff/).
  6. -
  7. Port mapping: Attempt UPnP-IGD + NAT-PMP + PCP via the portmapper crate (parallel; first router response wins; ~3s wait). PCP adds IPv6 firewall pinholes and works on iOS without the multicast entitlement. Auto-renewal is internal to portmapper. On Android, WiFi/Ethernet links acquire a WifiManager.MulticastLock for the lifetime of the mapping (cellular skipped). See port mapping.
  8. -
  9. NAT type detection: STUN probes to two public servers (3s timeout each). Classifies as Public/Easy/Hard/Unknown. Port-mapping success overrides to Public. Anchors skip probing. Result stored on ConnectionManager, shared in InitialExchangePayload, stored per-peer. See relay & NAT traversal.
  10. -
  11. Stale knowledge sweep: Remove all stored network-knowledge entries tagged to peers not in the current mesh. Clears stale reach data from previous sessions (e.g., unclean shutdown). Today this sweeps the N2/N3 share-list tables; under the target model it sweeps the uniques store.
  12. +
  13. UPnP mapping: Attempt UPnP/NAT-PMP port mapping (2s timeout). If successful, store external address for advertisements. Do not block startup if unavailable. See Section 11.
  14. +
  15. NAT type detection: STUN probes to two public servers (3s timeout each). Classifies as Public/Easy/Hard/Unknown. UPnP success overrides to Public. Anchors skip probing. Result stored on ConnectionManager, shared in InitialExchangePayload, stored per-peer. See Section 10.
  16. +
  17. Stale N2/N3 sweep: Remove all N2/N3 entries tagged to peers not in the current mesh. Clears stale reach data from previous sessions (e.g., unclean shutdown).
  18. Bootstrap anchors: Load from {data_dir}/anchors.json. If missing, use hardcoded default anchor.
  19. -
  20. Bootstrap: If the node has fewer than 5 connections at startup, probe known anchors — discovered (non-bootstrap) anchors first, bootstrap anchors only as a fallback phase when every discovered anchor failed. Probes run in batches of 3 (2s stagger between batches, 10s per-anchor timeout, no abort on first success). First success unblocks the bootstrap flow; remaining probes continue in background and fill peer connections naturally. Failed probes to anchors with last_seen > 3 days are immediately pruned from known_anchors. From the connected anchor: run a NAT filter probe, then request convection referrals and begin the growth loop immediately.
  21. +
  22. Bootstrap: If peers table is empty, connect to a bootstrap anchor. Request referrals and matchmaking (unless self or the other node is an anchor). Persist on that anchor's referral list until released (at referral count limit) while beginning the growth loop immediately.
-

Mesh target Rework

-

The mesh target is ~20 peers (plus +4–10 temporary referral slots that carry no knowledge exchange — see connections). Knowledge reach comes from depth (uniques known to 4 bounces, N1–N4), not from connection count.

-
- v0.8 change: Replaces the 101-peer mesh (10 preferred / 71 local / 20 wide on desktop). That width was designed for the pre-CDN push-event world; per-client overhead was too high, and the CDN now carries depth. The preferred tier was deleted in Iteration B; current code runs 71 local + 20 wide (91 slots desktop, 12 mobile). Narrowing to the single ~20-slot pool remains target (Roadmap item 4). -
-

Startup cycles

Spawned after bootstrap completes:

- - - - - - - - + + + + + + + +
CycleIntervalPurposeStatus
Update cadence schedulerDescending scale (minutes → days) per author, keyed on freshness × relationship tier, jitteredCDN update checks + uniques-list refresh; timing shaped to look like uniform network overhead. See update cadence.Rework — today: 60s pull tick with a 4h stale-author threshold
Routing diff120s (2 min)Announce network knowledge to mesh peers as a full two-pool uniques snapshot; skipped when the content digest is unchangedImplemented
Rebalance600s (10 min)Clean dead connections, signal growthRework — preferred-peer Priority 0 deleted (Iteration B); remaining priorities still target the wide model
Growth loopReactive (signal-driven on knowledge receipt)Fill the single mesh pool toward 20 with the most diverse candidates (fewest reporters wins, shallower bounce breaks ties); secondary peer-finding path alongside convectionImplemented
Convection triggerStochastic, per-disconnectOn each mesh disconnect (temp referral expiry does not count), random choice: do nothing / ask a random known anchor for an introduction / ask a mesh peer. See anchors.Implemented
Recovery loopReactive (mesh drops below 2)Emergency reconnect: mine retained uniques pools for anchor addresses, then known_anchors cache, then any connected anchor peers. Non-stochastic — below 2 it always acts.Implemented
Anchor reachability watcherEvent-driven (portmapper watch channel)Clears anchor candidacy after >5min of no port mapping; restores it when the mapping comes back. Network roams self-heal without restart. Mobile never auto-anchors.Implemented
CycleIntervalPurpose
Pull syncOn demand (3h Self Last Encounter threshold)Pull new posts from social + upstream file peers
Routing diff120s (2 min)Broadcast N1/N2 changes to mesh + keep-alive sessions
Rebalance600s (10 min)Clean dead connections, reconnect preferred, signal growth
Growth loop60s + reactive (on N2/N3 receipt)Fill empty mesh slots until 101 (90% threshold for reactive mode)
Recovery loopReactive (mesh empty)Emergency reconnect via anchors
Social/File connectivity check60sVerify <N4 access to N+10 of active social + file peers; open keep-alive sessions as needed
UPnP lease renewal2700s (45 min)Refresh UPnP port mapping before TTL expiry (desktop only)
- v0.8 change: The 600s anchor register loop is removed. Nodes no longer maintain ongoing registration with anchors; the per-disconnect stochastic convection action replaces it, and mesh churn makes convection callbacks self-sustaining. + Removed: Anchor register loop. Anchors are for forming initial mesh connections when bootstrapping, not for ongoing registration. Nodes only connect to anchors during bootstrap or recovery.
- -
-

3. Anchors & Connection Convection

+ +
+

3. N+10 Identification

-

Intent

-

An anchor is any directly-reachable, opted-in node — a standard ItsGoin node with a routable address, running the same code with no special protocol. Anchors are the network's entry and re-entry points: they serve bootstrap (first startup), recovery (mesh loss), and convection (topping up under-connected meshes), and they serve public posts over HTTP to browsers (see HTTP delivery). Anchors include VPS-deployed nodes (always-on) and any desktop with a working port mapping.

-
- v0.8 change: An anchor is no longer a "well-connected" role — the old candidacy checklist (mesh ≥ 50 peers, 2h uptime) is gone. Candidacy is pure reachability + opt-in. The anchor's referral service changes from a use-count-tiered registration list to the rolling convection window described below. -
+

Concept

+

Every node is identified not just by its NodeId but by its N+10: the node's own NodeId plus the NodeIds of its 10 preferred peers. This accelerates the capacity to find any node — if you can reach any of the 11 nodes in someone's N+10, you can find them.

-

Candidacy: reachability, not popularity

-

A node is a viable anchor when a complete stranger can connect to it directly — no introduction, no hole punch, no relay. Candidacy has three parts:

-
    -
  • Reachability watcher Implemented — an event-driven watcher on the portmapper's external-address channel. If the mapping disappears for >5 minutes, anchor candidacy is cleared; when the mapping comes back, it is restored. Mobile never auto-anchors (cellular IPs look public but aren't anchorable).
  • -
  • Opt-in Planned — anchor service must be explicitly enabled by the operator. Today anchor mode switches on automatically for any reachable desktop; the target adds an opt-in gate (mirroring the session-relay opt-in principle: the nodes most likely to pay for bandwidth get to choose).
  • -
  • Self-verification probe Implemented — external confirmation of cold reachability, described below.
  • -
+

Where N+10 appears

+ + + + + + + +
ContextWhat's included
Self identificationAll self-identification messages include the sender's N+10
Following someoneWhen you follow a peer, you store and maintain their N+10 in your social routes
Post headersEvery post header includes the author's current N+10. Updated whenever they post.
Blob headersBlob/file headers include: (1) the author's N+10, (2) the upstream file source's N+10 (if not the author), (3) N+10s of up to 100 downstream file hosts
Recent post listsAuthor manifests include the author's N+10 alongside their recent post list
-

Anchor self-verification

-

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 — a genuine stranger with no prior connection, no cached address, no warm path — reached via the peer that reported it (AnchorProbeRequest, 0xC3). The witness performs a raw cold QUIC connect to the candidate's external address (15s timeout), deliberately skipping the entire resolution cascade — hole punch, introduction, or relay success would be a false positive.

-

Asymmetric return path: on success the witness reports back over its fresh direct connection (AnchorProbeResult, 0xC4); on failure there is by definition no direct path, so the result routes back through the reporting peer.

-
- v0.8 (Iteration C): the anchor register cycle the probe used to ride is gone. The probe now rides the convection loop's 10-minute maintenance tick. Note the consequence: is_anchor_candidate no longer requires a successful probe — 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 Planned. -
+

Why this works

+

Preferred peers are bilateral agreements — stable, long-lived connections. By including them in identification, any node that can find any of your 10 preferred peers can transitively find you within one hop. This eliminates most discovery cascades for socially-connected nodes.

-

Connection convection Implemented

-

The anchor's referral service is a rotating referral chain — "connection convection." The anchor keeps only a small rolling window of recent callers; there is no peer database and no persistent registration.

-
    -
  1. A caller connects and announces "I'm joining / I need peers."
  2. -
  3. The anchor returns the addresses of the 2 most recent viable prior callers.
  4. -
  5. The caller's own address is handed out to the next 2 callers, then rotates out of the window.
  6. -
-

Window parameters (previously open, now decided in code): the window holds 16 entries; each entry is handed to 2 callers before rotating out; an entry older than 15 minutes is not handed out at all — 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 — a different port is the legitimate endpoint-dependent-mapping case, a different host is not. Only the observed 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.

-

Sparse-window supplement: a young anchor with an empty window may fill referrals from its own live mesh peers — but those peers never called and never offered themselves for redistribution, so the supplement is consent-shaped. It discloses no address (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.

-

Request classes: convection requests carry a one-bit class — entry (bootstrap or recovery: fewer than 2 connections) versus top-up (growth with a working mesh). Anchors always serve entry; top-ups are served capacity-permitting and refused cheaply (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 — a network where nobody tops up hands its next genuine joiner stale referrals.

-

Referral quality: the anchor prefers callers it is still connected to — and when both the referred peer and the caller are live on the anchor, it coordinates a RelayIntroduce hole-punch introduction between them instead of handing out a cold address. A referred peer lands in whatever slot class is free: a mesh slot first, and only the temporary referral band above the cap when the mesh is full — from there it graduates into a freed mesh slot or expires.

-

Trigger — stochastic, per-disconnect Implemented: each time a mesh peer disconnects — a temp referral slot expiring is not mesh churn and does not roll the dice — the node randomly chooses one of three actions: do nothing, request an introduction from a random known anchor (mined from the uniques pools — see bookkeeping below), or request an introduction via a connected mesh peer (the growth loop's diversity-scored path — see connection lifecycle). The randomness itself de-synchronizes the network's reaction to shared disconnection events — 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–30% of nodes), anchors rebuild the mesh among themselves and with their callers, absorbing even large disconnection events. The action-choice weights are adaptive, not fixed. The anchor ratio is locally observable — anchor entries are the ones carrying addresses in the pools, so anchor density among known uniques is a free statistic needing no census — 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 — the adaptive weights are designed to find that ratio rather than assume it. (The node-ID–to–persona ratio stays deliberately unknowable; this statistic never needs it.)

-
- Done (Iteration C): the older registration model is gone — 0xC0 AnchorRegister 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. -
- -

Anchor bookkeeping Implemented

-

Target: the uniques pools ARE the anchor directory. Anchor entries carry addresses in the N0–N3 pools (see Network Knowledge), so a node needing a connection mines its retained pools for a random anchor-flagged entry. Pool knowledge is overwritten memory: a slot's contributed entries are wiped only when a new handshake replaces them — 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.

-

The shipped known_anchors table survives as the bootstrap cache, capped at 32 entries and ordered by last_seen_ms (freshest first). Its old success_count ranking solved an anchor-scarcity problem that pool-mined abundance retires. It is written only for anchors we have actually connected to — never from a peer's announce — 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).

-

Selection order:

-
    -
  1. Uniques pools Implemented — anchor-flagged entries, shallowest and freshest first, including entries from disconnected peers' retained pools.
  2. -
  3. known_anchors bootstrap cache Implemented — ordered by last_seen_ms descending. Failed probes to entries not seen for 3 days are deleted on the spot (self-healing against stale data dirs).
  4. -
  5. Hardcoded default anchor(s) — 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.
  6. -
- -

When anchors are used

-
    -
  • Bootstrap: First startup with an empty peers table — batched probing as described in Identity & Bootstrap, then a convection referral request.
  • -
  • Recovery: When the mesh drops below 2 connections. Reconnect via anchor addresses mined from the retained uniques pools, then the known_anchors cache, then any still-connected anchor peers.
  • -
  • Convection top-up Implemented: the per-disconnect stochastic action described above. This is the ONLY ongoing anchor contact — there is no periodic registration.
  • -
  • itsgoin.net node: A permanent, well-connected ItsGoin node runs on itsgoin.net as part of the share-link redirect infrastructure (see share links). It participates as a standard anchor — it bootstraps new nodes and serves convection referrals — and is not special-cased in the protocol. Its value comes from permanent uptime, not from any privileged role.
  • -
- -

Session fallback for full anchors

-

When an anchor's mesh is full, callers fall back to a session connection. The anchor serves convection requests over session connections, not just mesh — a full mesh never makes an anchor unreachable for referrals.

+

Status: Partial

+

N+10 is partially implemented — preferred peers exist and are tracked, but N+10 is not yet included in all identification contexts (post headers, blob headers, self-identification messages). Currently preferred_tree in social routes provides similar functionality for relay selection.

- + +
-

4. Connections & Slot Architecture Implemented

+

4. Connections & Growth

Connection types

    -
  • Mesh connection — long-lived routing slot in a single ~20-slot pool. Structural backbone for search and propagation; participates in the N1–N4 knowledge system's uniques announce (two pools: forwardable N0–N2 + terminal N3 — see Network Knowledge). DB table: mesh_peers.
  • -
  • Temp referral connection Implemented — 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.
  • -
  • Cadence-driven session — 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 Update Cadence & Keep-Alive). There is no standing keep-alive pool — only the shaped check cycle recurs.
  • -
  • Session connection — short-lived, held open for active interaction (DM conversations, group activity, anchor matchmaking). Tracks remote_addr so a relay can inject observed addresses during introductions.
  • +
  • Mesh connection — long-lived routing slot. Structural backbone for discovery and propagation. DB table: mesh_peers.
  • +
  • Keep-alive session — long-lived connection for social or file layer peers that aren't in the mesh 101. Participates in N2/N3 routing. See Section 16.
  • +
  • Session connection — short-lived, held open for active interaction (DM conversations, group activity, anchor matchmaking). Tracks remote_addr so the relay can inject observed addresses for session peers during introductions.
  • Ephemeral connection — single request/response, no slot allocation.

Slot architecture

- - - - - + + + + + + +
Slot kindDesktopMobileStatusPurpose
Mesh (single pool)2015ImplementedLong-lived routing backbone; every slot equal, filled by growth loop + inbound
Temp referral+10+4ImplementedIntroduction facilitation above the cap; no knowledge exchange; graduate or expire. The +4..+10 ruling is a range; these are the shipped points in it.
Sessions (interactive)205ImplementedActive DM, group interaction, anchor matchmaking. At capacity, the oldest idle session is evicted for a new one.
Relay pipes102ImplementedSession-relay byte pipes. Serving others is opt-in and default OFF (see Relay & NAT Traversal).
Slot kindDesktopMobilePurpose
Preferred103Bilateral agreements, eviction-protected
Non-preferred9112Growth loop fills these with diverse peers
Total mesh10115Long-lived routing backbone
Keep-alive sessionsNo hard limitNo hard limitSocial/file layer peers not in mesh (max 50% of session capacity reserved for keep-alive)
Sessions (interactive)No hard limitNo hard limitActive DM, group interaction, anchor matchmaking
Relay pipes102Own-device relay by default; opt-in for relaying for others
- v0.8 change: The Preferred/Local/Wide slot taxonomy (10+71+20 = 101 desktop, 3+7+5 = 15 mobile) is replaced by one flat mesh pool of ~20 slots. Preferred peers are eliminated entirely — the CDN's file_holders model replaced the N+10 direct push/pull that preferred relationships existed to serve. Depth now comes from knowledge (4 bounces), not from connection count. -
-
- Why 20: the old 101-peer mesh was sized for the pre-CDN push-event world, and its per-client overhead was too high. The mesh's job is width — diverse random connectivity for searches and post propagation. Knowledge depth (N1–N4) recovers the reach that raw connection count used to provide. If uniques-list RAM proves excessive at 20 peers @ 4 bounces, the fallback is fewer peers at 3 bounces. + v0.2.0 change: Removed the distinction between "local" (71) and "wide" (20) non-preferred slots. The growth loop goes wide by default. Session counts are no longer hard-limited — an average computer can sustain ~1000 QUIC sessions without strain. The 50% keep-alive reservation ensures sessions remain available for interactive use.

MeshConnection struct

-

Each mesh connection tracks: node_id, connection (QUIC), remote_addr (captured from Incoming before accept), last_activity (AtomicU64, updated on every stream accept), connected_at. The per-slot slot_kind discriminator (Preferred/Local/Wide) is gone with the pool merge; a small enum distinguishes Mesh from TempReferral { expires_at_ms }. Allocation is mesh-first, then the temp band, then refuse — nothing in the rule can evict anything, so an established mesh peer is never displaced by an arriving stranger.

- -

Temp referral slots Implemented

-

Nodes hold temporary connections above the mesh cap — +10 on desktop, +4 on mobile, 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:

-
    -
  • No knowledge exchange — 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 mesh_peers, which is the table the announce builder reads. The knowledge gate covers the whole 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 null, not empty vectors, so the peer can tell "nothing to share" from "not sharing".
  • -
  • Graduate or expire — 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.
  • -
  • Graduation requires a free mesh slot — a temp referral never evicts an established mesh peer.
  • -
+

Each mesh connection tracks: node_id, connection (QUIC), slot_kind (Preferred or NonPreferred), remote_addr (captured from Incoming before accept), last_activity (AtomicU64), created_at.

Mutual mesh blacklist Planned

-

Targeted two-node stranger relationship. Both nodes opt in, maintaining genuine N2 stranger status indefinitely regardless of growth loop behavior. Stored in a local mesh_blacklist { node_id } table (table does not exist yet).

+

Targeted two-node stranger relationship. Both nodes opt in, maintaining genuine N2 stranger status indefinitely regardless of growth loop behavior. Stored in a local mesh_blacklist { node_id } table.

  • Growth loop skips blacklisted nodes during candidate selection
  • -
  • Incoming mesh upgrade from a blacklisted node → respond with RefuseRedirect (0x05)
  • +
  • Incoming mesh upgrade from blacklisted node → respond with RefuseRedirect (0x05)
  • Both nodes must add each other — asymmetric blacklist is valid but only prevents the blacklisting side from upgrading
  • -
  • Full session/ephemeral interaction still works — messages, probes, routing participation; blacklisted nodes never consume each other's mesh slots
  • +
  • Blacklisted nodes remain visible in N2 via shared N1 peers
  • +
  • Full session/ephemeral interaction still works — messages, probes, routing participation
  • +
  • Never consume each other's mesh slots
  • +
+

Production utility: Operators maintaining intentional stranger relationships for network diversity, preventing specific nodes from becoming preferred peers, or any scenario where two nodes want to cooperate at session level without mesh entanglement.

+ +

--max-mesh <n> CLI flag Planned

+

Topology control at network scale. Forces a node to cap its mesh connections, keeping it permanently in N2 of other nodes. Testing affordance only — not for production use.

+
    +
  • --max-mesh 0: Pure N2 participant, never takes mesh slots. Warning: free rider — consumes routing knowledge without carrying mesh load.
  • +
  • --max-mesh 3: Partial mesh, useful for testing sparse topologies
  • +
  • --max-mesh 101: Default, full normal behavior
  • +
  • Node responds to all protocol messages normally, never initiates or accepts mesh upgrades beyond the cap
  • +
  • Reuses existing RefuseRedirect (0x05) — no new protocol machinery
-

Mesh-cap override Implemented

-

Topology control for testing, shipped as the ITSGOIN_TEST_MESH_SLOTS environment override rather than the originally-planned --max-mesh <n> 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 OnceLock at first use — the cap cannot change under a running node. Refusals reuse RefuseRedirect (0x05) — no new protocol machinery. Testing affordance only.

- -

Keepalive Implemented

+

Keepalive

  • Interval: 30 seconds (MeshKeepalive message, 0xE0)
  • -
  • Zombie detection: no stream activity for 600s (10 min) = zombie, removed in rebalance
  • +
  • Zombie detection: No stream activity for 600s (10 min) = zombie, removed in rebalance
  • last_activity updated on every stream accept
- +

5. Connection Lifecycle

-

Growth Loop (reactive, signal-driven) Rework

-

There is no periodic growth timer. The loop blocks on a signal channel, coalesces queued signals, then runs connection attempts until slots fill or it backs off. Signals fire on:

-
    -
  • Routing knowledge receipt with new N1 additions (initial exchange, routing diff)
  • -
  • A disconnect that leaves free mesh slots
  • -
  • Completion of bootstrap/recovery referral connects
  • -
  • Failed auto-reconnect after an unexpected disconnect
  • -
  • Rebalance-cycle backstop (every cycle ends by signaling growth)
  • -
-
- v0.8 change: growth targets the whole 20-slot pool. (Today the loop stops when the Local sub-pool is full and Wide slots only fill from inbound — a casualty of the old taxonomy that dies with it.) Rework -
-
- v0.8 — stochastic growth Implemented: 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 Anchors & Connection Convection). -
+

5.1 Growth Loop (60s timer + reactive on N2/N3 receipt)

+

Timer: Fires every 60 seconds. Checks current mesh count. If < 101, runs a growth cycle.

+

Reactive trigger: Fires immediately after receiving a peer's N2/N3 list (from initial exchange or routing diff). Continues firing on each new N2/N3 receipt until mesh is 90% full (~91 connections). After 90%, switches to timer-only mode.

-

Candidate selection (N2 diversity scoring) Implemented:

+

Candidate selection (N2 diversity scoring):

score = 1.0 / reporter_count + (0.3 if not_in_N3)
    -
  • Fewer reporters = higher diversity = better candidate. Diversity matters more at 20 slots than it did at 101 — each slot is 5× scarcer.
  • -
  • Bonus for peers not already visible in N3 (locally discovered, not transitive)
  • -
  • Sorted descending; already-connected, self, and known-unreachable peers filtered out
  • -
  • Blacklist filter (Planned): skip nodes in mesh_blacklist
  • +
  • Fewer reporters = higher diversity = better candidate
  • +
  • Bonus for locally-discovered peers (not transitive)
  • +
  • Sorted descending, best candidate tried first
  • +
  • Growth loop goes wide by default — no local/wide distinction
  • +
  • Blacklist filter: Skip nodes in mesh_blacklist table (see Section 4)

Connection attempt cascade:

    -
  1. Direct connect (15s timeout) — stored address, or resolved via AddressRequest to the N2/N3 reporters who announced the candidate
  2. -
  3. Introduction fallback — ask each connected reporter to relay-introduce us (hole-punch coordination)
  4. +
  5. Direct connect (15s timeout) — use stored/resolved address
  6. +
  7. Introduction fallback — find N2 reporters who know this peer, ask each to relay-introduce us
-

Failure handling: consecutive-failure counter; after 3 consecutive failures the loop breaks and waits for the next signal. Failed candidates are marked unreachable and skipped in future scoring. After each success the counter resets, a routing diff is broadcast, and the loop pauses 500ms so the new peer's initial exchange can expand the candidate pool before the next pick.

+

Failure handling: Track consecutive failures. After 3 consecutive failures, back off (break loop, wait for next signal). Mark unreachable peers for future skipping.

-

Rebalance Cycle (every 600s) Rework

-

Executed in order:

+

5.2 Rebalance Cycle (every 600s)

+

Executed in priority order:

    -
  1. Dead connection removal: connections with close_reason() set, or idle > 600s (zombie)
  2. -
  3. Stale knowledge pruning: entries tagged to a disconnected reporter are cleared immediately at disconnect time; the cycle adds an age-based sweep (5 hours) plus social-route watcher pruning (30 days)
  4. -
  5. Reconnect recently dead: re-establish dropped mesh connections from stored addresses
  6. -
  7. Signal growth loop: backstop signal to fill any remaining free slots
  8. -
  9. Idle session cleanup: reap interactive sessions idle > 300s (5 min). Sessions with a reason to live are exempt: anchor-side referral-list peers and client-side sessions to known anchors.
  10. -
  11. Relay intro dedup pruning: clear seen_intros entries older than 30s once the map exceeds 500 entries
  12. +
  13. Dead connection removal: Remove connections with close_reason() set, or idle > 600s (zombie)
  14. +
  15. Stale entry pruning: N2/N3 entries tagged to a peer that is no longer connected are pruned immediately (on disconnect and on startup sweep). Age-based fallback: entries older than 7 days. Social route watchers older than 30 days.
  16. +
  17. Priority 0 — Preferred peer reconnection: Iterate preferred_peers table, reconnect any that are disconnected. If at capacity, evict the lowest-diversity non-preferred peer to make room. Prune preferred peers unreachable for 7+ days (slot released, does NOT auto-return on reconnect — must re-negotiate via MeshPrefer). After 7 days, social checkin frequency drops from 1–4 hours to daily until the 30-day reconnect watcher expires.
  18. +
  19. Priority 1 — Reconnect recently dead: Re-establish dropped non-preferred connections. Skip blacklisted nodes — do not attempt reconnection to peers in mesh_blacklist.
  20. +
  21. Priority 2 — Signal growth loop: Fill remaining empty slots via growth loop
  22. +
  23. Idle session cleanup: Reap interactive sessions idle > 300s (5 min). Keep-alive sessions are NOT reaped by idle timeout.
  24. +
  25. Relay intro dedup pruning: Clear seen_intros entries older than 30s, cap at 500
- v0.8 change: the cycle's old Priority 0 — preferred-peer reconnection with eviction of low-diversity peers — is deleted along with preferred peers (MeshPrefer, the 7-day unreachable prune, and the 30-day reconnect watcher all die with it). That was the only eviction path in rebalance, so the target cycle evicts nothing: low diversity alone never disconnects a peer, and temp referrals graduate only into free slots. + Note: Low diversity score alone does NOT trigger eviction. The only eviction path is Priority 0 (making room for a preferred peer).
-

Recovery Loop (reactive, mesh < 2) Rework

-

Trigger: fires when the mesh drops below 2 connections — the one non-stochastic case: at the edge of isolation the node always acts, immediately.

+

5.3 Recovery Loop (reactive, mesh empty)

+

Trigger: disconnect_peer() fires when last mesh connection drops.

    -
  1. Debounce 2 seconds (let cascading disconnects settle), coalesce queued signals
  2. -
  3. Gather anchors: mine the retained uniques pools for anchor-flagged addresses (Implemented — disconnected peers' pools survive as overwritten memory until new handshakes replace them), then the known_anchors bootstrap cache, then anchor-flagged entries in the peers table. Anchors that recently refused us are pushed to the back rather than dropped — a refusal is a load signal, not a blacklist.
  4. -
  5. 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)
  6. -
  7. Signal the growth loop as soon as any connection lands; stale anchors are pruned per the 3-day rule (see Anchors & Connection Convection)
  8. +
  9. Debounce 2 seconds (wait for cascading disconnects to settle)
  10. +
  11. Gather anchors: known_anchors table ordered by last_seen DESC (LIFO — most recently seen is most likely still reachable) → fallback to hardcoded default anchor(s) only if known_anchors empty or exhausted
  12. +
  13. For each anchor: connect, request referrals and matchmaking, try direct connect to each referral, fallback to hole punch via anchor for unreachable referrals
  14. +
  15. Persist on anchor's referral list until released, begin growth loop immediately
  16. +
  17. Post-bootstrap stale anchor cleanup: After successful bootstrap/recovery, probe known_anchors entries where last_seen > 7 days. Success: update last_seen. Failure: DELETE from known_anchors. Reuses existing anchor probe machinery (0xC3/0xC4). No new cycle or timer — runs as final step of bootstrap/recovery.
-
- v0.8 change: recovery today sends AnchorRegister and pulls from the anchor's registration-based referral list. The registration step dies with the convection redesign — the recovery call itself enrolls the caller in the anchor's rolling referral window. -
-

Initial Exchange (on every new mesh connection) Implemented

-

When two nodes connect into mesh slots, each sends an InitialExchange (0x02) carrying:

+

5.4 Initial Exchange (on every new connection)

+

When two nodes connect, they exchange:

    -
  • Knowledge share: N1 NodeIds (mesh peers + social contacts, merged) and deduplicated N2 NodeIds — no addresses
  • -
  • Profile: PublicProfile (display name, bio, avatar CID, visibility flag)
  • -
  • Delete records: signed post deletions
  • -
  • Post IDs: local post IDs for replica tracking
  • -
  • Peer addresses: connected peers with addresses, for social routing
  • -
  • NAT profile: NAT type, mapping behavior (EIM/EDM), filtering behavior — feeds hole-punch strategy selection
  • -
  • Observed address: what the sender sees as the receiver's address (STUN-like reflexive discovery)
  • -
  • Anchor address: the sender's stable advertised address, if it is an anchor
  • -
  • HTTP capability: whether the sender serves /p/ over HTTP, plus its external HTTP address
  • -
  • CDN posture: device role (intermittent/available/persistent) and cache-pressure score (0–255)
  • +
  • N+10: Our NodeId + 10 preferred peers' NodeIds
  • +
  • N1 share: mesh peers + social contacts NodeIds (merged, no addresses)
  • +
  • N2 share: deduplicated N2 NodeIds (no addresses)
  • +
  • Profile: PublicProfile (display name, bio, avatar CID, public_visible flag)
  • +
  • Delete records: Signed post deletions
  • +
  • Post IDs: All local post IDs (for replica tracking)
  • +
  • Peer addresses: N+10 address list for connected peers
-

Processing: their N1 → our N2 table (tagged to reporter), their N2 → our N3 table (tagged to reporter). Store profile, apply deletes, record replica overlaps. New N1 knowledge signals the growth loop immediately.

-
- v0.8 (Iteration C, done): the knowledge-share component is now the uniques announce (two pools: forwardable N0–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 Network Knowledge). The handshake's other fields carry forward unchanged, except that a temporary referral slot now sends null uniques and an empty peer-address list. Implemented -
+

Processing: Their N1 → our N2 table (tagged to reporter). Their N2 → our N3 table (tagged to reporter). Store profile, apply deletes, record replica overlaps. Trigger growth loop immediately with new N2/N3 candidates if mesh < 90% full.

-

Incremental Routing Diffs (every 120s + 4h full state) Rework

-

NodeListUpdate (0x01) 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 → our N2 adds, their N2 adds → our N3 adds; diffs with N1 additions signal the growth loop.

-
- v0.8 (Iteration C, done): the 2-minute broadcast is now a full uniques announce under the N1–N4 model, skipped entirely when the content digest is unchanged. Folding its cadence into the update-cadence system's traffic shaping (see Update Cadence & Keep-Alive) is Planned for Iteration D. -
+

5.5 Incremental Routing Diffs (every 120s + on change)

+

NodeListUpdate (0x01) contains N1 added/removed, N2 added/removed. Sent via uni-stream to all mesh peers and keep-alive sessions. Receiver processes: their N1 adds → our N2 adds, their N2 adds → our N3 adds, etc.

- + +
-

6. Network Knowledge: N1–N4 Uniques Implemented

+

6. Network Knowledge Layers (N1/N2/N3)

-

Every node maintains a layered index of uniques — IDs it can help other nodes reach. The index spans four bounces (N1–N4) and is exchanged only between directly-connected mesh peers. Collectively, these per-node indexes form the network's distributed search index: a pull (see Sync Protocol) is an exchange of uniques lists — "if you want these IDs, talk to me and I'll help you find them" — 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 Files & Storage).

- -
- v0.8 change: replaces the N1/N2/N3 share-list model (101-peer mesh, 3 bounces, mesh NodeIds only). The shipped code is the seed and survives: N1/N2 shares on connect (build_n1_share/build_n2_share), incremental diffs, reporter-tagged reachable_n2/reachable_n3 tables — extended one bounce deeper and widened from "mesh NodeIds" to all uniques. -
- -

What is a unique

-

A unique is any ID a node has a live path to, regardless of why it knows it:

-
    -
  • Mesh peers — our ~20 direct mesh connections
  • -
  • Directs — social contacts we hold routes for (follows, audience, DM partners)
  • -
  • CDN file authors — authors of files we hold or recently fetched
  • -
  • CDN file peers — holders we exchanged files with
  • -
-

Sources are merged before announcing — 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 deliberately kept in the lists: they are network noise that protects anonymity and future hooks for anonymous encrypted messaging over temp IDs. Comment TTL expiry (see Delete Propagation) retires them naturally — when the content dies, the throwaway ID falls out of everyone's uniques.

-

Our own posting identities are never in our own uniques Implemented. Our node ID — and, when we are an anchor, our address — ride slice 0 of the same announce, so listing one of our personas at bounce 1 would be a signed-by-transport persona→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 — 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 else who holds our content; we simply must not be the reporter. See Graph Privacy.

-

Temp referral slots (see Connections) are the one class of direct connection that does not enter the exchange: they carry no knowledge state in either direction until they graduate into mesh slots.

- -

The four layers

- - - - - + + + +
LayerBounceSourceAnnounced?Stored in
N11Our own uniques (merged from all sources above)YesDerived at build time from mesh_peers + social_routes + file_holders + post_replicas + cdn_manifests + comments
N22Peers' announced N1, tagged to reporterYesreachable, bounce = 2
N33Peers' announced N2, tagged to reporterYesreachable, bounce = 3
N44Peers' announced N3, tagged to reporterNever — terminatesreachable, bounce = 4
LayerSourceContainsShared?Stored in
N1Our connections + social contactsNodeIds onlyYes (as "N1 share")mesh_peers + social_routes
N2Peers' N1 sharesNodeIds tagged by reporterYes (as "N2 share")reachable_n2
N3Peers' N2 sharesNodeIds tagged by reporterNeverreachable_n3
-

There is one reachable table, keyed (reporter_node_id, reachable_id), with bounce, id_class, is_anchor, addresses and updated_at columns — not a table per layer. The composite key gives per-reporter set-merge for free, and the upsert keeps the shallowest bounce ever heard from that reporter, so one ID can never occupy two depths from one source (the ambiguity the old reachable_n2/reachable_n3 pair papered over by sorting).

-

Announce protocol Rework

-
    -
  • To/from N1 only. Uniques are exchanged exclusively with direct mesh peers — never forwarded on a node's behalf.
  • -
  • Every announce is a full snapshot Implemented (full: true) 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 seq) and skips the send entirely when nothing changed, 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.
  • -
  • An announcement carries two explicitly separated pools. Pool 1 — the forwardable pool: our N0–N2 uniques (self + own uniques + two bounces), deduplicated internally. Pool 2 — the terminal pool: our N3 uniques, deduplicated against pool 1. The receiver stores each entry shifted one bounce deeper (pool 1 → their N1–N3, pool 2 → their N4), all tagged to us as reporter.
  • -
  • The terminal pool is used, never forwarded. 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 — 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.)
  • -
  • Node-class and author-class IDs travel in separate packed arrays Implemented. A slice is { nodes, authors, anchors }: two base64 blobs of concatenated 32-byte IDs plus the address-bearing anchor array. The separation is load-bearing, not cosmetic — 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.
  • -
  • Anchor entries carry addresses; everything else is a bare ID. 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 anchor directory — and with IPv6 pushing an estimated 20–30% of nodes into anchor candidacy, every node's pools hold a deep reserve of re-entry addresses. A received anchor entry is an index row, not an address record: 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 — iroh having proven the node ID owns that path — is promoted into peers.is_anchor and the known_anchors 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.
  • -
  • Sizes are enforced, not merely budgeted Implemented. The uniques opcodes read against a dedicated 2 MB ceiling rather than the 64 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 — nothing may ever be stored past bounce 4.
  • -
  • Addresses are filtered on receipt as well as on send Implemented. Inbound anchor addresses are normalized (IPv4-mapped IPv6 → v4) and dropped unless publicly routable; an anchor entry left with no usable address degrades to a bare node entry. Storing a peer-supplied 127.0.0.1 or RFC1918 address would turn the index into attacker-directed internal probing from our own host — and every such dial enters iroh's per-endpoint path store permanently.
  • -
  • Disconnect retention Implemented: when a peer drops, its reported entries are kept. Pool knowledge is overwritten memory — 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 Anchors). Staleness is bounded by the 5-hour prune, not by disconnection. Wiping on disconnect would empty the index — and collapse the anchor-density prior that steers the adaptive convection weights — at precisely the moment the node needs both most.
  • -
  • Refreshes ride the regular update cadence (see Update Cadence & Keep-Alive) so index traffic blends into uniform network overhead.
  • -
- -

Deduplication

-

Dedup happens at both store and announce. Store side: incoming uniques are set-merged per reporter — the same ID reported twice costs one row. Announce side: an ID already announced at a shallower layer is not repeated at deeper layers, so each announcement names each ID at most once, at its closest bounce. Duplication counts are never transmitted (topology leak); dedup keeps the lists to their unique-ID floor.

- -

Size budget

-

Upper bounds at 20 mesh peers, pure fan-out (real graphs overlap heavily and dedup shrinks all of this):

- - - - - - -
LayerBoundRaw size (32-byte IDs)
N1~20 mesh + directs + CDN contacts< 10 KB
N2≤ 400~13 KB
N3≤ 8,000~256 KB
N4≤ 160,000+~5 MB
-

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 — 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 (<20 peers @ 3 bounces was the explicit fallback in the design ruling).

- -

Device-tiered depth

-

Depth is a per-device choice, not a protocol constant. Desktop and anchor nodes hold the full N1–N4. Mobile holds 3 bounces Implemented: it drops the terminal pool on receipt and advertises depth = 3 in its announce, so senders skip building the terminal slice for it — 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.

-

Bloom-compressed N4 Planned was the alternative (~10 bits/entry at 1% false-positive → ~200 KB for 160k entries) and is not implemented. A Bloom N4 would answer the only question N4 exists for — "can this peer help me reach ID X?" — without supporting enumeration, which N4 never needs since it is never announced. Dropping is the simpler of the two and is what ships.

- -

Indexed access

-

A node has indexed access to a target if the target appears anywhere in its N1–N4: the target is then resolvable by reporter-chain queries alone, with no worm search or relay introduction. The update-cadence system (see Update Cadence & Keep-Alive) uses indexed access to decide whether extra session connections are needed for social and file operations. (This replaces the old "<N4 access" term, which meant "within 3 hops" back when N4 didn't exist as a layer.)

+

<N4 access

+

A node has <N4 access to a target if the target appears in its N1, N2, or N3 tables. This means the target is reachable within 3 hops without needing worm search or relay introduction. The social/file connectivity check (see Section 16) uses <N4 access to determine whether keep-alive sessions are needed.

What is NEVER shared

    -
  • Addresses for non-anchor entries (anchors' addresses ride the pools by design — they are self-declared reachable infrastructure; every other ID resolves on-demand via chain queries. Social-routing payloads are the deliberate, consented exception — see Social Routing and Graph Privacy)
  • -
  • N4 entries (terminal — held for search, never re-announced)
  • +
  • Addresses (resolved on-demand via chain queries)
  • +
  • N3 entries (search-only, never forwarded)
  • Duplication counts (topology leak)
  • -
  • Why we know an ID — mesh, social, and CDN sources are merged in the announcement
  • +
  • Which NodeIds are social contacts vs mesh peers (merged in N1 share)
-

Address resolution cascade (connect_by_node_id) Implemented

+

Address resolution cascade (connect_by_node_id)

- + - - - - + + + + +
StepMethodTimeoutSource
0Social route cachesocial_routes cached addresses, then known-peer referrals
0Social route cachesocial_routes table (cached addresses for follows/audience)
1Peers tableStored address from previous connection
2–3N2/N3 reporter chainvariesAsk the peer who reported the target; N3 chains through the reporter's reporter
4Worm searchboundedPoint query beyond the index (see Worm Search)
5Relay introduction15sHole punch coordinated via an intermediary (see Relay & NAT Traversal)
6Session relayOpt-in only, default OFF on both the serving and using side (see Relay & NAT Traversal)
2N2 ask reportervariesAsk the mesh peer who reported target in their N1
3N3 chain resolvevariesAsk reporter's reporter (2-hop chain)
4Worm search3s totalBurst to all peers → nova to N2 referrals (each does own burst)
5Relay introduction15sHole punch via intermediary relay
6Session relayPipe traffic through intermediary (own-device or opt-in)
-

Peers that fail all direct steps are marked likely-unreachable, so later attempts skip straight to introduction rather than burning timeouts on dead addresses — 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–4), and depth is a graded penalty in the scorer so a bounce-4 sighting can never outrank a bounce-2 one. The third reporter-chain link — resolving an N4 hit through the reporter's reporter — has not landed Planned: today an N4 entry is resolved by asking its direct reporter, exactly like N2 and N3.

- +
-

7. Three-Layer Architecture (Mesh / Social / File) Rework

+

7. Three-Layer Architecture (Mesh / Social / File)

-

The network operates across three layers, each with its own connections, routing, and purpose. The division of labor is: mesh is width, CDN is depth. The mesh gives every node a diverse random slice of the network — enough width that searches terminate and the uniques index covers the graph. The CDN gives content a home and every lookup a concrete target — updates, messages, and FoF-adjacent content discovery all resolve to "find a holder, fetch from it."

+

The network operates across three distinct layers, each with its own connections, routing, and purpose. The separation enables specialized behavior without the layers interfering with each other.

- - - - + + + +
LayerJobConnectionsWhat travels
MeshWidth: diverse connectivity, N1–N4 uniques index, searches, referrals~20 mesh slots + 4–10 temp referral slots (see Connections)Uniques announcements, worm queries, introduction traffic
SocialFollows, audience, DMs — the human relationshipsSocial routes + on-demand session connectionsAddress updates, check-ins, DM delivery
FileDepth: content storage and distributionFlat file_holders, cap 5 per file (see Files & Storage) + on-demand fetchesPosts, blobs, comments, engagement
LayerPurposeConnectionsSync trigger
MeshStructural backbone: N1/N2/N3 routing, diversity, discovery101 mesh slots (preferred + non-preferred)N/A — mesh is infrastructure, not content
SocialFollows, audience, DMs — the human relationshipsSocial routes + keep-alive sessions as neededPull posts when Self Last Encounter > 4 hours
FileContent storage and distribution — blobs, CDN treesUpstream/downstream file peers + keep-alive sessions as neededPull on blob request, push on post creation
-

Key principle: the mesh carries the index, the CDN carries the content

-

Pulls between mesh peers exchange uniques lists — the search index — never post bodies or blobs. When the index says an author or file exists, the actual bytes come from a CDN holder via a point fetch. So mesh connections stay cheap and optimizable purely for topology (diversity scoring has no social bias), while content bandwidth concentrates on the flat holder set where replication and eviction manage it.

-
- v0.8 change: replaces the old rule "pull sync never pulls posts from mesh peers." Under uniques pulls, mesh peers are exactly who you pull from — but what you pull is the index, not content. The old rule's intent (no content flooding over mesh links) survives; the post-carrying pull it was guarding against is retired (see Sync Protocol). -
+

Key principle: mesh is not for content

+

Pull sync does not pull posts from mesh peers. Mesh connections exist for routing diversity and discovery. Content flows through the social layer (posts from people you follow) and the file layer (blobs from upstream/downstream hosts). This separation means mesh connections can be optimized purely for network topology without social bias.

Cross-layer benefits

-

Each layer feeds the others through the uniques index. Social directs and CDN file contacts enter our N1 alongside mesh peers, so a friendship or a file transfer improves everyone's reach — a follow can provide the N2 entry that helps a stranger's chain query terminate, and a mesh peer can provide the reporter chain that reconnects two friends after an address change. Because sources are merged before announcing, this sharing costs no relationship privacy.

+

Each layer's connections contribute to finding nodes and referrals for the other layers. Keep-alive sessions from the social and file layers participate in N2/N3 routing, which improves <N4 access for all three layers. A social keep-alive session might provide the N2 entry that helps the mesh growth loop find a diverse new peer, and vice versa.

- -
-

8. Social Routing Implemented

+ +
+

8. Anchors

+

Intent

+

Anchors are "just peers that are directly reachable" — standard ItsGoin nodes with a routable address. They run the same code with no special protocol. Their value comes from being directly connectable for bootstrapping new nodes into the network and matchmaking (introducing peers to each other). Anchors include VPS-deployed nodes (always-on) and desktop nodes with UPnP port mappings (see Section 11).

+

Each profile can carry a preferred anchor list — infrastructure addresses, not social signals.

-

Caches addresses for follows and audience members, separate from mesh connections. Social contacts are consented relationships, so — unlike the uniques exchange — these payloads do carry addresses: that is the point of the layer (see Graph Privacy for the exact scoping). Every social route is also an N1 unique, announced source-merged like any other.

+

Status: Complete (with gaps)

-

social_routes table

- - - - - - - - - - -
FieldPurpose
node_idThe social contact's NodeId
addressesTheir known IP addresses
peer_addressesKnown-peer referral addresses (PeerWithAddress list) — peers of theirs we can ask when their own addresses go stale
relationFollow / Audience / Mutual
statusOnline / Disconnected
last_connected_msWhen we last connected
last_seen_msWhen we last heard anything about them
reach_methodDirect / Relay / Indirect
-
- v0.8 change: the preferred_tree column and all N+10 semantics are retired with preferred peers (see Connections). peer_addresses survives as plain known-peer referrals with no preferred-tier meaning. -
- -

Wire messages Implemented

- - - - - -
CodeNameStreamPurpose
0x70SocialAddressUpdateUniSent when a social contact's address changes or they reconnect
0x71SocialDisconnectNoticeUniRemoved in v0.8 — zero senders existed; social-route staleness is inferred from checkin timeouts
0x72SocialCheckinBiKeepalive with address + known-peer referral updates
- -

Reconnect watchers Implemented

-

reconnect_watchers table: when peer A asks about disconnected peer B, A is registered as a watcher. When B reconnects, A gets a SocialAddressUpdate notification and the watch is cleared. Watchers are pruned after 30 days.

- -

Social route lifecycle

+

When anchors are used

    -
  • Follow → store their addresses + referrals, upgrade to Mutual (if audience)
  • -
  • Unfollow → downgrade/remove
  • -
  • Approve audience → Mutual/Audience
  • +
  • Bootstrap: First startup with empty peers table. Connect to anchor, request referrals and matchmaking, persist on referral list while growing mesh.
  • +
  • Recovery: When mesh drops to 0 connections. Same flow as bootstrap.
  • +
  • Not ongoing: Nodes do NOT register with anchors on a loop. Anchors are for forming initial connections, not for ongoing presence.
  • +
  • itsgoin.net node: A permanent, well-connected ItsGoin node runs on itsgoin.net as part of the share link redirect infrastructure (see Section 26). This node participates in the network as a standard anchor — it bootstraps new nodes, accepts referral requests, and is included in known_anchors by peers that connect through it. It is not special-cased in the protocol. Its value as an anchor comes from permanent uptime and high mesh connectivity, not from any privileged role.
-

Role under the v0.8 model

-

Social routing is the warm path for the people you actually talk to: step 0 of the address resolution cascade, the delivery route for DMs, and the freshness signal that drives the top tiers of the update cadence (see Update Cadence & Keep-Alive — messaged, followed, and vouched relationships check most often). Content from social contacts still arrives via the CDN like everything else; the social layer only carries the addresses and the relationship state.

-
- -
-

9. Relay & NAT Traversal Implemented

+

Anchor referral mechanics

+

When a bootstrapping node connects, the anchor provides referrals from its mesh and referral list. The node persists on the anchor's referral list until released at the referral count limit. During this time, the anchor can matchmake — introducing the new node to other peers requesting referrals.

-

How a node reaches a peer behind NAT: a mutual contact performs introduction (signaling only), then both sides hole punch. Relaying actual session bytes through a third party is a separate, opt-in facility (see below) — introduction and hole punching are always on.

- -

Relay selection (find_relays_for) Rework

-

Find up to 3 relay candidates from knowledge-layer reporters, prioritized:

+

Anchor selection order

    -
  1. N2 reporters: Our mesh peers who announced the target among their own uniques. They are connected to the target right now (or were very recently). TTL=0.
  2. -
  3. N3 reporters: Peers whose announced knowledge places the target two hops away. TTL=1 — the relay chains the introduction through its own peer.
  4. -
  5. Deep uniques reporters: With N1–N4 knowledge (see Network Knowledge), the uniques index names which mesh peer "can help reach" the target even when the target sits three or four bounces out. The introduction chains along the announce path, decrementing TTL at each hop (max TTL=2).
  6. +
  7. known_anchors tableORDER BY last_seen DESC (LIFO). The most recently seen anchor is most likely still reachable, particularly given short-lived home desktop anchors.
  8. +
  9. Hardcoded default anchor(s) — only if known_anchors is empty or exhausted. A brand-new node hits hardcoded anchors once on first bootstrap, populates known_anchors from that session, and the hardcoded list recedes to pure fallback.
  10. +
+

No scoring, no success counting, no prediction. Attempt, move to next on failure. The known_anchors table stores only: node_id, addresses, last_seen.

+ +

Anchor self-verification Complete

+

Nodes with UPnP-mapped IPv4 or IPv6 public addresses cannot self-certify as anchors — they need external verification that they are genuinely reachable by cold direct connect. A node is a viable anchor only if a complete stranger can connect to it directly with no introduction, no hole punch, and no relay.

+ +

Witness selection

+

Node A (candidate anchor) selects a witness from its own N2 table entries NOT present in its N1. These are genuine strangers — no prior connection, no cached address, no warm path. A selects one (call it C) and knows C's address via the N1 reporter (call it B) who reported C in their N1 share.

+ +

Probe message flow

+
A → B (N1 reporter of C): AnchorProbeRequest {
+    target_addr,     // A's external address to test
+    witness,         // C's NodeId
+    return_via,      // B's NodeId (for failure reporting)
+}
+
+B → C: forward AnchorProbeRequest
+
+C: cold direct QUIC connect to target_addr
+   — MUST use only raw QUIC connect (step 1 of connect_by_node_id)
+   — MUST skip entire resolution cascade, hole punch, introduction, relay
+   — 15s timeout
+
+SUCCESS: C → A directly (on new connection): AnchorProbeResult { reachable: true }
+FAILURE: C → B → A: AnchorProbeResult { reachable: false }
+

Asymmetric return path: If cold connect fails, by definition there is no direct path from C to A. C reports failure through B (who has a live connection to A). On success, C has a fresh direct connection and uses it. The return_via field tells C which node to route failure through.

+

Why bypass the cascade: The normal connect_by_node_id cascade has 7 steps including hole punch and relay. If C uses the full cascade, a successful result via relay is a false positive. The probe handler must be a special code path: raw QUIC connect only.

+ +

Anchor candidacy checklist

+
is_anchor_candidate():
+  - has UPnP mapping OR has IPv6 public address
+  - probe succeeded within last 30 minutes
+  - mesh ≥ 50 peers (sufficient N2 density)
+  - uptime ≥ 2 hours continuous
+  - NOT mobile (platform check at build time)
+ +

Probe refresh schedule

+ + + + + + + +
TriggerAction
Startup (after UPnP attempt)Run initial probe
UPnP renewal if address changedRe-probe
Every 30 minutes while anchor-declaredPeriodic re-probe
Any failed inbound connectionImmediate re-probe
Two consecutive probe failuresStop advertising as anchor, revert to normal peer
+ +

Session fallback for full anchors

+

When an anchor's mesh is full (101/101), new nodes fall back to a session connection for matchmaking. The anchor accepts referral requests over session connections, not just mesh.

+ +

Remaining gaps

+ + + + + +
GapImpact
Profile anchor lists not used for discoveryProfiles have an anchors field but it's not consulted during address resolution
No anchor-to-anchor awarenessAnchors don't discover each other unless they connect through normal mesh growth
Bootstrap chicken-and-eggA fresh anchor with few peers produces few N2 candidates for new nodes. Growth stalls because there's nothing to grow from.
+
+ + +
+

9. Referrals

+

Status: Complete

+ +

Referral list mechanics (anchor side)

+

Anchors maintain an in-memory HashMap of registered peers. Each entry: { node_id, addresses, use_count, disconnected_at }.

+ + + + + + +
PropertyValue
Tiered usage caps3 uses if list < 50, 2 uses at 50+, 1 use at 100+
Disconnect grace2 minutes before pruning
Sort orderLeast-used first (distributes load)
Auto-supplementWhen explicit list is sparse (< 3 entries), supplement with random mesh peers
+
+ + +
+

10. Relay & NAT Traversal

+

Status: Complete

+ +

Relay selection (find_relays_for)

+

Find up to 3 relay candidates, prioritized:

+
    +
  1. Preferred tree intersection: Target's preferred_tree (from social_routes, ~100 NodeIds) intersected with our connections. Prefer our own preferred peers within that tree. TTL=0.
  2. +
  3. N2 reporters: Our mesh peers who reported the target in their N1 share. TTL=0.
  4. +
  5. N3 via preferred tree: Target's preferred_tree intersected with N3 reporters. TTL=1.
  6. +
  7. N3 reporters: Any N3 reporter for the target. TTL=1.
-
- v0.8 change: the two preferred-tree candidate tiers (intersecting the target's ~100-node preferred_tree with our connections) are removed along with preferred peers. Done (Iteration B): the tiers were deleted from find_relays_for (connection.rs); code now keeps only knowledge-layer reporters. -

RelayIntroduce flow (0xB0/0xB1)

    @@ -532,18 +466,17 @@
  1. Requester receives RelayIntroduceResult { target_addresses, relay_available }, then:
    • hole_punch_parallel(): Try all returned addresses in parallel, retry every 2s, 30s total timeout
    • -
    • If hole punch fails, relay_available is true, and the local user has enabled session relay: open SessionRelay (0xB2) pipe through the intermediary
    • +
    • If hole punch fails and relay_available: open SessionRelay (0xB2) pipe through the intermediary
-

Anchors also perform introduction as part of connection convection: when a convection caller and a referred peer are both live on the anchor, the anchor coordinates RelayIntroduce between them (see Anchors).

Session relay (relay pipes)

Intermediary splices bi-streams between requester and target. Desktop: max 10 concurrent pipes. Mobile: max 2. Each pipe has a 50MB byte cap and 2-min idle timeout.

- Session relay is OPT-IN ONLY and DISABLED BY DEFAULT — including for anchor-mode nodes (servers are most likely to pay for bandwidth). Gated by the relay.session_relay_enabled setting on both serving (can_accept_relay_pipe) and using (the auto-fallback after a failed hole punch). Settings UI exposes the toggle. Hole-punch-failure never silently routes a peer-to-peer session through an unrelated third party's bandwidth. + v0.7.2 change: Session relay is now OPT-IN ONLY and DISABLED BY DEFAULT — including for anchor-mode nodes (servers are most likely to pay for bandwidth). Gated by the relay.session_relay_enabled setting on both serving (can_accept_relay_pipe) and using (the auto-fallback after a failed hole punch). Settings UI exposes the toggle. Hole-punch-failure no longer silently routes a peer-to-peer session through an unrelated third party's bandwidth.
-

This rule covers only full byte-piping. Small relay-style signaling/discovery — RelayIntroduce for hole-punch coordination, worm_lookup multi-hop search, uniques-list announce exchange — remains always-on; that's not session relay. The anchor's HTTP proxy path (anchor fetches a post via QUIC and serves it back over HTTP) is also not session relay — it's the anchor doing its own QUIC fetch on the browser's behalf.

+

This rule covers only full byte-piping. Small relay-style signaling/discovery — RelayIntroduce for hole-punch coordination, worm_lookup multi-hop search, N1/N2/N3 share-list exchange — remains always-on; that's not session relay. The anchor's HTTP proxy path (anchor fetches a post via QUIC and serves it back over HTTP) is also not session relay — it's the anchor doing its own QUIC fetch on the browser's behalf.

Deduplication & cooldowns

@@ -553,23 +486,24 @@

Hole punch mechanics

-

Both sides filter self-reported addresses to publicly-routable only (no Docker bridge, VPN, or LAN IPs) and prepend the port-mapped external address if available. The relay injects each party's observed public address (from the QUIC connection) at the front of the list. All paths use hole_punch_parallel(): parse returned addresses into QUIC EndpointAddr, spawn parallel connect attempts to every address simultaneously. Each attempt: 2s timeout, retried until 30s total deadline. First successful connection wins.

+

Both sides filter self-reported addresses to publicly-routable only (no Docker bridge, VPN, or LAN IPs) and prepend UPnP external address if available. The relay injects each party's observed public address (from the QUIC connection) at the front of the list. All paths use hole_punch_parallel(): parse returned addresses into QUIC EndpointAddr, spawn parallel connect attempts to every address simultaneously. Each attempt: 2s timeout, retried until 30s total deadline. First successful connection wins.

-

NAT type detection Implemented (interim: public STUN servers)

+

NAT type detection

+

Status: Complete (interim: public STUN servers)

On startup, each node classifies its NAT type as one of four categories:

    -
  • Public — observed address matches local, or port-mapped. Directly reachable.
  • +
  • Public — observed address matches local, or UPnP-mapped. Directly reachable.
  • Easy — same mapped port from multiple probes (endpoint-independent mapping / cone NAT). Hole punch will likely succeed.
  • Hard — different mapped ports per destination (symmetric / address-dependent mapping). Port is unpredictable.
  • Unknown — detection failed or not yet run.

Current implementation (interim)

-

Raw STUN Binding Requests (20 bytes, no crate dependency) sent to stun.l.google.com:19302 and stun.cloudflare.com:3478 from a single UDP socket. XOR-MAPPED-ADDRESS parsed from each response (IPv4 + IPv6 supported). Comparison: same mapped port = Easy, different = Hard, matches local = Public. 3s timeout per server. Port-mapping success overrides to Public. Anchors skip probing entirely (already Public).

+

Raw STUN Binding Requests (20 bytes, no crate dependency) sent to stun.l.google.com:19302 and stun.cloudflare.com:3478 from a single UDP socket. XOR-MAPPED-ADDRESS parsed from each response (IPv4 + IPv6 supported). Comparison: same mapped port = Easy, different = Hard, matches local = Public. 3s timeout per server. UPnP success overrides to Public. Anchors skip probing entirely (already Public).

-

Target design (multi-anchor STUN) Planned

-

When the network has enough anchors, replace public STUN servers with anchor-reported your_observed_addr from InitialExchange. Connecting to two or more anchors at different public IPs provides the same classification without external dependencies. Anchor convection makes this cheap: the convection trigger already contacts anchors periodically.

+

Target design (multi-anchor STUN)

+

When the network has enough anchors, replace public STUN servers with anchor-reported your_observed_addr from InitialExchange. Connecting to two or more anchors at different public IPs provides the same classification without external dependencies.

NAT type sharing

NAT type is included as a string field ("public"/"easy"/"hard"/"unknown") in InitialExchangePayload. Stored per-peer in the peers table (nat_type TEXT column). Available for hole punch decisions before any connection attempt.

@@ -579,14 +513,15 @@ Peer APeer BStrategy Public / EasyAnyHole punch (likely success) Hard NATEasy NATHole punch (B's port is predictable) - Hard NATHard NATPort scanninghole_punch_with_scanning() tries standard punch first, then escalates to tiered port scanning (±500, ±2000, full ephemeral range). Currently disabled — see below. + Hard NATHard NATPort scanninghole_punch_with_scanning() tries standard punch first, then escalates to tiered port scanning (±500, ±2000, full ephemeral range) -

All hole punch paths use hole_punch_with_scanning(). NAT profiles (NatMapping + NatFiltering) from InitialExchange determine whether scanning is attempted. Behavioral inference updates filtering classification from connection outcomes.

+

All hole punch paths use hole_punch_with_scanning() which replaces the former hard+hard skip. NAT profiles (NatMapping + NatFiltering) from InitialExchange determine whether scanning is attempted. Behavioral inference updates filtering classification from connection outcomes.

-

Advanced NAT traversal (EDM port scanner) Rework

+

Advanced NAT traversal (EDM port scanner)

+

Status: Disabled in v0.7.3 — refactor pending

- Disabled since v0.7.3. hole_punch_with_scanning() currently does only Step 1 (quick punch to the anchor-observed address) and Step 2 (parallel punch to all known addresses over a 30s window). No port scan. + v0.7.3: the EDM port scanner is DISABLED. hole_punch_with_scanning() currently does only Step 1 (quick punch to the anchor-observed address) and Step 2 (parallel punch to all known addresses over a 30s window). No port scan.

Why: iroh's Endpoint accumulates every endpoint.connect() target into a per-endpoint paths set and probes them all in the background under QUIC NAT-traversal. A 100-probes/sec / 5-min scan inserted ~30,000 paths; iroh then probed all of them. Observed at 22MB/s outbound from a single client — DoS-grade.

Refactor target: replace per-probe endpoint.connect() with raw socket.send_to() on the endpoint's bound UDP socket. The probe still opens a NAT mapping on our side; we just don't ask iroh to manage the path. The original scanner body is preserved as edm_port_scan_disabled_v0_7_3 in connection.rs, including PortWalkIter, scanner_semaphore, role-based scanner/puncher split, and the tokio::select! orchestration — refactor against that.

The description below documents the intended design the refactor will deliver against.

@@ -600,9 +535,9 @@

STUN probing at startup classifies mapping (EIM/EDM). Filtering is determined reliably via the anchor filter probe.

NAT filter probe (0xC6/0xC7)

-

At startup, after first anchor contact, each node with Unknown filtering sends a NatFilterProbe bi-stream request to its anchor. The anchor creates a temporary QUIC endpoint on a random port and attempts to connect to the node’s observed address (2s timeout). If the connection succeeds, the node is Open (address-restricted or better — accepts packets from any port on the anchor’s IP). If it times out, the node is PortRestricted.

-

The result feeds into all subsequent InitialExchange payloads, so peers know each other’s exact filtering type.

-

Note: “Public” NAT type does not automatically mean Open filtering. A node may be public on IPv6 but NATed on IPv4. The filter probe tests actual reachability from a different port, regardless of self-declared NAT type. Startup logs report public (v4 only), public (v6 only), or public (v4+v6).

+

After anchor registration, each node with Unknown filtering sends a NatFilterProbe bi-stream request to its anchor. The anchor creates a temporary QUIC endpoint on a random port and attempts to connect to the node’s observed address (2s timeout). If the connection succeeds, the node is Open (address-restricted or better — accepts packets from any port on the anchor’s IP). If it times out, the node is PortRestricted.

+

This probe runs once at startup (during anchor register cycle) and the result feeds into all subsequent InitialExchange payloads, so peers know each other’s exact filtering type.

+

Note: “Public” NAT type does not automatically mean Open filtering. A node may be public on IPv6 but NATed on IPv4. The filter probe tests actual reachability from a different port, regardless of self-declared NAT type. Startup logs now report public (v4 only), public (v6 only), or public (v4+v6).

NAT combination matrix

@@ -645,27 +580,28 @@ - +
-

10. Port Mapping — UPnP-IGD + NAT-PMP + PCP Implemented

+

11. Port Mapping — UPnP-IGD + NAT-PMP + PCP

+

Status: Complete (v0.7.2)

Purpose

Asks the local gateway router to forward an external port to this node's local QUIC port. A successful mapping makes the node directly reachable from the internet without hole punching — any peer with the external address can connect immediately. Three protocols are attempted in parallel; the first router-response wins.

-

Protocols

+

Protocols (v0.7.2)

  • UPnP-IGD — long-standing consumer-router default. Discovery via SSDP multicast on 239.255.255.250:1900. Behavior varies; many routers ship with UPnP disabled by default.
  • NAT-PMP (RFC 6886) — Apple lineage; widespread on routers that ever shipped Bonjour. Unicast to the gateway on UDP/5351.
  • PCP (RFC 6887) — modern IETF-track successor to NAT-PMP. Unicast on UDP/5351. Supports both IPv4 NAT mapping and IPv6 firewall pinholes. Works on iOS without the multicast networking entitlement.
-

Implementation uses the portmapper crate (also used by iroh internally), wrapped in the upnp.rs module (named for historical reasons; it covers all three protocols); network.rs holds the resulting mapping handle alongside the iroh Endpoint — port mapping is an Endpoint concern, not a connection concern.

+

Implementation uses the portmapper crate (also used by iroh internally). Replaces the v0.7.1 hand-rolled igd-next-only path.

Startup flow

bind Endpoint → spawn portmapper Client (UDP) → wait up to 3s for first protocol response → bootstrap (TCP mapping fires in parallel for HTTP serving)
  1. Probe all three protocols in parallel: portmapper's background service fires UPnP-IGD discovery + NAT-PMP unicast + PCP unicast concurrently. First success wins; failures from the others are absorbed silently.
  2. -
  3. UDP mapping for QUIC: maps the local QUIC port to an external port. Required for direct inbound. Address feeds self-identification, InitialExchange, anchor candidacy, and peer address advertisements.
  4. -
  5. TCP mapping for HTTP: separate parallel attempt for HTTP serving (see HTTP Content Delivery). Independent of UDP — either can succeed alone. Phones with permissive NAT can serve HTTP directly to browser fetches.
  6. +
  7. UDP mapping for QUIC: maps the local QUIC port to an external port. Required for direct inbound. Address feeds N+10 identification, InitialExchange, anchor registration, and peer address advertisements.
  8. +
  9. TCP mapping for HTTP: separate parallel attempt for HTTP serving (see Section 25). Independent of UDP — either can succeed alone. Phones with permissive NAT can serve HTTP directly to browser fetches as of v0.7.2.
  10. Per-platform behavior: All three protocols on desktop. On Android, a WiFi/Ethernet gate skips probing on cellular (no UPnP/PCP gateway exposed by carriers) and a WifiManager.MulticastLock is held for the lifetime of the mapping so UPnP-IGD's SSDP responses actually arrive. On iOS, PCP and NAT-PMP work without the multicast entitlement; UPnP-IGD silently fails until the entitlement is granted.
@@ -680,25 +616,22 @@

Network roams between UPnP-capable WiFi networks self-heal. Mobile devices never auto-anchor regardless — cellular IPs look public but sit behind CGNAT.

-

Port mapping drives anchor candidacy

-

A node with a successful mapping is directly reachable from the internet — which is the only thing that makes an anchor an anchor. Anchor candidacy under v0.8 is exactly this: stable direct reachability (the watcher above) + opt-in, not a peer-count threshold. When the mapping succeeds on an opted-in non-mobile node, is_anchor is set and the node begins offering the convection service (see Anchors); when the mapping is lost, it reverts to non-anchor and peers prune the stale entry via failed-probe pruning. Any desktop on a home network with a mapping-capable router becomes a potential entry point for the network, with no manual server deployment.

-

Shutdown

Explicitly release the mapping on clean shutdown. Routers have finite mapping tables — releasing is good citizenship.

Integration with existing address logic

-

The mapped external address is treated the same as any other address the node knows about. It feeds into:

+

The UPnP external address is treated the same as any other address the node knows about. It feeds into:

    -
  • Self-identification: Included when identifying ourselves to peers so they store a routable address
  • +
  • N+10 identification: Included in self-identification so peers store a routable address
  • InitialExchange: Advertised to new connections
  • -
  • Anchor convection: The address handed to convection callers as a referral (see Anchors)
  • -
  • Social routing: Available in the social route address cache for follows/audience
  • +
  • Anchor registration: Included in bootstrap/recovery registration
  • +
  • Social routing: Available in social route address cache for follows/audience
  • Relay introduction results: Returned alongside hole-punch candidate addresses
  • -
  • HTTP serving eligibility: A node only serves browser traffic (or lists itself as a redirect target) if it has confirmed TCP reachability — either via a TCP port mapping or a known public IPv6 address (see HTTP Content Delivery)
  • +
  • Share link host lists: The UPnP external address, when mapped for TCP, determines whether this node includes itself in share link host lists (see Section 26). A node only self-includes if it has confirmed TCP reachability — either via UPnP TCP mapping or a known public IPv6 address.

Why this matters for mobile

-

Mobile devices on cellular networks cannot use UPnP (carrier NAT doesn't expose it). However, if the peers they're trying to reach (especially desktop nodes and anchors) have port mappings, those peers become directly reachable from the phone without hole punching. The phone doesn't need a mapping — the other side does.

+

Mobile devices on cellular networks cannot use UPnP (carrier NAT doesn't expose it). However, if the peers they're trying to reach (especially desktop nodes and anchors) have UPnP mappings, those peers become directly reachable from the phone without hole punching. The phone doesn't need UPnP — the other side does.

Honest limitations

@@ -706,79 +639,81 @@ - +
UPnP disabled on routerSome ISPs ship routers with UPnP off. Mapping silently fails, fallback to hole punch.
Double NATISP modem + user router: mapping reaches inner router but not outer. Partial help at best.
Cellular networksNo UPnP at all. This is purely a desktop/home-network feature.
Carrier-grade NAT (CGNAT)ISP shares one public IP across many customers. Mapping reaches the ISP's NAT, not the internet. Same as double NAT.
Carrier-grade NAT (CGNAT)ISP shares one public IP across many customers. UPnP maps to the ISP's NAT, not the internet. Same as double NAT.
- Design principle: Port mapping is a best-effort enhancement that improves direct connection reliability for the common case. It is not a dependency. The hole punch fallback chain already handles all failure cases — mapping just reduces how often you fall back to it. + Design principle: UPnP is a best-effort enhancement that improves direct connection reliability for the common case. It is not a dependency. The hole punch + relay fallback chain already handles all failure cases — UPnP just reduces how often you fall back to them.
+ +

UPnP nodes are anchors

+

A node with a successful UPnP mapping is directly reachable from the internet — which is the only thing that makes an anchor an anchor. When UPnP mapping succeeds, the node self-declares as an anchor (is_anchor = true). Other peers will add it to their known_anchors table, providing diverse bootstrap paths back into the network.

+

When the UPnP mapping is lost (lease renewal fails, shutdown), the node reverts to non-anchor. Peers that stored it as an anchor will naturally age it out via last_seen — LIFO ordering means stale anchors drop to the bottom. The 7-day post-bootstrap cleanup probes stale entries and removes failures. No special cleanup needed beyond the existing anchor infrastructure.

+

This means any desktop on a home network with UPnP-capable router becomes a potential bootstrap point for the network, dramatically increasing the number of available anchors without any manual server deployment.

+ +

Implementation

+

Crate: igd-next (async support, well-maintained fork of igd). Implementation lives in network.rs alongside the iroh Endpoint — UPnP mapping is an Endpoint concern, not a connection concern.

- +
-

11. LAN Discovery Planned (passive lookup Implemented)

+

12. LAN Discovery

+

Status: Complete

-

What exists today: passive mDNS address lookup

-

iroh's built-in MdnsAddressLookupBuilder is registered on the endpoint (service name "irohv1", backed by the swarm-discovery crate). It resolves addresses passively: when this node already knows a peer's NodeId and tries to connect, mDNS supplies the peer's LAN address if the peer is on the same network segment, and the connection rides the local link. There is no subscribe loop, no proactive session establishment, and no LAN-specific treatment beyond address resolution.

-
- v0.8 change: earlier revisions of this document badged the full active flow as complete; an audit found only the passive lookup exists. The active design below is kept as a low-priority target. -
+

mDNS-based LAN discovery is integrated via iroh's built-in MdnsAddressLookupBuilder. It works automatically — peers on the same local network are discovered and connected without manual configuration. iroh's mDNS address lookup broadcasts peer presence on the local network via multicast DNS (service name "irohv1", backed by the swarm-discovery crate).

-

Active discovery flow

+

Discovery flow

  1. Hold the mDNS handle: Build MdnsAddressLookup explicitly (not via the endpoint builder) so we retain a clone for subscribing.
  2. Spawn a LAN scan loop: Call mdns.subscribe().await to get a stream of DiscoveryEvent::Discovered and DiscoveryEvent::Expired events.
  3. -
  4. On discovery: Extract NodeId + LAN addresses from the event. If not already connected, initiate a direct connection + initial exchange. Register as a LAN session (its own planned session class — see below).
  5. +
  6. On discovery: Extract NodeId + LAN addresses from the event. If not already connected, initiate a direct connection + initial exchange. Register as a LAN session (a keep-alive session tagged as local).
  7. On expiry: Clean up the LAN session. Peer left the network or powered off.

LAN sessions

LAN peers are special: zero-cost bandwidth, sub-millisecond latency, and very likely someone you know (same household/office). They deserve their own treatment beyond regular mesh or session slots:

    -
  • Automatic keep-alive: LAN sessions stay open as long as the peer is on the network (mDNS heartbeat). No idle timeout. Not counted against session slot limits. This is a deliberate, explicitly-scoped exception to the no-standing-sessions rule (see Update Cadence & Keep-Alive), justified by zero-cost local bandwidth.
  • -
  • Sync priority: Update checks and uniques exchanges go to LAN peers first — instant delivery over the local link.
  • -
  • Local relay: relaying traffic between LAN peers costs nothing, so limits on LAN-local relaying can be much more generous than over the internet. Relaying a LAN peer out to the wider internet — e.g., a phone behind carrier NAT riding the desktop's port-mapped connection — is session relay: it consumes the serving device's WAN bandwidth and remains gated by relay.session_relay_enabled, opt-in and OFF by default (see Relay & NAT Traversal). A same-owner linked-device carve-out may be proposed later, but only as an explicit opt-in extension, never as automatic behavior.
  • +
  • Automatic keep-alive: LAN sessions stay open as long as the peer is on the network (mDNS heartbeat). No idle timeout. Not counted against session slot limits.
  • +
  • Sync priority: Pull sync and push notifications go to LAN peers first — instant delivery over the local link.
  • +
  • Local relay: LAN peers can relay for each other to the wider internet. A phone behind carrier NAT can relay through the desktop's UPnP-mapped connection. Bandwidth is free (local network), so relay limits can be much more generous than over the internet.
  • Blob transfer: Large blob transfers between LAN peers are essentially free. Prefer LAN peers as blob sources when available.

Design rationale

-

Today, two devices on the same WiFi network only connect over the LAN if one already knows the other's NodeId. That's a narrow win — they're on the same network segment and should find each other unaided. Active LAN discovery turns mDNS from a passive address resolver into an active peer source, exploiting the fact that local bandwidth is essentially unlimited.

-

The LAN-session pattern means a household with one well-connected desktop and several phones creates its own mini-mesh: the desktop provides anchor-like connectivity (routing the phones' traffic through it requires the owner's session-relay opt-in), and everyone syncs instantly over the LAN even when the internet connection drops.

+

Today, two distsoc devices on the same WiFi network can only find each other if they happen to share a peer that reports them in N2. This is absurd — they're on the same network segment. LAN discovery turns mDNS from a passive address resolver into an active peer source, exploiting the fact that local bandwidth is essentially unlimited.

+

The keep-alive + relay pattern means a household with one well-connected desktop and several phones creates its own mini-mesh: the desktop provides anchor-like connectivity, the phones stay connected through it, and everyone syncs instantly over the LAN even when the internet connection drops.

- Implementation note: iroh's MdnsAddressLookup::subscribe() returns a Stream<DiscoveryEvent>. The DiscoveryEvent::Discovered variant includes EndpointInfo with NodeId + IP addresses. Custom user_data can be set via endpoint.set_user_data_for_address_lookup() to embed app-specific metadata (e.g., display name) in the mDNS TXT record. Low priority relative to the v0.8 mesh work. + Implementation note: iroh's MdnsAddressLookup::subscribe() returns a Stream<DiscoveryEvent>. The DiscoveryEvent::Discovered variant includes EndpointInfo with NodeId + IP addresses. Custom user_data can be set via endpoint.set_user_data_for_address_lookup() to embed distsoc-specific metadata (e.g., display name) in the mDNS TXT record.
- +
-

12. Worm Search Implemented

-

The network's point-query mechanism: given a specific NodeId, post ID, or blob ID, find someone who has it. Used at step 4 of connect_by_node_id, after direct-address and N2/N3 resolution fail. Under v0.8 it is the fallback behind the uniques index — deep N1–N4 knowledge answers most "who can reach X" questions locally, and worm search covers the remainder.

- -
- No keyword search — by design. Worm search takes exact IDs only. A global keyword flood-search was considered and rejected for v0.8: in a 20-wide mesh it degenerates into hop-TTL flooding whose abuse and jamming limits would destroy its value. Person-discovery is handled by the opt-in registry instead (see Discovery & First Contact). -
+

13. Worm Search

+

Status: Complete

+

Used at step 4 of connect_by_node_id, after N2/N3 resolution fails.

Algorithm

    -
  1. Build needles: target NodeId + up to 10 of the target's recent peers (from their stored profile) — 11 needles total. Finding anyone who can reach any needle gives a path to the target.
  2. -
  3. Local check: Search own connections + N2/N3 for any needle. Also check local storage, known file_holders, and the blob store for any requested post/blob content.
  4. -
  5. Burst (500ms timeout): Send WormQuery{ttl=0} (0x60) to all mesh peers in parallel. Each peer runs the same local check against its own knowledge.
  6. -
  7. Nova (1.5s timeout): Each burst response includes a random referral to one of the responder's other peers (historically called a "wide referral," after the retired Wide slot tier). Connect to those referrals and send WormQuery{ttl=1}; the referred peer fans out to its own mesh with ttl=0. Expansion is one hop at a time rather than a flood, keeping network pressure low.
  8. +
  9. Build needles: target NodeId + target's N+10 (up to 10 preferred peers from their profile/cached N+10)
  10. +
  11. Local check: Search own connections + N2/N3 for any of the 11 needles. Also check local storage, CDN downstream tree, and blob store for any requested post/blob content.
  12. +
  13. Burst (500ms timeout): Send WormQuery{ttl=0} (0x60) to all mesh peers in parallel. Each peer checks their local connections + N2/N3, plus local storage and CDN tree for post/blob content.
  14. +
  15. Nova (1.5s timeout): Each burst response includes a random "wide referral" — an N2 peer. Connect to those referrals and send WormQuery{ttl=1}. The referred peer does its own 101-burst (fans out to all its mesh peers with ttl=0). This reaches ~10K nodes with only ~202 relay hops, keeping network pressure low by expanding one hop at a time rather than flooding.
  16. Total timeout: 3 seconds for the entire search.
-

With a ~20-peer mesh the raw two-hop fan-out is a few hundred nodes — smaller than the old 101-mesh's reach — but under the v0.8 knowledge model Rework each queried peer will answer from much deeper knowledge: its N1–N4 uniques (tens of thousands of IDs at 4 bounces) plus its file_holders map. (Today peers answer from N2/N3 share lists.) Coverage shifts from "how many nodes did the query touch" to "how much does each touched node know".

Content search

WormQuery carries optional post_id and blob_id fields, enabling unified search for nodes, posts, and blobs in a single query. Each peer checks:

    -
  • Posts: local storage (direct match), then the flat file_holders map (up to 5 known holders per post — see Content Propagation)
  • -
  • Blobs: local blob store, then CDN post ownership (get_blob_post_idfile_holders)
  • +
  • Posts: local storage (direct match), CDN downstream tree (post_downstream — up to 100 known hosts per post)
  • +
  • Blobs: local blob store, CDN post ownership (get_blob_post_idpost_downstream)
-

WormResponse carries post_holder and blob_holder fields alongside the node search results. A content hit (post or blob holder found) is treated as a successful response even without a node match.

+

WormResponse carries post_holder and blob_holder fields alongside the existing node search results. A content hit (post or blob holder found) is treated as a successful response even without a node match.

+

The CDN layer is the key multiplier: each node's downstream tree can cover hundreds of posts across dozens of hosts, giving every peer thousands of "I know where that is" answers. Combined with social layer knowledge, even a 202-hop nova covers enormous content space.

PostFetch (0xD4/0xD5)

-

Lightweight single-post retrieval after worm search identifies a holder. Opens a bi-stream to the holder and requests one post by ID. Much lighter than a full pull exchange — no filtering, no batch processing, just the target post.

+

Lightweight single-post retrieval after worm search identifies a holder. Opens a bi-stream to the holder and requests one post by ID. Much lighter than full PullSync — no follow filtering, no batch processing, just the target post.

Dedup & cooldown

@@ -788,156 +723,226 @@
- + +
+

14. Preferred Peers

+

Status: Complete

+ +

Negotiation (MeshPrefer, 0xB3)

+
    +
  • Bilateral: Requester sends MeshPrefer{requesting: true}, responder accepts/rejects
  • +
  • Acceptance: Both sides persist to preferred_peers table, upgrade slot to PeerSlotKind::Preferred
  • +
  • Rejection reasons: "not connected", "preferred slots full (N/M)"
  • +
+ +

Properties

+
    +
  • Eviction-protected: Never evicted during rebalance (only non-preferred peers can be evicted)
  • +
  • Priority reconnect: Reconnected first in rebalance (Priority 0), before any growth
  • +
  • Pruned after 7 days unreachable: If a preferred peer can't be reached for 7 days, the slot is released. The bilateral agreement is cleared — reconnection requires a new MeshPrefer handshake. A reconnect watcher persists for 30 days at low priority (daily check). This prevents churn from aggressive pruning while ensuring slots aren't held indefinitely for offline peers.
  • +
  • N+10 component: Your 10 preferred peers' NodeIds are included in your N+10 for all identification (see Section 3)
  • +
  • Preferred tree: Each social route caches a preferred_tree (~100 NodeIds) — the target's preferred peers' preferred peers. Used for relay selection.
  • +
+
+ + +
+

15. Social Routing

+

Status: Complete

+

Caches addresses for follows and audience members, separate from mesh connections.

+ +

social_routes table

+ + + + + + + + + + + +
FieldPurpose
node_idThe social contact's NodeId
nplus10Their N+10 (NodeId + 10 preferred peers)
addressesTheir known IP addresses
peer_addressesTheir N+10 contacts (PeerWithAddress list)
relationFollow / Audience / Mutual
statusOnline / Disconnected
last_connected_msWhen we last connected
reach_methodDirect / Relay / Indirect
preferred_tree~100 NodeIds for relay tree
+ +

Wire messages

+ + + + + +
CodeNameStreamPurpose
0x70SocialAddressUpdateUniSent when a social contact's address changes or they reconnect
0x71SocialDisconnectNoticeUniSent when a social contact disconnects
0x72SocialCheckinBiKeepalive with address + N+10 updates
+ +

Reconnect watchers

+

reconnect_watchers table: when peer A asks about disconnected peer B, A is registered as a watcher. When B reconnects, A gets a SocialAddressUpdate notification. Watchers pruned after 30 days. Low priority — daily check frequency for watchers older than 7 days.

+ +

Social route lifecycle

+
    +
  • Follow → store their N+10, upgrade to Mutual (if audience)
  • +
  • Unfollow → downgrade/remove
  • +
  • Approve audience → Mutual/Audience
  • +
+
+ +
-

13. Update Cadence & Keep-Alive Rework

+

16. Keep-Alive Sessions

+

Status: Planned

Purpose

-

One scheduled system drives all recurring traffic: content freshness checks, uniques-list refresh, and connection keep-alive. Every node runs a check cycle whose per-item frequency descends from minutes to days, computed from two axes:

-
    -
  • Freshness — how recently the content saw a comment, update, or engagement. Fresh content is checked often; stale content decays toward daily checks.
  • -
  • Relationship tier — how much the local user cares, in descending priority: actively searching/engaging right now → own content (posts of ours held elsewhere) → messaged → followed → pinned → vouched → vouched-by → reacted-to. Untiered content sits at the floor cadence.
  • -
-

The effective interval is the product of both axes: a followed author's post commented on an hour ago might check every few minutes; a reacted-to post untouched for a month checks daily.

+

When the mesh 101 doesn't provide <N4 access to all the nodes we need for social and file operations, keep-alive sessions bridge the gap. These are long-lived connections that participate in N2/N3 routing but are not part of the mesh 101.

-

What rides the cycle

+

Social/File connectivity check (every 60s)

+

Periodically check whether we can reach every node we need. A node is considered reachable if either:

    -
  • Content update checks: fetch new comments/edits for held posts from their holders (CDN-level, no mesh involvement).
  • -
  • Uniques-list refresh: the uniques announce (two pools: forwardable N0–N2 + terminal N3 — see Network Knowledge) with mesh peers piggybacks on the same scheduler — it doubles as the mesh keep-alive.
  • -
  • Replication traffic: holder-count maintenance and the author-concealment push (see Content Propagation) are deliberately scheduled through the same shaped cycle.
  • +
  • We have <N4 access to their N+10 (within N1/N2/N3), or
  • +
  • There is an anchor within N2 of them — we can ask that anchor to matchmake on demand without maintaining a persistent connection
  • +
+

Only when neither condition is met do we open a keep-alive session. With UPnP auto-anchors (see Section 11) scattered throughout the network, the odds of an anchor being within N2 of any given peer increase significantly, reducing the number of keep-alive sessions needed.

+

Nodes to check:

+
    +
  • Nodes we DM'd in the last 4 hours
  • +
  • All follows
  • +
  • All audience members
  • +
  • All file upstream peers (for blobs we host)
  • +
  • All file downstream peers (for blobs we serve)
  • +
+

For any node whose N+10 is NOT reachable within N3, open a keep-alive session to the closest available node in their N+10 (or to them directly if possible). This ensures we can always find and reach our social and file contacts without worm search.

+ +

Keep-alive session behavior

+
    +
  • N2/N3 routing: Keep-alive sessions exchange N1/N2 diffs and participate in routing, similar to mesh connections. They expand our network knowledge without consuming mesh slots.
  • +
  • Not counted in mesh 101: Keep-alive sessions are a separate pool. They don't affect mesh diversity scoring or slot management.
  • +
  • Capacity limit: Max 50% of total session capacity is reserved for keep-alive sessions. The other 50% remains available for interactive sessions (DMs, group activity).
  • +
  • Not idle-reaped: Unlike interactive sessions (5-min idle timeout), keep-alive sessions persist as long as the connectivity need exists.
  • +
  • Reevaluated periodically: The 60s connectivity check closes keep-alive sessions that are no longer needed (e.g., the target now appears in N3 via a mesh connection).
-

Same-author dedup

-

A successful check against one of an author's posts is evidence about all of them: their other posts' checks are deprioritized (pushed later in the cycle) rather than fired back-to-back. This is the main lever for finding the lowest viable cadence — per-author, not per-post, contact frequency.

+

Practical ceilings

+ + + + +
PlatformCeilingBinding constraint
Desktop~300–500Routing diff broadcast overhead — NodeListUpdate to all sessions every 120s. Memory and connection count are not the bottleneck.
Mobile~25–50Battery (radio wake-ups per heartbeat cycle) and OS background restrictions (iOS/Android will kill background sockets).
-

Jitter as traffic shaping

-

Every interval carries random jitter, and checks are spread rather than batched at tier boundaries. The goal is that a node's outbound request stream looks like uniform network overhead: an observer watching traffic timing should not be able to infer which authors the user cares about, which posts are theirs, or where content originated. Request-timing correlation is the primary deanonymizer in a P2P network; the cadence system is the defense (adversary model in Graph Privacy).

+

Mobile priority stack

+

When approaching the mobile ceiling, keep-alive sessions are prioritized:

+
    +
  1. DMs last 30 min — active conversations take highest priority
  2. +
  3. Follows — people you follow
  4. +
  5. Audience — people following you
  6. +
  7. File peers — upstream/downstream blob hosts
  8. +
+

Lower-priority sessions are closed first to make room.

-

Mobile

-

Mobile stretches all cadence bands and batches wakeups — the radio is the battery cost, not the bytes. Background-restricted platforms run the cycle only while the foreground service is alive.

+

Hysteresis

+

Don't open a keep-alive session for a contact who just barely fell outside N3. Wait for persistent unreachability — the contact must be absent from N1/N2/N3 for multiple consecutive connectivity checks (e.g., 3 checks = 3 minutes) before opening a keep-alive. This prevents churn from nodes that transiently appear and disappear at the N3 boundary.

-
- v0.8 change: this replaces the previously planned "keep-alive session pool" (a 60s connectivity check holding up to 50% of session capacity open to social/file contacts). Deep N1–N4 knowledge plus CDN holder search make standing reachability sessions unnecessary — sessions open on demand and close when idle; only the shaped check cycle recurs. -
+

Reject + redirect

+

When a node is at its keep-alive session capacity (50% of total sessions), it refuses new keep-alive requests with a redirect — offering a random N2 node that also has <N4 access to the target. Same pattern as mesh RefuseRedirect but for the keep-alive pool. The requester tries the suggested peer instead.

-

Implementation status

-

The seed exists: a freshness-tiered engagement check in storage (get_posts_due_for_engagement_check) schedules per-post checks at 5 min / 1 h / 4 h / 24 h keyed on last-engagement age. The relationship-tier axis, same-author dedup, jittered shaping, and the uniques-refresh piggyback are not built — the current code is the freshness axis only.

+

Cross-layer benefit

+

Keep-alive sessions from the social and file layers feed N2/N3 entries back into the mesh layer. A social keep-alive to a friend's preferred peer might provide N2 entries that help the mesh growth loop. Similarly, a file keep-alive to an upstream host might provide access to nodes the mesh has never seen. The three layers compound each other's reach.

- -
-

14. Content Propagation

+ +
+

17. Content Propagation

Intent

"Attention creates propagation": when you view something, you cache it. The cache is optionally offered for serving. Hot content spreads naturally through demand. Cold content decays unless intentionally hosted.

-

Content travels through the network exclusively via the CDN: the mesh provides width (who exists, who can reach whom — the N1–N4 uniques index), the CDN provides depth (the actual posts, blobs, comments, and updates). A pull tells you who to talk to; the CDN holder model is how the bytes move.

+

The CDN vision: every file by author X carries an author manifest with the author's N+10 and recent post list. If you hold any file by author X, you passively know X's recent posts and can find X through their N+10.

-
- v0.8 change: the flat holder model replaces the CDN hosting tree (1 upstream + 100 downstream per blob, relay-down-tree ManifestPush, BlobDeleteNotice healing). There are no upstream/downstream relationships anywhere — every holder is a peer of equal standing. -
- -

The flat holder model Implemented

-

Every file (blob CID) has a single flat set of known holders in the file_holders table, capped at 5 per file with LRU eviction — the 5 most recently interacted-with holders survive, older entries are dropped. A holder entry records the peer's NodeId, last-known addresses, last interaction time, and a direction flag:

- - - - - -
DirectionMeaning
sentWe served this file to the peer — they likely hold it now
receivedWe fetched this file from the peer — they definitely held it
bothTraffic has flowed both ways (entry promoted on conflict)
-

Holder knowledge accretes as a side effect of every transfer: serving a blob registers the requester, fetching a blob registers the source, replication acceptance registers both ends, and engagement propagation refreshes entries. No registration protocol exists — the table is a passive record of observed reality, which is why the LRU cap is safe: stale entries age out under real traffic.

- -

Holder redirect at capacity Implemented

-

When a node serving a BlobRequest already knows 5 holders for the file, it still serves the bytes but declines to register the requester (cdn_registered: false) and instead returns its holder list as redirect candidates. The requester records those peers as additional sources for future lookups. This spreads lookup load across the holder set instead of concentrating it on whichever node happens to be well-known.

- -

Manifest freshness Implemented

+

Status: Partial

    -
  • ManifestPush (0x94): when an author posts again, updated AuthorManifests for their blobs are pushed to the file's known holders; a receiving holder re-pushes newly-stored manifests to its own connected holders of the same CID, excluding the sender. No tree structure — propagation follows the flat holder set.
  • -
  • ManifestRefreshRequest/Response (0x92/0x93): pull-side counterpart — "has this manifest changed since updated_at?" Used by the update-cadence cycle (see Update Cadence & Keep-Alive) to pick a refresh source from file_holders.
  • -
  • BlobHeaderDiff/BlobHeaderRequest (0xD0/0xD1): engagement state (comments, reactions, encrypted slots, FoF ops) travels as header diffs between holders; a header can be fetched without retransferring blob data.
  • +
  • BlobRequest/BlobResponse (0x90/0x91) for peer-to-peer blob fetch
  • +
  • AuthorManifest (ed25519-signed, 10+10 post neighborhood) travels with blob responses
  • +
  • CDN hosting tree (1 upstream + 100 downstream per blob)
  • +
  • ManifestPush propagates updates down the tree
  • +
  • BlobDeleteNotice for tree healing on eviction
  • +
  • Blob eviction with social-aware priority scoring
-

Passive discovery via manifest neighborhoods

-

Every AuthorManifest carries a neighborhood of the author's surrounding PostIds. Holding any file by author X therefore passively reveals some of X's other recent posts — hosts of old content are pulled toward new content by the same author. Attention creates propagation. In v0.8 the neighborhood is deliberately small: it is capped to the author's concealment budget of 2–5 publicly revealed posts (below), not a full recent-history window.

+

Passive discovery via neighborhood diffs

+

Passive file-chain propagation is enabled through BlobHeader neighborhood diffs. Every blob header carries the author's 25+25 post neighborhood (25 previous + 25 following). When a host receives a BlobHeaderDiff (0x96), they learn about the author's newer posts without explicit subscription. Hosts of old content are pulled toward new content by the same author naturally — attention creates propagation.

-

Active replication cycle Rework

-

Authors own durability for their own recent content. Every 10 minutes, a node checks its own posts younger than 72 hours; any with fewer than 2 known holders are offered to connected peers via ReplicationRequest. Target selection ranks peers by device role (Available desktops > Persistent anchors > Intermittent phones) plus advertised cache pressure. Accepting peers store the blob and both ends record each other in file_holders.

-
- Known bug (fix required): the cycle queries "own posts" by network NodeId, but posts are authored under posting IDs (split in v0.6.1) — the under-replicated set is always empty and the cycle is currently inert. The machinery is correct; the author lookup must move to posting identities. -
- -

Author concealment & traffic shaping Planned

-

The replication cycle is also the privacy mechanism. An author's node publicly holds and reveals only 2–5 of its own posts at any time; the surplus is pushed out to holder sets on other nodes via replication requests, so the full catalog is served from behind public mesh IDs rather than from the author's device. Combined with the jittered update cadence, the goal is that update and replication traffic is indistinguishable from uniform network overhead — an observer watching traffic cannot tell where content originates or which node is the author. The adversary model is detailed in Network Graph Privacy.

- -

Deletion

-

There is no delete notification message. Deletes are signed control posts that propagate through the same manifest/pull machinery as any other post; holders that process one drop the content and its holder entries, and everyone else's copy ages out via LRU eviction. Details in Delete Propagation & Expiry.

+

Remaining gaps

+ + + + +
GapImpact
N+10 not yet in file headersBlob headers should include author N+10, upstream N+10, and downstream N+10s. Currently only AuthorManifest travels with blobs.
No "fetch from any peer who has it"Blobs are fetched from specific peers. No content-addressed routing ("who has blob X?").
- +
-

15. Files & Storage

+

18. Files & Storage

-

Blob storage Implemented

+

Blob storage Complete

- + -
PropertyValue
CID formatBLAKE3 hash of blob data (32 bytes, hex-encoded)
Filesystem path{data_dir}/blobs/{hex[0..2]}/{hex} (256 shards)
Metadata tableblobs (cid, post_id, author, size_bytes, mime_type, created_at, stored_at, last_accessed_at, pinned)
Metadata tableblobs (cid, post_id, author, size_bytes, created_at, last_accessed_at, pinned)
Max blob size10 MB
Max attachments per post4
Delivery budgetPer-hour outbound serving cap set by device role; auto-resets hourly. Requests beyond budget answered found: false.

Blob content immutability

-

Blob data is BLAKE3-addressed — the CID is the hash of the content. This means blob content is immutable by definition. Any mutable metadata (engagement, holder knowledge, signatures) MUST be stored separately in a BlobHeader. Inline mutable headers are architecturally incompatible with content addressing.

+

Blob data is BLAKE3-addressed — the CID is the hash of the content. This means blob content is immutable by definition. Any mutable metadata (neighborhood, host lists, signatures) MUST be stored separately in a BlobHeader. Inline mutable headers are architecturally incompatible with content addressing.

-

BlobHeader Implemented

-

Mutable structure stored and transmitted separately from blob data. Carries engagement state and encrypted slots for private posts; mutations travel as BlobHeaderDiff ops between holders.

+

BlobHeader Complete

+

Mutable structure stored and transmitted separately from blob data. Carries engagement state, CDN metadata, and encrypted slots for private posts.

BlobHeader {
     post_id,                // PostId this header belongs to
-    author,                 // Author posting ID
-    reactions,              // Vec of public reactions (emoji + reactor + timestamp)
+    author,                 // Author NodeId
+    reactions,              // Vec of public reactions (emoji + reactor NodeId + timestamp)
     comments,               // Vec of comments (text + author + timestamp + signature)
-    policy,                 // Author-controlled CommentPolicy (incl. FriendsOfFriends)
+    policy,                 // Author-controlled comment/react policy
     updated_at,             // Timestamp of last header update
     thread_splits,          // Linked thread posts when comments exceed 16KB
     receipt_slots,          // Encrypted delivery/read/react receipt slots (private posts)
     comment_slots,          // Encrypted comment slots (private posts)
-    prior_author,           // Original author before cross-identity post merge (optional)
 }
-

Header diffs also carry the FoF lifecycle ops (FoFRevocation, FoFAccessGrant, FoFKeyBurn — see Friend-of-Friend Visibility) and, in the v0.8 target, each comment carries its expiry timestamp fixed at creation (see Delete Propagation & Expiry).

+
    +
  • Post neighborhood: 25 previous + 25 following PostIds. Forward slots are empty at publish time and populate via BlobHeaderDiff propagation as the author continues posting. Empty forward slots are not an error condition.
  • +
  • Downstream host count: min(100, floor(170MB / blob_size_bytes)) — smaller blobs allow more downstream hosts, larger blobs reduce the count to cap per-host storage overhead.
  • +
  • BlobHeaderRequest: Lightweight header-only fetch — retrieve just the header without retransferring blob data. Useful for neighborhood updates and host discovery.
  • +
  • Self Last Encounter: Stored per-author, becomes the newer of what's stored and "file last update." Determines when to trigger pull sync.
  • +
-

Blob transfer flow (0x90/0x91) Implemented

+

Blob transfer flow (0x90/0x91)

  1. Requester sends BlobRequest { cid, requester_addresses }
  2. -
  3. Host checks local BlobStore and delivery budget: +
  4. Host checks local BlobStore:
      -
    • Has blob, budget OK: Return base64-encoded data + CDN manifest (with current holder count). If the host knows fewer than 5 holders, register the requester (cdn_registered: true); otherwise return the known holders as cdn_redirect_peers.
    • -
    • No blob / budget exhausted: Return found: false
    • +
    • Has blob: Return base64-encoded data + CDN manifest + file header (N+10s, recent posts). Try to register requester as downstream (max 100). If full, return existing downstream as redirect candidates.
    • +
    • No blob: Return found: false
  5. -
  6. Requester verifies CID, stores blob locally, records the host in file_holders (direction received), and stores any redirect peers as additional holder knowledge.
  7. +
  8. Requester verifies CID, stores blob locally, records upstream in blob_upstream table. Updates Self Last Encounter for the author based on file header.
-

Manifests Rework

+

CDN hosting tree Complete

    -
  • AuthorManifest: ed25519-signed by the post author (posting key). Contains the anchor post, creation/update timestamps, and the author's post neighborhood. In v0.8 the neighborhood is capped to the 2–5 post concealment budget and the manifest carries no device addresses — readers reach the author's content through file_holders search, never through addresses tied to the posting identity.
  • -
  • CdnManifest: AuthorManifest + serving-host metadata. The v0.8 wire format drops the legacy tree fields (source, source addresses, downstream count) as part of the clean protocol break (see Sync Protocol).
  • +
  • AuthorManifest: ed25519-signed by post author, contains post neighborhood (25 previous + 25 following posts — see BlobHeader above), author N+10, author addresses
  • +
  • CdnManifest: AuthorManifest + hosting metadata (host NodeId/addresses, source, downstream count)
  • +
  • Tree structure: Each blob has 1 upstream source + up to 100 downstream hosts
  • +
  • ManifestPush (0x94): Author/admin pushes updated manifests downstream, which relay to their downstream
  • +
  • ManifestRefreshRequest/Response (0x92/0x93): Check if manifest has been updated since last fetch
  • +
  • BlobDeleteNotice (0x95): Notify tree when blob is deleted; includes upstream info for tree healing
-
- v0.8 change (privacy fix): today's AuthorManifest ships the device's real addresses under a posting-identity author and a 10+10 post neighborhood. Both violate the v0.8 privacy rulings: posting identities must be location-anonymous, and the neighborhood must not reveal more than the concealment budget. This fix is a prerequisite for the discovery registry (Discovery & First Contact). -
-

Blob eviction Implemented

-
priority = pin_boost + share_boost + (relationship × heart_recency × freshness / (peer_copies + 1))
+

Blob eviction Complete

+
priority = pin_boost + share_boost + (relationship * heart_recency * freshness / (peer_copies + 1))
- - - + + - + +
FactorCalculation
pin_boost1000.0 if pinned, else 0.0. Own blobs auto-pinned.
share_boost100.0 at 3+ known holders; 50.0 × n / 3 for 1–2 holders; 0 otherwise. Holder count comes from file_holders (max 5) — content with a healthy holder set is being actively served and stays cached longer.
relationship5.0 (own content), 2.0 (followed author), 0.1 (stranger). The own-content tier currently compares against the network ID and never matches post-split; own blobs stay protected via auto-pin. Fix rides the ID-class sweep (see Identity Architecture).
heart_recencyLinear decay over 30 days since last access: max(0, 1 - age/30d)
relationship5.0 (us), 3.0 (mutual follow+audience), 2.0 (follow), 1.0 (audience), 0.1 (stranger)
heart_recencyLinear decay over 30 days: max(0, 1 - age/30d)
freshness1 / (1 + post_age_days)
peer_copiesKnown replica count from post_replicas, counted only if confirmed within the staleness window
peer_copiesKnown replica count (from post_replicas, only if < 1 hour old)
share_boost+100.0 if 3+ downstream peers (shared link with healthy distribution), scaled linearly for 1–2 downstream peers (33.3 per peer). Keeps shared content cached longer.
-

Eviction is also the passive half of delete propagation: unreferenced blobs (orphaned by a delete control post that hasn't reached this node) simply score low and age out.

Pin modes Planned

The CDN is delivery infrastructure, not storage. Authors own durability. Pinning extends content in the local delivery pool — it is not a network obligation.

@@ -948,12 +953,12 @@
- +
-

16. Erasure-Coded CDN Replication Planned

+

18b. Erasure-Coded CDN Replication Planned

Problem

-

Full-copy replication works well when the replicating node chose to pull the content — a follow relationship or explicit action establishes user consent. But the ReplicationRequest (0xE1) protocol also pushes content to infrastructure nodes that never chose to host it. A node holding a full copy of content it never reviewed faces potential liability for that content.

+

The existing CDN hosting tree (Section 18) replicates full blob copies to downstream peers. This works well when the replicating node chose to pull the content — a follow relationship or explicit action establishes user consent. But the ReplicationRequest (0xE1) protocol also pushes content to infrastructure nodes that never chose to host it. A node holding a full copy of content it never reviewed faces potential liability for that content.

Encryption does not solve this for public posts: the content is plaintext by definition. A different mechanism is needed that makes it technically impossible for a CDN node to possess reconstructable content.

Approach: sub-threshold erasure shards

@@ -965,13 +970,10 @@ TierStorageDefense Author’s nodeFull copyPublisher responsibility (content originator) Pulled content (follows)Full copyUser consent — explicit follow relationship - Private auto-replicationEncrypted (CEK envelope, Encryption Architecture)Replicating nodes are provably not keyring recipients — existing encryption architecture handles this + Private auto-replicationEncrypted (CEK envelope, Section 20)Replicating nodes are provably not keyring recipients — existing encryption architecture handles this Public auto-replicationErasure-coded shardsSub-threshold shard — reconstruction impossible from any single holder -

Composition with the flat holder model

-

Each shard is content-addressed like any other blob and tracked through the same file_holders machinery, keyed on the shard's own CID. The 5-holder LRU cap applies per shard, so the 10 slots of a 3-of-10 chain are 10 independent holder sets — the cap and the slot count do not interact. Eviction scoring, holder redirect, and manifest refresh work unchanged; sharding is an additional layer on the auto-replication path only, and full copies continue to flow through the flat model untouched.

-

Shard assignment

Slot assignment is deterministic from the PostId via DHT-style hashing, carried in the existing BlobHeader metadata — no additional discovery round required. Each node enforces single-slot acceptance: it only accepts shard push offers for its assigned slot, rejecting others. This prevents a bad actor from accumulating multiple shards toward the reconstruction threshold. Slot assignment is acceptance policy, not exclusivity — transient duplicate holders for the same slot are harmless and add redundancy.

@@ -983,169 +985,129 @@

Interaction with full copies

-

As content gains followers, the follow graph naturally absorbs redundancy through full-copy pulls. The shard layer can back off:

+

As content gains followers, the follow graph naturally absorbs redundancy through full-copy pull sync. The shard layer can back off:

    -
  • 2+ known full copies: equivalent to ≥4 live shards → shard chain deprioritizes, may decay
  • +
  • 2+ full copies in mesh: equivalent to ≥4 live shards → shard chain deprioritizes, may decay
  • 1 full copy: shard chain reformation trigger
  • 0 full copies: shard chain is sole redundancy, maintain aggressively

This means popular content automatically shifts from CDN shard infrastructure to the social follow graph. The shard layer only works hard for content nobody has explicitly chosen to keep — exactly the content with the highest liability exposure.

Re-replication

-

When a slot goes dark, a new shard holder is assigned via DHT. The new holder determines which chunks belong to its slot and pulls only those chunks from the live shard holders that have them. No shard holder ever reconstructs the full content — each node only ever possesses its own slot’s chunks. The pulling node identifies what it needs, requests those specific chunks, and aggressively refuses anything outside its assigned slot. The author’s node can go offline permanently once shard replication is established.

+

When a slot goes dark, a new shard holder is assigned via DHT. The new holder determines which chunks belong to its slot and pulls only those chunks from the live shard holders that have them. No shard holder ever reconstructs the full content — each node only ever possesses its own slot’s chunks. The pulling node identifies what it needs, requests those specific chunks, and aggressively refuses anything outside its assigned slot. The author’s node can go offline permanently once mesh replication is established.

Replication window

-

Active shard push replication applies the same 72-hour window as full-copy replication: only posts less than 72 hours old are actively pushed to shard holders. Beyond 72 hours, the shard chain relies on natural decay and pull-based replication only.

+

Active shard push replication applies the same 72-hour window as full-copy replication (Section 19): only posts less than 72 hours old are actively pushed to shard holders. Beyond 72 hours, the shard chain relies on natural decay and pull-based replication only.

Exception — share link re-promotion: When a share link is added to a post’s BlobHeader, the 72-hour active replication window resets from that event. This ensures that content being actively shared gets CDN-prioritized delivery regardless of original post age. The re-promotion window is 72 hours from the share link addition, not from the original post timestamp.

Implementation path

-

Extends the existing ReplicationRequest/ReplicationResponse (0xE1/0xE2) protocol. Shard slot metadata fits in the existing BlobHeader. Flat holder tracking and eviction scoring continue to work unchanged for full copies. Shard traffic also serves the traffic-shaping goal: shard pushes are one more class of replication traffic that makes origin-tracing harder.

+

Extends the existing ReplicationRequest/ReplicationResponse (0xE1/0xE2) protocol. Shard slot metadata fits in the existing BlobHeader. The CDN hosting tree, downstream registration, and eviction scoring (Section 18) continue to work unchanged for full copies — sharding is an additional layer for the auto-replication path only.

+ +
-

17. Sync Protocol Rework

+

19. Sync Protocol

-
- v0.8 change: clean protocol break. v0.8 has no wire compatibility with v0.7.x or earlier. All v0.6.x receive-only compat handlers (DeleteRecord 0x51, VisibilityUpdate 0x52), the dual pull-matching path (have_post_ids fallback), and serde-default field shims are removed rather than maintained. There is no ALPN negotiation and never was — a node speaks exactly one protocol version. -
- -

Wire format Implemented

+

Wire format

[1 byte: MessageType] [4 bytes: length (big-endian)] [length bytes: JSON payload]
-

Max payload: 64 MB. ALPN: itsgoin/4. Every message travels on its own QUIC stream — uni-directional for fire-and-forget pushes, bi-directional for request/response pairs. Unknown message-type bytes fail the stream; unknown fields inside known payloads are ignored by serde.

-
- Done (Iteration B): the v0.8 break bumped the ALPN to itsgoin/4, so pre-v0.8 nodes are refused cleanly at the QUIC handshake rather than failing mid-conversation. The constant is now named plain ALPN (the versioned name ALPN_V2 had rotted against its value). -
+

Max payload: 16 MB. ALPN: itsgoin/3.

-

Message types (40 total; rows marked “Removed in v0.8” are kept for history) Implemented

+

Pull sync: social + file layers, not mesh

+
+ v0.2.0 change: Pull sync pulls posts from social layer peers (follows, audience) and upstream file peers, NOT from mesh peers. Mesh connections exist for routing diversity, not content. This separates infrastructure from content flow. +
+

Self Last Encounter: For each peer we sync with, we track the timestamp of our last successful sync. When Self Last Encounter ages beyond 4 hours, a pull sync is triggered. Self Last Encounter is updated to the newer of: (a) what's currently stored, or (b) the "file last update" timestamp from file headers received during blob transfers. Since file headers include the author's recent post list, downloading a blob from any peer hosting that author's content can update Self Last Encounter for the author.

+ +

Pull sync filtering

+
    +
  • PullSyncRequest: Includes requester's follow list + post IDs they already have
  • +
  • PullSyncResponse: Sender filters posts through should_send_post(): +
      +
    1. Author is requester → always send (own posts relayed back)
    2. +
    3. Public + author in requester's follows → send
    4. +
    5. Encrypted + requester in wrapped key recipients → send
    6. +
    7. Otherwise → skip
    8. +
    +
  • +
+ +

Message types (49 total)

- - - + + + - - + + + + + + - - - + + + - - + + - - - - + + + + + + - - + + - - + + - - - - + - - + + - +
HexNameStreamPurpose
0x01NodeListUpdateUniIncremental knowledge-layer diff broadcast
0x02InitialExchangeBiFull state exchange on connect: N1/N2 IDs, profile, post IDs, peer addresses, NAT observations, device role, anchor address
0x03AddressRequestBiResolve NodeId → address via tagged reporter
0x01NodeListUpdateUniIncremental N1/N2 diff broadcast
0x02InitialExchangeBiFull state exchange on connect
0x03AddressRequestBiResolve NodeId → address via reporter
0x04AddressResponseBiAddress resolution reply
0x05RefuseRedirectUniRefuse mesh + suggest alternative
0x40PullSyncRequestBiPull exchange (v0.8: uniques-index exchange — see below)
0x41PullSyncResponseBiPull exchange reply
0x40PullSyncRequestBiRequest posts filtered by follows
0x41PullSyncResponseBiRespond with filtered posts
0x42PostNotificationUniLightweight "new post" push to social contacts
0x43PostPushUniDirect encrypted post delivery to recipients
0x44AudienceRequestBiRequest audience member list
0x45AudienceResponseBiAudience list reply
0x50ProfileUpdateUniPush profile changes
0x51DeleteRecordUniReceive-only compat; deletes are signed control posts (Delete Propagation). Removed in v0.8.
0x52VisibilityUpdateUniReceive-only compat; visibility re-wraps propagate via control posts. Removed in v0.8.
0x60WormQueryBiPoint query for nodes, posts, or blobs beyond local knowledge (worm search)
0x51DeleteRecordUniSigned post deletion
0x52VisibilityUpdateUniRe-wrapped visibility after revocation
0x60WormQueryBiBurst/nova search for nodes, posts, or blobs beyond N3
0x61WormResponseBiWorm search reply (node + post_holder + blob_holder)
0x70SocialAddressUpdateUniSocial contact address changed
0x71SocialDisconnectNoticeUniSocial contact disconnected. Removed in v0.8 — zero senders existed; checkin timeouts carry the signal.
0x72SocialCheckinBiKeepalive + address + peer-address update
0x71SocialDisconnectNoticeUniSocial contact disconnected
0x72SocialCheckinBiKeepalive + address + N+10 update
0x90BlobRequestBiFetch blob by CID
0x91BlobResponseBiBlob data + CDN manifest + file header
0x92ManifestRefreshRequestBiCheck manifest freshness
0x93ManifestRefreshResponseBiUpdated manifest reply
0x94ManifestPushUniPush updated manifests to known file holders
0xA1GroupKeyRequestBiOrphaned: no live sender, handler has an ID-class bug. Removed in v0.8 — group seeds travel as encrypted posts (Encryption).
0xA2GroupKeyResponseBiOrphaned reply pair of 0xA1. Removed in v0.8.
0xB0RelayIntroduceBiHole-punch introduction via mutual peer (always-on signaling, not session relay)
0x94ManifestPushUniPush updated manifests downstream
0x95BlobDeleteNoticeUniCDN tree healing on eviction
0xA0GroupKeyDistributeUniDistribute circle group key to member
0xA1GroupKeyRequestBiRequest group key for a circle
0xA2GroupKeyResponseBiGroup key reply
0xB0RelayIntroduceBiRequest relay introduction
0xB1RelayIntroduceResultBiIntroduction result with addresses
0xB2SessionRelayBiSplice bi-streams (opt-in; relay.session_relay_enabled OFF by default on every role, including anchors — see Relay & NAT Traversal)
0xB3MeshPreferBiPreferred-peer negotiation. Removed in v0.8 — the preferred tier is eliminated.
0xB2SessionRelayBiSplice bi-streams (own-device default)
0xB3MeshPreferBiPreferred peer negotiation
0xB4CircleProfileUpdateUniEncrypted circle profile variant
0xC0AnchorRegisterUniRegister with anchor. Still live; retires when anchor convection replaces the register loop (Roadmap item 5, Anchors).
0xC1AnchorReferralRequestBiRequest peer referrals (v0.8: the convection call)
0xC0AnchorRegisterUniRegister with anchor (bootstrap/recovery only)
0xC1AnchorReferralRequestBiRequest peer referrals from anchor
0xC2AnchorReferralResponseBiReferral list reply
0xC3AnchorProbeRequestBiA → B → C: test cold reachability of address
0xC4AnchorProbeResultBiC → A (success) or C → B → A (failure)
0xC5PortScanHeartbeatUniDormant: part of the disabled EDM scanner; no live senders or handlers. Fate rides the raw-UDP scanner refactor.
0xC6NatFilterProbeBiNAT filtering classification probe via third party
0xC7NatFilterProbeResultBiFiltering probe result
0xD0BlobHeaderDiffUniIncremental engagement update (reactions, comments, policy, slots, FoF ops)
0xD0BlobHeaderDiffUniIncremental engagement update (reactions, comments, policy, thread splits)
0xD1BlobHeaderRequestBiRequest full engagement header for a post
0xD2BlobHeaderResponseBiFull engagement header response (JSON, only if newer)
0xD3PostDownstreamRegisterUniHolder registration: “I now hold this post” → sender's file_holders (name is historical; no tree exists)
0xD2BlobHeaderResponseBiFull engagement header response (JSON)
0xD3PostDownstreamRegisterUniRegister as downstream for a post (CDN tree entry)
0xD4PostFetchRequestBiRequest a single post by ID from a known holder
0xD5PostFetchResponseBiSingle post response (SyncPost or not-found)
0xD6TcpPunchRequestBiAsk holder to punch TCP toward browser IP
0xD7TcpPunchResultBiPunch result + HTTP address for redirect
0xE0MeshKeepaliveUni30s connection heartbeat
0xE1ReplicationRequestBiRequest peer to cache specific posts (Content Propagation)
0xE1ReplicationRequestBiRequest peer to cache specific posts
0xE2ReplicationResponseBiAccept/reject replication request
-

Retired type bytes, never reused: 0x420x45 (PostNotification/PostPush/AudienceRequest/AudienceResponse, v0.6.2), 0x95 (BlobDeleteNotice — holders evict via LRU, no notice needed), 0xA0 (GroupKeyDistribute — group seeds travel as encrypted posts).

-

Pull = uniques-index exchange Implemented

-

In v0.8 a pull is not a post transfer. A pull exchanges uniques lists: the set of unique IDs each side can help the other reach — mesh peers, direct contacts, CDN file authors, and CDN file peers, including throwaway/anonymous IDs (deliberate noise that protects real identities). The semantics are “if you want one of these IDs, talk to me and I'll help you find their CDN holders to connect to and search.” The union of these lists across the mesh forms a distributed search index — the same index that powers person-discovery for the registry. What a node knows and announces at each depth (N1–N4, termination rules, dedup, size budgets) is specified in Network Knowledge; this section covers only the wire exchange.

-

Content itself never rides the pull path: posts and blobs travel through the CDN (PostFetchRequest/BlobRequest against holders resolved through the index — see Content Propagation). The old “never pull from mesh peers” principle dissolves cleanly: the mesh exchanges the index, the CDN carries the content.

-

The pull opcodes (0x40/0x41) now carry only the index — 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 transitional content-sync pair (0x46 ContentSyncRequest / 0x47 ContentSyncResponse) Rework, which exists until the CDN cadence work in Iteration D can carry everything. Read caps differ accordingly: the uniques opcodes read against a 2 MB ceiling, not the 64 MB file-transfer cap.

-
- What it replaces: today PullSyncRequest (0x40) carries a merged query list plus per-author since_ms timestamps (Self Last Encounter), and the responder returns posts newer than each timestamp after should_send_post() 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 update cadence system. -
- -

Recipient matching (merged pull) Implemented

-

The query list a node sends with a pull merges its public follows with every posting identity it holds, so DMs addressed to any of its personas match. The responder's filter, should_send_post(), sends a post when:

-
    -
  1. Author is the requester → always send (own posts relayed back)
  2. -
  3. Public or FoFClosed + author in the query list → send (FoFClosed carries no recipient IDs on the wire; it propagates by author like public content)
  4. -
  5. Encrypted + any wrapped-key recipient in the query list → send
  6. -
  7. GroupEncrypted + any known group member in the query list → send
  8. -
  9. Otherwise → skip
  10. -
-

The network NodeId is never an author or recipient (persona split), so it never appears in query lists. This matching machinery survives the v0.8 pull redefinition as the visibility filter on CDN content fetches.

- -

Manifest machinery Implemented

-

Each post's AuthorManifest carries the author, creation/update timestamps, a post neighborhood (up to 10 previous + 10 following ManifestEntry items), and an ed25519 signature over the canonical digest. It travels wrapped in a CdnManifest alongside blob responses and via ManifestPush (0x94). On receipt:

+

Engagement propagation

+

Reactions, comments, and policy changes propagate via BlobHeaderDiff (0xD0) through the CDN tree:

    -
  • Signature verified; unsigned or forged manifests dropped
  • -
  • Newer-wins: stored only if updated_at beats the local copy
  • -
  • Holder tracking: the pusher is recorded in file_holders (they evidently hold the file)
  • -
  • Relay: the manifest is re-pushed to connected file holders of the same CID, excluding the sender
  • -
  • Post discovery: neighborhood post IDs from followed authors that are missing locally are fetched via PostFetch, capped at 10 per push to avoid storms
  • +
  • Push (real-time): On react/comment, the diff is sent to both downstream peers (CDN tree children) and all upstream peers (up to 3, who we got the post from). Each intermediate node re-propagates both directions, excluding sender. This flows the diff up to the author and down to all holders.
  • +
  • Auto downstream registration: Nodes that receive a post via pull sync or push notification automatically send PostDownstreamRegister (0xD3) to the sender, ensuring bidirectional diff flow.
  • +
  • Pull (safety net): Tiered frequency based on content age: 5min (<72h), 1hr (3-14d), 4hr (14-30d), 24hr (>30d). Requests BlobHeaderRequest (0xD1) with local header timestamp. Peers respond with full header only if newer. Additive merge — store_reaction upserts, store_comment inserts with ON CONFLICT DO NOTHING. Writes batched per chunk (single lock acquisition).
  • +
  • Tombstones: Deleted reactions and comments are not hard-deleted. Instead, a deleted_at timestamp is set on the record. Tombstones propagate via pull sync headers. Prevents deleted engagement from being re-introduced by peers that haven't yet received the deletion.
  • +
  • Planned: Pull engagement from both upstream and downstream peers to catch missed diffs from either direction.
-

ManifestRefreshRequest/Response (0x92/0x93) let any holder check manifest freshness for a CID and receive the newer copy. This is how a post's neighborhood keeps growing on CDN copies after the author publishes newer posts.

-
- v0.8 change: manifests lose all device-address fields. AuthorManifest.author_addresses and the CdnManifest host metadata (host_addresses, source, source_addresses, downstream_count) link a posting identity to device locations — a privacy bug — and the tree fields describe a topology that no longer exists. Holder resolution goes through the uniques index + file_holders instead. The neighborhood is also capped to the 2–5 post concealment budget (see Files & Storage). -
-

Engagement propagation via file holders Implemented

-

Reactions, comments, policy changes, slot writes, and FoF ops propagate as BlobHeaderDiff (0xD0) batches through the flat holder set — there is no tree:

-
    -
  • Push: on receipt of a diff, the sender is recorded as a holder, ops are verified and applied, the header JSON is rebuilt from the authoritative tables, and the diff is re-propagated to all known file_holders of the post (flat set, max 5), excluding the sender.
  • -
  • Holder registration: nodes that receive a post via pull or manifest discovery send PostDownstreamRegister (0xD3) to the sender, which simply records them in the sender's file_holders — ensuring diffs flow both ways.
  • -
  • Pull (safety net): tiered polling by engagement freshness — 5 min (<72h), 1 hr (3–14d), 4 hr (14–30d), 24 hr (older) — via a single SQL pass over last_engagement_ms/last_check_ms. BlobHeaderRequest (0xD1) carries the local header timestamp; peers respond with the full header only if theirs is newer. In v0.8 this cadence folds into the update cadence system with jitter as traffic shaping.
  • -
  • Tombstones: deleted reactions and comments keep a deleted_at timestamp instead of being hard-deleted, and propagate through header rebuilds — preventing re-introduction by peers that missed the deletion.
  • -
- - - - - - - - - -
Diff opPurpose
AddReaction / RemoveReactionSigned reaction add; removal by reactor or post author
AddComment / EditComment / DeleteCommentSigned comment lifecycle, gated by CommentPolicy
SetPolicyAuthor-controlled comment/react permissions + blocklist
ThreadSplitRegister a comment-overflow post
WriteReceiptSlot / WriteCommentSlot / AddCommentSlotsEncrypted slot writes (below)
FoFRevocation / FoFAccessGrant / FoFKeyBurnFoF key lifecycle ops, author-signed (Friend-of-Friend Visibility)
Unknownserde catch-all: ops from newer versions are silently skipped
- -

Engagement security Implemented

+

Engagement security Complete

Engagement operations are cryptographically verified on receipt to prevent forgery and unauthorized modification:

    -
  • Reaction signatures: each reaction carries an ed25519 signature over BLAKE3(reactor || post_id || emoji || timestamp_ms), verified before storing.
  • -
  • Comment signatures: verify_comment_signature() runs on every comment received via BlobHeaderDiff; forged comments are rejected. FoF-gated comments additionally pass the CDN-level pub_post_set gate (FoF).
  • -
  • Reaction removal: only the original reactor or the post author can remove a reaction; the QUIC-authenticated sender is checked against both.
  • -
  • Comment edit/delete: only the comment author can edit; comment author or post author can delete. Sender identity comes from QUIC transport authentication (iroh ed25519).
  • -
  • Header author: rebuilt headers take the author from the stored post, never from the payload — relay nodes cannot misattribute.
  • -
  • Source of truth: the reactions and comments tables are authoritative; blob_headers JSON is a derived snapshot rebuilt after each engagement op.
  • +
  • Reaction signatures: Each reaction carries an ed25519 signature over BLAKE3(reactor || post_id || emoji || timestamp_ms). Verified before storing. Unsigned reactions from older nodes accepted for backward compatibility (#[serde(default)] on signature field).
  • +
  • Comment signatures: Comments carry an ed25519 signature (existing field). verify_comment_signature() now called on receipt via BlobHeaderDiff. Forged comments rejected.
  • +
  • Reaction removal: Only the reactor (original author of the reaction) or the post author can remove a reaction. Sender verified against reactor and payload.author.
  • +
  • Comment edit/delete: Only the comment author can edit; comment author or post author can delete. Sender identity verified via QUIC transport authentication (iroh ed25519).
  • +
  • BlobHeader author: When rebuilding headers after engagement diffs, the author is verified against the stored post’s actual author, not trusted from the payload. Prevents relay nodes from misattributing headers.
  • +
  • BlobHeader source of truth: The reactions and comments tables are authoritative. The blob_headers JSON is a derived snapshot rebuilt after each engagement operation. When they diverge (e.g., after a BlobHeaderResponse with a newer snapshot), the next engagement op rebuilds from tables.
-
- v0.8 change: the empty-signature acceptance path for reactions (a v0.6.x #[serde(default)] compat shim) is removed with the clean break — signatures become mandatory. -
-

Encrypted receipt & comment slots Implemented

-

Private posts (Friends, Circle, Direct) carry encrypted slots in their BlobHeader for delivery receipts, read receipts, reactions, and private comments. The CDN propagates these as opaque bytes — only participants with the post's CEK can decrypt them. (FoFClosed posts skip this path; their wrap-slot mechanism covers both reads and comments.)

-
    -
  • Pre-filled noise: all slots are filled with random bytes at post creation; writing replaces noise with same-size ciphertext, so writes are indistinguishable from creation to observers.
  • -
  • Slot key: slot_key = BLAKE3_derive_key("itsgoin/slot/v1", CEK) — only participants who can decrypt the post can read or write slots.
  • -
  • Receipt slots (64 bytes each): one per participant, index = position in the NodeId-sorted participant list. Decrypted: [1B state][8B timestamp LE][23B emoji/padding]; states empty/delivered/seen/reacted. The author may pre-feed their own slot with a reaction at creation.
  • -
  • Comment slots (256 bytes each): ceil(participants / 3) initial slots. Decrypted: [32B author][8B timestamp LE][216B UTF-8 + padding]. First-available selection; when exhausted, any participant appends new noise-filled slots via AddCommentSlots.
  • -
  • CDN-safe: relay nodes store and forward slot bytes without decryption; slots travel inside BlobHeaderDiff, no dedicated messages.
  • -
-

DM conversations render these as delivery indicators: sent → delivered → seen checkmarks, emoji on react. Opening a conversation auto-marks incoming messages as seen.

- -

Device roles & bandwidth budgets Implemented

-

Each node advertises its device role in InitialExchange, which sets hourly budgets for replication (pulling posts to cache) and delivery (serving requests from peers):

+

Device roles & bandwidth budgets Complete

+

Each node advertises its device role in InitialExchange, which determines its bandwidth budgets for replication (pulling posts to cache) and delivery (serving requests from peers):

@@ -1153,53 +1115,201 @@
RoleReplication / hourDelivery / hour
Intermittent (phones)100 MB1 GB
Persistent (anchors)200 MB1 GB
    -
  • Budgets auto-reset every hour; anchors reserve delivery headroom for browser-facing HTTP serving
  • -
  • Role is self-declared from device type; peers respect advertised budgets when selecting replication targets (Content Propagation)
  • +
  • Budgets auto-reset every hour
  • +
  • Role is self-declared based on device type and advertised to peers in InitialExchange
  • +
  • Peers respect advertised budgets when selecting replication targets
-

Connection rate limiting Implemented

+

Active CDN replication Complete

+

All devices proactively replicate recent under-replicated posts to peers, not just passively serve on request:

+
    +
  • 10-minute cycle: All devices initiate replication checks every 10 minutes
  • +
  • Target prioritization: Desktops > anchors > phones, scored by available bandwidth budget and connection quality
  • +
  • Selection criteria: Posts less than 72 hours old with fewer than 2 downstream replicas are selected for replication
  • +
  • Protocol: ReplicationRequest (0xE1) asks a peer to cache specific posts; ReplicationResponse (0xE2) accepts or rejects based on available budget and storage
  • +
  • Graceful degradation: In small networks with few peers, the cycle runs but finds few or no viable targets — no wasted bandwidth. As the network grows, replication naturally increases.
  • +
+ +

Connection rate limiting

Incoming QUIC connections that fail authentication are rate-limited per source IP to prevent CPU exhaustion from rogue or stale nodes:

  • First 3 failures: logged normally, connection attempts proceed
  • 4+ failures: silently dropped with exponential backoff (2n-3 seconds, capped at ~256s)
  • Successful connection: clears the failure count for that IP
  • -
  • Cleanup: stale entries pruned after 5 minutes of inactivity
  • +
  • Cleanup: stale entries removed every 60 seconds (pruned after 5 minutes of inactivity)
-

Knowledge freshness Implemented

-

Gossiped knowledge decays unless refreshed. Today's timers, which carry forward under the N1–N4 uniques model (Network Knowledge):

+

N2/N3 freshness

    -
  • TTL: N2/N3 entries expire after 5 hours (pruned during the rebalance cycle)
  • -
  • Full state broadcast: every 4 hours, nodes re-broadcast complete state (not just diffs) to catch missed incrementals
  • -
  • Disconnect cleanup: when a mesh peer disconnects, everything they reported is immediately removed (clear_peer_n2/clear_peer_n3)
  • -
  • Startup sweep: on boot, all gossiped entries and mesh-peer rows are cleared — stale from the previous session, rebuilt as connections establish
  • +
  • TTL: N2/N3 entries expire after 5 hours (pruned during rebalance cycle)
  • +
  • Full state broadcast: Every 4 hours, nodes re-broadcast their complete N1/N2 state (not just diffs) to catch any missed incremental updates
  • +
  • Disconnect cleanup: When a mesh peer disconnects, all N2/N3 entries they reported are immediately removed (clear_peer_n2/clear_peer_n3)
  • +
  • Startup sweep: On boot, all N2/N3 and mesh_peers entries are cleared — they're stale from the previous session and will be rebuilt as connections establish
-

Bootstrap isolation recovery Implemented

+

Bootstrap isolation recovery

Prevents network segments from becoming permanently isolated from the main network:

    -
  • 24-hour check: starting 24 hours after boot, nodes verify the bootstrap anchor is within their knowledge reach
  • +
  • 24-hour check: Starting 24 hours after boot, nodes verify the bootstrap anchor is within their N1/N2/N3 reach
  • If absent: reconnect to bootstrap, request referrals, connect to referred peers
  • -
  • Sticky N1: the bootstrap joins a sticky N1 set for 24 hours, so mesh peers learn about it via routing diffs and can independently bridge back
  • -
  • Self-limiting: once the bootstrap is within reach, the check passes silently; goes quiet as the network grows
  • +
  • Sticky N1: The bootstrap is added to a sticky N1 set for 24 hours, so mesh peers learn about it via routing diffs and can independently bridge back to the main network
  • +
  • Self-limiting: Once the bootstrap is in N3, the check passes and no action is taken. Goes silent as the network grows.
-

Schema versioning Implemented

+

Schema versioning

SQLite databases track their schema version via PRAGMA user_version. On startup:

  • If the database version is older than MIN_MIGRATABLE_VERSION, the database is reset (preserving identity key)
  • If older than the current version, data migrations run once and the version is bumped
  • -
  • Schema-level changes are handled by init_tables() (CREATE TABLE IF NOT EXISTS) and migrate() (column-level ALTER TABLE checks)
  • +
  • Schema-level changes (new tables, columns) are handled by init_tables() (CREATE TABLE IF NOT EXISTS) and migrate() (column-level ALTER TABLE checks)
-

IPv6 HTTP address advertisement Implemented

-

Nodes with public IPv6 addresses advertise their actual routable address (from endpoint.addr().ip_addrs()) paired with their bound port, rather than the bind address (0.0.0.0). This enables direct browser-to-node HTTP serving for share links. Unroutable addresses (0.0.0.0, 127.x) are filtered out in the tiered web-serving redirect path (HTTP Delivery).

-
- -
-

18. Encryption

+

Upstream tracking (post_upstream)

+

Each post tracks which peer it was received from in the post_upstream table (post_id → peer_node_id). Set during pull sync and push notification. Used for:

+
    +
  • Engagement diff propagation toward the post author (hop-by-hop upstream)
  • +
  • Future: N+10 identification in blob headers (upstream source's N+10)
  • +
-

Envelope encryption (1-layer) Implemented

+

IPv6 HTTP address advertisement

+

Nodes with public IPv6 addresses advertise their actual routable address (from endpoint.addr().ip_addrs()) paired with their bound port, rather than the bind address (0.0.0.0). This enables direct browser-to-node HTTP serving for share links. Unroutable addresses (0.0.0.0, 127.x) are filtered out in the tiered web serving redirect path.

+ +

Encrypted receipt & comment slots

+

Private posts (Friends, Circle, Direct) carry encrypted slots in their BlobHeader for delivery receipts, read receipts, reactions, and private comments. The CDN propagates these as opaque bytes — only participants with the post’s CEK can decrypt them.

+ +

Design principles

+
    +
  • Pre-filled noise: All slots are filled with random bytes on post creation. Writing to a slot replaces noise with encrypted content of the same size, making writes indistinguishable from creation to observers.
  • +
  • Slot key derivation: slot_key = BLAKE3_derive_key("itsgoin/slot/v1", CEK). Only participants who can decrypt the post can read/write slots.
  • +
  • CDN-safe: Relay nodes store and propagate slot bytes without decryption. No new protocol messages needed — slots travel via BlobHeaderDiff.
  • +
+ +

Receipt slots (64 bytes each)

+
    +
  • Allocation: 1 per participant (including author)
  • +
  • Slot assignment: Participants sorted by NodeId; slot index = position in sorted list
  • +
  • Decrypted format: [1 byte: state][8 bytes: timestamp_ms BE][23 bytes: emoji/padding]
  • +
  • States: 0=empty/noise, 1=delivered, 2=seen, 3=reacted
  • +
  • Author pre-feed: Author can write their own slot with a reaction emoji at creation time
  • +
+ +

Comment slots (256 bytes each)

+
    +
  • Allocation: ceil(participants / 3) initial slots, expandable via AddCommentSlots diff op
  • +
  • Decrypted format: [32 bytes: author_node_id][8 bytes: timestamp_ms BE][216 bytes: UTF-8 content + zero padding]
  • +
  • Slot selection: First available (all-zero content after decryption = available)
  • +
  • Growth: When all comment slots are used, any participant can append new noise-filled slots
  • +
+ +

Wire operations

+ + + + + +
OpPurpose
WriteReceiptSlotUpdate a receipt slot (state change: delivered → seen → reacted)
WriteCommentSlotWrite encrypted comment to a slot
AddCommentSlotsAppend new noise-filled comment slots when capacity is exhausted
+ +

UI

+

DM conversations display delivery indicators: single checkmark (sent), double checkmark (delivered/on device), blue double checkmark (seen), emoji (reacted). Opening a conversation auto-marks incoming messages as seen. Messages have a react button for emoji responses.

+
+ +

Protocol v4: Header-Driven Sync Complete

+

Major sync protocol revision that replaces the current pull-everything-from-everyone model with header-driven discovery, per-author tracking, and tiered engagement polling. Reduces bandwidth by ~90% for established nodes.

+ +

Core principle: headers as notification

+

The AuthorManifest already carries a post neighborhood (20 previous + 20 following PostIds). When this neighborhood is pushed via ManifestPush (0x94) or travels with blob responses, receiving nodes can diff their local post list against the neighborhood to discover new posts without a full pull sync. The CDN tree becomes the notification system.

+ +

Self Last Encounter (per-author sync tracking)

+

Implements the v0.2.0 design intent: track last_sync_ms per followed author. Pull sync triggers only when now - last_sync_ms > check_interval. Updated on:

+
    +
  • Successful pull sync response containing that author's posts
  • +
  • ManifestPush/BlobHeaderDiff received for that author
  • +
  • Blob transfer that includes that author's file header
  • +
+ +

Slim PullSyncRequest

+

Current: { follows: Vec<NodeId>, have_post_ids: Vec<PostId> } — sends ALL post IDs every request.

+

v4: { follows: Vec<NodeId>, since_ms: HashMap<NodeId, u64> } — per-author timestamps. Response contains only posts newer than the requester's timestamp for each author. Drops request size from O(posts) to O(follows).

+ +

Tiered pull sync frequency

+

Instead of pulling from ALL mesh peers every 5 minutes, pull is driven by Self Last Encounter staleness:

+
    +
  • Pull only triggers for authors where last_sync_ms is stale AND no header updates were received in the interval
  • +
  • Default check interval: 4 hours (reduced from current 5-minute blanket pull)
  • +
  • Pull targets known CDN sources first (peers in post_upstream / post_downstream for that author), falling back to mesh peers only if CDN sources are unavailable
  • +
  • Serial, not parallel — one peer at a time, stop when delta is received
  • +
+ +

Tiered engagement check rates

+

Engagement header polling (BlobHeaderRequest 0xD1) frequency scales with content age and activity:

+ + + + + + +
Content age / last engagementCheck interval
< 72 hours5 minutes
3–14 days1 hour
14–30 days4 hours
> 30 days24 hours
+

DB tracks last_engagement_ms and last_check_ms per post. A single SQL query filters posts due for check:

+
SELECT post_id FROM posts
+WHERE last_check_ms < now_ms - CASE
+  WHEN last_engagement_ms > now_72h THEN 300000
+  WHEN last_engagement_ms > now_14d THEN 3600000
+  WHEN last_engagement_ms > now_30d THEN 14400000
+  ELSE 86400000
+END
+

Checks are serial (one peer at a time). A single “no new engagement” response is treated as authoritative — if that peer missed an update, it would have been replicated to them by now.

+

On connect/wake: if since_last_check > check_rate for any post, an automatic check runs immediately.

+ +

Multi-upstream (3 max)

+

Extend post_upstream from 1 entry to up to 3 per post:

+
    +
  • Primary upstream: First source of the post (current behavior)
  • +
  • Backup upstreams: Additional sources discovered via CDN tree or replication
  • +
  • Upstream unavailable: If primary is unreachable, promote backup. If no backups, use the CDN node map to find the author’s upstream chain and request connection — accepted or directed to an alternate via RefuseRedirect
  • +
  • Unregistration: BlobDeleteNotice (0x95) already handles “I no longer hold this.” Extend to upstream: “I’m no longer your upstream for this post.”
  • +
  • Overhead is minimal: 3 rows per post vs 100 rows per post for downstream
  • +
+ +

Auto-prefetch followed authors

+

When a post by a followed author appears in any header (PullSyncResponse, ManifestPush, BlobHeaderDiff neighborhood), prefetch the post and its blobs if:

+
    +
  • Post age < 90 days
  • +
  • Post not already in local storage
  • +
  • Within replication budget
  • +
+

Encrypted posts without key: Store the encrypted post in DB marked as “not-for-us.” The node contributes to CDN availability for those who CAN decrypt it. Normal cache decay handles cleanup — these posts are given low eviction priority (stranger relationship score of 0.1) and will be evicted when space is needed.

+ +

Header-driven post discovery flow

+
    +
  1. Author creates post → updates their AuthorManifest neighborhood (20 before + 20 after)
  2. +
  3. ManifestPush (0x94) propagates to downstream CDN tree
  4. +
  5. Each receiving node diffs the neighborhood against local post list
  6. +
  7. New post IDs discovered → fetch via PostFetch (0xD4/0xD5) from the peer that sent the manifest
  8. +
  9. Blobs prefetched for followed authors (within budget)
  10. +
  11. Node registers as downstream for the new post
  12. +
  13. Self Last Encounter updated for the author → suppresses pull sync for that author
  14. +
+

This makes the CDN tree the primary content distribution channel, with pull sync serving only as a safety net for missed headers.

+ +

Migration path

+

v4 is backward-compatible via ALPN negotiation. Nodes running v3 continue to work with the existing pull model. v4 nodes detect peer capability during InitialExchange and use the optimized paths when both sides support them. The v3 pull cycle remains as a fallback for mixed-version networks.

+ +

New DB columns required

+ + + + + + +
TableColumnPurpose
postslast_engagement_msTimestamp of most recent reaction/comment
postslast_check_msTimestamp of last engagement check
followslast_sync_msSelf Last Encounter per followed author
post_upstream(expand to 3 rows)Multi-upstream with priority
+ +
+ + +
+

20. Encryption

+ +

Envelope encryption (1-layer) Complete

  1. Generate random 32-byte CEK (Content Encryption Key)
  2. Encrypt content: ChaCha20-Poly1305(plaintext, CEK, random_nonce)
  3. @@ -1207,77 +1317,71 @@
  4. For each recipient (including self):
    • X25519 DH: our_ed25519_private (as X25519) * their_ed25519_public (as montgomery)
    • -
    • Derive wrapping key: BLAKE3_derive_key("itsgoin/cek-wrap/v1", shared_secret)
    • +
    • Derive wrapping key: BLAKE3_derive_key("distsoc/cek-wrap/v1", shared_secret)
    • Wrap CEK: ChaCha20-Poly1305(CEK, wrapping_key, random_nonce) → 60 bytes per recipient
-

Recipients are posting identities (personas), never network identities. The wrapping key derivation runs against the recipient's posting pubkey; the sender wraps with the authoring persona's posting secret.

Visibility variants

- + - +
VariantOverheadAudience limit
PublicNoneUnlimited
Encrypted { recipients }~60 bytes per recipient~500 (design target; not enforced in code)
Encrypted { recipients }~60 bytes per recipient~500 (256KB cap)
GroupEncrypted { group_id, epoch, wrapped_cek }~100 bytes totalUnlimited (one CEK wrap for the group)
FoFClosed98-byte wrap slot + 32-byte pub_post_set entry per admitted V_x, bucket-paddedBucketed (8/16/32/64/128/256, then +128 steps)
FoFClosed v0.7.0~154 bytes per admitted V_x, paddedBucketed (8/16/32/64/128/256, then +128 steps)

PostId integrity

PostId = BLAKE3(Post) covers the ciphertext, NOT the recipient list. Visibility is separate metadata. This means visibility can be updated (re-wrapped) without changing the PostId.

-

Group keys (circles) Implemented

+

Group keys (circles) Complete

  • Each circle gets its own ed25519 keypair
  • group_id = BLAKE3(initial_public_key) — permanent identifier
  • -
  • Group seed wrapped per-member via X25519 DH (KDF domain: "itsgoin/group-key-wrap/v1")
  • +
  • Group seed wrapped per-member via X25519 DH (KDF domain: "distsoc/group-key-wrap/v1")
  • Epoch rotation: On member removal, generate new keypair, increment epoch, re-wrap for remaining members
  • -
  • Distribution: the group seed travels as a normal encrypted post (VisibilityIntent::GroupKeyDistribute) authored by the admin's persona, with each member's posting ID as a recipient. Members receive it via the ordinary CDN / pull paths and unwrap with their posting secret. No dedicated wire push exists — there is no wire-level signal that one endpoint is coordinating group membership with another.
  • +
  • Wire: GroupKeyDistribute (0xA0), GroupKeyRequest/Response (0xA1/0xA2)
-
- v0.8 change: the orphaned GroupKeyRequest/Response (0xA1/0xA2) pair is deleted — its handler compares network IDs against posting-ID member lists and can never grant (ID-class bug family), and no sender was ever wired. Post-based distribution is the only path. -

Three-tier access revocation

Three levels of revocation, chosen based on threat level:

-

Tier 1: Remove Going Forward (default) Implemented

+

Tier 1: Remove Going Forward (default)

Revoked member is excluded from future posts automatically. They retain access to anything they already received. This is the default behavior when removing a circle member — no special action needed.

When to use: Normal membership changes. Someone leaves a group, you unfollow someone. The common case.

Cost: Zero. Just stop including them in future recipient lists.

-

Tier 2: Rewrap Old Posts (cleanup) Rework

-

Same CEK, re-wrap for remaining recipients only. The revoked member can no longer unwrap the CEK even if they later obtain the ciphertext. The updated visibility propagates as a signed control post authored by the post's persona — peers verify the control post's signature against the target's author before applying, so only the author can rewrite visibility.

+

Tier 2: Rewrap Old Posts (cleanup)

+

Same CEK, re-wrap for remaining recipients only. The revoked member can no longer unwrap the CEK even if they later obtain the ciphertext. Propagate updated visibility headers via VisibilityUpdate (0x52).

When to use: Revoked member never synced the post (common with pull-based sync — encrypted posts only sent to recipients). You want to clean up access lists.

Cost: One WrappedKey operation per remaining recipient, no content re-encryption.

-

Required fix: the entry-point guard compares post.author (a posting ID) against the node's network ID — always different since the identity split — so every revoke currently bails before doing anything, and the rewrap is keyed with the wrong identity pair. Same ID-class bug family as the group-key handler. The control-post propagation machinery underneath is correct.

-

Tier 3: Delete & Re-encrypt (nuclear) Rework

-

Generate new CEK, re-encrypt content, wrap new CEK for remaining recipients, issue a control-post delete for the old post ID (see Delete Propagation), repost with new content but same logical identity. Well-behaved nodes honor the delete.

+

Tier 3: Delete & Re-encrypt (nuclear)

+

Generate new CEK, re-encrypt content, wrap new CEK for remaining recipients, push delete for old post ID, repost with new content but same logical identity. Well-behaved nodes honor the delete.

When to use: Revoked member already has the ciphertext and could unwrap the old CEK. Only for content that poses an actual danger/risk if the revoked member retains access. Recommended against in most cases.

Cost: Full re-encryption + delete propagation + new post propagation. Heavy.

-

Required fix: shares the Tier-2 entry-point author-check bug.

Trust model: The app honors delete requests from content authors by default. A modified client could ignore deletes, but this is true of any decentralized system. For legal purposes: the author has proof they issued the delete and revoked access.
-

Private profiles Implemented

+

Private profiles (Phase D-4) Complete

Different profile versions per circle, encrypted with the circle/group key. A peer sees the profile version for the most-privileged circle they belong to. CircleProfileUpdate (0xB4) wire message. Public profiles can be hidden (public_visible=false strips display_name/bio).

- +
-

19. Friend-of-Friend Visibility Implemented

+

20a. Friend-of-Friend Visibility v0.7.0

- Distinct from directory vouches. The "FoF vouch" described here is a cryptographic primitive for post readership and comment gating (per-persona symmetric key V_me). It is unrelated to the directory vouch system in the Directory Trust Layer, which governs discovery-layer trust and bot-ring resistance. The two share vocabulary but operate at different layers. + Distinct from directory vouches. The "FoF vouch" described here is a cryptographic primitive for post readership and comment gating (per-persona symmetric key V_me). It is unrelated to the directory vouch system in section 27, which governs discovery-layer trust and bot-ring resistance. The two share vocabulary but operate at different layers.

The problem

@@ -1290,32 +1394,31 @@ PublicAll readers (unchanged) Friends-onlyPersonas you have vouched for Friends-of-FriendsYour vouchees + every vouchee of anyone who vouched for you (emergent FoF) - Custom PlannedAuthor-selected subset of held vouch keys + Custom v2Author-selected subset of held vouch keys

Core primitives

  • V_me: a 32-byte symmetric key owned by each persona. Distributed to everyone the persona vouches for. Anyone holding V_alice can decrypt wrap slots Alice sealed under it.
  • Keyring: per-persona, holds the persona's own V_me plus every V_x received from vouchers. The union of these is what makes FoF reach emergent: an author wraps a post slot under every V_x they hold, and any reader whose keyring intersects with that set can decrypt.
  • -
  • Wrap slot: an anonymous AEAD ciphertext in the post header, sealed under one V_x. Dual-derived: a 48-byte read part carries the post's CEK, a 48-byte sign part carries a per-slot signing seed. With the 2-byte prefilter tag, each slot is 98 bytes on the wire. No recipient ID visible.
  • -
  • slot_binder_nonce: a random 32-byte value in the post header that all slot derivations are bound to. It plays the role "post_id in the KDF info" would — but PostId = BLAKE3(post) depends on the wrap slots themselves, so keying on the PostId would be circular.
  • -
  • Prefilter tag: 2 bytes, HMAC(V_x, slot_binder_nonce)[:2], on each slot. Readers precompute tags for keys in their keyring and skip non-matching slots, cutting trial-decrypt cost ~65,000× per post.
  • +
  • Wrap slot: an anonymous AEAD ciphertext in the post header, sealed under one V_x. Carries the post's CEK plus a per-slot signing key. No recipient ID visible on the wire.
  • +
  • Prefilter tag: a 2-byte HMAC(V_x, post_id)[:2B] on each slot. Readers precompute tags for keys in their keyring and skip non-matching slots, cutting trial-decrypt cost ~65,000× per post.
  • pub_post_set: list of all admitted signing pubkeys for a post's FoF set. Inline in the post header, randomly ordered. Allows CDN-level verification of comment signatures without revealing membership identities.

Distribution: vouches ride bio posts

-

Vouches are NOT delivered via DM. Instead, the voucher publishes anonymous sealed wrappers (one per recipient) inside their bio post. The sealing is an HPKE-style construction — per-batch ephemeral X25519 keypair, ECDH against the recipient's persona key, BLAKE3-derived wrapping key and nonce (domain itsgoin/vouch-grant/v1, bound to the bio post's ID), ChaCha20-Poly1305 seal. It is not RFC 9180 HPKE, but provides the same recipient anonymity property: wrappers carry no recipient identifier, the KDF context is recipient-free, and every wrapper in a batch shares one ephemeral pubkey. Each wrapper is 48 bytes (32-byte sealed V_me + 16-byte AEAD tag).

+

Vouches are NOT delivered via DM. Instead, the voucher publishes anonymous HPKE-sealed wrappers (one per recipient) inside their bio post. HPKE (RFC 9180) provides recipient anonymity — wrapper ciphertext reveals nothing about the recipient's pubkey. Each wrapper is 48 bytes (32-byte sealed V_me + 16-byte AEAD tag); one shared ephemeral pubkey per batch.

Readers auto-scan bio posts of accounts they follow, trial-decrypting each wrapper against each of their personas. Cost is ~60µs per wrapper per persona on mobile — a 200-wrapper bio scanned against 3 personas is ~36 ms. The bio's VouchGrantBatch is padded with dummy wrappers and shuffled on every publish so observers see neither vouch-set size nor change-targets.

Two modes

Mode 2: Public body, FoF-gated comments

-

Body is plaintext in the CDN (indexable, cacheable, shardable — unchanged from existing public-post path). Comments are encrypted under a CEK derived from the wrap-slot CEK, so only FoF members can read them. Non-FoF observers see only ciphertext + signature fields. The compose UI exposes this via CommentPermission::FriendsOfFriends, carried in the post's CommentPolicy.

+

Body is plaintext in the CDN (indexable, cacheable, shardable — unchanged from existing public-post path). Comments are encrypted under a CEK derived from the wrap-slot CEK, so only FoF members can read them. Non-FoF observers see only ciphertext + signature fields. The compose UI exposes this via a new CommentPolicy::FriendsOfFriends variant.

Mode 1: FoFClosed (body + comments encrypted)

-

PostVisibility::FoFClosed variant. Body is encrypted under CEK (same wrap-slot mechanism as comments). Both readership and comment authority emerge from keyring intersection with the post's wrap_slots.

+

New PostVisibility::FoFClosed variant. Body is encrypted under CEK (same wrap-slot mechanism as comments). Both readership and comment authority emerge from keyring intersection with the post's wrap_slots.

CDN-level comment verification

@@ -1327,16 +1430,13 @@
  • The comment's group_sig validates against that pub_x.
  • Any failure → drop, do not forward. This kills the bandwidth-amplification attack that a single admitted-but-malicious FoF member could otherwise mount: their forgeries cannot pass the propagation gate.

    -
    - The first-contact mechanism reuses this exact gate: a bio post that opts into contact publishes a greeting-slot key in its pub_post_set, so stranger greetings pass CDN comment verification like any FoF comment. See Discovery & First Contact. -

    Privacy properties

    • Unilateral: vouching is a one-way act, no handshake. The FoF graph forms without bilateral negotiation.
    • Graph-private: wrap slots carry no recipient IDs. Observers cannot enumerate who can read a post.
    • Bucketed padding: slot count and body size are deterministically padded to fixed buckets (power-of-2 up to 256, then +128 steps for slots; same shape up to 256 KB then +256 KB steps for bodies). Observers learn the bucket, not the position within it.
    • -
    • Recipient anonymity on vouch distribution: the sealed wrappers' key privacy ensures bio-post wrappers do not reveal recipients.
    • +
    • Recipient anonymity on vouch distribution: HPKE key privacy ensures bio-post wrappers do not reveal recipients.
    • Per-post chain pseudonym (accepted tradeoff): the pub_x_index in a comment lets observers correlate "these N comments came through the same chain" within a single post. Cross-post correlation is broken because keys regenerate per post.
    @@ -1344,7 +1444,7 @@

    Three complementary mechanisms:

    Per-post comment revocation (default)

    -

    The author signs a RevocationEntry for a specific pub_x on a specific post. Propagation nodes delete locally-stored comments by that signer, remove the entry from pub_post_set, append to revocation_list, and forward the diff. Retroactive: the mesh self-cleans as the diff sweeps through. The same entry type is the off-switch for greeting slots (Discovery & First Contact).

    +

    The author signs a RevocationEntry for a specific pub_x on a specific post. Propagation nodes delete locally-stored comments by that signer, remove the entry from pub_post_set, append to revocation_list, and forward the diff. Retroactive: the mesh self-cleans as the diff sweeps through.

    Persona-wide V_me rotation

    @@ -1364,290 +1464,202 @@

    Body encryption, wrap slots, and HKDF/HMAC are all symmetric — PQ-safe. Comment signing uses Ed25519 today; the spec shape is algorithm-agnostic so ML-DSA-65 (~2 KB pubkey, ~3.3 KB signature) can substitute, optionally with a Merkle-commit variant on pub_post_set to keep header size bounded.

    Implementation

    -

    Full crypto-level byte layouts, data models, wire-format additions, ship criteria, and integration tests are specified in docs/fof-spec/. All five layers shipped in v0.7.0:

    +

    Full crypto-level byte layouts, data models, wire-format additions, ship criteria, and integration tests are specified in docs/fof-spec/. The implementation is layered for bottom-up shipping:

    - - - - - + + + + +
    LayerScopeStatus
    1Vouch primitive (V_x keys, keyring, bio-post sealed wrappers, scan policy)Implemented
    2Mode 2: public posts with FoF-gated comments, CDN-level verificationImplemented
    3Mode 1: FoFClosed body + wrap slots + anonymous prefilterImplemented
    4Rotation, revocation, key lifecycle (grandfather + cascade + key-burn)Implemented
    5Unlock cache + prefilter optimization (perf-critical at scale)Implemented
    1Vouch primitive (V_x keys, keyring, bio-post HPKE wrappers, scan policy)v0.7.0
    2Mode 2: public posts with FoF-gated comments, CDN-level verificationv0.7.0
    3Mode 1: FoFClosed body + wrap slots + anonymous prefilterv0.7.0
    4Rotation, revocation, key lifecycle (grandfather + cascade + key-burn)v0.7.0
    5Unlock cache + prefilter optimization (perf-critical at scale)v0.7.0
    - +
    -

    20. Delete Propagation & Comment Expiry

    +

    21. Delete Propagation

    +

    Status: Complete

    -

    Deletes are signed control posts Implemented

    -

    A delete is a control post: a small public post with VisibilityIntent::Control, signed by the target's authoring persona, naming the PostId to delete. It propagates exactly like any other post — it enters the author's neighbor manifests (following_posts lists on their other posts) and rides manifest diffs out to file_holders; anyone following any of the author's posts picks it up via the normal CDN / pull paths.

    -

    On receipt, a node verifies the control post's signature against the target post's author before applying. Application is atomic with the control-post insert: record in deleted_posts (INSERT OR IGNORE), then DELETE the target from posts. The deleted_posts table also guards against re-ingesting the deleted post from a stale holder later.

    +

    Delete records

    +

    DeleteRecord { post_id, author, timestamp_ms, signature } — ed25519-signed by author. Stored in deleted_posts table (INSERT OR IGNORE). Applied: DELETE from posts table WHERE post_id AND author match.

    -
    - v0.8 change: the direct-push delete paths are gone entirely — DeleteRecord (0x51) uni-stream push and BlobDeleteNotice (0x95) were retired in v0.6.2, and the receive-only 0x51/0x52 compat handlers die with the v0.8 clean protocol break. Control posts via manifests are the only delete path. -
    - -

    Blob cleanup on delete Implemented

    +

    Propagation paths

      -
    1. Locally: blob metadata and CDN records for the post's blobs are removed, blob files deleted from disk.
    2. -
    3. Remotely: no notice is sent. Copies held by other file_holders become orphans — nothing references them, nothing re-shares them — and are evicted naturally by the CDN's LRU eviction cycle (see Content Propagation).
    4. +
    5. InitialExchange: All delete records exchanged on connect
    6. +
    7. DeleteRecord message (0x51): Pushed via uni-stream to connected peers on creation
    8. +
    9. PullSync: Included in responses for eventual consistency
    -

    Comment expiry: rand(30–365 days) TTL Planned

    -

    Every comment gets an expiry timestamp at creation: created_at + rand(30–365 days). The expiry is fixed at creation, signed, and carried with the comment — it is part of the comment's identity, not a per-holder policy. Every holder in the network expires the comment at (near) the same moment.

    -
      -
    • Why carried, not local: if each holder chose its own retention, staggered disappearance would fingerprint individual holders and their policies. A carried timestamp makes expiry a network-wide event that reveals nothing about who holds what.
    • -
    • Why random: a fixed TTL would let observers date a comment's creation from its expiry. A 30–365-day uniform draw breaks that inference.
    • -
    • Identity hygiene: throwaway persona IDs used for greeting comments (Discovery & First Contact) exist in the network only through their comments. When the comments expire, the IDs vanish from uniques lists entirely — the network forgets them by construction.
    • -
    • No silent extension: the TTL cannot be edited — re-posting the content is a new comment with a new ID and a fresh draw.
    • -
    +

    CDN cascade on delete

    +
      +
    1. Send BlobDeleteNotice to all downstream hosts (with our upstream info for tree healing)
    2. +
    3. Send BlobDeleteNotice to upstream
    4. +
    5. Clean up blob metadata, manifests, downstream/upstream records
    6. +
    7. Delete blob from filesystem
    8. +
    - +
    -

    21. Social Graph Privacy & Traffic Shaping

    - -

    What is never shared

    +

    22. Social Graph Privacy

    +

    Status: Complete

      -
    • Follows are never shared in gossip or profiles. Implemented
    • -
    • Uniques announcements carry bare IDs — except anchors. The uniques announce (see Network Knowledge) ships NodeIds without addresses; the sole exception is anchor-flagged entries, whose addresses ride the pools by design (self-declared reachable infrastructure — 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. Implemented
    • -
    • N4 is never re-announced — 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 — that promotion would have reset the horizon of every address-bearing entry on every hop, forever. Implemented
    • -
    • Posting identities never map to device addresses on the wire. Addresses attach to network identities only; the persona split (see Identity Architecture) keeps the two namespaces unlinkable. ImplementedAuthorManifest.author_addresses 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 — every one of them, plus the author of any comment we wrote locally, which covers per-greeting throwaway IDs — 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 Network Knowledge.
    • -
    - -

    Where addresses do travel, and why

    -

    Routing needs somewhere to connect. Addresses appear in exactly these payload classes, all scoped to network identities:

    -
      -
    • Social-routing peer addresses: the initial exchange includes addresses of our currently connected mesh peers. These are network endpoints an observer at that vantage could already see us talking to — sharing them reveals mesh topology (public by design), not social links.
    • -
    • Point lookups: worm address-requests and lookup responses return addresses for a specific queried network ID.
    • -
    • Connection convection referrals: anchors hand out addresses of recent callers, and RelayIntroduce coordination carries both ends' addresses for hole-punching (see Anchors).
    • -
    - -

    Uniques lists as cover traffic

    -

    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 (above), so the noise floor is self-renewing rather than monotonically accumulating. Our own personas and our own greeting throwaways are the deliberate exception — they are excluded, because we would be the reporter. Implemented

    - -

    The timing adversary Planned

    -

    With content encrypted and recipient lists off the wire, the remaining deanonymizer is request-timing correlation: an observer who can watch traffic sees who fetches what, when. 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:

    -
      -
    • Jittered, tiered update cadence: CDN update checks run on a descending-frequency schedule (minutes → days) keyed on freshness × relationship tier, with jitter on every timer (see Update Cadence). No check fires because a post appeared; checks fire because a schedule slot came up.
    • -
    • Replication shaped as overhead: replication pushes are paced and jittered so an author seeding their own content is indistinguishable from a holder performing routine redundancy maintenance. The goal: update + replication traffic reads as uniform network overhead, with no observable "origin burst".
    • -
    • 2–5 visible posts per author: the public CDN surface reveals at most 2–5 posts per author at any time; the surplus is pushed out to holders via replication requests and reached through the uniques index. This caps what enumeration of any single node's public holdings discloses about any author's corpus — and hides the author's own node behind ordinary public mesh IDs.
    • -
    • Comment TTL: long-lived comment trails are the easiest correlation anchors across time; the 30–365-day expiry bounds how long any trail exists.
    • +
    • Follows are never shared in gossip or profiles
    • +
    • N1 share merges mesh peers + social contacts into one list (indistinguishable)
    • +
    • No addresses ever shared in routing updates
    • +
    • N3 is never shared outward (search-only)
    - Honest scope: this defeats passive observers correlating timing and public holdings. A global adversary who can watch every link, or an adversary who compromises your own device, is out of scope — as in every friend-to-friend design. + Known temporary weakness: An observer who diffs your N1 share over time can infer your social contacts (they're the stable members while mesh peers rotate). This will be addressed when CDN file-swap peers are added to N1, making the stable set larger and harder to distinguish.
    -
    -

    22. Identity Management

    -

    Multi-identity per device Implemented

    + +
    +

    23. Identity Management

    + +

    Multi-identity per device Complete

    A single device can hold multiple identities, each with its own ed25519 keypair, database, blob store, follows, and posts. One identity is active at a time — switching performs a hot-swap (Node teardown + rebuild, ~3-5 seconds).

    • Directory structure: itsgoin-data/identities/{node_id_hex}/ — each identity gets its own subdirectory with identity.key, itsgoin.db, blobs/, and meta.json
    • +
    • Legacy migration: Flat itsgoin-data/ layout auto-migrates to per-identity subdirectories on first launch
    • Create, import, switch, delete via Settings UI
    • Key permissions: identity.key files written with 0600 permissions (Unix)
    -

    Multi-device: posting-key bundles Implemented

    -

    Devices are linked by sharing posting keys, never network keys. Each device keeps its own network key (its own QUIC NodeId); what travels between your devices is the export bundle — a ZIP containing the identity key, all posting identities, posts, blobs, follows, and settings. Transfer happens out-of-band between your own devices; the network sees no linking message.

    +

    Multi-device identity Planned

    +

    Multiple devices share the same identity key (ed25519 keypair, same NodeId). All devices ARE the same node from the network’s perspective. Posts from any device appear as the same author.

      -
    • Export: Settings → Export produces the ZIP bundle. All posting keys held by the identity are included (posting_identities.json), so every persona restores on the target device.
    • -
    • Import as personas (the multi-device path): the bundle’s posting keys are added to the current identity’s persona set and its posts are inserted as-authored — original PostIds, authors, and signatures intact. Content encrypted to any imported key becomes decryptable because the device now holds those secrets. Idempotent; duplicates are skipped.
    • -
    • Import as new identity: alternatively, the bundle can be imported as a separate identity on the device (creates the identity subdirectory from the bundle’s key; data restores on first switch).
    • -
    • QR / file-share linking flows for pairing a new device without manual file transfer Planned
    • +
    • Setup: Export identity.key from one device, import on another using the identity management UI
    • +
    • Device identity: Each device generates a unique device keypair for self-routing and conflict resolution (planned)
    • +
    • Own-device relay: Route traffic through your own devices (planned)
    -
    - Raw key import restores the network key only. Pasting a bare identity.key hex string creates an identity around that network key — it carries no posting identities, posts, or follows. To move personas between devices, use the ZIP bundle. -
    -
    - v0.8 change: the earlier “same identity key on all devices” multi-device design is deleted — it predates the network/posting key split and contradicted it. Devices never share a network key; see Identity Architecture. -
    -

    Post import & merge Implemented

    -

    Two ways to bring another identity’s posts into the current one, both wired into the import wizard:

    -
      -
    • Import public posts: public posts from the bundle are re-created under the current identity with new PostIds.
    • -
    • Merge with key: supply the original identity’s key; encrypted posts are decrypted with it, then re-created under the current identity. Posts the original identity could not read are skipped. Original timestamps are preserved and the prior author is recorded in the BlobHeader for provenance.
    • -
    +

    Post import & merge Planned

    +

    Import posts from another identity into the current one. Public posts imported directly. Encrypted posts require the original identity’s key for decryption, then re-encrypted under the current identity. Merge creates new posts (new PostId, new author) with original timestamps preserved and prior author noted in BlobHeader.

    +
    -

    23. Reciprocity (Reconsidered) Planned

    +

    24. Phase 2: Reciprocity (Reconsidered)

    +

    Status: Reconsidered

    The original Phase 2 design centered on hosting quotas (3x rule), chunk audits, and tit-for-tat QoS. On reflection, the attention-driven delivery model makes quota enforcement unnecessary. The CDN is a delivery amplifier, not a storage system — hot content propagates through demand, cold content decays. Authors are responsible for their own content durability.

    Tit-for-tat QoS solves the wrong problem: it optimizes for fairness in a storage-obligation model that no longer exists. What matters is that the delivery network functions efficiently, which it does through natural attention dynamics.

    If reciprocity mechanisms are needed at scale, they should address delivery quality (bandwidth, latency, uptime) rather than storage quotas. This remains an open design area.

    -
    -

    24. Identity Architecture

    -

    ItsGoin separates network identity (per-device routing/connection key) from posting identity (the face/persona authoring content). This is the architectural foundation for multi-device, multi-persona, and DM-level traffic-graph privacy — and it fully survives into v0.8: the uniques index treats posting IDs (including throwaway ones) as first-class searchable entries, and the registry + greeting-comment first-contact design (Discovery & First Contact) builds directly on the split.

    - -

    Two layers of identity Implemented

    -

    Each device has ONE network key — used for QUIC connections, endpoint ID, mesh routing. It’s never linked on the wire to any posting key.

    -

    Each user can hold MANY posting keys simultaneously — no “active” persona, no switching. Each posting key is a persona (Public, Private, Work, Family, etc). Posts are signed with the posting key chosen at compose time. The two key layers are always distinct: fresh installs generate an independent default posting identity, and the v0.6.1 migration rotates the network key of any install whose default posting key still equaled it. No node’s network key ever doubles as a posting key.

    -

    Privacy invariant: peers cannot determine which network IDs belong to a given posting ID, which posting IDs belong to the same network ID, or which posting IDs belong to the same user. These associations are private to the device owner.

    -
    - Known violation — manifest addresses Rework: today’s AuthorManifest ships the device’s real addresses under a posting-identity author, linking persona to location on the wire. The v0.8 target removes device addresses from manifests entirely (holders are found via the CDN holder search, not via addresses embedded in author-signed data). This fix must land before or with the registry — location-anonymous discovery depends on it. Details in Files & Storage. -
    - -

    Persona types Implemented

    -
      -
    • Public posting IDs — main persona(s), openly associated with “you”
    • -
    • Private posting IDs — smaller-context personas for close contacts or specific groups
    • -
    • Contextual posting IDs — per-relationship or per-group; user creates one per context and sticks with it
    • -
    • Throwaway posting IDs — single-purpose IDs (e.g., the outer signer of a greeting comment). Deliberately kept in uniques lists as network noise; retired naturally by comment TTL expiry (see Deletes & Expiry)
    • -
    - -

    Required fixes: network-ID/posting-ID confusion Rework

    -

    The key split left four code paths comparing the device’s network NodeId where posting IDs now live. Since the two are always distinct, each path silently fails. All four are required v0.8 fixes (first item on the roadmap):

    -
      -
    • Visibility revocation: the author check compares post.author (posting ID) against the network ID, so every revoke bails with “not the author”; the rewrap path also pairs the default posting secret with the network ID.
    • -
    • Group key granting: the GroupKeyRequest handler checks the requester’s network ID against a posting-ID member list and wraps with the network key — it can never grant. (No send side exists; the message pair is retired in the v0.8 protocol.)
    • -
    • Replication cycle: under-replicated own posts are queried by network ID, so the set is always empty and the cycle is inert.
    • -
    • Blob-eviction relationship tier: eviction scoring compares candidate.author (a posting ID) against the network ID, so the 5.0 own-content tier never matches. Masked in practice because own blobs are auto-pinned (pin boost 1000), but the tier is inert.
    • -
    -

    The fix class is uniform: every self.node_id comparison or query against post/persona authors must use posting identities (all of them, not just the default). A full sweep for further instances is part of the fix.

    - -

    Multi-device is a special case Implemented

    -

    “Two devices holding the same posting key” is a trivial case of the multi-key model. Link happens out-of-band between the user’s own devices via the export/import bundle (Identity Management) — the export includes all posting identities so they restore on the second device, which keeps its own network key. The network sees no cross-device message announcing the relationship; each device pulls content for its posting IDs via the normal CDN. The fact that two network nodes hold the same posting key is only discoverable if an observer has private knowledge (which they shouldn’t).

    -

    In-app QR / file-share flows for easy linking between an existing device and a new one are not wired yet — today it’s manual export-then-import.

    - -

    Ephemeral rotating IDs for DM threads Planned

    -

    The intent: DM threads and group messages use per-thread unique posting IDs that rotate per message. Each encrypted message would include a handshake field — the next posting ID to use. Observer sees a stream of distinct posting IDs with no cryptographic tie between them, defeating thread-level traffic correlation. Desync recovery via a sliding window of the last N accepted IDs. Message history kept in a local encrypted-to-self archive that replicates across linked devices via self-follow.

    -

    v0.8 removes most of the old blockers. The uniques index deliberately carries throwaway/anonymous posting IDs (they double as network noise), so an ephemeral author is findable through the same distributed search as any other ID; comment TTL expiry retires spent IDs without leaving a permanent trail; and the greeting-comment machinery provides the cold-contact channel. What still needs a crisp design is freshness: how a just-generated posting ID becomes reachable fast enough to receive a reply before the next rotation.

    - -

    CDN: per-file holder sets Implemented

    -

    Each file (post, blob, manifest) has its own flat holder set — up to 5 most-recent peers, LRU-capped. Once a file already has 5 holders, further requesters are redirected to existing holders instead of being registered. Eviction scoring is relationship-tiered (own content > followed > other). There is no tree rooted at the author and no upstream/downstream structure anywhere.

    -

    When a new post is created, the creator’s updated AuthorManifests (whose neighborhoods now reference the new post) are pushed to each prior post’s holder set via ManifestPush; recipients see the reference and fetch the new post via PostFetch. Notifications thus route via network-ID peers who happen to hold related files — content always arrives via pull, never pushed directly. Full mechanics in Files & Storage and Sync Protocol.

    - -

    DM privacy model Implemented

    -

    Three mechanisms eliminate the “A messaged B” traffic signal:

    -
      -
    1. CDN-only propagation. There is no direct push for encrypted posts. All encrypted posts propagate via the file-holder CDN, indistinguishable on the wire from any other encrypted content.
    2. -
    3. Merged pull + recipient-match. Content queries are a uniform list of IDs; a serving peer returns posts matching author ∈ query_list OR wrapped_key.recipient ∈ query_list. The client always includes every one of its own posting identities alongside follows, so there is no distinguishable “searching for DMs” traffic fingerprint. This matching machinery survives the v0.8 pull redefinition — it runs at the CDN content-fetch step (see Sync Protocol).
    4. -
    5. Greeting comments for cold contact Planned. First contact from outside the graph arrives as an HPKE-sealed comment on a bio post, signed by a throwaway outer ID — see Discovery & First Contact.
    6. -
    - -

    What the user sees

    -
      -
    • One merged incoming feed (all content to all personas), with filter-by-persona pills Implemented
    • -
    • Per-post “(you) as <persona>” label on own posts authored by a non-default persona Implemented
    • -
    • Persona picker at post-creation time, auto-hidden with a single persona Implemented
    • -
    • Settings → Personas to create, delete, and set default Implemented
    • -
    • Reply/comment default persona = whichever key decrypted the post Planned
    • -
    • Contextual compose defaults (e.g., posting to a circle auto-picks that circle’s last-used persona) Planned
    • -
    • DMs to different personas as distinct inbox threads Planned
    • -
    - -

    Key/collision safety

    -

    Posting keys and network keys are ed25519 seeds (256 bits of entropy). Birthday paradox reaches 50% collision at ~2128 keys generated — not a concern even at aggressive rotation rates across a global userbase. The operational risk is weak RNG during key generation; we rely on the platform CSPRNG everywhere.

    - -

    Status

    -

    The persona split is fully implemented: key-layer plumbing, multi-persona storage and UX, CDN-only encrypted propagation, and merged-pull recipient matching are all live. Ephemeral rotating DM IDs remain planned. The network-ID/posting-ID bug family above is the one required rework before v0.8 features (registry, concealment budget) can sit on this foundation.

    -
    - +
    -

    25. HTTP Post Delivery Implemented

    - +

    25. HTTP Post Delivery

    +

    Status: Complete

    +

    Direct peer-to-browser HTTP serving is implemented. For share link delivery, this is now part of the tiered web serving strategy (redirect → TCP punch → QUIC proxy) described in Section 26.

    Intent

    -

    Every ItsGoin node that is publicly reachable can serve its cached public posts directly to browsers over HTTP — no extra infrastructure, no additional dependencies, no new binary. The same port number used for QUIC (UDP) carries a TCP listener for a minimal raw HTTP/1.1 handler baked into the binary. This makes every publicly-reachable node a browser-accessible content endpoint, enabling share links that deliver content peer-to-browser without routing post bytes through itsgoin.net.

    +

    Every ItsGoin node that is publicly reachable can serve its cached public posts directly to browsers over HTTP — no extra infrastructure, no additional dependencies, no new binary. The same QUIC UDP port used for app traffic is accompanied by a TCP listener on the same port number. UDP goes to the QUIC stack as always. TCP goes to a minimal raw HTTP/1.1 handler baked into the binary.

    +

    This makes every publicly-reachable node a browser-accessible content endpoint, enabling share links that deliver content peer-to-browser without routing any post bytes through itsgoin.net.

    Dual listener architecture

    -
    <port>/UDP  →  QUIC (app protocol)
    -<port>/TCP  →  HTTP/1.1 (read-only, two routes)
    -

    Both listeners bind the same port number. The OS routes UDP and TCP to separate sockets — no conflict, no protocol ambiguity. The handler is raw tokio::net::TcpListener, no HTTP crate, zero new dependencies.

    +
    <port>/UDP  →  QUIC (existing app protocol)
    +<port>/TCP  →  HTTP/1.1 (new, read-only, single route)
    +

    Both listeners bind on the same port. The OS routes UDP and TCP to separate sockets — no conflict, no protocol ambiguity.

    -

    Routes

    - - - - -
    RouteServes
    GET /p/<postid_hex>Post rendered as a minimal HTML page (static compiled-in footer, no template engine)
    GET /b/<blob_hex>Raw attachment bytes (images/video referenced from the post page via /b/ URLs)
    +

    HTTP handler

    +

    The handler is intentionally minimal — implemented with raw tokio::net::TcpListener, no HTTP crate, no new dependencies. Approximately 150–200 lines of Rust.

    +

    Single valid route: GET /p/<postid_hex> HTTP/1.1

      -
    • Both hex arguments must be exactly 64 lowercase hex characters (BLAKE3 hash). Anything else — any other path, method, or malformed request — is a hard close with no response. Do not be helpful to malformed requests.
    • -
    • Posts must be PostVisibility::Public. Blobs are served only if they are an attachment of a public post. Encrypted content is never served over HTTP regardless of what the node holds.
    • -
    • Connections are keep-alive: one connection serves sequential requests (the post page, then its attachments) inside its slot.
    • +
    • postid_hex must be exactly 64 lowercase hex characters (BLAKE3 hash)
    • +
    • Any other path, method, or malformed request: hard close with no response (not even a 400). Do not be helpful to malformed requests.
    • +
    • Post must be public (PostVisibility::Public). Encrypted posts are never served over HTTP regardless of whether the node holds the content.
    - -

    Connection budget

    -

    The server runs a two-tier slot budget instead of a flat connection cap:

    - - - - - - -
    BudgetValuePurpose
    Content slots5Full service: post pages + blob bytes
    Redirect slots15302-only service when content slots are full
    Per-IP cap1One connection per client IP across both tiers
    Header timeout5 sSlow-request defense; exceeded → hard close
    -

    A new connection tries to acquire a content slot first; if those are full it gets a redirect slot, where post requests are answered with a 302 to another holder (below) and blob requests are hard-closed. Over-budget connections are closed immediately — no queue, no wait.

    +

    Response: Minimal HTML page containing the post content with a small footer:

    +
    <footer>
    +  This post is on the ItsGoin network — content lives on people's devices,
    +  not servers. <a href="https://itsgoin.com">Get ItsGoin</a>
    +</footer>
    +

    The footer HTML is a static string constant compiled into the binary (~2KB). No template engine, no dynamic footer generation.

    Security constraints

    - - - - - + + + + +
    ConcernMitigation
    Connection exhaustion5+15 slot budget, 1 per IP, immediate close over budget
    Slow HTTP attacks5-second read timeout for request headers
    Content enumerationIdentical response (hard close) for “not found” and “not public.” No timing oracle, no distinguishable error codes.
    Malformed requestsHard close only. No error response, not even a 400.
    Encrypted contentNever served. Public-visibility check is mandatory on both routes.
    Connection exhaustionHard cap: 20 concurrent HTTP connections. New connections over the cap are immediately closed. No queue, no wait.
    Slow HTTP attacks5-second read timeout for complete request headers. Exceeded → hard close.
    Content enumerationIdentical response (hard close) for “post not found” and “post not public.” No timing oracle, no distinguishable error codes.
    Malformed requestsHard close only. No error response.
    Encrypted contentNever served. Public visibility check is mandatory before any response.

    Which nodes serve HTTP

    -

    A node starts the HTTP listener only if it is publicly TCP-reachable, meaning any of:

    +

    A node serves HTTP only if it is publicly TCP-reachable:

      -
    • Active TCP port mapping via UPnP-IGD / NAT-PMP / PCP (see Port Mapping). Since v0.7.2 this includes mobile: phones on WiFi/Ethernet behind a permissive NAT serve HTTP too.
    • -
    • Public IPv6 address — serves directly (desktop, anchor, or mobile).
    • -
    • Explicit public bind (servers started with --bind).
    • +
    • IPv6 public address — serves directly
    • +
    • IPv4 + UPnP mapping — serves if TCP is included in the UPnP mapping (see Section 11 update)
    • +
    • IPv4 behind NAT without UPnP — cannot serve HTTP, but can still appear as a host in share links for app-protocol delivery. The CDN tree and itsgoin.net redirect handler route around unreachable nodes automatically.
    -

    Nodes behind a NAT that refuses all three mapping protocols cannot serve HTTP but can still appear as holders for app-protocol delivery; the share-link tiers (see Share Links) route around them. A serving node advertises its HTTP capability and address to peers so they can be selected as redirect targets. This is the same direct-reachability signal that drives anchor candidacy (see Anchors & Connection Convection) — in practice the HTTP-serving population and the anchor-candidate population are the same nodes.

    -

    302 load shedding via file holders

    -

    A redirect-slot request is answered from the flat holder model:

    +

    302 load shedding via CDN tree

    +

    When a node is overwhelmed (at the 20-connection cap) or chooses to redirect:

      -
    1. Verify the post exists locally and is public (else hard close).
    2. -
    3. Query file_holders for the post (flat set, cap 5 — see Content Propagation).
    4. -
    5. Filter each holder's known addresses to publicly-routable ones.
    6. -
    7. TCP-probe candidates with a 200 ms timeout; 302 → http://<addr>:<port>/p/<postid> to the first live one.
    8. -
    9. All dead → hard close.
    10. +
    11. Query post_downstream table for the requested postid
    12. +
    13. Filter downstream hosts to those with a known public address (IPv6 or UPnP-mapped IPv4)
    14. +
    15. 302 → http://[their_address]:<port>/p/<postid>
    -

    The receiving node applies the same logic recursively if its own content slots are full. Load spreads across whichever holders are publicly reachable, mirroring the attention-driven holder model at the HTTP layer.

    -
    - v0.8 change: redirect candidates come from the flat file_holders set; the old post_downstream tree (and the “CDN tree” framing of HTTP load shedding) is gone. -
    +

    The receiving node applies the same logic recursively if needed. This mirrors the app-layer CDN tree behavior at the HTTP layer — the same attention-driven propagation model, the same tree structure, now accessible to browsers.

    + +

    Binary size impact

    +

    Zero new dependencies. Negligible compiled size delta (~10–20KB). No App Store size concerns. No install size impact for existing users.

    - + - -
    -

    27. Discovery & First Contact Planned

    - -

    The problem

    -

    The mesh-CDN architecture deliberately has no global feed and no keyword flood-search (see Worm Search for why flooding is rejected). That leaves two gaps: discovery — how do you find a person you don't already have a graph path to? — and first contact — how does a stranger send you an initial message when every messaging rail (DMs, FoF comments) requires an existing key relationship? This section is the designed answer: an opt-in registry for discovery, and greeting comments for first contact. Both compose with machinery that already exists (bio posts, CDN comment verification, HPKE-sealed wrappers, RevocationEntry, comment TTL).

    - -

    The registry: opt-in discoverable bio posts

    -

    Registration is a flag on a public bio post: “this persona wants to be findable.” A discoverable bio post links a public persona (display name, bio text, avatar) to a public author ID (a posting identity) — and nothing else. There is no directory server; the registry is the set of discoverable-flagged bio posts, replicated as ordinary CDN content.

    -
      -
    • Location-anonymous by construction: a registry entry maps name → author ID only. Resolving the author ID to actual content goes through normal CDN holder search — never through an address in the entry. This depends on the AuthorManifest privacy fix (manifests must not carry device addresses — see Files & Storage); the fix must land before or with the registry.
    • -
    • Replication set: flagging a bio discoverable is explicit consent to wide replication. Discoverable bios form their own replication set that opted-in holders spread beyond the 2–5-post concealment budget that applies to ordinary content — registration trades concealment for reach, deliberately and per-persona.
    • -
    • Search: a searching node fetches the registry post's comment chain (below) via an ordinary pull, then queries locally. No flood queries; search cost lands on the searcher, not the network.
    • -
    • Consent gradient: registered personas are searchable by anyone. Unregistered personas are reachable only through the graph — someone must encounter their author ID via the uniques index (see Network Knowledge), a comment thread, or a vouch. Nothing about an unregistered persona is indexed.
    • -
    -

    The v0.6.2 Discover tab — a local list of named, public-visible profiles already held in storage — is the UI seed for this: the registry generalizes it from “profiles I happen to hold” to a replicated, searchable set.

    - -

    The registry post: rendezvous & enumeration Planned — tester-critical

    -

    Discoverable bios alone leave a gap: how does a searcher enumerate them? The answer is a well-known “registrations here” post whose comment chain is the registry index. A registration is a signed public comment on that post — structurally the same open-slot comment as a greeting (see below), aimed at a well-known target instead of a stranger's bio. One implementation serves both features.

    -
      -
    • The post: published at network genesis by the default anchor's persona; its ID is derived from a well-known seed the app ships with (shard(seed, k) — shard count starts at 1, and the derivation scheme exists from day one so splitting by hash(persona_id) later is a threshold change, not a migration). It replicates as ordinary CDN content; anchors hold it by durability, not appointment.
    • -
    • The entry: a registration comment carries {display name, search keywords, persona ID / public author ID} signed by the registering persona's posting key — self-certifying: any holder can verify the entry against the key it names, no authority consulted.
    • -
    • Self-cleaning: registration comments carry a fixed 30-day TTL (unlike ordinary comments' randomized TTL — that randomness serves anonymity, which a deliberately-public registration doesn't want). Staying listed means re-signing a fresh entry; holders keep one active entry per persona ID (newest wins). The chain self-cleans and is never more than 30 days stale.
    • -
    • Delete / modify: a delete request signed by the same persona key is honored by every holder (verifiable from the entry itself — the network agreement is checkable, not honor-system). Modify = delete + re-register; no separate primitive needed.
    • -
    • Moderation hook: the registry post's author retains the standard per-post RevocationEntry power — manual spam removal for the alpha era, at the acknowledged cost that the author could censor entries. Acceptable while the author is the project's own anchor; the trust layer (see Directory Trust Layer) is the eventual replacement.
    • -
    • Flood limits: registrations fit a single small size bucket and are rate-capped per holder. Proof-of-work was considered and rejected: its cost lands inverted (a phone registrant pays seconds of battery; a botnet rig pays effectively nothing), so it taxes the users we want while barely inconveniencing the adversary we fear. The registry's real defenses are the bounded blast radius and vouch gating below.
    • -
    -
    - Honest threat model: personas are deliberately near-free in this system (throwaway IDs are a feature), so no per-key or proof-of-work measure changes the spam economics against a motivated adversary — and this network must expect motivated adversaries with an interest in making decentralized social feel broken. The alpha registry survives casual spam only. Two things make that acceptable: (1) bounded blast radius — searches are local and the registry is an opt-in convenience layer, so a fully-flooded registry blocks only new stranger discovery while every existing relationship, follow, vouch, and sync continues untouched; griefing spends real resources to inconvenience only people who haven't met anyone yet. (2) Vouch-gated registration (see Directory Trust Layer) is the durable fix once utilization supports vouch introductions — the registry post's format survives that transition, only the acceptance rule tightens. Sharding is deliberately deferred to the same moment: by the time volume demands shards, registration demands vouches. -
    - -

    Greeting comments: sealed first contact

    -

    First contact rides the comment rails. A bio post that opts into contact includes a greeting slot in its pub_post_set (see Friend-of-Friend Visibility) whose signing keypair is published — derivable by anyone from material in the bio post header. A stranger's greeting comment signed with the greeting key passes the CDN-level comment-verification gate that otherwise drops non-member comments, so it propagates like any FoF comment.

    -
      -
    • Sealed content: the greeting body is HPKE-sealed to the bio author's posting key. Inside: the sender's real persona ID and a return path. Outside: an opaque ciphertext authored by a throwaway outer ID. Observers learn that the bio received a greeting, not from whom.
    • -
    • Blends in: FoF Mode 2 already makes encrypted comment blobs commonplace on public posts. Greetings are one more ciphertext among many — if everyone's anonymous, anonymity doesn't stand out.
    • -
    • Off-switch: the author revokes the greeting pub_x via the existing per-post RevocationEntry sweep — propagation nodes delete stored greetings and stop accepting new ones. No new revocation machinery.
    • -
    • Retirement: comments expire at a random TTL fixed at creation (see Delete Propagation & Comment Expiry), so throwaway greeting IDs eventually vanish from the network — and from everyone's uniques lists — entirely.
    • -
    - -

    Spam controls

    - - - - - - -
    ControlMechanism
    Active choiceNo greeting slot in the bio → no greetings possible. The choice is made visibly at first profile publish — pre-checked YES with opt-out before publishing (alpha default; revisit for public release). Revocable per persona at any time.
    Size bucketGreetings fit a single small padded bucket — no attachments, no long-form spam payloads.
    Holder-side count capsNodes holding a bio cap how many unexpired greetings they store and forward per bio.
    Rate capsHolder-side per-bio limits on unexpired greetings stored and forwarded. (Proof-of-work stamps were considered and rejected — inverted cost: phones pay, botnets don't.)
    -
    - Known limit: the greeting slot's pub_x_index is observable, so greetings are identifiable as a class (never by sender). Mitigation: friends can route innocuous chatter through the open greeting key as padding, so greeting traffic on a bio is not automatically stranger traffic. -
    - -

    The composed flow

    -
    -

    Find a persona via registry search (or encounter their author ID through the graph) → greet with a sealed greeting comment on their bio post — real identity and a return path ("where I'll watch for a response") inside → the author unseals it and replies with a sealed message to that return path, from their own fresh temp ID. The conversation continues over rotating temporary IDs — each message names the next rendezvous, no two messages share an outer identity, and observers see only never-before-seen IDs dropping ciphertext on unrelated open-slot posts. No vouch is ever required to talk. Vouching is fully disconnected from messaging: it is a relationship act that lives in the People tab — granting FoF readership via the normal bio-post wrapper batch — and deliberately has no control on any messaging surface, so vouching someone is always a considered decision, never a reflex mid-conversation. Inbox actions: Reply (primary), Dismiss. People you message surface in People for relationship management. Every temp ID expires with its comment's TTL; nothing permanent links the approach, the conversation, or the relationship that followed.

    -
    -
    - v1 scope: return paths start as the participants' bio posts; full rendezvous-hopping across arbitrary open-slot posts follows once an open-slot post index exists. The anonymity set for hopping is exactly the set of posts accepting open-slot comments — made large by the default-checked greeting choice at first publish. -
    -
    - - +
    -

    28. Directory Trust Layer (Future) Planned

    +

    27. Directory Service (Planned)

    -

    The trust layer is an opt-in convenience layer for curated discovery and creator protection. It is not node access — losing trust-layer standing does not disconnect anyone from the network or from their existing connections. This asymmetry is load-bearing: humans with mature relationships shrug off directory loss; bots and content thieves depend on it entirely.

    +

    The directory is an opt-in convenience layer for discovery and creator protection. It is not node access — losing directory presence does not disconnect anyone from the network or from their existing connections. This asymmetry is load-bearing: humans with mature relationships shrug off directory loss; bots and content thieves depend on it entirely.

    - Distinct from FoF cryptographic vouches. The "vouch" described in this section is a directory-layer trust signal governing discovery and bot-ring resistance. It is unrelated to the cryptographic vouch (V_me) in Friend-of-Friend Visibility, which gates post readership and commenting via per-persona symmetric keys. The two share vocabulary but operate at different layers. -
    - -
    - v0.8 change: the directory no longer defines its own entry/listing mechanics. The registry is the entry point — anyone can register a discoverable bio. The trust layer, when built, curates standing over registry entries: vouch-weighted visibility, moderation, and creator protection on top of the flat registry. Everything below is a future layer beyond the v0.8 scope. + Distinct from FoF cryptographic vouches. The "vouch" described in this section is a directory-layer trust signal governing discovery and bot-ring resistance. It is unrelated to the cryptographic vouch (V_me) in section 20a, which gates post readership and commenting via per-persona symmetric keys. The two share vocabulary but operate at different layers.

    Scope

      -
    • Whitelist track — vouch-weighted discoverability and graph-scoped visibility over registry entries.
    • +
    • Whitelist track — discoverability, vouch-based entry, graph-scoped visibility.
    • Blacklist track — community-flagged accounts and content; voluntary node-level replication refusal.
    • -
    • Out of scope — node access, message delivery, post sync, existing follows, and bare registry presence. All continue to work without trust-layer standing.
    • +
    • Out of scope — node access, message delivery, post sync, existing follows. All continue to work without directory membership.
    • +
    +

    Because directory loss is a low-cost outcome for real humans and a high-cost outcome for bad actors, enforcement thresholds can be deliberately aggressive without meaningful false-positive risk.

    + +

    Entry

    +

    Two paths to directory listing, both yielding equal discoverability but different trust-building capacity:

    +
      +
    • Vouch entry — 1 vouch from a directory member with remaining vouch capacity.
    • +
    • Paid entry — fee in lieu of vouch. Grants directory presence only; vouch capacity starts at zero and grows only from received human vouches. Prevents ring-bootstrapping: a bad actor cannot pay their way to vouch power.
    -

    Because trust-layer loss is a low-cost outcome for real humans and a high-cost outcome for bad actors, enforcement thresholds can be deliberately aggressive without meaningful false-positive risk.

    Vouch capacity

    A member's outbound vouch capacity is derived from received vouches:

    • Base rate: 1 outbound vouch per 30 days per received vouch, up to a hard cap of 5 outbound vouches per 30 days.
    • -
    • A registered persona with zero received vouches has zero outbound capacity — presence in the registry grants findability, never vouch power.
    • +
    • A member with zero received vouches (paid-entry only) has zero outbound capacity.
    • Capacity regenerates; unused capacity does not accumulate beyond the monthly window.

    Trust signals are internal, not purchasable. Aged off-platform accounts (FB, X, etc.) can be bought cheaply; in-system tenure crossed with graph density cannot. Vouch weight derives from the voucher's in-system graph depth and their history of non-revoked vouches — not account age.

    Cascade punishment

    -

    A "bad vouch" is a vouch later determined to have been extended to a bot, impersonator, content thief, or other removable actor. Punishment is asymmetric — mild for humans who miscalled a single vouch, devastating for botnets whose strength is their dense internal vouch graph.

    +

    A "bad vouch" is a vouch later determined to have been extended to a bot, impersonator, content thief, or other directory-removable actor. Punishment is asymmetric — mild for humans who miscalled a single vouch, devastating for botnets whose strength is their dense internal vouch graph.

    - - + +
    OffenseImmediate consequenceRecovery requirement
    1st bad vouchAll given and received vouches invalidated (member remains listed, but cannot vouch)2 NEW vouches received before outbound vouching resumes
    2nd bad vouchRemoved from trust-layer visibility1 new vouch to relist + 2 additional new vouches before vouching again
    3rd bad vouchRemoved from trust-layer visibility; 1-year outbound vouch freeze4 new vouches to relist; no outbound vouching for 12 months regardless of received vouches
    2nd bad vouchRemoved from directory1 new vouch to relist + 2 additional new vouches before vouching again
    3rd bad vouchRemoved from directory; 1-year outbound vouch freeze4 new vouches to relist; no outbound vouching for 12 months regardless of received vouches

    "NEW" is strictly defined: a voucher who has never previously vouched this member and is not within the first-degree graph of any prior voucher for this member. This blocks ring members from cycling each other through as "recovery" vouchers.

    Cascade radius. Invalidation of received vouches propagates the effective penalty upward: the bad actor's vouchers lose the credibility granted by that downstream relationship. One verified human assertion against a single botnet node can cascade through the entire dense cluster because bots' own topology carries the invalidation. They build the weapon that points back at them.

    Graph-relative visibility

    -

    Trust-layer discovery is not global. A viewer sees vouched entries N hops from their own social graph (N tunable; start with N=3). Bots at the fringe of the graph are structurally invisible to most humans. Visibility is further rate-limited (Y new profiles per viewer per day, Y tunable) to make harvesting economically unattractive without affecting normal discovery.

    +

    The directory is not global. A viewer sees directory entries N hops from their own social graph (N tunable; start with N=3). Bots at the fringe of the graph are structurally invisible to most humans. Visibility is further rate-limited (Y new profiles per viewer per day, Y tunable) to make harvesting economically unattractive without affecting normal discovery.

    This scoping is also why the vouch system's bot-ring problem is bounded: even a successful ring has no harvest value if it cannot be seen by real users.

    Verification (circuit breaker)

    @@ -1794,14 +1759,14 @@

    The reporting pipeline is lightweight, member-facing, and feeds a single review queue with multiple severity tracks:

    • Impersonation — a member reports an account copying their (or someone they know's) identity. High-confidence signal: verifiable via profile comparison and content signatures.
    • -
    • Bad vouch — a member flags an entry they vouched for, or vouches in their extended graph. Triggers cascade punishment on confirmation.
    • +
    • Bad vouch — a member flags a directory entry they vouched for, or vouches in their extended graph. Triggers cascade punishment on confirmation.
    • Content theft — a creator reports unauthorized reposting. Evidence is the signed original versus the repost.
    • Automated topology detection — graph analysis flags ring structures, abnormal clustering coefficients, and low-bridging clusters. Feeds the same queue as human reports.

    Reports from verified accounts carry higher weight. A confirmed report against one member in a tight cluster opens review on the whole cluster.

    Blacklist

    -

    Blacklist is a higher-severity tier than trust-layer suspension. Two states:

    +

    Blacklist is a higher-severity tier than directory suspension. Two states:

    • Under review — enough independent reports to warrant scrutiny; member still discoverable but flagged.
    • Confirmed blacklisted — entry persists at the identity level. A bad actor creating a new identity starts from zero; the old identity's blacklist entry remains as a reputational cost that outlasts the account.
    • @@ -1809,10 +1774,10 @@

      Escalation paths:

      • Confirmed impersonation → direct blacklist.
      • -
      • Vouch violations → trust-layer suspension first, blacklist only on repeated/escalated offenses.
      • +
      • Vouch violations → directory suspension first, blacklist only on repeated/escalated offenses.
      • Content theft → voluntary replication refusal (below); blacklist for repeat offenders.
      -

      The blacklist must be slower and more evidence-bound than suspension. Suspension costs a bot everything but costs a human almost nothing; blacklist has real network consequences (below) and must not be a censorship weapon.

      +

      The blacklist must be slower and more evidence-bound than directory suspension. Suspension costs a bot everything but costs a human almost nothing; blacklist has real network consequences (below) and must not be a censorship weapon.

      Voluntary network compliance

      Nodes may configure policies to decline replication or delivery of blacklisted content and accounts. This is opt-in, not forced. The effect is architectural starvation: stolen or abusive content cannot sustain replication when hosts collectively decline to be its infrastructure, while the legitimately signed original continues propagating normally.

      @@ -1822,7 +1787,7 @@

      The platform does not insert or intermediate ads. Creators may embed ads directly in their content or feed, as part of the signed post.

      • Ads inside signed content cannot be stripped without breaking the ed25519 signature — tampering is detectable.
      • -
      • A repost that strips ads produces an unsigned-or-differently-signed copy, which compliance rules treat as non-compliant.
      • +
      • A repost that strips ads produces an unsigned-or-differently-signed copy, which compliance rules (below) treat as non-compliant.
      • A repost that preserves ads intact monetizes the original creator automatically.
      • Ad revenue therefore follows the legitimate copy by construction, not by enforcement.
      @@ -1840,16 +1805,16 @@

      Most platforms have a structural conflict of interest with creators: fakes inflate engagement metrics, thieves generate content volume, both drive ad revenue. ItsGoin's incentives are inverted by design. Fakes degrade the vouch system; thieves attack the network's most valuable users; bots pollute the replication layer the network depends on. Every bad actor makes ItsGoin worse as software, not just ethically. The enforcement mechanisms above are therefore load-bearing, not policy theater.

      Implementation status

      -

      Designed, not implemented — and deliberately beyond the v0.8 scope. Requires:

      +

      Designed, not implemented. Requires:

        -
      • Trust-layer storage schema (vouch records, blacklist records, report queue, graph-density caches)
      • +
      • Directory storage schema (vouch records, blacklist records, report queue, graph-density caches)
      • New protocol messages (vouch, revoke, report, verify challenge/response)
      • Graph-topology analysis job (automated ring/cluster detection)
      • -
      • Client UX for vouching, reporting, verification
      • +
      • Client UX for discovery, vouching, reporting, verification
      • Node-side replication policy config (blacklist honoring)
      • Review-queue tooling for contested cases
      -

      Recommended staging: minimum viable slice = registry (see Discovery & First Contact) + single-vouch trust entries + impersonation reports → manual review queue. Defer cascade math, automated topology detection, verification override, and repost compliance until real graph data exists to calibrate thresholds.

      +

      Recommended staging: minimum viable slice = invite-tree directory + single-vouch entry + impersonation reports → manual review queue. Defer cascade math, automated topology detection, verification override, and repost compliance until real graph data exists to calibrate thresholds.

      Tunable parameters

      @@ -1857,7 +1822,7 @@ - + @@ -1865,94 +1830,105 @@
      DIRECTORY_VOUCH_INTERVAL30 daysTime between outbound vouches per received vouch
      DIRECTORY_VOUCH_CAP5Hard cap on outbound vouches per 30-day window
      DIRECTORY_GRAPH_HOPS3Visibility radius for discovery
      DIRECTORY_DISCOVERY_RATE~10 / day (provisional)New profiles visible per viewer per day; tune against real graph data
      DIRECTORY_DISCOVERY_RATETBD / dayNew profiles visible per viewer per day
      RECOVERY_VOUCHES_1ST2NEW vouches to restore voucher status after 1st bad vouch
      RECOVERY_VOUCHES_2ND1 + 2Relist + additional vouches after 2nd bad vouch
      RECOVERY_VOUCHES_3RD4Relist vouches after 3rd bad vouch
      REPOST_DISCUSSION_RATIO1:4Max embed duration relative to original (discussion track)
    - + + +
    +

    28. Identity Architecture Mostly Shipped (v0.6.0)

    +

    v0.6.0 separates network identity (per-device routing/connection key) from posting identity (the face/persona authoring content). This is the architectural foundation for multi-device, multi-persona, and DM-level traffic-graph privacy. Five of the original six phases shipped as v0.6.0; ephemeral rotating DM IDs (§28.4) are deferred pending connection-model design work.

    + +

    28.1 Two layers of identity Shipped

    +

    Each device has ONE network key — used for QUIC connections, endpoint ID, mesh routing. It's never linked on the wire to any posting key.

    +

    Each user can hold MANY posting keys simultaneously — no "active" persona, no switching. Each posting key is a persona (Public, Private, Work, Family, etc). Posts are signed with the posting key chosen at compose time. On first launch after upgrading, the existing identity becomes the default posting key (same bytes as the network key for upgraders, so all prior signed content keeps verifying).

    +

    Privacy invariant: peers cannot determine which network IDs belong to a given posting ID, which posting IDs belong to the same network ID, or which posting IDs belong to the same user. These associations are private to the device owner.

    + +

    28.2 Persona types Shipped

    +
      +
    • Public posting IDs — main persona(s), openly associated with "you"
    • +
    • Private posting IDs — smaller-context personas for close contacts or specific groups
    • +
    • Contextual posting IDs — per-relationship or per-group; user creates one per context and sticks with it
    • +
    + +

    28.3 Multi-device is a special case Partial

    +

    "Two devices holding the same posting key" is a trivial case of the multi-key model. Link happens out-of-band between the user's own devices via the existing identity export/import bundle — the export includes all posting identities so they restore on the second device. The network sees no cross-device message announcing the relationship; each device pulls content for its posting IDs via the normal CDN. The fact that two network nodes hold the same posting key is only discoverable if an observer has private knowledge (which they shouldn't).

    +

    In-app QR / file-share flows for easy linking between an existing device and a new one are not wired yet — today it's manual export-then-import.

    + +

    28.4 Ephemeral rotating IDs for DM threads Deferred

    +

    The intent: DM threads and group messages use per-thread unique posting IDs that rotate per message. Each encrypted message would include a handshake field — the next posting ID to use. Observer sees a stream of distinct posting IDs with no cryptographic tie between them, defeating thread-level traffic correlation. Desync recovery via a sliding window of the last N accepted IDs. Message history kept in a local encrypted-to-self archive that replicates across linked devices via self-follow.

    +

    Why deferred: the current connection model is per-network-ID (peers establish QUIC sessions keyed by stable NodeId; holder-set learning assumes durable addressable authors). Ephemeral authors that come into existence per-thread need a crisper design for how a freshly generated posting ID becomes reachable fast enough to receive replies before the next rotation. The pieces needed to implement rotation (per-file holder sets, merged pull with recipient-match, per-posting-ID signing) are all in place — what's missing is the cold-contact discovery semantics for ephemeral recipients.

    + +

    28.5 CDN: per-file holder sets Shipped

    +

    The earlier upstream/downstream tree (which assumed a single author network endpoint) is replaced by a flat per-file holder set with header-diff propagation.

    +

    Each file (post, blob, manifest) has its own holder set (up to 5 most-recent peers, LRU-capped). When a new post is created, the creator updates the headers of recent prior posts (their manifests now reference the new post), then pushes the header diff to each updated prior post's holder set. Recipients apply the header diff, see the reference to the new post, and pull it through normal sync.

    +

    Notifications thus route via network-ID peers who happen to hold related files — not via any tree rooted at the author. Content always arrives via pull, never pushed directly. Four legacy tables (post_upstream, post_downstream, blob_upstream, blob_downstream) were dropped in v0.6; a one-way migration seeds file_holders from them on first launch.

    + +

    28.6 DM privacy model Shipped

    +

    Two of the three originally-planned mechanisms shipped in v0.6.0 and eliminate the "A messaged B" traffic signal for follower-to-follower and cold-contact-via-pull scenarios:

    +
      +
    1. CDN-only propagation. Direct PostPush for encrypted posts is removed. All encrypted posts propagate via the file-holder CDN, indistinguishable on the wire from any other encrypted content.
    2. +
    3. Merged pull + recipient-match. Pull sync's query is a uniform list of NodeIds; server returns posts matching author ∈ query_list OR wrapped_key.recipient ∈ query_list. Client always includes its own NodeId alongside follows. No distinguishable "searching for DMs" traffic fingerprint — the query looks identical to a routine follow-pull.
    4. +
    5. Comment-as-introduction for cold contact. Any public post with open comments serves as a message-request surface. The machinery exists (engagement diffs flow through the post's holder network); a dedicated UX affordance for "start a conversation by commenting" is still on the todo list.
    6. +
    + +

    28.7 What the user sees

    +
      +
    • One merged incoming feed (all content to all personas), with filter-by-persona pills Shipped
    • +
    • Per-post "(you) as <persona>" label on own posts authored by a non-default persona Shipped
    • +
    • Persona picker at post-creation time, auto-hidden with a single persona Shipped
    • +
    • Settings → Personas to create, delete, and set default Shipped
    • +
    • Reply/comment default persona = whichever key decrypted the post Not yet
    • +
    • Contextual compose defaults (e.g., posting to a circle auto-picks that circle's last-used persona) Not yet
    • +
    • DMs to different personas as distinct inbox threads Not yet
    • +
    + +

    28.8 Key/collision safety Shipped

    +

    Posting keys and network keys are ed25519 seeds (256 bits of entropy). Birthday paradox reaches 50% collision at ~2128 keys generated — not a concern even at aggressive rotation rates across a global userbase. The operational risk is weak RNG during key generation; we rely on the platform CSPRNG everywhere.

    + +

    28.9 Rollout status

    +
      +
    1. Phase 1 Shipped v0.6.0 — Direct PostPush for encrypted posts removed. Encrypted DMs propagate via ManifestPush like any other content.
    2. +
    3. Phase 2 Shipped v0.6.0 — File-holder model + header-diff propagation replaces upstream/downstream. Legacy tables dropped with a one-way seed migration.
    4. +
    5. Phase 3 Shipped v0.6.0 — Merged pull + recipient-match search. DM search indistinguishable from follow-pull. post_recipients index added.
    6. +
    7. Phase 4 Shipped v0.6.0 — Posting-key / network-key split plumbing. posting_identities table; seed migration copies network key in as default persona for upgraders.
    8. +
    9. Phase 5 Shipped v0.6.0 — Multi-persona UX (Settings page, compose picker, feed filter pills, per-post persona labels).
    10. +
    11. Phase 6 Deferred — Ephemeral rotating IDs for DM threads + local self-archive. See §28.4 for the open design question.
    12. +
    +

    v0.6 is a hard network fork from v0.5. The two versions do not interoperate. This was an explicit simplification once the user base was small enough to migrate everyone directly — no dual-write tables, no legacy message handlers, no mixed-network testing. Users cross via the existing export/import identity bundle; see the download page for the upgrade path.

    +
    + +
    -

    Appendix A: Timeout & Interval Reference

    -

    Every constant below exists in code today (cited per row in source comments) or comes from a v0.8 ruling (marked Planned). Constants tied to retired subsystems (preferred peers, register loop, keep-alive pool) are gone; the two registration-model referral constants remain, badged Rework, until the convection window replaces them.

    +

    Appendix A: Timeout Reference

    - - - - - - - + + + + - - - - - - - - - - - - - - - + + + + + + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + +
    ConstantValuePurpose
    MESH_KEEPALIVE_INTERVAL30sPing to prevent zombie detection + NAT timeout
    ZOMBIE_TIMEOUT600s (10 min)No stream activity → dead connection
    SESSION_IDLE_TIMEOUT300s (5 min)Reap idle sessions (referral-list peers and known anchors exempt)
    MESH_KEEPALIVE_INTERVAL30sPing to prevent zombie detection
    ZOMBIE_TIMEOUT600s (10 min)No activity → dead connection
    SESSION_IDLE_TIMEOUT300s (5 min)Reap idle interactive sessions (NOT keep-alive)
    SELF_LAST_ENCOUNTER_THRESHOLD14400s (4 hours)Trigger pull sync when last encounter exceeds this
    QUIC_CONNECT_TIMEOUT15sDirect connection establishment
    SESSION_KEEPALIVE_PROBE3sPer-peer keepalive send timeout; failure marks the connection dead
    HOLE_PUNCH_TIMEOUT30sOverall hole punch window (parallel over all known addresses)
    HOLE_PUNCH_ATTEMPT2sPer-attempt timeout before retry within the window
    RELAY_INTRO_TIMEOUT15sRelay introduction request round-trip
    RELAY_PIPE_IDLE120s (2 min)Session-relay pipe idle before close (opt-in serving only)
    RELAY_MAX_BYTES50 MBMax bytes relayed per pipe before close (opt-in serving only)
    RELAY_COOLDOWN300s (5 min)Per-target relay cooldown after a failed introduction
    RELAY_INTRO_DEDUP30s (cap 500)Dedup window for forwarded introductions (seen_intros)
    HOLE_PUNCH_TIMEOUT30sOverall hole punch window
    HOLE_PUNCH_ATTEMPT2sPer-address attempt within window
    RELAY_INTRO_TIMEOUT15sRelay introduction request
    RELAY_PIPE_IDLE120s (2 min)Relay pipe idle before close
    RELAY_COOLDOWN300s (5 min)Per-target relay cooldown
    RELAY_INTRO_DEDUP30sDedup intro forwarding
    WORM_TOTAL_TIMEOUT3sEntire worm search
    WORM_FAN_OUT_TIMEOUT500msPer-peer fan-out query
    WORM_BLOOM_TIMEOUT1.5sNova round: follow-up queries to burst-response referrals (historically "wide referrals")
    WORM_BLOOM_TIMEOUT1.5sBloom round to wide referrals
    WORM_DEDUP10sIn-flight worm dedup
    WORM_COOLDOWN300s (5 min)Miss cooldown before retry
    CONVECTION_WINDOW_SIZE16Implemented Rolling caller window on the anchor. Small on purpose: the point is recency, not coverage. (Retired REFERRAL_DISCONNECT_GRACE 120s and REFERRAL_LIST_CAP 50 from the registration model.)
    CONVECTION_HANDOUT_CAP2Implemented Hand-outs before a window entry rotates out ("2 before, 2 after")
    CONVECTION_REFERRALS2Implemented Referrals returned per served request
    CONVECTION_ENTRY_MAX_AGE15 minImplemented Older window entries are not handed out — a stale address costs the next caller a punch timeout
    CONVECTION_SUPPLEMENT_CAP2 per 15 minImplemented Hand-out budget for mesh peers used to supplement a sparse window (address-free, introduction-only)
    ANCHOR_REFUSAL_PENALTY5 minImplemented How long a refusing anchor is de-prioritised; the penalty map is swept on every insert
    UNIQUES read cap2 MBImplemented Dedicated ceiling for 0x01/0x40/0x41, distinct from the 64 MB file-transfer cap
    UNIQUES build / accept caps4,000 & 12,000 / 8,000 & 24,000Implemented Per-slice build caps (shallow, terminal) and per-bounce accept caps (N2–N3, N4); received slices are truncated, not rejected
    KNOWLEDGE_STALE_PRUNEImmediate on disconnect + 5h fallbackRemove reach entries tagged to disconnected reporters; age fallback for stragglers. Boot sweep clears all non-mesh entries.
    RECONNECT_WATCHER_EXPIRY30 daysReconnect-notification watchers pruned after expiry
    GROWTH_LOOPSignal-driven (no timer)Runs on disconnects, knowledge additions, rebalance backstop; 3-failure backoff, 500ms pause between attempts
    REBALANCE_CYCLE600s (10 min)Dead/zombie sweep, knowledge prune, session reap, growth backstop
    ROUTING_DIFF_CYCLE120sIncremental knowledge diff broadcast to connected peers
    FULL_STATE_BROADCAST4hFull knowledge-state broadcast (diff-loss repair)
    RECOVERY_DEBOUNCE2sRecovery loop debounce after the mesh < 2 signal
    STICKY_N1_TTL24hBootstrap peer advertised as sticky N1 (isolation recovery)
    PULL_CYCLE_TICK60sPull cycle tick; first tick full pull. v0.8: replaced by the update-cadence scheduler.
    SELF_LAST_ENCOUNTER4hPer-author staleness threshold before a pull fires
    ENGAGEMENT_CHECK_TIERS5 min / 1h / 4h / 24hFreshness-keyed engagement re-check (active 72h / recent 14d / aging 30d / cold). Seed of the v0.8 update-cadence scheduler.
    REPLICATION_CYCLE600s (120s initial delay)Own-post replication check: posts <72h old with <2 known holders. Currently inert (author-lookup bug, Roadmap item 1).
    PORTMAP_DISCOVERY_WAIT3sWatch-channel wait for first router response across UPnP-IGD / NAT-PMP / PCP (never blocks startup; renewal is internal to portmapper)
    ANCHOR_REACHABILITY_LOSS300s (5 min)Clear is_anchor after sustained port-mapping loss; restored on recovery
    ANCHOR_PROBE_INTERVAL30 minAnchor self-verification re-probe spacing. Rework: today's gate also requires ≥50 connections + 2h uptime (dying peer-count model); v0.8 re-keys candidacy to the reachability watcher.
    BOOTSTRAP_BATCH_SIZE3Concurrent anchor probes in flight
    BOOTSTRAP_BATCH_STAGGER2sDelay between probe batch dispatches
    BOOTSTRAP_PER_ANCHOR_TIMEOUT10sPer-anchor probe timeout during batched bootstrap
    ANCHOR_STALE_THRESHOLD3 daysFailed probe to an anchor with last_seen older than this deletes the known_anchors row
    NAT_FILTER_PROBE_TIMEOUT10sThird-party NAT filtering probe at startup anchor contact
    HTTP_HEADER_TIMEOUT5sHTTP request header read timeout
    HTTP_REDIRECT_PROBE200msTCP liveness probe of a holder before issuing a 302
    CONVECTION_ACTIONstochastic, per-disconnectImplemented On each mesh disconnect: random {nothing | anchor introduction | mesh-peer introduction}; weights adaptive = local anchor-density prior + refusal feedback
    CONVECTION_CLASSentry / top-up (1 bit)Implemented Entry (<2 connections) always served; top-up refused cheaply under load — refusal feeds the adaptive weights
    COMMENT_TTLrand(30–365 days)Planned Comment expiry, fixed at creation and carried with the comment
    UPDATE_CADENCEminutes → daysRework Descending-frequency check scale keyed on freshness × relationship tier, jittered (seed: ENGAGEMENT_CHECK_TIERS above; see Update Cadence & Keep-Alive)
    REFERRAL_DISCONNECT_GRACE120s (2 min)Anchor keeps peer in referral list after disconnect
    N2/N3_STALE_PRUNEImmediate on disconnect + 7 day fallbackRemove reach entries tagged to disconnected peers; age-based fallback for stragglers
    N2/N3_STARTUP_SWEEPOn bootRemove all N2/N3 entries tagged to peers not in current mesh
    PREFERRED_UNREACHABLE_PRUNE7 daysRelease preferred slot (must re-negotiate MeshPrefer on reconnect)
    RECONNECT_WATCHER_EXPIRY30 daysLow-priority reconnect awareness; daily check after 7 days
    GROWTH_LOOP_TIMER60sPeriodic growth loop check
    CONNECTIVITY_CHECK60sSocial/file <N4 access check for keep-alive sessions
    DM_RECENCY_WINDOW14400s (4 hours)DM'd nodes included in connectivity check
    UPNP_DISCOVERY_TIMEOUT2sGateway discovery on startup (do not block)
    UPNP_LEASE_RENEWAL2700s (45 min)Refresh port mapping before TTL expiry
    ANCHOR_PROBE_INTERVAL1800s (30 min)Periodic re-probe while anchor-declared
    ANCHOR_PROBE_TIMEOUT15sCold connect attempt by witness
    ANCHOR_STALE_THRESHOLD7 daysPost-bootstrap cleanup probes known_anchors older than this
    @@ -1961,256 +1937,203 @@

    Appendix B: Design Constraints

    - - - - - - - - - - - - - + + - - - - - - - - - - - - - + + + + + + + +
    ConstraintValueNotes
    Mesh slots~20 (Desktop) / 15 (Mobile)Single pool, no tiers. Rework — current code runs 101/15 across a Preferred/Local/Wide split
    Temp referral slots+10 desktop / +4 mobile, above the capImplemented Introduction facilitation only; no uniques or peer-address exchange; graduate or expire (5 min TTL)
    Knowledge depth4 bounces (N4 terminates)Planned Mobile may hold 3 bounces or Bloom-compress N4
    Session slots20 (Desktop) / 5 (Mobile)Oldest-idle evicted at capacity
    Relay pipes10 (Desktop) / 2 (Mobile)Session relay is opt-in, OFF by default on every role including anchors
    Max payload (wire)64 MBLength-prefixed JSON framing
    Visibility metadata cap256 KBApplies to WrappedKey lists in encrypted posts
    Max recipients (per-recipient wrapping)~500256KB / ~500 bytes JSON per WrappedKey
    Max blob size10 MBPer attachment
    Max attachments per post4
    File holders per content5 (LRU)Flat file_holders set; registration past 5 answers with a holder redirect
    Publicly revealed posts per author2–5Planned Concealment budget; surplus pushed out via replication (see Social Graph Privacy)
    Comment TTLrand(30–365 days)Planned Fixed at creation, carried with the comment; retires throwaway greeting IDs
    Max recipients (per-recipient wrapping)~500 / 256 KBDesign target for WrappedKey lists; not enforced in code
    Public post encryption overheadZeroNo WrappedKeys, unlimited audience
    known_anchors table{ node_id, addresses, last_seen, success_count }Ranked by success_count, capped at 5; failed probes to rows older than 3 days self-prune
    Wire compatibilityNonev0.8 is a clean protocol break from ≤v0.7.x (zero-users ruling). ALPN bumps itsgoin/3itsgoin/4 so old nodes are refused at handshake (see Sync Protocol)
    Public post encryption overheadZeroNo WrappedKeys, no sharding, unlimited audience
    Max payload (wire)16 MBLength-prefixed JSON framing
    Mesh slots101 (Desktop) / 15 (Mobile)Preferred + non-preferred, no local/wide distinction
    Keep-alive session cap50% of session capacityEnsures interactive sessions remain available
    Keep-alive ceiling (desktop)~300–500Binding constraint: routing diff broadcast overhead
    Keep-alive ceiling (mobile)~25–50Binding constraint: battery + OS background restrictions
    mesh_blacklist table{ node_id }Targeted mutual stranger relationships for testing/diversity
    known_anchors table{ node_id, addresses, last_seen }LIFO ordered, 7-day stale cleanup via probe
    - +

    Appendix C: Implementation Scorecard

    -

    Code status as of v0.7.3, measured against the v0.8 target. Implemented = code matches the target today. Rework = built, but differently; migration required. Planned = not built.

    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    AreaStatus
    Mesh & knowledge
    Single ~20-slot mesh pool (tiers eliminated)Implemented — Done (Iteration C): one pool, 20 desktop / 15 mobile
    Preferred-peer elimination (slots, MeshPrefer, prunes, watchers)Implemented — Done (Iteration B, Roadmap item 3)
    Temp referral slots (+4..+10, no knowledge exchange)Implemented — Done (Iteration C): +10 desktop / +4 mobile, above the cap, never evicting a mesh peer
    Uniques announce, payload N1–N3 (N4 stored but terminates, dedup at store + announce)Implemented — Done (Iteration C): two-pool announce, terminal N3, single reachable table, size caps enforced
    Pull as uniques-index exchange (distributed search index)Implemented — Done (Iteration C); post bodies moved to the transitional 0x46/0x47 content sync
    Growth loop (signal-driven, diversity scoring, stochastic anchor path)Implemented — Done (Iteration C): whole-pool counting, bounded SQL shortlist, graded depth penalty, per-disconnect stochastic action
    Worm search (point queries: posts, blobs, nodes; no keyword flooding)Implemented
    Social routing cache + checkins + reconnect watchersImplemented — preferred_tree field retires with preferred peers
    Update-cadence scheduler (freshness × relationship, jitter, dedup)Rework — freshness-only engagement tiers exist
    Anchors & connectivity
    Reachability-based anchor candidacy (portmapper watcher, bidirectional)Implemented
    Anchor opt-in gatePlanned — reachable desktops auto-anchor today
    Connection convection (2-before/2-after rolling referrals, per-disconnect stochastic trigger)Implemented — Done (Iteration C): rolling window, entry/top-up classes, adaptive weights, 0xC0 register loop retired
    Batched bootstrap probes + 3-day stale-anchor self-pruneImplemented
    Anchor self-verification probe (3rd-party witness)Implemented — scheduling re-keys to the watcher in v0.8
    Port mapping (UPnP-IGD + NAT-PMP + PCP via portmapper, all platforms)Implemented
    Relay introduction + hole punch (single quick + 30s parallel)Implemented
    NAT type detection (STUN) + filter probeImplemented
    EDM port scanner via raw UDP (bypassing iroh's path store)Planned — disabled body preserved as edm_port_scan_disabled_v0_7_3
    Session relay (opt-in byte pipe, OFF by default, both sides gated)Implemented
    Connection rate limiting (per-IP failure backoff)Implemented
    LAN discovery — passive mDNS address lookupImplemented
    LAN discovery — active flow (subscribe, LAN sessions, sync priority)Planned — low priority
    Identity & personas
    Network ↔ posting identity split + v0.6.1 key rotationImplemented
    Multi-persona per device (create/switch/import/export)Implemented
    Posting-key ZIP bundles: export, import-as-personas, post import & mergeImplemented
    ID-class bug fixes (revocation, group-key grant, replication lookup, eviction own-content tier)Rework — Roadmap item 1
    Ephemeral rotating DM identityPlanned
    Throwaway-ID retirement via comment TTLPlanned
    Encryption & visibility
    Envelope encryption (X25519 + ChaCha20-Poly1305, itsgoin/cek-wrap/v1)Implemented
    Group keys as encrypted posts + epoch rotationImplemented
    Tier-2/3 revocation (control-post propagation)Rework — machinery built; entry-point author-check bug blocks every revoke
    Private profiles per circleImplemented
    FoF visibility Layers 1–5 (vouch, gated comments, FoFClosed, rotation/burn, unlock cache)Implemented
    Sync & content
    Wire protocol (length-prefixed JSON, 64 MB payload)Implemented — 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
    Merged pull with recipient matching (all posting identities)Implemented — payload semantics change with the uniques redefinition
    Manifest machinery (push, refresh, header diffs)Implemented
    AuthorManifest privacy (no device addresses, 2–5 neighborhood)Rework — code ships addresses + 10+10 neighborhood
    Engagement propagation via flat file_holders + signature verificationImplemented
    Deletes as signed control posts (+ LRU blob orphaning)Implemented
    Comment TTL expiry (rand 30–365d, fixed at creation)Planned
    Author concealment (2–5 visible posts) + traffic-uniformity shapingPlanned
    Push notifications, reactions, comments, tombstones, auto-sync on followImplemented
    Audience sharding (large-audience optimization)Planned
    Files & CDN
    Blob storage (BLAKE3, 256-shard layout) + transfer with delivery budgetsImplemented
    Flat holder model (cap 5, LRU, redirect at capacity)Implemented
    Blob eviction (pin / share-boost / relationship-tier scoring)Implemented
    Active replication cycle (10 min, 72h window, <2 holders)Rework — cycle ships but is inert (author-lookup bug); becomes the concealment push-out path
    Erasure-coded CDN replication (3-of-10 sub-threshold shards)Planned
    Pin modes (anchor pin / fork pin / personal vault)Planned
    Web, share links & discovery
    HTTP post delivery (/p/ + /b/, 5+15 slot budget, holder redirect, mobile serving)Implemented
    Share link Phase 1 (itsgoin.net/p/<postid>, anchor resolves holders)Implemented
    itsgoin.net QUIC proxy + PostFetch + TCP punchImplemented
    Universal Links / App LinksPlanned
    Registry (registrations-here post + discoverable bios, location-anonymous)Planned — tester-critical, roadmap #2
    Greeting comments (HPKE-sealed first contact via published slot key)Planned
    Directory trust layer (vouch graph, cascade punishment, blacklist)Planned
    Mesh connection architecture (101 slots, preferred/non-preferred)Complete
    N1/N2/N3 knowledge layersComplete
    Growth loop (60s timer + reactive on N2/N3)Partial (timer exists, reactive trigger needs update)
    Preferred peers + bilateral negotiationComplete
    N+10 identificationPartial (preferred peers exist, N+10 not in all headers)
    Worm search (nodes + content search for posts/blobs)Complete
    Relay introduction + hole punchComplete
    Session relay (own-device default)Partial (relay works, own-device restriction not implemented)
    Social routing cacheComplete
    Three-layer architecture (Mesh/Social/File)Partial (layers exist conceptually, pull sync still uses mesh)
    Keep-alive sessionsPlanned
    Self Last Encounter sync triggerPlanned
    Algorithm-free reverse-chronological feedComplete
    Envelope encryption (1-layer)Complete
    Group keys for circlesComplete
    Three-tier access revocationPartial (Tier 1+2 work, Tier 3 crypto exists but no UI)
    Private profiles per circleComplete
    Pull-based sync with follow filteringComplete
    Push notifications (post/profile/delete)Complete
    Blob storage + transferComplete
    CDN hosting tree + manifestsComplete
    Blob eviction with priority scoringComplete
    Anchor bootstrap + referralsComplete
    Delete propagation + CDN cascadeComplete
    Multi-device identityPlanned
    UPnP port mapping (desktop)Complete
    NAT type detection (STUN) + hard+hard skipComplete
    Advanced NAT traversal (role-based scanning + filter probe)Complete
    LAN discovery (mDNS scan + auto-connect)Complete
    Content propagation via attentionPartial
    BlobHeader separation from blob contentComplete
    25+25 neighborhood with HeaderDiff propagationPartial (engagement diffs work, neighborhood diffs planned)
    BlobHeaderDiff message (engagement)Complete
    Reactions (public + private encrypted)Complete
    Comments + author policy enforcementComplete
    Engagement sync via BlobHeaderRequest after pull syncComplete
    Notification settings (messages/posts/nearby)Complete
    Tiered DM polling (recency-based schedule)Complete
    Auto-sync on followComplete
    Post CDN tree (post_downstream)Complete
    Anchor self-verification (reachability probe)Complete
    Mutual mesh blacklistPlanned
    --max-mesh flag (test affordance)Planned
    Audience shardingPlanned
    Custom feedsPlanned
    HTTP post delivery (TCP listener, single route, load shedding)Complete
    Share link generation (postid + author NodeId)Complete
    itsgoin.net QUIC proxy handler (on-demand fetch + render)Complete
    PostFetch (0xD4/0xD5) single-post retrievalComplete
    Universal Links / App Links (itsgoin.net/p/*)Planned
    itsgoin.net ItsGoin node (anchor + web handler)Complete
    UPnP TCP port mapping alongside UDPComplete
    -

    Appendix D: Roadmap — v0.8 Critical Path

    -

    The path from today's code to the v0.8 target, in order. Items 1–2 clear the ground; 3–7 are the architecture shift; 8–10 build on it.

    - +

    Appendix D: Critical Path Forward

    +

    The highest-impact items, in priority order:

    -

    1. ID-class bug fixes

    -

    One bug family: the v0.6.1 persona split left code comparing the network NodeId where posting IDs now live. Four known instances: Tier-2/3 visibility revocation (author check bails on every revoke), the GroupKeyRequest handler (checks the wrong ID class, wraps with the wrong key — can never grant), the replication cycle (queries own posts by network ID, so the under-replicated list is always empty), and the blob-eviction relationship tier (own-content boost compares the network ID; masked by auto-pin). Fix all four, then sweep every self.node_id comparison against post/persona authors for further instances.

    +

    1. Three-layer separation (pull sync from social/file, not mesh)

    +

    Implement Self Last Encounter tracking and move pull sync to social + upstream file peers. This is the foundation for the layered architecture.

    -

    2. Registry + greeting comments — tester-critical

    -

    The app is eminently useful only if people can find each other — this is what makes it usable for testers, so it jumps the queue. Ship the “registrations here” post (open-slot signed registration comments, fixed 30-day TTL, one entry per persona, self-certifying signed deletes, holder-side rate/size caps — no PoW) and greeting comments (HPKE-sealed first contact via the published greeting-slot key, messaging-first with Reply/Vouch/Dismiss) — one open-slot comment implementation serves both. Prerequisite folded in: remove device addresses from AuthorManifest first (registered author IDs must be location-anonymous). Known-bounded: survives until deliberate bot-flooding; the trust layer (item 10) is the durable fix.

    +

    2. N+10 in all identification

    +

    Add N+10 (NodeId + 10 preferred peers) to self-identification, post headers, blob headers, and social routes. Dramatically improves findability.

    -

    3. Cruft purge

    -

    Done (Iteration B) — landed with the itsgoin/4 ALPN bump; 0xC0 AnchorRegister deliberately kept until anchor convection (item 5). The zero-users ruling removes all wire-compat obligations. Delete: v0.6.x receive-only compat handlers (0x51/0x52), the sender-less 0x71 SocialDisconnectNotice, and the dual pull-matching path; the preferred-peer subsystem (slots, MeshPrefer negotiation, 7-day prune, 30-day watcher plumbing); dead senders and constants (hostlist encoder, no-op renewal cycle, orphaned GroupKeyRequest/Response); and legacy tree fields in wire structs. Clean protocol break — bump ALPN to itsgoin/4 here.

    +

    3. Keep-alive sessions

    +

    Implement social/file connectivity check and keep-alive sessions for peers not reachable within N3. Cross-layer N2/N3 routing from keep-alive sessions.

    -

    4. N1–N4 uniques knowledge layer

    -

    Done (Iteration C) — 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–N3 share lists with the two-pool uniques announce: forwardable N0–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.

    +

    4. UPnP port mapping

    +

    Best-effort NAT traversal for desktop/home networks. Makes nodes directly reachable without hole punching. External address feeds into N+10 and all peer advertisements. Especially impactful for mobile-to-desktop connectivity.

    -

    5. Anchor convection

    -

    Done (Iteration C) — except the opt-in gate, which stays low priority per the ruling. Third-party anchor claims are index rows only; known_anchors and peers.is_anchor are written from proven handshakes. Retire the register loop. Anchor candidacy = reachability watcher + opt-in (opt-in gate is low priority — 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 — random {do nothing | anchor introduction | mesh-peer introduction} with adaptive weights (anchor-density prior + refusal feedback). Anchors are mined from the uniques pools; known_anchors recedes to a bootstrap cache.

    +

    5. Growth loop reactive trigger

    +

    Fire growth loop immediately on N2/N3 receipt until 90% full. Currently only timer-based.

    -

    6. Post concealment (2–5 budget)

    -

    Cap the public post neighborhood to the 2–5 concealment budget (the AuthorManifest address removal already landed with item 2). Surplus posts are pushed out via replication so update/replication traffic hides content origin.

    +

    6. Multi-device identity

    +

    Same identity key across devices with device-specific identity for self-discovery and own-device relay.

    -

    7. Update-cadence system

    -

    Replace the fixed pull tick with the descending-frequency scheduler: check intervals from minutes to days keyed on content freshness × relationship tier, same-author dedup, jittered timing so update and replication traffic reads as uniform network overhead. Uniques-list refresh rides these exchanges. Comment TTL (rand 30–365d) lands with this work.

    +

    7. File-chain propagation

    +

    Make AuthorManifest with N+10 and recent posts work passively. Enable discovery of new content from any blob holder.

    -

    8. Raw-UDP EDM refactor

    -

    Revive the disabled port scanner by replacing per-probe endpoint.connect() with raw socket.send_to() on the endpoint's bound UDP socket, so probes never enter iroh's path store (the v0.7.3 DoS-grade outbound cause). The preserved edm_port_scan_disabled_v0_7_3 body is the starting point.

    +

    8. Share links + HTTP post delivery

    +

    The viral growth mechanism. Every share becomes a product demo for non-app users and opens natively for app users. Dependencies in order:

    +
      +
    1. UPnP TCP mapping (small addition to existing UPnP code)
    2. +
    3. Raw TCP HTTP listener (150–200 lines, zero new dependencies)
    4. +
    5. Host list generation at share time (query post_downstream, encode, embed in URL)
    6. +
    7. itsgoin.net redirect handler + known_good DB (server-side, independent of app releases)
    8. +
    9. itsgoin.net loading screen
    10. +
    11. Universal Links / App Links registration (static JSON files + Tauri config)
    12. +
    13. itsgoin.net ItsGoin node (run the binary, configure as anchor)
    14. +
    +

    Steps 4–7 are itsgoin.net infrastructure, deployable independently of app releases. Steps 1–3 ship in the app. Step 6 requires an app store release to activate but can be deployed to itsgoin.net ahead of time.

    -

    9. Erasure-coded CDN replication

    -

    Replace full-copy auto-replication on the public tier with 3-of-10 sub-threshold shards, each shard a content-addressed blob with its own holder set. Removes the liability profile of infrastructure nodes holding reconstructable copies of content they never reviewed.

    -
    -
    -

    10. Directory trust layer

    -

    Vouch capacity, cascade punishment, graph-relative visibility, verification circuit-breaker, blacklist — built on top of the registry as the entry point. Minimum viable slice: single-vouch listing + impersonation reports → manual review. Defer cascade math until real graph data exists to calibrate thresholds.

    +

    9. Own-device relay restriction

    +

    Restrict relay pipes to own-device by default, opt-in for relaying for others.

    - + +
    +

    Appendix E: Features Designed But Not Built

    + + + + + + + + + + + + + + + + + + + + + + + + + +
    FeatureSourceStatus
    Three-layer pull sync (social/file, not mesh)v0.2.0 designPlanned
    N+10 in all identification & headersv0.2.0 designPlanned
    Keep-alive sessionsv0.2.0 designPlanned
    Multi-device identityv0.2.0 designPlanned
    Own-device relay restrictionv0.2.0 designPlanned
    Self Last Encounter sync triggerv0.2.0 designPlanned
    Anchor pin vs Fork pin distinctionproject discussion.txtPlanned
    Audience sharding for groups > 250ARCHITECTURE.mdPlanned
    Repost as first-class post typeproject discussion.txtPlanned
    Custom feeds (keyword/media/family rules)project discussion.txtPlanned
    Bounce routing (social graph as routing)ARCHITECTURE.mdPlanned
    Reactions (public + private encrypted)v0.2.11Complete
    RefuseRedirect handling (retry suggested peer)protocol.rsPartial (send-only)
    Profile anchor list used for discoveryARCHITECTURE.mdPartial (field exists)
    File-chain propagation (passive post discovery)DesignPartial (manifest exists)
    Anchor-to-anchor gossip/registryObserved gapPlanned
    BlobHeader as separate mutable structurev0.2.11Complete
    BlobHeaderDiff incremental propagation (engagement)v0.2.11Complete
    Post export/backup tooling (author durability)v0.2.4 designPlanned
    Anchor reachability probe (self-verification)v0.2.6Complete
    Mutual mesh blacklistv0.2.4 designPlanned
    --max-mesh flag (test topology control)v0.2.4 designPlanned
    Relay-assisted port scanning (advanced NAT traversal)v0.2.6Complete
    +
    + +
    -

    Appendix E: File Map

    -

    As of v0.7.3 code. Use this as a navigation map, not an exhaustive line count.

    +

    Appendix F: File Map

    crates/core/
       src/
    -    lib.rs                     — module registration, parse_connect_string, parse_node_id_hex,
    -                                 DEFAULT_ANCHOR_POSTING_ID
    -    types.rs                   — Post, PostId, NodeId, PublicProfile, PostVisibility (incl. FoFClosed),
    -                                 WrappedKey, VisibilityIntent, Circle, DeviceProfile slot tables,
    -                                 AuthorManifest/CdnManifest, BlobHeader + diff ops, DeviceRole budgets
    -    content.rs                 — compute_post_id (BLAKE3), verify_post_id
    -    crypto.rs                  — X25519 conversion, CEK wrap (itsgoin/cek-wrap/v1), group-key wrap,
    -                                 encrypt/decrypt_post, FoF wrap slots + prefilter, slot keys
    -    blob.rs                    — BlobStore (256-shard layout), compute_blob_id, verify_blob,
    -                                 delivery budget tracking
    -
    -    identity.rs                — IdentityManager: multi-identity dirs, create/list/switch/delete,
    -                                 network vs posting identity (v0.6.0 split)
    -    profile.rs                 — profile posts (signed, content-addressed profile updates)
    -    control.rs                 — control posts: audience removal, delete control posts,
    -                                 persona admin ops
    -    fof.rs                     — Friend-of-Friend visibility: V_x wrap slots, anonymous prefilter,
    -                                 pub_post_set, CDN-level comment verification,
    -                                 V_me rotation / cascade-revoke / KeyBurnDiff
    -    group_key_distribution.rs  — circle group key distribution as encrypted posts + epoch rotation
    -    activity.rs                — in-memory ring buffer (200 events) of node activity
    -                                 (growth/rebalance/recovery/anchor/connection/relay) for the UI
    -                                 status panel — NOT engagement storage
    -    announcement.rs            — release announcement post handling
    -
    -    storage.rs                 — SQLite (auto-migration): posts, peers, follows, profiles,
    -                                 circles/circle_members/circle_profiles, deleted_posts,
    -                                 post_replicas, peer_neighbors, mesh_peers, reachable_n2/n3,
    -                                 social_routes, reconnect_watchers, blobs, blob_headers,
    -                                 cdn_manifests, file_holders (flat CDN, cap 5), post_hosts,
    -                                 reactions/comments/comment_policies/thread_meta,
    -                                 seen_engagement/seen_messages, group_keys/group_member_keys/
    -                                 group_seeds, known_anchors, ignored_peers, relay_cooldowns,
    -                                 worm_cooldowns, settings, post_recipients, posting_identities,
    -                                 preferred_peers (dropped in v0.8 Iteration B), + 10 vouch/FoF tables
    -                                 (vouch_keys_own/received, vouch_bio_scan_cache, own_vouch_targets,
    -                                 fof_revocations, own_post_slot_provenance, vouch_unlock_cache,
    -                                 vouch_unreadable_posts, own_fof_post_ceks, fof_key_burns).
    -                                 post/blob_upstream+downstream tables dropped in v0.6.1-beta.
    -    protocol.rs                — MessageType enum, ALPN (itsgoin/4),
    -                                 length-prefixed JSON framing, InitialExchangePayload
    -    connection.rs              — ConnectionManager + ConnHandle/ConnectionActor (actor pattern):
    -                                 mesh + session slots, initial exchange, N1/N2 diff broadcast,
    -                                 pull sync, worm search, relay introduction, referral list,
    -                                 anchor self-probe, engagement diff handling. EDM scanner
    -                                 preserved as edm_port_scan_disabled_v0_7_3 (#[allow(dead_code)]).
    -                                 session_relay_enabled gate (opt-in OFF by default).
    -    network.rs                 — iroh Endpoint, accept loop, connect_to_peer, growth loop,
    -                                 connect_by_node_id support, mDNS passive address lookup,
    -                                 bidirectional anchor reachability watcher, per-IP rate limiting
    -    node.rs                    — Node struct, post CRUD, follow/unfollow, profile/circle CRUD,
    -                                 encrypted post creation, startup cycles, bootstrap with
    -                                 probe_anchors_batched + stale-anchor prune, pull/diff/rebalance/
    -                                 recovery/replication cycles, delete control posts,
    -                                 connect_by_node_id resolution cascade, blob eviction scoring
    -    web.rs                     — itsgoin.net web handler: QUIC proxy for share links, tiered
    -                                 serving (redirect/TCP punch/proxy), on-demand post fetch
    -    http.rs                    — raw-TCP HTTP listener: /p/ + /b/ routes, 5+15 slot budget,
    -                                 holder 302 redirect with 200ms probe, render_post_html
    -    upnp.rs                    — port mapping: UPnP-IGD + NAT-PMP + PCP via portmapper crate;
    -                                 PortMapping holds Client for service lifetime; watch_external()
    -    stun.rs                    — STUN probes (NAT type detection)
    -    android_wifi.rs            — Android-only JNI bridge: WifiManager.MulticastLock,
    -                                 WiFi/Ethernet detection, NodeService.stopFromNative
    -    export.rs / import.rs      — posting-key ZIP bundles: export, import-as-personas,
    -                                 import-as-new-identity, post import & merge (merge_with_key)
    +    lib.rs          — module registration, parse_connect_string, parse_node_id_hex
    +    types.rs        — Post, PostId, NodeId, PublicProfile, PostVisibility, WrappedKey,
    +                      VisibilityIntent, Circle, PeerRecord, Attachment
    +    content.rs      — compute_post_id (BLAKE3), verify_post_id
    +    crypto.rs       — X25519 key conversion, DH, encrypt_post, decrypt_post, BLAKE3 KDF
    +    blob.rs         — BlobStore, compute_blob_id, verify_blob
    +    storage.rs      — SQLite: posts, peers, follows, profiles, circles, circle_members,
    +                      mesh_peers, reachable_n2/n3, social_routes, blobs, group_keys,
    +                      preferred_peers, known_anchors; auto-migration
    +    protocol.rs     — MessageType enum (39 types), ALPN (itsgoin/3),
    +                      length-prefixed JSON framing, read/write helpers
    +    connection.rs   — ConnectionManager + ConnHandle/ConnectionActor (actor pattern):
    +                      mesh QUIC connections (MeshConnection), session connections,
    +                      slot management, initial exchange, N1/N2 diff broadcast,
    +                      pull sync, relay introduction. All external access via ConnHandle.
    +    network.rs      — iroh Endpoint, accept loop, connect_to_peer,
    +                      connect_by_node_id (7-step cascade), mDNS discovery
    +    node.rs         — Node struct (ties identity + storage + network), post CRUD,
    +                      follow/unfollow, profile CRUD, circle CRUD, encrypted post creation,
    +                      startup cycles, bootstrap, anchor register cycle
    +    web.rs          — itsgoin.net web handler: QUIC proxy for share links,
    +                      on-demand post fetch via content search, blob serving
    +    http.rs         — HTML rendering for shared posts (render_post_html)
     
     crates/cli/
    -  src/main.rs       — interactive REPL + anchor/daemon mode (--bind, --daemon, --web)
    +  src/main.rs       — interactive REPL + anchor mode (--bind, --daemon, --web)
     
     crates/tauri-app/
    -  src/lib.rs        — Tauri v2 commands (DTOs + IPC handlers): persona CRUD, identity
    -                       import/export, exit_app (Android NodeService stop), session-relay toggle
    -  gen/android/      — Android shell: NodeService.kt foreground service with
    -                       companion stopFromNative() static
    +  src/lib.rs        — Tauri v2 commands (38 IPC handlers), DTOs
     
     frontend/
    -  index.html        — single-page UI: 5 tabs (Feed / My Posts / People / Messages / Settings),
    -                       close-app button with inline SVG power icon, session-relay toggle
    +  index.html        — single-page UI: 5 tabs (Feed / My Posts / People / Messages / Settings)
       app.js            — Tauri invoke calls, rendering, identicon generator, circle CRUD
    -  style.css         — dark theme, post cards, visibility badges, transitions
    -
    -website/
    -  design.html       — this document (canonical design reference)
    -  download.html     — release notes
    -  index.html, tech.html, contribute.html
    -  style.css         — shared site styling
    + style.css — dark theme, post cards, visibility badges, transitions
    diff --git a/website/download.html b/website/download.html index f3df77b..023c118 100644 --- a/website/download.html +++ b/website/download.html @@ -26,52 +26,6 @@

    Download ItsGoin

    Available for Android, Linux, and Windows. Free and open source.

    -

    v0.8.0-alpha — July 30, 2026

    -

    Clean protocol break. ALPN moves to itsgoin/4 — v0.8 nodes and v0.7.x nodes refuse each other at the QUIC handshake rather than half-interoperating. Alpha: testers only. See design.html for the full v0.8 architecture.

    - - - -

    Finding people — the headline feature

    -
      -
    • Registry — opt-in discoverability. A well-known "registrations here" post whose comment chain is the directory. Registering publishes a signed comment carrying your display name, keywords, and public author ID — self-certifying, so any holder can verify it against the key it names, with no authority involved. Entries carry a fixed 30-day expiry and auto-renew while you stay listed, so the chain self-cleans and is never more than a month stale. Unlisting sends a signed delete every holder honors. Your author ID stays location-anonymous: it maps to content through ordinary CDN holder search, never to an address.
    • -
    • Greeting comments — talking to strangers. Messaging with no prior key relationship. A greeting is an opaque comment on a bio post, signed with a published open-slot key so it passes the CDN's comment-verification gate, with its body sealed to the recipient's posting key. Inside: who you really are and a return path. Outside: ciphertext authored by a throwaway ID. Replies carry a fresh key and the next rendezvous, so a conversation hops temporary identities and no two messages share an outer identity. Since encrypted comment blobs are already commonplace on public posts, greetings blend into ordinary traffic — if everyone's anonymous, anonymity doesn't stand out.
    • -
    • Vouching is deliberately separate. No vouch control exists on any messaging surface. Talking to someone never grants them anything; vouching is a considered relationship act that lives in the People tab.
    • -
    • Comment expiry. Ordinary comments now carry a randomized 30–365 day lifetime fixed at creation and covered by the signature, so every holder expires them at the same moment. Throwaway conversation identities eventually vanish from the network entirely.
    • -
    • No proof-of-work. Considered and rejected: its cost lands inverted — a phone registrant pays seconds of battery while a botnet rig pays effectively nothing. Flood limits are holder-side size and count caps; the durable answer is vouch-gated registration once the graph supports it.
    • -
    - -

    Privacy & correctness fixes

    -
      -
    • Manifests no longer carry device addresses. AuthorManifest shipped your device's network addresses under a posting-identity author — a direct link between a persona and the machine hosting it. The field is gone from the struct and the signature digest, with startup migrations that re-sign your own stored manifests and purge unverifiable foreign copies. This is what makes a registered author ID safe to publish.
    • -
    • Persona-split bug family fixed. The v0.6.1 network-key/posting-key split left roughly fourteen places comparing a network NodeId where posting identities now live. Consequences included: visibility revocation silently failed on every post, group-key distribution to later-added circle members never worked, encrypted attachments wouldn't decrypt, the replication cycle never found under-replicated content, and your own blobs missed their eviction protection tier. Every "is this mine?" check now tests membership across all of your posting personas.
    • -
    • Comment ingest is verified everywhere. Two pull paths stored incoming comments with no signature verification at all. All three ingest sites now share one acceptance gate. Also fixed during review: forged tombstones could delete comments through the ingest path, a delete path trusted the sender rather than a signature, a forged policy message could poison a post's comment intake remotely, and the frontend's HTML escaper didn't escape quotes (an attribute-breakout XSS reachable through registry names, which also affected seven pre-existing sites).
    • -
    - -

    Protocol slimming

    -
      -
    • Message types 46 → 40, about 1,400 lines of Rust removed. Deleted: DeleteRecord (0x51) and VisibilityUpdate (0x52), both superseded by signed control posts; SocialDisconnectNotice (0x71) and MeshPrefer (0xB3), which nothing had sent in generations; GroupKeyRequest/Response (0xA1/0xA2), an orphaned pair that could never have granted a key after the persona split; and the legacy half of the dual pull-matching path.
    • -
    • Preferred peers eliminated. The preferred tier existed to serve direct N+10 push/pull that the CDN's holder model replaced. Gone: the slot tier, the preferred_peers table, preferred-tree routing semantics, the rebalance priority pass, the relay candidate tiers, and the 7-day and 30-day watchers.
    • -
    - -

    Upgrade note: v0.8.0-alpha cannot talk to v0.7.x at all — that is intentional, and the anchor moves with this release. Existing data directories migrate in place on first run (manifest re-signing, legacy profile cleanup, registry materialization).

    -

    v0.7.3 — May 15, 2026

    Bandwidth + bootstrap hardening on top of v0.7.2. Wire-compatible with v0.7.0/v0.7.1/v0.7.2.

    diff --git a/website/tech.html b/website/tech.html index 4ad4925..6ddade0 100644 --- a/website/tech.html +++ b/website/tech.html @@ -124,8 +124,8 @@ wrapped_key[i] = X25519_DH(author_ed25519, recipient_ed25519[i]) XOR CEK<
    -

    Sync protocol (v4)

    -

    The protocol uses a single ALPN (itsgoin/4) with 40 message types multiplexed over QUIC bi-streams and uni-streams.

    +

    Sync protocol (v3)

    +

    The protocol uses a single ALPN (itsgoin/3) with 37+ message types multiplexed over QUIC bi-streams and uni-streams.

    Pull-based sync