feat: v0.8 Iteration C — narrow mesh, N1-N4 uniques index, anchor convection

Topology rework. The mesh's job is width; depth now comes from knowledge.

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

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

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

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGiPD2cF75mnvneSCjdDC5
This commit is contained in:
Scott Reimers 2026-07-30 13:03:12 -04:00
parent 36e3871c4b
commit e756fabb11
14 changed files with 5503 additions and 2367 deletions

View file

@ -27,6 +27,12 @@ check() { # check <desc> <cmd...>
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() {
@ -91,7 +97,7 @@ sleep 3
echo "search rust" >&15
sleep 8
check "node3 re-search finds nothing after signed delete" \
bash -c 'tail -20 /tmp/itsgoin-cli3.log | grep -q "no registry matches"'
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.
@ -128,7 +134,8 @@ sleep 4
echo "search rust" >&15
sleep 8
check "node3 sees exactly one (newest) entry for node2's persona" \
bash -c 'tail -6 /tmp/itsgoin-cli3.log | grep -c "rust" | grep -q "^1$" && tail -6 /tmp/itsgoin-cli3.log | grep -q AliceV2'
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 =="

278
scripts/c_topology_test.sh Executable file
View file

@ -0,0 +1,278 @@
#!/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 <desc> <cmd...>
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 <node> <id_hex> <bounce>
[ "$(sq "$1" "SELECT count(*) FROM reachable WHERE lower(hex(reachable_id))='$2' AND bounce=$3")" != "0" ]
}
anywhere() { # anywhere <node> <id_hex>
[ "$(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 <n> <port> [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 ]