The file C:\Users\decid\Documents\projects\spt-core\crates\spt-net\src\net\endpoint.rs has been updated successfully. (file state is current in your context — no need to Read it back)

impl std::error::Error for NetError {}

/// A bound spt-core network endpoint: the node's reachable presence on the subnet.
pub struct NetEndpoint {
    inner: Endpoint,
}

impl std::error::Error for NetError {}

/// Which IP families a production-scope endpoint actually binds
/// (REQ-NET-FAMILY-GATE). iroh binds dual-stack by default; a half-broken family
/// (e.g. IPv6 whose AAAA resolves but whose path is dead) silently burns
/// discovery/connect time on every attempt. We probe each family once at bind and
/// drop the *dead* one — never a fixed "prefer IPv4" (an IPv6-only network must
/// still work).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum BindFamilies {
    /// Both families bind (the iroh default — both healthy, or the probe was
    /// inconclusive and we must not strand the node).
    Dual,
    /// IPv4 only — IPv6 is dead or force-disabled.
    V4Only,
    /// IPv6 only — IPv4 is dead or force-disabled.
    V6Only,
}

/// WAN reachability probe targets — Cloudflare's public anycast resolver on each
/// family (no DNS step; a literal address per family). A bounded TCP connect to
/// `:443` is the same discriminator the field incident used by hand
/// (`Test-NetConnection dns.iroh.link -Port 443`): it catches a half-broken family
/// whose route exists but whose path is dead, which a mere route-table check
/// (UDP connect) would miss.
const PROBE_V4: &str = "1.1.1.1:443";
const PROBE_V6: &str = "[2606:4700:4700::1111]:443";

/// Per-family reachability probe timeout. Short so a healthy bind is not slowed
/// (a reachable family answers in tens of ms; only a DEAD family waits this out,
/// and that wait replaces a 75 s silent rendezvous failure).
const FAMILY_PROBE_TIMEOUT: Duration = Duration::from_millis(1500);

/// Parse a force-off env flag (`SPT_DISABLE_IPV6` / `SPT_DISABLE_IPV4`): present
/// and "truthy" ⇒ force the family off. Empty / `0` / `false` / `no` read as off
/// (the flag is not set), mirroring a conventional boolean env knob. Factored from
/// [`env_flag`] so the parse is unit-testable without touching the environment.
fn flag_is_on(val: Option<&str>) -> bool {
    match val {
        Some(v) => {
            let v = v.trim();
            !v.is_empty()
                && !v.eq_ignore_ascii_case("0")
                && !v.eq_ignore_ascii_case("false")
                && !v.eq_ignore_ascii_case("no")
        }
        None => false,
    }
}

/// Whether the named env var is a set boolean flag (see [`flag_is_on`]).
fn env_flag(var: &str) -> bool {
    flag_is_on(std::env::var(var).ok().as_deref())
}

/// Pure family selection from per-family reachability + the force-off overrides
/// (REQ-NET-FAMILY-GATE). No sockets — this is the unit-testable core.
///
/// - A **force-off** override is absolute: the family is never bound regardless of
///   its probe. Both forced off is an error (nothing to bind).
/// - With neither forced, a family is dropped ONLY when it probes dead AND the
///   other works. **Both-dead (or both-healthy) falls back to dual-stack** — an
///   inconclusive probe (locked-down egress, fully offline host) must not strand
///   the node by binding nothing; dual-stack is exactly today's behavior.
// [impl->REQ-NET-FAMILY-GATE]
fn select_families(
    v4_reachable: bool,
    v6_reachable: bool,
    disable_v4: bool,
    disable_v6: bool,
) -> Result<BindFamilies, NetError> {
    match (disable_v4, disable_v6) {
        (true, true) => Err(NetError::Bind(
            "both IPv4 and IPv6 force-disabled (SPT_DISABLE_IPV4 + SPT_DISABLE_IPV6) \
             — no family left to bind"
                .into(),
        )),
        // A forced family is dropped regardless of probe; the survivor is the only
        // option (so its own probe is irrelevant — honor the explicit choice).
        (true, false) => Ok(BindFamilies::V6Only),
        (false, true) => Ok(BindFamilies::V4Only),
        (false, false) => Ok(match (v4_reachable, v6_reachable) {
            (true, false) => BindFamilies::V4Only,
            (false, true) => BindFamilies::V6Only,
            // both healthy OR both inconclusive → don't strand: bind dual-stack.
            _ => BindFamilies::Dual,
        }),
    }
}

/// One family's WAN reachability: a bounded blocking TCP connect (tokio's `net`
/// feature is off in this crate, so `std::net` on a blocking thread), `true` iff
/// the handshake completes within [`FAMILY_PROBE_TIMEOUT`].
async fn probe_family(target: &str) -> bool {
    let Ok(addr) = target.parse::<std::net::SocketAddr>() else {
        return false;
    };
    tokio::task::spawn_blocking(move || {
        std::net::TcpStream::connect_timeout(&addr, FAMILY_PROBE_TIMEOUT).is_ok()
    })
    .await
    .unwrap_or(false)
}

/// Decide which families a production-scope (`BindScope::All`) endpoint binds:
/// the force-off overrides first (no probe needed — a forced choice is
/// deterministic), otherwise probe both families concurrently and drop a dead one.
/// Relay-disabled (LAN/air-gapped) binds skip the WAN probe and keep dual-stack —
/// there is no WAN to reach, so the probe would only ever read both-dead.
// [impl->REQ-NET-FAMILY-GATE]
async fn decide_bind_families(relay: &RelayPolicy) -> Result<BindFamilies, NetError> {
    let disable_v4 = env_flag("SPT_DISABLE_IPV4");
    let disable_v6 = env_flag("SPT_DISABLE_IPV6");
    if disable_v4 || disable_v6 {
        // A forced choice needs no probe.
        return select_families(true, true, disable_v4, disable_v6);
    }
    if matches!(relay, RelayPolicy::Disabled) {
        return Ok(BindFamilies::Dual);
    }
    let (v4, v6) = tokio::join!(probe_family(PROBE_V4), probe_family(PROBE_V6));
    select_families(v4, v6, false, false)
}

/// A bound spt-core network endpoint: the node's reachable presence on the subnet.
pub struct NetEndpoint {
    inner: Endpoint,
}

+/// Which IP families a production-scope endpoint actually binds

+/// (REQ-NET-FAMILY-GATE). iroh binds dual-stack by default; a half-broken family

+/// (e.g. IPv6 whose AAAA resolves but whose path is dead) silently burns

+/// discovery/connect time on every attempt. We probe each family once at bind and

+/// drop the *dead* one — never a fixed "prefer IPv4" (an IPv6-only network must

+#[derive(Debug, Clone, Copy, PartialEq, Eq)]

+    /// Both families bind (the iroh default — both healthy, or the probe was

+    /// inconclusive and we must not strand the node).

+    /// IPv4 only — IPv6 is dead or force-disabled.

+    /// IPv6 only — IPv4 is dead or force-disabled.

+/// WAN reachability probe targets — Cloudflare's public anycast resolver on each

+/// family (no DNS step; a literal address per family). A bounded TCP connect to

+/// `:443` is the same discriminator the field incident used by hand

+/// (`Test-NetConnection dns.iroh.link -Port 443`): it catches a half-broken family

+/// whose route exists but whose path is dead, which a mere route-table check

+/// Per-family reachability probe timeout. Short so a healthy bind is not slowed

+/// (a reachable family answers in tens of ms; only a DEAD family waits this out,

+/// and that wait replaces a 75 s silent rendezvous failure).

+const FAMILY_PROBE_TIMEOUT: Duration = Duration::from_millis(1500);

+/// Parse a force-off env flag (`SPT_DISABLE_IPV6` / `SPT_DISABLE_IPV4`): present

+/// and "truthy" ⇒ force the family off. Empty / `0` / `false` / `no` read as off

+/// (the flag is not set), mirroring a conventional boolean env knob. Factored from

+/// [`env_flag`] so the parse is unit-testable without touching the environment.

+fn flag_is_on(val: Option<&str>) -> bool {

+                && !v.eq_ignore_ascii_case("0")

+                && !v.eq_ignore_ascii_case("false")

+                && !v.eq_ignore_ascii_case("no")

+/// Whether the named env var is a set boolean flag (see [`flag_is_on`]).

+    flag_is_on(std::env::var(var).ok().as_deref())

+/// Pure family selection from per-family reachability + the force-off overrides

+/// (REQ-NET-FAMILY-GATE). No sockets — this is the unit-testable core.

+/// - A **force-off** override is absolute: the family is never bound regardless of

+///   its probe. Both forced off is an error (nothing to bind).

+/// - With neither forced, a family is dropped ONLY when it probes dead AND the

+///   other works. **Both-dead (or both-healthy) falls back to dual-stack** — an

+///   inconclusive probe (locked-down egress, fully offline host) must not strand

+///   the node by binding nothing; dual-stack is exactly today's behavior.

+        (true, true) => Err(NetError::Bind(

+            "both IPv4 and IPv6 force-disabled (SPT_DISABLE_IPV4 + SPT_DISABLE_IPV6) \

+        // A forced family is dropped regardless of probe; the survivor is the only

+        // option (so its own probe is irrelevant — honor the explicit choice).

+        (true, false) => Ok(BindFamilies::V6Only),

+        (false, true) => Ok(BindFamilies::V4Only),

+        (false, false) => Ok(match (v4_reachable, v6_reachable) {

+            (true, false) => BindFamilies::V4Only,

+            (false, true) => BindFamilies::V6Only,

+            // both healthy OR both inconclusive → don't strand: bind dual-stack.

+/// One family's WAN reachability: a bounded blocking TCP connect (tokio's `net`

+/// feature is off in this crate, so `std::net` on a blocking thread), `true` iff

+/// the handshake completes within [`FAMILY_PROBE_TIMEOUT`].

+async fn probe_family(target: &str) -> bool {

+    let Ok(addr) = target.parse::<std::net::SocketAddr>() else {

+    tokio::task::spawn_blocking(move || {

+        std::net::TcpStream::connect_timeout(&addr, FAMILY_PROBE_TIMEOUT).is_ok()

+/// Decide which families a production-scope (`BindScope::All`) endpoint binds:

+/// the force-off overrides first (no probe needed — a forced choice is

+/// deterministic), otherwise probe both families concurrently and drop a dead one.

+/// Relay-disabled (LAN/air-gapped) binds skip the WAN probe and keep dual-stack —

+/// there is no WAN to reach, so the probe would only ever read both-dead.

+async fn decide_bind_families(relay: &RelayPolicy) -> Result<BindFamilies, NetError> {

+    let disable_v4 = env_flag("SPT_DISABLE_IPV4");

+    let disable_v6 = env_flag("SPT_DISABLE_IPV6");

+        // A forced choice needs no probe.

+        return select_families(true, true, disable_v4, disable_v6);

+    if matches!(relay, RelayPolicy::Disabled) {

+    let (v4, v6) = tokio::join!(probe_family(PROBE_V4), probe_family(PROBE_V6));

+    select_families(v4, v6, false, false)


Now wire the family decision into `bind_scoped` and apply single-family binds in the `BindScope::All` arm.


Edit

        let builder = match scope {
            BindScope::All => builder,
            BindScope::Loopback => builder

        // Per-family bind gate (REQ-NET-FAMILY-GATE): production-scope binds drop a
        // dead/force-disabled IP family so a half-broken family never silently
        // burns discovery/connect time. Hermetic scopes (Loopback/Port) keep
        // dual-stack untouched — they reach no WAN and must stay deterministic
        // (no probe, no Windows-firewall surprise).
        let families = match scope {
            BindScope::All => decide_bind_families(&relay).await?,
            BindScope::Loopback | BindScope::Port(_) => BindFamilies::Dual,
        };
        match families {
            BindFamilies::Dual => {}
            BindFamilies::V4Only => {
                eprintln!("NET_FAMILY_GATE: binding IPv4-only (IPv6 unreachable or disabled)")
            }
            BindFamilies::V6Only => {
                eprintln!("NET_FAMILY_GATE: binding IPv6-only (IPv4 unreachable or disabled)")
            }
        }
        let builder = match scope {
            // Dual-stack keeps the iroh default binds; a single-family gate clears
            // the default IP transports and re-adds only the surviving family's
            // unspecified socket (same clear+bind_addr mechanism BindScope uses).
            BindScope::All => match families {
                BindFamilies::Dual => builder,
                BindFamilies::V4Only => builder
                    .clear_ip_transports()
                    .bind_addr(std::net::SocketAddr::from((
                        std::net::Ipv4Addr::UNSPECIFIED,
                        0,
                    )))
                    .map_err(|e| NetError::Bind(e.to_string()))?,
                BindFamilies::V6Only => builder
                    .clear_ip_transports()
                    .bind_addr(std::net::SocketAddr::from((
                        std::net::Ipv6Addr::UNSPECIFIED,
                        0,
                    )))
                    .map_err(|e| NetError::Bind(e.to_string()))?,
            },
            BindScope::Loopback => builder


        let builder = match scope {
            BindScope::All => builder,
            BindScope::Loopback => builder

        // Per-family bind gate (REQ-NET-FAMILY-GATE): production-scope binds drop a
        // dead/force-disabled IP family so a half-broken family never silently
        // burns discovery/connect time. Hermetic scopes (Loopback/Port) keep
        // dual-stack untouched — they reach no WAN and must stay deterministic
        // (no probe, no Windows-firewall surprise).
        let families = match scope {
            BindScope::All => decide_bind_families(&relay).await?,
            BindScope::Loopback | BindScope::Port(_) => BindFamilies::Dual,
        };
        match families {
            BindFamilies::Dual => {}
            BindFamilies::V4Only => {
                eprintln!("NET_FAMILY_GATE: binding IPv4-only (IPv6 unreachable or disabled)")
            }
            BindFamilies::V6Only => {
                eprintln!("NET_FAMILY_GATE: binding IPv6-only (IPv4 unreachable or disabled)")
            }
        }
        let builder = match scope {
            // Dual-stack keeps the iroh default binds; a single-family gate clears
            // the default IP transports and re-adds only the surviving family's
            // unspecified socket (same clear+bind_addr mechanism BindScope uses).
            BindScope::All => match families {
                BindFamilies::Dual => builder,
                BindFamilies::V4Only => builder
                    .clear_ip_transports()
                    .bind_addr(std::net::SocketAddr::from((
                        std::net::Ipv4Addr::UNSPECIFIED,
                        0,
                    )))
                    .map_err(|e| NetError::Bind(e.to_string()))?,
                BindFamilies::V6Only => builder
                    .clear_ip_transports()
                    .bind_addr(std::net::SocketAddr::from((
                        std::net::Ipv6Addr::UNSPECIFIED,
                        0,
                    )))
                    .map_err(|e| NetError::Bind(e.to_string()))?,
            },
            BindScope::Loopback => builder

// bind real-interface UDP sockets for its SSDP/NAT-PMP probes: useless

// on loopback, and the other source of per-test-binary Windows firewall

+        // Per-family bind gate (REQ-NET-FAMILY-GATE): production-scope binds drop a

+        // dead/force-disabled IP family so a half-broken family never silently

+        // burns discovery/connect time. Hermetic scopes (Loopback/Port) keep

+        // dual-stack untouched — they reach no WAN and must stay deterministic

+        // (no probe, no Windows-firewall surprise).

+            BindScope::All => decide_bind_families(&relay).await?,

+            BindScope::Loopback | BindScope::Port(_) => BindFamilies::Dual,

+                eprintln!("NET_FAMILY_GATE: binding IPv4-only (IPv6 unreachable or disabled)")

+                eprintln!("NET_FAMILY_GATE: binding IPv6-only (IPv4 unreachable or disabled)")

+            // Dual-stack keeps the iroh default binds; a single-family gate clears

+            // the default IP transports and re-adds only the surviving family's

+            // unspecified socket (same clear+bind_addr mechanism BindScope uses).

+            BindScope::All => match families {

+                BindFamilies::Dual => builder,

+                BindFamilies::V4Only => builder

+                    .clear_ip_transports()

+                    .bind_addr(std::net::SocketAddr::from((

+                        std::net::Ipv4Addr::UNSPECIFIED,

+                    .map_err(|e| NetError::Bind(e.to_string()))?,

+                BindFamilies::V6Only => builder

+                        std::net::Ipv6Addr::UNSPECIFIED,


Now W1 unit tests (pure selection + flag parsing, no live sockets).


// [unit->REQ-NET-1] the endpoint binds to the node's own ed25519 identity: the

    // [unit->REQ-NET-FAMILY-GATE] the family-selection core (no live sockets): a
    // dead family is dropped only when the other works; both-dead and both-healthy
    // both stay dual-stack (never strand the node on an inconclusive probe); and a
    // force-off override is absolute, dropping its family regardless of the probe.
    #[test]
    fn family_selection_drops_dead_keeps_working() {
        // Probe-driven (no overrides).
        assert_eq!(
            select_families(true, true, false, false).unwrap(),
            BindFamilies::Dual,
            "both healthy → dual-stack"
        );
        assert_eq!(
            select_families(true, false, false, false).unwrap(),
            BindFamilies::V4Only,
            "IPv6 dead → IPv4-only"
        );
        assert_eq!(
            select_families(false, true, false, false).unwrap(),
            BindFamilies::V6Only,
            "IPv4 dead → IPv6-only (NOT a fixed prefer-IPv4 — IPv6-only nets must work)"
        );
        assert_eq!(
            select_families(false, false, false, false).unwrap(),
            BindFamilies::Dual,
            "both dead/inconclusive → dual-stack (an unreliable probe must not strand the node)"
        );

        // Force-off overrides are absolute — the forced family's probe is ignored.
        assert_eq!(
            select_families(true, true, false, true).unwrap(),
            BindFamilies::V4Only,
            "SPT_DISABLE_IPV6 → IPv4-only even though IPv6 probed healthy"
        );
        assert_eq!(
            select_families(true, true, true, false).unwrap(),
            BindFamilies::V6Only,
            "SPT_DISABLE_IPV4 → IPv6-only"
        );
        assert_eq!(
            select_families(false, true, false, true).unwrap(),
            BindFamilies::V4Only,
            "forced IPv6-off wins; the surviving IPv4 binds even though it probed dead"
        );
        assert!(
            select_families(true, true, true, true).is_err(),
            "both families force-disabled → error (nothing left to bind)"
        );
    }

    // [unit->REQ-NET-FAMILY-GATE] the force-off flag parser, with no environment
    // touched (env reads race under the shared test process — gated to the pure
    // parser; the env wiring is a one-line `std::env::var` lookup).
    #[test]
    fn family_force_off_flag_parsing() {
        assert!(flag_is_on(Some("1")));
        assert!(flag_is_on(Some("true")));
        assert!(flag_is_on(Some("yes")));
        assert!(flag_is_on(Some(" 1 ")), "surrounding whitespace is trimmed");
        assert!(!flag_is_on(Some("")), "empty is off");
        assert!(!flag_is_on(Some("0")));
        assert!(!flag_is_on(Some("false")));
        assert!(!flag_is_on(Some("no")));
        assert!(!flag_is_on(None), "unset is off");
    }

    // [unit->REQ-NET-1] the endpoint binds to the node's own ed25519 identity: the


    // [unit->REQ-NET-FAMILY-GATE] the family-selection core (no live sockets): a
    // dead family is dropped only when the other works; both-dead and both-healthy
    // both stay dual-stack (never strand the node on an inconclusive probe); and a
    // force-off override is absolute, dropping its family regardless of the probe.
    #[test]
    fn family_selection_drops_dead_keeps_working() {
        // Probe-driven (no overrides).
        assert_eq!(
            select_families(true, true, false, false).unwrap(),
            BindFamilies::Dual,
            "both healthy → dual-stack"
        );
        assert_eq!(
            select_families(true, false, false, false).unwrap(),
            BindFamilies::V4Only,
            "IPv6 dead → IPv4-only"
        );
        assert_eq!(
            select_families(false, true, false, false).unwrap(),
            BindFamilies::V6Only,
            "IPv4 dead → IPv6-only (NOT a fixed prefer-IPv4 — IPv6-only nets must work)"
        );
        assert_eq!(
            select_families(false, false, false, false).unwrap(),
            BindFamilies::Dual,
            "both dead/inconclusive → dual-stack (an unreliable probe must not strand the node)"
        );

        // Force-off overrides are absolute — the forced family's probe is ignored.
        assert_eq!(
            select_families(true, true, false, true).unwrap(),
            BindFamilies::V4Only,
            "SPT_DISABLE_IPV6 → IPv4-only even though IPv6 probed healthy"
        );
        assert_eq!(
            select_families(true, true, true, false).unwrap(),
            BindFamilies::V6Only,
            "SPT_DISABLE_IPV4 → IPv6-only"
        );
        assert_eq!(
            select_families(false, true, false, true).unwrap(),
            BindFamilies::V4Only,
            "forced IPv6-off wins; the surviving IPv4 binds even though it probed dead"
        );
        assert!(
            select_families(true, true, true, true).is_err(),
            "both families force-disabled → error (nothing left to bind)"
        );
    }

    // [unit->REQ-NET-FAMILY-GATE] the force-off flag parser, with no environment
    // touched (env reads race under the shared test process — gated to the pure
    // parser; the env wiring is a one-line `std::env::var` lookup).
    #[test]
    fn family_force_off_flag_parsing() {
        assert!(flag_is_on(Some("1")));
        assert!(flag_is_on(Some("true")));
        assert!(flag_is_on(Some("yes")));
        assert!(flag_is_on(Some(" 1 ")), "surrounding whitespace is trimmed");
        assert!(!flag_is_on(Some("")), "empty is off");
        assert!(!flag_is_on(Some("0")));
        assert!(!flag_is_on(Some("false")));
        assert!(!flag_is_on(Some("no")));
        assert!(!flag_is_on(None), "unset is off");
    }

    // [unit->REQ-NET-1] the endpoint binds to the node's own ed25519 identity: the

+    // [unit->REQ-NET-FAMILY-GATE] the family-selection core (no live sockets): a

+    // dead family is dropped only when the other works; both-dead and both-healthy

+    // both stay dual-stack (never strand the node on an inconclusive probe); and a

+    // force-off override is absolute, dropping its family regardless of the probe.

+    fn family_selection_drops_dead_keeps_working() {

+        // Probe-driven (no overrides).

+            select_families(true, true, false, false).unwrap(),

+            "both healthy → dual-stack"

+            select_families(true, false, false, false).unwrap(),

+            select_families(false, true, false, false).unwrap(),

+            "IPv4 dead → IPv6-only (NOT a fixed prefer-IPv4 — IPv6-only nets must work)"

+            select_families(false, false, false, false).unwrap(),

+            "both dead/inconclusive → dual-stack (an unreliable probe must not strand the node)"

+        // Force-off overrides are absolute — the forced family's probe is ignored.

+            select_families(true, true, false, true).unwrap(),

+            "SPT_DISABLE_IPV6 → IPv4-only even though IPv6 probed healthy"

+            select_families(true, true, true, false).unwrap(),

+            "SPT_DISABLE_IPV4 → IPv6-only"

+            select_families(false, true, false, true).unwrap(),

+            "forced IPv6-off wins; the surviving IPv4 binds even though it probed dead"

+            select_families(true, true, true, true).is_err(),

+            "both families force-disabled → error (nothing left to bind)"

+    // [unit->REQ-NET-FAMILY-GATE] the force-off flag parser, with no environment

+    // touched (env reads race under the shared test process — gated to the pure

+    // parser; the env wiring is a one-line `std::env::var` lookup).

+    fn family_force_off_flag_parsing() {

+        assert!(flag_is_on(Some("1")));

+        assert!(flag_is_on(Some("true")));

+        assert!(flag_is_on(Some("yes")));

+        assert!(flag_is_on(Some(" 1 ")), "surrounding whitespace is trimmed");

+        assert!(!flag_is_on(Some("")), "empty is off");

+        assert!(!flag_is_on(Some("0")));

+        assert!(!flag_is_on(Some("false")));

+        assert!(!flag_is_on(Some("no")));

+        assert!(!flag_is_on(None), "unset is off");

// iroh EndpointId equals the node's public key (same seed → same keypair).


Activating REQ-NET-FAMILY-GATE (doc already satisfied; add impl+unit).


required_stages = ["doc"]  # doc = ADR-0030. impl+unit activate at the build milestone: per-family reachability probe at bind + bind-only-working-families + SPT_DISABLE_IPV6/IPV4 overrides; impl-verify the iroh single-family bind API + whether iroh hole-punch already fast-fails. unit = family-selection logic (both/v4-dead/v6-dead/forced) without live sockets.

required_stages = ["doc", "impl", "unit"]  # doc = ADR-0030. ACTIVATED v0.17.0 W1: impl = per-family reachability probe at NetEndpoint::bind (production scope) + bind-only-working-families (clear_ip_transports + single bind_addr) + SPT_DISABLE_IPV6/IPV4 overrides. iroh single-family bind API verified (clear_ip_transports + one bind_addr, the existing BindScope mechanism). unit = select_families (both/v4-dead/v6-dead/forced, no live sockets) + flag parser.


The file C:\Users\decid\Documents\projects\spt-core\traceable-reqs.toml has been updated successfully. (file state is current in your context — no need to Read it back)

title = "spt-core binds only IP families that are actually REACHABLE (ADR-0030), so a half-broken family (e.g. IPv6 whose AAAA resolves but whose path is dead) never silently consumes connection/discovery time. At NetEndpoint::bind (endpoint.rs), probe each family's reachability ONCE and bind only the working ones: dual-stack when both healthy; IPv4-only when IPv6 is dead; IPv6-only when IPv4 is dead (drop the DEAD family — NOT a fixed prefer-IPv4; IPv6-only networks must keep working). Re-evaluated on daemon restart (once-at-bind, no live re-eval in v1). Explicit overrides SPT_DISABLE_IPV6 / SPT_DISABLE_IPV4 force a family off (escape hatch + determinism + testing, mirroring SPT_NTP_SERVER); a forced-off family is never bound regardless of the probe. Reusable beyond join — every spt connection benefits. (next milestone)"

-required_stages = ["doc"]  # doc = ADR-0030. impl+unit activate at the build milestone: per-family reachability probe at bind + bind-only-working-families + SPT_DISABLE_IPV6/IPV4 overrides; impl-verify the iroh single-family bind API + whether iroh hole-punch already fast-fails. unit = family-selection logic (both/v4-dead/v6-dead/forced) without live sockets.

+required_stages = ["doc", "impl", "unit"]  # doc = ADR-0030. ACTIVATED v0.17.0 W1: impl = per-family reachability probe at NetEndpoint::bind (production scope) + bind-only-working-families (clear_ip_transports + single bind_addr) + SPT_DISABLE_IPV6/IPV4 overrides. iroh single-family bind API verified (clear_ip_transports + one bind_addr, the existing BindScope mechanism). unit = select_families (both/v4-dead/v6-dead/forced, no live sockets) + flag parser.

title = "`spt subnet join` never fails SILENTLY (ADR-0030; the field incident showed no output at all). (a) LIVE progress during the meet (replace the one-shot \"Searching…\" cli.rs:6268 with periodic elapsed/deadline) so silence ≠ hang; (b) DETAILED failure on meet-exhaustion — rendezvous candidates + families attempted (IPv4/IPv6) + relay-vs-direct + the last concrete error — surfaced BEFORE any code prompt (a dead subnet must not make the user fetch a code); connect_seed_holder (pairhost.rs:437) and dial_via_rendezvous (meet.rs:281) currently swallow per-attempt errors — thread the last error up with attempt context; (c) PROPAGATE the terminal event — brain.rs:1024 `_ => continue` must deliver a daemon NoSeedHolder/PairFail to the CLI as a printed error (this is WHY the user saw nothing); (d) `--verbose`/`SPT_LOG` discovery TRACE (per-probe derived id, discovery path mDNS/n0-DNS/relay, per-family timeouts), opt-in — no such knob exists today. (next milestone)"


W1 + W4 evidence + activations in place. Now gate. First check for workspace-scoped orphan `spt.exe` (never touch the AppData prod daemon).


PowerShell

Get-Process spt -ErrorAction SilentlyContinue | Select-Object Id, Path | Format-Table -AutoSize


    Id Path
    -- ----
 78884 C:\Users\decid\AppData\Local\spt-core\bin\spt.exe
149588 C:\Users\decid\AppData\Local\spt-core\bin\spt.exe


Both `spt.exe` are the AppData prod daemon — left untouched. No workspace orphan. Building + running the new tests.


cargo nextest run -p spt-net -p spt-daemon -p spt -E 'test(family_selection_drops_dead_keeps_working) | test(family_force_off_flag_parsing) | test(advertised_status_splits_unbound_warm_from_cold_suspended) | test(remote_picker_renders_suspended_dead_perch_offline)' 2>&1 | Select-Object -Last 40


Error: Exit code 101
   Compiling ring v0.17.14
   Compiling rustls v0.23.40
   Compiling rustls-webpki v0.103.13
   Compiling tokio-rustls v0.26.4
   Compiling noq-proto v0.17.0
   Compiling rustls-platform-verifier v0.7.0
   Compiling hickory-net v0.26.0-beta.4
   Compiling hyper-rustls v0.27.9
   Compiling tokio-websockets v0.13.2
   Compiling reqwest v0.13.4
   Compiling hickory-resolver v0.26.0-beta.4
   Compiling noq v0.18.0
   Compiling iroh-relay v0.98.0
   Compiling iroh v0.98.2
   Compiling spt-net v0.16.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-net)
   Compiling spt-daemon v0.16.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon)
   Compiling spt v0.16.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt)
rustc-LLVM ERROR: out of memory
Allocation failed
error: could not compile `spt-daemon` (lib test)

Caused by:
  process didn't exit successfully: `C:\Users\decid\.rustup\toolchains\stable-x86_64-pc-windows-msvc\bin\rustc.exe --crate-name spt_daemon --edition=2021 crates\spt-daemon\src\lib.rs --error-format=json --json=diagnostic-rendered-ansi,artifacts,future-incompat --emit=dep-info,link -C embed-bitcode=no -C debuginfo=2 --test --check-cfg cfg(docsrs,test) --check-cfg "cfg(feature, values())" -C metadata=a3b098159792ea2b -C extra-filename=-1060ee9c83ae2f01 --out-dir C:\Users\decid\Documents\projects\spt-core\target\debug\deps -C incremental=C:\Users\decid\Documents\projects\spt-core\target\debug\incremental -L dependency=C:\Users\decid\Documents\projects\spt-core\target\debug\deps --extern base64=C:\Users\decid\Documents\projects\spt-core\target\debug\deps\libbase64-b548805f762d34b4.rlib --extern ed25519_dalek=C:\Users\decid\Documents\projects\spt-core\target\debug\deps\libed25519_dalek-97fab2c71a4e1d4b.rlib --extern hmac=C:\Users\decid\Documents\projects\spt-core\target\debug\deps\libhmac-e996b185f044bfe1.rlib --extern interprocess=C:\Users\decid\Documents\projects\spt-core\target\debug\deps\libinterprocess-95598dbd831d4f3c.rlib --extern serde=C:\Users\decid\Documents\projects\spt-core\target\debug\deps\libserde-2cfe227265f9e30b.rlib --extern serde_json=C:\Users\decid\Documents\projects\spt-core\target\debug\deps\libserde_json-17281f0e02bd2a5a.rlib --extern sha2=C:\Users\decid\Documents\projects\spt-core\target\debug\deps\libsha2-8b10433dc072d985.rlib --extern spt_live=C:\Users\decid\Documents\projects\spt-core\target\debug\deps\libspt_live-5d5e126a8bcba4b0.rlib --extern spt_msg=C:\Users\decid\Documents\projects\spt-core\target\debug\deps\libspt_msg-a5109f1c152ef1cf.rlib --extern spt_net=C:\Users\decid\Documents\projects\spt-core\target\debug\deps\libspt_net-2c8c147a0b74f29d.rlib --extern spt_proto=C:\Users\decid\Documents\projects\spt-core\target\debug\deps\libspt_proto-bc6cf23a6a7dd9ae.rlib --extern spt_runtime=C:\Users\decid\Documents\projects\spt-core\target\debug\deps\libspt_runtime-149ae660ec2c18e9.rlib --extern spt_store=C:\Users\decid\Documents\projects\spt-core\target\debug\deps\libspt_store-820a5391ba4811a7.rlib --extern spt_term=C:\Users\decid\Documents\projects\spt-core\target\debug\deps\libspt_term-ccaa5cc960766b7f.rlib --extern tempfile=C:\Users\decid\Documents\projects\spt-core\target\debug\deps\libtempfile-e58c7f10086d5d2b.rlib --extern tokio=C:\Users\decid\Documents\projects\spt-core\target\debug\deps\libtokio-5cfdff6656aca6b6.rlib -L native=C:\Users\decid\Documents\projects\spt-core\target\debug\build\libsqlite3-sys-dfb857ecd39841b9\out -L native=C:\Users\decid\Documents\projects\spt-core\target\debug\build\blake3-60c87a81e6cc6ba6\out -L native=C:\Users\decid\Documents\projects\spt-core\target\debug\build\blake3-60c87a81e6cc6ba6\out -L native=C:\Users\decid\Documents\projects\spt-core\target\debug\build\aws-lc-sys-1a05d93e0719da20\out -L native=C:\Users\decid\Documents\projects\spt-core\target\debug\build\ring-9c61033a2441dbc4\out -L "native=C:\Program Files\Microsoft Visual Studio\2022\Community\VC\Tools\MSVC\14.44.35207\atlmfc\lib\x64" -L native=C:\Users\decid\.cargo\registry\src\index.crates.io-1949cf8c6b5b557f\windows_x86_64_msvc-0.52.6\lib -L "native=C:\Program Files\Microsoft Visual Studio\2022\Community\VC\Tools\MSVC\14.44.35207\atlmfc\lib\x64" -L "native=C:\Program Files\Microsoft Visual Studio\2022\Community\VC\Tools\MSVC\14.44.35207\atlmfc\lib\x64" -L "native=C:\Program Files\Microsoft Visual Studio\2022\Community\VC\Tools\MSVC\14.44.35207\atlmfc\lib\x64"` (exit code: 0xc0000409, STATUS_STACK_BUFFER_OVERRUN)
warning: build failed, waiting for other jobs to finish...
error: command `'C:\Users\decid\.rustup\toolchains\stable-x86_64-pc-windows-msvc\bin\cargo.exe' test --no-run --message-format json-render-diagnostics --package spt-net --package spt-daemon --package spt` exited with code 101


LLVM OOM during parallel test-binary codegen (RAM, not a code error). Reducing job parallelism and building per-crate. Starting with spt-net (W1).


$env:CARGO_BUILD_JOBS=2; cargo nextest run -p spt-net -E 'test(family_selection_drops_dead_keeps_working) | test(family_force_off_flag_parsing)' 2>&1 | Select-Object -Last 25


   Compiling hickory-resolver v0.26.0-beta.4
   Compiling hyper-rustls v0.27.9
   Compiling reqwest v0.13.4
   Compiling igd-next v0.17.1
   Compiling noq v0.18.0
   Compiling netwatch v0.16.0
   Compiling iroh-dns v0.98.0
   Compiling tokio-websockets v0.13.2
   Compiling acto v0.8.2
   Compiling swarm-discovery v0.6.0-alpha.2
   Compiling portmapper v0.16.0
   Compiling backon v1.6.0
   Compiling papaya v0.2.4
   Compiling spt-msg v0.16.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-msg)
   Compiling iroh v0.98.2
   Compiling tempfile v3.27.0
   Compiling spt-net v0.16.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-net)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 1m 04s
────────────
 Nextest run ID d8534241-3bd7-47df-93eb-2e1e34a30de0 with nextest profile: default
    Starting 2 tests across 1 binary (125 tests skipped)
        PASS [   0.011s] (1/2) spt-net net::endpoint::tests::family_selection_drops_dead_keeps_working
        PASS [   0.013s] (2/2) spt-net net::endpoint::tests::family_force_off_flag_parsing
────────────
     Summary [   0.016s] 2 tests run: 2 passed, 125 skipped


W1 unit tests pass + spt-net compiles. Now spt-daemon (W4) — per-crate to dodge the OOM.


$env:CARGO_BUILD_JOBS=2; cargo nextest run -p spt-daemon -E 'test(advertised_status_splits_unbound_warm_from_cold_suspended) | test(advertisement_follows_resting_transitions_epoch_bumped)' 2>&1 | Select-Object -Last 20


   Compiling spt-net v0.16.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-net)
   Compiling spt-daemon v0.16.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 1m 33s
────────────
 Nextest run ID 578ecb80-fa09-4cae-853f-9c0e3a690175 with nextest profile: default
    Starting 2 tests across 36 binaries (484 tests skipped)
        PASS [   0.052s] (1/2) spt-daemon registryhost::tests::advertised_status_splits_unbound_warm_from_cold_suspended
        PASS [   0.057s] (2/2) spt-daemon registryhost::tests::advertisement_follows_resting_transitions_epoch_bumped
────────────
     Summary [   0.058s] 2 tests run: 2 passed, 484 skipped


W4 unit + the existing resting test pass. Now the picker int test (spt crate).


$env:CARGO_BUILD_JOBS=2; cargo nextest run -p spt -E 'test(remote_picker_renders_suspended_dead_perch_offline) | test(gather_renders_live_unbound) | test(row_status_drives_unbound_display)' 2>&1 | Select-Object -Last 20


   Compiling spt v0.16.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 35.83s
────────────
 Nextest run ID 621ab48c-e7fc-4ccb-8041-84a5312d7620 with nextest profile: default
    Starting 3 tests across 43 binaries (308 tests skipped)
        PASS [   0.014s] (1/3) spt::bin/spt picker::data::tests::row_status_drives_unbound_display
        PASS [   0.023s] (2/3) spt::bin/spt picker::data::tests::remote_picker_renders_suspended_dead_perch_offline
        PASS [   0.092s] (3/3) spt::bin/spt picker::data::tests::gather_renders_live_unbound
────────────
     Summary [   0.093s] 3 tests run: 3 passed, 308 skipped


All new tests green. Per the seam-change rule, checking for existing tests that assert the OLD dead-perch→Dormant behavior, and running the full lib suites.


crates\spt-daemon\tests\daemon_e2e.rs-169-    \ ── 2. Psyche-loop: the daemon hosts the Psyche; liveness is the status ─────
crates\spt-daemon\tests\daemon_e2e.rs-170-    use spt_store::info::{read_info, write_info, PidValue};
crates\spt-daemon\tests\daemon_e2e.rs:171:    use spt_store::liveness::{is_perch_alive, STATUS_ONLINE};
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\daemon_e2e.rs-172-
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\daemon_e2e.rs-173-    let psyche = host
--
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\daemon_e2e.rs-189-    write_info(&psyche.perch_path, &rec).unwrap();
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\daemon_e2e.rs-190-    assert!(
crates\spt-daemon\tests\daemon_e2e.rs:191:        is_perch_alive(&psyche.perch_path),
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\daemon_e2e.rs-192-        "a dead summarizer pid must NOT flip a daemon-hosted perch offline"
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\daemon_e2e.rs-193-    );
--
crates\spt-daemon\tests\daemon_e2e.rs-295-    BrainLifecycle::mark_offline(&psyche.perch_path);
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\daemon_e2e.rs-296-    assert!(
crates\spt-daemon\tests\daemon_e2e.rs:297:        !is_perch_alive(&psyche.perch_path),
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\daemon_e2e.rs-298-        "mark_offline flips the perch offline"
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\daemon_e2e.rs-299-    );
--
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\attach.rs-277-
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\attach.rs-278-    let status = brain.net_status().expect("status");
crates\spt-daemon\tests\attach.rs:279:    // Dialing our OWN advertised address is refused at the transport — fail-fast,
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\attach.rs-280-    // never a deadlock; local attach must not be built on a self-dial.
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\attach.rs-281-    let err = brain
--
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\mesh.rs-144-
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\mesh.rs-145-impl MeshNode {
crates\spt-daemon\tests\mesh.rs:146:    /// Stand up a node under `root`: broker, a single advertised perch, a
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\mesh.rs-147-    /// registry, and pump paths whose roster lists exactly `members` (the push
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\mesh.rs-148-    /// target — seed the full mesh membership for the converged-roster state).
--
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\replicate.rs-133-    )
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\replicate.rs-134-    .expect("no collision")
crates\spt-daemon\tests\replicate.rs:135:    .expect("visible — advertised");
crates\spt-daemon\tests\replicate.rs-136-    assert_eq!(admitted, MergeOutcome::Inserted);
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\replicate.rs-137-
--
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\rosterprop.rs-160-        sa.find("home", &c_hex).and_then(|e| e.address.clone()),
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\rosterprop.rs-161-        Some(c_addr),
crates\spt-daemon\tests\rosterprop.rs:162:        "A learned C's advertised address (the Mesh-D6 reach precondition)"
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\rosterprop.rs-163-    );
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\rosterprop.rs-164-    assert!(sa.is_member("home", &b_hex), "A learned B (its self-entry)");
--
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\twohost.rs-394-}
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\twohost.rs-395-
crates\spt-daemon\tests\twohost.rs:396:/// Create a perch record for `id` on this host (the registry-advertised,
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\twohost.rs-397-/// WAN-message-addressable unit).
crates\spt-daemon\tests\twohost.rs-398-fn seed_perch(id: &str) -> PathBuf {
--
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\twohost.rs-934-    );
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\twohost.rs-935-
crates\spt-daemon\tests\twohost.rs:936:    // ── A's local seeds: perch (registry-advertised), mind (B pulls it),
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\twohost.rs-937-    // the marker notif (fires on B), the trusted release-keys file. ──
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\twohost.rs-938-    seed_common_stores(&rig);
--
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\tests\sync.rs-436-        Instance {
crates\spt-daemon\tests\sync.rs-437-            node: "node-a".into(),
crates\spt-daemon\tests\sync.rs:438:            status: Status::Dormant,
crates\spt-daemon\tests\sync.rs-439-            epoch: 1,
crates\spt-daemon\tests\sync.rs-440-            resources: None,


No integration test asserts the old dead→Dormant behavior (Dormant elsewhere is merge *input*, not advertise output). Running full lib unit suites for the touched crates, sequentially.


$env:CARGO_BUILD_JOBS=2; cargo nextest run -p spt-net --lib 2>&1 | Select-Object -Last 12


        PASS [   0.039s] (117/126) spt-net net::update::tests::record_split_across_chunks_reassembles
        PASS [   0.042s] (118/126) spt-net net::update::tests::records_round_trip_and_unknown_kind_is_skipped
        PASS [   0.038s] (119/126) spt-net net::wanmsg::tests::forged_origin_field_is_inert
        PASS [   0.043s] (120/126) spt-net net::wanmsg::tests::message_round_trips_and_tolerates_unknown_field
        PASS [   0.042s] (121/126) spt-net net::wanmsg::tests::route_splits_local_and_remote_and_passes_refusals
        PASS [   0.043s] (122/126) spt-net net::xfer::tests::forged_origin_field_is_inert
        PASS [   0.260s] (123/126) spt-net net::pairing::wire::tests::repair_member_keeps_seed
        PASS [   0.041s] (124/126) spt-net net::xfer::tests::record_split_across_chunks_reassembles
        PASS [   0.043s] (125/126) spt-net net::xfer::tests::records_round_trip_and_unknown_kind_is_skipped
        PASS [   0.256s] (126/126) spt-net net::pairing::wire::tests::wrong_code_fails_and_records_no_membership
────────────
     Summary [   0.539s] 126 tests run: 126 passed, 1 skipped


spt-net lib green (126). Now spt-daemon lib.


$env:CARGO_BUILD_JOBS=2; cargo nextest run -p spt-daemon --lib 2>&1 | Select-Object -Last 15


        PASS [   0.040s] (368/380) spt-daemon wan::tests::wan_user_msg_restamped_unless_origin_user_backed
        PASS [   0.082s] (369/380) spt-daemon update::tests::plan_verified_yields_a_plan_for_a_valid_release
        PASS [   0.044s] (370/380) spt-daemon xfer::tests::resolve_under_confines_to_root
        PASS [   0.067s] (371/380) spt-daemon xfer::tests::recv_state_chunks_idempotent_and_commit_replays_safely
        PASS [   0.132s] (372/380) spt-daemon update::tests::plan_verified_update_set_uses_selected_platform_spec
        PASS [   0.555s] (373/380) spt-daemon shellhost::tests::close_shell_ephemeral_tears_down_and_frees_the_slot
        PASS [   0.560s] (374/380) spt-daemon shellwake::tests::reconcile_flips_watchers_with_instance_state
        PASS [   0.596s] (375/380) spt-daemon shellhost::tests::close_shell_delivers_pre_close_then_kills_and_flips_offline
        PASS [   0.706s] (376/380) spt-daemon shellhost::tests::close_shell_dead_child_is_prompt_and_manifestless_never_erases
        PASS [   0.960s] (377/380) spt-daemon shellwake::tests::watcher_crash_exits_respawn_then_give_up
        PASS [   2.278s] (378/380) spt-daemon reap::tests::job_reaps_enrolled_child_and_grandchild
        PASS [   1.052s] (379/380) spt-daemon sync::tests::reconcile_driver_structurally_excludes_live_role
        PASS [   1.409s] (380/380) spt-daemon sync::tests::select_refs_scopes_two_tiers
────────────
     Summary [   7.502s] 380 tests run: 380 passed (4 leaky), 0 skipped


spt-daemon lib green (380). Now spt unit suite, then clippy + traceable.


$env:CARGO_BUILD_JOBS=2; cargo nextest run -p spt --lib --bin spt 2>&1 | Select-Object -Last 12


Error: Exit code 101
error: no library targets found in package `spt`
error: command `'C:\Users\decid\.rustup\toolchains\stable-x86_64-pc-windows-msvc\bin\cargo.exe' test --no-run --message-format json-render-diagnostics --package spt --lib --bin spt` exited with code 101


Right — `spt` has no lib target. Using `--bin spt` only.


$env:CARGO_BUILD_JOBS=2; cargo nextest run -p spt --bin spt 2>&1 | Select-Object -Last 12


        PASS [   0.235s] (252/261) spt::bin/spt wansend::tests::refusals_render_copy_paste_targets
        PASS [   0.248s] (253/261) spt::bin/spt wansend::tests::load_snapshots_degrades_on_junk
        PASS [   1.842s] (254/261) spt::bin/spt cli::tests::endpoint_role_overwrite_is_the_sole_writer
        PASS [   0.522s] (255/261) spt::bin/spt wansend::tests::wan_send_ships_to_the_resolved_node
        PASS [   0.727s] (256/261) spt::bin/spt wansend::tests::remote_rest_suspends_and_wakes_with_the_deferred_gate
        PASS [   2.548s] (257/261) spt::bin/spt cli::tests::purge_removes_every_record
        PASS [   3.079s] (258/261) spt::bin/spt cli::tests::fork_forks_homes_and_deletes_exactly_the_source
        PASS [   5.954s] (259/261) spt::bin/spt cli::tests::adapter_translate_proof_gates_on_commit
        PASS [  11.662s] (260/261) spt::bin/spt cli::tests::shell_spawn_gates_on_the_registered_set
        PASS [  17.014s] (261/261) spt::bin/spt cli::tests::owner_shutdown_gated_by_can_shutdown_grant
────────────
     Summary [  18.649s] 261 tests run: 261 passed, 0 skipped


All unit suites green (spt-net 126, spt-daemon 380, spt 261). Now clippy --workspace + traceable check.


$env:CARGO_BUILD_JOBS=2; cargo clippy --workspace --all-targets 2>&1 | Select-Object -Last 20


    Checking spt-proto v0.16.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-proto)
    Checking spt-store v0.16.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-store)
    Checking spt-msg v0.16.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-msg)
    Checking spt-term v0.16.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-term)
    Checking spt-runtime v0.16.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-runtime)
    Checking spt-net v0.16.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-net)
    Checking spt-live v0.16.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-live)
    Checking mock-adapter v0.16.0 (C:\Users\decid\Documents\projects\spt-core\adapters\mock)
    Checking spt-daemon v0.16.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon)
    Checking spt v0.16.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt)
    Checking xtask v0.16.0 (C:\Users\decid\Documents\projects\spt-core\crates\xtask)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 25.46s


Clippy clean. Now traceable-reqs check + xtask check (no CLI surface changed → reference.md should be stable).


traceable-reqs check 2>&1 | Select-Object -Last 30


  [OK] REQ-TERM-5  required: [doc, impl, unit, int]  stages: +doc +impl +unit +int
       Adapter-declared digest extractor seam: a `[digest]` manifest section declaring an imperative extractor (native harness log -> the {role,text,tool,ts} contract; defaults to the [history] source files with an own-source escape hatch), `api digest-entry` push fallback, register-time validation of the section, adapter-declared presentation defaults (window depth, arg-truncation, sprint-collapse) that any consumer may override, and a `spt adapter digest-proof` author tool plus runtime skip-diagnostics (no silent drop). Reverses M9's no-manifest-seam stance; no declarative DSL.
  [OK] REQ-TERM-6  required: [impl, unit, int]  stages: -doc +impl +unit +int
       Thread-spanning digest across session boundaries: a per-endpoint session ledger (`<perch>/sessions.log`) appended at first bind and by `api boundary` on `/clear`|`/compact` session rotation, the digest enumerating the last K sessions so its rolling window bridges a boundary, and a distinctive in-timeline boundary marker (DigestEntry::Boundary). The digest follows the live-agent thread, not a single session.
  [OK] REQ-TERM-7  required: [impl, unit, int]  stages: -doc +impl +unit +int
       Two-origin digest merge: spt-owned context-injection entries (psyche_download | echo_mirror | owl_message) appended by spt to the endpoint `digest.log`, timestamp-interleaved with the adapter's extracted activity records into one ordered timeline, via a distinct context-injection record category. Data model only this milestone; GUI collapse/expand and the echo-reads-digest delta loop are deferred to the surfaces that consume them.
  [OK] REQ-TRANSLATE-COMMAND  required: [doc, impl, unit]  stages: +doc +impl +unit -int
       `[message-idle-translation-binary]` accepts a `command` (opaque; args + ADAPTER-STATIC {adapter_dir}/{adapter_name} substitution ONLY — ratified v0.16.0 W1, NOT session {key}: the translation binary is a persistent process serving all sessions on the endpoint (session/event ctx arrives per-message via the stdin Init/Event protocol, never the spawn argv) and the live-update respawn site has no session ctx (a {id}-bearing command would MissingKey→spool); program token resolved against install_dir like [digest].extractor/[session.psyche_init]) in addition to the bare `path`. `path` is DEPRECATED — keeps parsing (manifest forward/back-compat) but emits a registration warning steering to command. Exactly one of {path, command} (both-set refused at registration; neither = no translation binary). The spawn lifecycle + stdin/stdout JSON-lines protocol (Init/Event/Input → key/text/delay_ms/commit) are UNCHANGED — command alters only how the executable+args are located/launched (read_translation_path → read_translation_command). Unblocks folding `claude-spt translate` into the one consolidated binary (downstream ADR-0006). (v0.16.0)
  [OK] REQ-UPD-1  required: [impl, unit, int]  stages: -doc +impl +unit +int
       Peer-propagated update over P2P
  [OK] REQ-UPD-2  required: [impl, unit]  stages: -doc +impl +unit -int
       All binaries signature-verified before handoff
  [OK] REQ-UPD-3  required: [impl, unit, int]  stages: -doc +impl +unit +int
       No endpoint process terminates/suspends during self-update
  [OK] REQ-UPD-4  required: [impl, unit]  stages: -doc +impl +unit -int
       Update gated on user confirmation by default; opt-in full-auto
  [OK] REQ-UPD-5  required: [impl, unit]  stages: -doc +impl +unit -int
       spt-core ripple-updates registered adapters
  [OK] REQ-UPD-6  required: [doc, impl, unit, int]  stages: +doc +impl +unit +int
       Platform-targeted update sets and debug rollout: signed multi-platform update metadata, recipient platform selection, channel-scoped monotonic counters, debug-channel opt-in via release-key overlay, local staging plus pull-based peer propagation, and maintainer-only convergence tooling (ADR-0016)
  [OK] REQ-UPD-7  required: [impl, unit]  stages: -doc +impl +unit -int
       Origin-source update bootstrap (`spt update fetch`): pull the latest signed release directly from the GitHub release origin (`SaberMage/spt-releases`) — the per-platform artifact + its `<asset>.release.json` SignedRelease metadata — and stage it through the EXISTING verify→stage pipeline (the same `plan_verified` gate: two-key signature + channel + monotonic rollback floor + SHA-256), after which the normal consent-notif / `spt update apply` flow is unchanged. Closes the peer-only-discovery gap (REQ-UPD-1): a first-in-fleet / isolated node can update with no peer to pull from. The signed-release anchor keeps the GitHub transport untrusted-but-verified.
  [OK] REQ-UPD-8  required: [impl, unit]  stages: -doc +impl +unit -int
       Platform-safe `spt update fetch` + apply platform-guard (v0.3.1 cross-OS brick fix): `spt update fetch` stages the signed multi-platform `SignedUpdateSet` (`update-set.json` + every platform artifact it names), never a platform-blind single `SignedRelease`, so local apply selects `current_platform()` and P2P re-serve lets each peer select ITS own platform. Defense-in-depth: `apply_staged` REFUSES a staged single-release artifact unless it is platform-stamped for THIS node (an unstamped pre-v0.3.2 single, or a single stamped for another OS, fail-safe refuses — the guard that alone prevents the v0.3.1 brick where a Linux ELF was applied as `spt.exe`). UX: a friendly post-apply message (`Updated spt-core to vX.Y.Z.` + changelog URL) driven by an additive `product_version` metadata field, with a release-counter fallback when absent.
  [OK] REQ-UPD-9  required: [doc, impl, unit]  stages: +doc +impl +unit -int
       `gh_release` adapter [update] avenue (optional signing): an adapter declares `[update] avenue = "gh_release", repo = "user/repo"` (+ optional `asset`, default `adapter.spt`; + optional Ed25519 `signing_key`); spt-core's ripple compares the repo's LATEST GitHub release version against the installed adapter version and, when newer, auto-updates by fetching the release `.spt` archive (the REQ-INSTALL-9 `--release` fetch primitive) → verifies the `.spt` against `signing_key` if declared, else HTTPS+GitHub first-acquisition trust → re-extracts + re-registers the adapter root. Lets a harness adapter ship updates from its own GitHub releases with NO signing tooling or plugin coupling (removes the perri file_pull/delegated avenue blockers). Acquisition-trust mirrors `--release` + the installer first-fetch; does not alter spt-core self-update (REQ-UPD-1..8).
  [OK] REQ-VIEWER-SKIP-TO-LIVE-ON-EVICT  required: [doc, impl, unit, int]  stages: +doc +impl +unit +int
       A `rc --view` VIEWER that overflows its broker subscription queue and is EVICTED (OutputLog::append try_send Full → viewers.remove, REQ-HAZARD-VIEWER-ISOLATION session-protection) must SKIP TO LIVE, not die silently. ROOT (v0.13.0, b4 JIT item 2 = p0_paste + post-b4 a_journaled-Linux, ONE root): serve_attach forwards each frame (read_event→b64decode→re-encode AttachRecord→net_stream_send) SLOWER than the drain fans out under flood → its VIEWER_CHANNEL_DEPTH(256) channel overflows → the drain evicts (viewers.remove drops the ViewerSink → drops tx → viewer_writer's rx.recv() Err → the writer returns WRITING NOTHING) → serve_attach's brain.read_event() just STOPS getting Output (no EOF, no error) → serve_attach blocks forever → the operator receives nothing (attach_received_output=FALSE). Eviction-of-a-hopelessly-behind-viewer is CORRECT session-protection (keep it); SILENT+PERMANENT eviction is the bug. VIEWER-only → B2-SAFE (a viewer never advances delivered_through / is not authoritative / exposes no resume cursor). FIX (doyle-gated, skip-to-live = tail -f reconnect): (1) explicit broker→viewer EVICTION SIGNAL (KIND_VIEWER_EVICTED, written in the viewer_writer thread OFF the log lock, DISTINCT from session-exit EOF so serve must NOT tear down on it); (2) serve_attach re-subscribes from the CURRENT ring floor (skip-to-live, replays nothing, sees the next live burst) — resetting the cold serve-brain's next_seq so the post-eviction forward-jump replay is accepted (the legacy reject-gap path, brain.rs:618-626, would otherwise FATAL the forward jump); (3) HARD constraint NO evict→resubscribe busy-loop: serve_attach rate-limits re-subscribes (RESUBSCRIBE_INTERVAL) so under max-flood the operator sees intermittent LIVE bursts, never a CPU spin. (v0.13.0)
  [OK] REQ-WHOAMI-1  required: [doc, impl, unit]  stages: +doc +impl +unit -int
       `spt whoami` is a thin ALIAS for `spt endpoint list` (full output: the SELF pin + the subnet roster) — the standalone bare-id command is dropped (the `id=$(spt whoami)` capture was never a real pattern: env vars don't persist between agent tool calls). The one new render: the `endpoint list` SELF pin carries the Self endpoint's authored `endpoint description` (info::read_info(...).resources) when present, inline after the liveness state. whoami stays a top-level hot-path verb (parse unchanged, REQ-MSG-9).


Let me confirm my three REQs and the overall verdict.


traceable-reqs check 2>&1 | Select-String -Pattern 'REQ-NET-FAMILY-GATE|REQ-PRESENCE-LIVENESS-TRUTH|FAIL|ERROR|missing|invalid|PASS|covered|summary|total' -Context 0,1


<persisted-output>
Output too large (144.7KB). Full output saved to: C:\Users\decid\.ccs\instances\bigscreen\projects\C--Users-decid-Documents-projects-spt-core\8c8eef1f-6837-4fc5-a65f-0633fc292f63\tool-results\b2g6rwbut.txt

Preview (first 2KB):
>        An adapter update is live and daemon-coordinated (the adapter analog of brain self-update, ADR-0004): for an endpoint
with a running RESIDENT adapter binary (today the `[message-idle-translation-binary]`), the CLI keeps fetch+verify and hands
the APPLY to the daemon over IPC, which per affected endpoint (1) STOPS the resident binary -> releases the OS file lock
(fixes the Windows 'Access denied (os error 5)' overwrite failure), (2) swaps on disk ONLY files whose CRC differs from the
staged archive (unchanged files + their still-running binaries untouched), (3) RE-CLONES the new on-disk manifest into the
running `BrainLifecycle` (the in-memory manifest is cached at bringup and otherwise goes stale -> binaries+manifest back on
the same page), (4) RESTARTS the resident binary from the new files. An endpoint NOT running -> CLI swaps directly (no lock,
...
</persisted-output>

>        An adapter update is live and daemon-coordinated (the adapter analog of brain self-update, ADR-0004): for an endpoint
with a running RESIDENT adapter binary (today the `[message-idle-translation-binary]`), the CLI keeps fetch+verify and hands
the APPLY to the daemon over IPC, which per affected endpoint (1) STOPS the resident binary -> releases the OS file lock
(fixes the Windows 'Access denied (os error 5)' overwrite failure), (2) swaps on disk ONLY files whose CRC differs from the
staged archive (unchanged files + their still-running binaries untouched), (3) RE-CLONES the new on-disk manifest into the
running `BrainLifecycle` (the in-memory manifest is cached at bringup and otherwise goes stale -> binaries+manifest back on
the same page), (4) RESTARTS the resident binary from the new files. An endpoint NOT running -> CLI swaps directly (no lock,
no cache). Only the resident class is cycled; ephemeral adapter binaries (Psyche loop, `[digest]` extractor, `[session.*]`
runners, hooks) self-heal on next spawn and are excluded. The daemon keeps a per-endpoint registry of resident adapter
children. (ADR-0025, v0.13.2)
    [OK] REQ-ADAPTER-MULTIPLATFORM-SPT  required: [doc, impl, unit, int]  stages: +doc +impl +unit +int
>        A `.spt` adapter archive may pack multiple platforms in one signed asset: shared `manifest.toml` + `strings/` at the
root, role binaries under per-Rust-target-triple subdirectories (ADR-0016 triple vocabulary, e.g. `x86_64-pc-windows-msvc/`);
install/update extracts the shared root plus ONLY `current_platform()`'s triple subdir, flattened into `install_dir` so flat
`<install_dir>/<program>` resolution (REQ-INSTALL-11) is unchanged. Name stays `adapter.spt` (plain-tar or gzip, `--asset`
optional default); one whole-archive Ed25519 signature over the fat archive (REQ-UPD-9 single-artifact verify). A legacy flat
archive (no triple subdirs) extracts as today (free back-compat); a multi-platform archive sets `min_spt_core_version >=
0.13.2` (forward-compat gate, readable before extract); a multi-platform archive missing the recipient's triple -> typed
`NoArtifactForPlatform`, never a silent no-op. Large adapters may still split per-platform (single-triple archives via
`--asset`, or ADR-0016 update-set machinery). (ADR-0024, v0.13.2)
    [OK] REQ-ADAPTER-PROOF-DIR-OVERRIDE  required: [doc, impl, unit, int]  stages: +doc +impl +unit +int
>        `spt adapter translate-proof <adapter> --event <envelope> [--session <id>]` — the author-time EMIT-half proof tool
for `[message-idle-translation-binary]` (ADR-0022), symmetric to `spt adapter digest-proof` (REQ-TERM-5). It spawns and feeds
the adapter's declared translation binary EXACTLY as the daemon does at idle-delivery — running the REAL
`spt_daemon::translation` driver VERBATIM (no protocol reimplementation): `TranslationChild::spawn` the binary, send the
`{type:"init",endpoint_id,node}` line then the `{type:"event",envelope}` line, and read back the emitted
`{key}`/`{text}`/`{delay_ms}`/`{commit}` keystroke-command stream — then prints it author-readable (each Key with its
`key_to_bytes` rendering, Text quoted, Delay in ms, Commit marker) with counts. It fills the SAME `{id}`→option and
`{session_id}`→(--session, else a placeholder) keys into the `--event` envelope the daemon fills at runtime, so an envelope
that proofs here feeds faithfully live. EMIT-half ONLY: it proves the binary's spawn+feed+emit contract; it does NOT exercise
the daemon's atomic PTY apply / controller-buffering (that stays covered by the W2 inject_control_wedge int gate) — `--help`
says so. Exit codes mirror digest-proof: 0 ok, 1 on spawn-fail / zero commands / no-commit-or-output / unparseable, 2 when the
adapter declares no `[message-idle-translation-binary]` section. The `TranslationChild` Drop does the bounded no-zombie reap.
(v0.13.x)
    [OK] REQ-ADAPTER-UPDATE-MESSAGE  required: [doc, impl, unit]  stages: +doc +impl +unit -int
>        Composite adapter update — an avenue-agnostic `[update.post]` sub-table `{ command, self_verifies }` run AFTER the
primary avenue (gh_release/file_pull/delegated) resolves, in the same `spt adapter update` (ADR-0029). Runs UNCONDITIONALLY
(even on an adapter version no-op — the post-step's own idempotent check decides). PUBLISHED stdin JSON seam: one line
`{adapter_applied, adapter_name, profile_name, version, previous_version, adapter_dir}` (additive keys; post-step ignores
unknown). stdout decides the notice: custom text SUPERSEDES [update].message; a reserved sentinel fires the static
[update].message; empty = no notice. exit code orthogonal (0 ok / nonzero failed). Precedence: dynamic-stdout >
sentinel/manifest-message > nothing. NO [update.post] declared ⇒ today's adapter_applied→[update].message unchanged; post-step
FAILS ⇒ loud warning + fall back to adapter_applied→message. FAILURE-ISOLATED: a committed gh_release pull is never rolled
back if the post-step fails (independent channels). (v0.16.0)
    [OK] REQ-ADAPTER-VERSION-CMD  required: [doc, impl, unit]  stages: +doc +impl +unit -int
>        `spt adapter version <name>` prints a registered adapter's declared version — the EXISTING mandatory
`[adapter].version` manifest field (manifest.rs already requires it; NOT a `[strings].version`, NOT `get-string`, no second
version source). A new `AdapterCmd::Version{option}` resolves the option's merged view via `registry::resolve_option` like the
sibling adapter subcommands and prints `manifest.adapter.version`; an unresolvable option errors (exit 1) the same way.
(v0.13.2 W6)
    [OK] REQ-API-1  required: [impl, unit, int]  stages: -doc +impl +unit +int
>        `spt --help` (and every subcommand --help) renders the inline Markdown authored in the clap doc-comments as terminal
styling, never as literal markers: `**bold**` → ANSI bold, `` `code` `` → ANSI cyan, `[text](url)` → `text`. The markers are
STRIPPED either way — a raw `**` or backtick must NEVER reach the user (the operator-reported v0.12.0 defect: help text reads
`**ctrl-b**` and stray backticks verbatim). Color/bold escapes are emitted ONLY when the help is going to a real terminal AND
color is not suppressed (NO_COLOR unset · CLICOLOR != 0 · CLICOLOR_FORCE forces on); a pipe / redirect / CI / NO_COLOR falls
back to strip-only (clean plaintext, zero escapes) so machine-readable help is byte-identical regardless of marker syntax.
Pure transform over the clap-rendered help string at the single run()/bare_invocation chokepoint; preserves pre-existing ANSI
(CSI sequences passed through untouched), never spans markers across a newline, leaves unmatched/empty markers literal, and
does not alter the help layout. (v0.12.1)
    [OK] REQ-CLI-JSON  required: [impl, unit, int]  stages: -doc +impl +unit +int
>        A global `--json` flag (clap global=true) honored by the READ/STATUS command set: endpoint list/whoami, daemon
status, subnet status/show-code, endpoint description/role, adapter list/version, notif list, grant list, access list, shell
list, how-to (endpoint digest already has it). Action commands do not honor it. A shared print_json() helper + a coverage TEST
asserting every command in the set emits valid JSON (guards against the missing-shared-formatter drift). Output uses explicit
per-command output DTOs with committed field names — internal structs are NOT serialized verbatim (their fields would become a
public contract; JSON is a consumed wire-parity surface). (v0.16.0)
    [OK] REQ-CLI-OUTPUT-MARKDOWN  required: [impl, unit]  stages: -doc +impl +unit -int
>        Peer address seeding, both cold starts: durable peer-addrs.json (identity dir) maps peer pubkey → last-known dialable
address; the pump's resolver consults it FIRST with id-only discovery fallback on miss or dial failure (a stale addr never
strands a peer); written by the pairing ceremony (both sides, from the live connection) and by the pump on successful connect;
post-join first sync and post-restart resync converge in seconds, not ~1 min (M8 decisions 14, 20)
    [OK] REQ-CONV-2  required: [impl, unit]  stages: -doc +impl +unit -int
>        Net-bind boot-race resilience: a daemon that comes up net-less (NetHost::start failed — e.g. the systemd unit
autostarted before the network/DNS stack was ready, `Failed to create an address lookup service`) must SELF-HEAL — retry the
net bring-up in the background with capped backoff and, on success, attach net to the broker + spawn the dispatcher/peer-pump
(which today are gated on `net_up` at boot and so never start, leaving the node silently unreachable until a manual restart —
kitsubito 2026-06-08). Status surfaces the net-less state honestly (a net-less broker renders as 'no connection', not only a
pump-STALLED line with a bogus pre-boot heartbeat age). The installer's autostart unit waits for the network
(`Wants=/After=network-online.target`) as belt-and-suspenders.
    [OK] REQ-DIGEST-CURSOR  required: [doc, impl, unit]  stages: +doc +impl +unit -int
>        Public CLI --help (the clap `///` doc-comments) and the generated `docs-site/src/cli/reference.md` MUST NOT contain
internal tracker/decision codes — `REQ-*`, `F-###`, `M#-W#`, `ADR-####`. They are meaningless to an end user reading --help
and ship to GH-Pages. A CI-gated scan (the `xtask check` docs gate) fails on any such token in the GENERATED reference.md
(which by construction contains only clap help, so rustdoc `///` on fns/structs is OUT of scope and keeps its REQ/ADR
cross-refs). Substance is kept; codes are rewritten to plain language. (v0.13.2 W6)
    [OK] REQ-ELEVATE-1  required: [doc, impl, unit]  stages: +doc +impl +unit -int
>        A legitimately dead PTY child (real crash/kill) + an undrained operator pump must NOT wedge the broker for all other
clients. ROOT (v0.12.0 real-harness defect): loopback attach output is a blocking write_all into a bounded 64KB tokio duplex
(nethost.rs:1040,1090); when the operator's rc pump stops draining (tab closed) the buffer fills and write_all blocks forever
(the 'loopback never hangs' assumption at nethost.rs:1103 is false), parking a worker in the 2-worker net runtime
(nethost.rs:640); a couple of these saturate BOTH workers → every new attach / `endpoint run` stalls right after
'PUMP_IPC_READER: spawned' → 30s FIRST_EVENT_GRACE → 'no output / dead or wedged'; `daemon stop` cannot join the stuck
workers. DISTINCT from the removed B1 path-(c) mutex deadlock. DISPOSITION = PROVE-DON'T-CHANGE (doyle GATE-PASS @e883f45,
2026-06-18): this ROOT is the SUPERSEDED v0.12.0 hypothesis — the post-L0 code ALREADY prevents the wedge, so NO fail-fast /
worker-count code was added. serve_attach forwards fire-and-forget (net_stream_send op_id=None) and the broker-side
send_stream is already BROKER-QUIC-DEADLINE-bounded (bounded_block_on, 10s); the loopback duplex is drained broker-INTERNALLY
by the operator row's own read pump (RecvHalf::Loopback, retentive_cap==0 → evict-not-park) so a dead rc (a dropped IPC
subscriber) never backs peer_w up; bounded_block_on parks the BROKER DISPATCH thread, not a net worker → no worker-pool
exhaustion (full mechanism in the required_stages comment). Folds the status=online sub-check: a dead spt-hosted endpoint is
marked OFFLINE within one reconcile tick on abrupt child death (broker exit-waiter reaps the session → B2 sees it absent) —
PROVEN, no change. (v0.12.1)
    [OK] REQ-HAZARD-BIND-CWD-UNSET  required: [impl, unit, int]  stages: -doc +impl +unit +int
>        A bound endpoint's `info.cwd` is SET at bind so a freshly-created perch appears under its own project tab. ROOT
(found, v0.13.0): `info.cwd` is NEVER set on bind — `cmd_bind` (spt-hosted) and `bind_from_seed` (harness-hosted) never thread
cwd into `establish_perch`/`rec.cwd`. FIX: `cmd_bind` reads its own `current_dir` (the broker spawned it in `project_cwd`);
`bind_from_seed` passes `seed.cwd` (already captured at seed time, currently DISCARDED). DISTINCT from
REQ-PICKER-HISTORY-FRESH (v0.12.1) — that unioned cwd-origin into picker MEMBERSHIP but tested merge_origin_project with a
PROVIDED origin; it never asserted `info.cwd` is actually set on bind, so a real `endpoint run` perch still had an empty cwd
and the union had nothing to union. This is the v0.12.1 P1 'appears under its own project right away' claim that was REFUTED
in the changelog — delivered for real here. (v0.13.0)
    [OK] REQ-HAZARD-BRAIN-RESPAWN-PATH  required: [doc, impl, unit, int]  stages: +doc +impl +unit +int
>        A bare brain restart leaves EXACTLY ONE `{id}-psyche` process per endpoint — no duplicate. On an abrupt brain death
stop_host never runs (the LiveSet + owned child handles die with the brain) and Breap's job/group only reaps at DAEMON stop,
so the PRIOR brain's Psyche stays ALIVE; the respawned brain's reconcile re-hosts a SECOND Psyche and overwrites the
`{id}-psyche` perch pid, leaving the old one untracked + alive = a duplicate that lingers until daemon-stop (the operator's
'brain kill+restart wedged everything'). FIX: at brain start, BEFORE the first reconcile re-hosts, reap any pre-existing
`{id}-psyche` orphan — ID-SPECIFICALLY (recycle-safe on the shared box, where sibling agents share the `claude` basename):
scoped-kill the recorded pid ONLY IF it is alive AND its exe basename == the adapter's psyche program (normalize_basename) AND
its COMMAND LINE contains the full psyche id `<id>-psyche` (baked via {id}); a sibling never carries THIS id, and any
unreadable signal FAILS SAFE (decline to reap — a missed dup is bounded by Breap, a wrong-kill is catastrophic). CAVEAT: the
cmdline carries `<id>-psyche` only when the adapter's psyche_init.command uses {id} (the norm); a non-{id} adapter safely
MISSES the reap (today's behavior, Breap bounds it) — never a wrong-kill. (v0.12.0)
    [OK] REQ-HAZARD-BROKER-PROCESS-ISOLATION  required: [doc, impl, unit, int]  stages: +doc +impl +unit +int
>        The broker bounds every brain-waiting QUIC op (dial / open_stream / send_stream) so a black-holed or dead peer fails
PROMPTLY with an ORDINARY error the broker REPLIES, never an unbounded await. The bound (< the brain's 30s
PUMP_PEER_IO_TIMEOUT so the BROKER fires first) surfaces to the pump as a normal broker error reply → peer_outcome's
non-TimedOut arm → drop conn + redial next tick, the round CONTINUES and the heartbeat keeps advancing — it must NEVER
manifest as the brain's own read-deadline (the A-half poison → supervised-restart path REQ-HAZARD-PUMP-IPC-DEADLINE guards).
Exactly-once is preserved: a timed-out journaled op fails INSIDE its apply_once closure so no phantom conn_id/stream_id is
recorded and a fresh tick re-dials cleanly. The happy path is unchanged (a live peer completes with zero added latency; the
bound only bites a non-responsive peer). This is the ROOT-cause cure for the 2.2h hfenduleam pump wedge — a dead roster peer
whose QUIC path the broker awaited unbounded — recurring on hfenduleam 2026-06-16.
    [OK] REQ-HAZARD-BROKER-SEED-WIRE-SKEW  required: [doc, impl, unit]  stages: +doc +impl +unit -int
>        A daemon-state wire-format change (e.g. the v0.9.0 adapter-agnostic Seed) does NOT take effect until a DELIBERATE
full broker restart: the broker serves the seed-control channel and is RESIDENT across a brain-only self-update (ADR-0004
no-terminate-during-update forbids auto-killing it), so a NEW-version CLI talking to a still-resident OLD broker fails the
seed handshake — the old broker cannot deserialize the new Seed (its formerly-required `adapter` field is gone) and drops the
conn without an ack, which surfaces to the CLI as a raw UnexpectedEof 'failed to fill whole buffer'. spt-core must (a) surface
an ACTIONABLE diagnostic on that seed-ack EOF (name the stale-broker cause + the `spt daemon stop` fix — the broker restarts
on the next api call), never the cryptic io error; and (b) document the operational rule (a deliberate broker restart is
required on any daemon-state wire change — NOT automatic) + the FORWARD discipline (daemon-state/Seed schema changes stay
additive + serde-default so a resident OLD broker tolerates a NEW CLI across a brain-only update; note this would NOT have
rescued 0.9.0 itself, since the old broker's `adapter` was a required field). perri PREP-4 FINDING 1 (v0.9.0 CLI vs stale
0.8.x broker).
    [OK] REQ-HAZARD-CASCADE-WIPE-GUARD  required: [impl, unit]  stages: -doc +impl +unit -int
>        A surfaced concurrent context pair is durably preserved (both versions, tracked artifacts) until a strictly
dominating write clears it; no reconcile failure path discards an unmerged version (6.6, ADR-0013)
    [OK] REQ-HAZARD-CONPTY-DSR  required: [impl, unit]  stages: -doc +impl +unit +int
>        Two `controller_writer` threads must never race ONE brain↔broker connection's socket. ROOT (doyle, instrumented
RACEDIAG repro on kitsubito): on a brain-restart re-serve the handoff brain registers as controller on the SAME session TWICE
over the SAME `Brain::conn` socket — (1) `Brain::handoff` eagerly `subscribe(prior.session_id, prior.next_seq=1)` →
`become_controller(from_seq=1)`, initial=[1], spawns writer-A (writes seq 1); (2) `serve_attach` re-handles the replayed
`Request{from_seq:0}` → `attach_as(sid,0)` → `become_controller(from_seq=0)`,


$env:NO_COLOR=1; $out = traceable-reqs check 2>&1 | Out-String; ($out -split "`n") | Where-Object { $_ -match 'REQ-NET-FAMILY-GATE|REQ-PRESENCE-LIVENESS-TRUTH' -or $_ -match '^\s*(Summary|Total|All|FAIL|ERROR|[0-9]+ requirement)' -or $_ -match 'OK\]|FAIL\]' -and $_ -match 'FAMILY|PRESENCE' }; "`n--- exit/verdict ---"; ($out -split "`n") | Select-Object -Last 6


       `spt subnet join` is two-phase (meet-before-code) so the entered code is FRESH at the ceremony regardless of discovery time (ADR-0030). The meet selector is the PUBLIC `(subnet-name, TOTP-epoch)` (rendezvous_token = SHA256(domain ‖ name ‖ totp_step)); the secret TOTP-code is the SPAKE2 password ONLY, never a discovery input — so the code is collected AFTER a member is found. Extend the brain.pair_join event stream (brain.rs:1009): CLI PairMeetReq{subnet} → daemon meets (name, current-epoch) resolving the seed-holder's REAL stable pairing address → MetMember event → CLI prompts the code (cli.rs cmd_subnet_join :6236) → PairCodeSubmit{code} → daemon dials the held real-address on SPT_PAIR_ALPN + SPAKE2 → PairJoined/PairFail. Daemon holds the real-address between phases, bounded by a 5-MINUTE wait-for-code timeout; a wrong-code retry re-runs the CEREMONY ONLY against the held address (no re-search). The non-interactive `--code` path stays one-shot (no prompt; relies on REQ-NET-FAMILY-GATE fast discovery, fails loudly per REQ-JOIN-DIAGNOSTICS on staleness). Security unchanged — the meet is pre-trust/unauthenticated (SPT_PAIR_MEET_ALPN); auth stays in SPAKE2. (next milestone)

  [OK] REQ-NET-FAMILY-GATE  required: [doc, impl, unit]  stages: +doc +impl +unit -int

  [OK] REQ-PRESENCE-LIVENESS-TRUTH  required: [impl, unit, int]  stages: -doc +impl +unit +int

--- exit/verdict ---
       `gh_release` adapter [update] avenue (optional signing): an adapter declares `[update] avenue = "gh_release", repo = "user/repo"` (+ optional `asset`, default `adapter.spt`; + optional Ed25519 `signing_key`); spt-core's ripple compares the repo's LATEST GitHub release version against the installed adapter version and, when newer, auto-updates by fetching the release `.spt` archive (the REQ-INSTALL-9 `--release` fetch primitive) → verifies the `.spt` against `signing_key` if declared, else HTTPS+GitHub first-acquisition trust → re-extracts + re-registers the adapter root. Lets a harness adapter ship updates from its own GitHub releases with NO signing tooling or plugin coupling (removes the perri file_pull/delegated avenue blockers). Acquisition-trust mirrors `--release` + the installer first-fetch; does not alter spt-core self-update (REQ-UPD-1..8).

  [OK] REQ-VIEWER-SKIP-TO-LIVE-ON-EVICT  required: [doc, impl, unit, int]  stages: +doc +impl +unit +int

       A `rc --view` VIEWER that overflows its broker subscription queue and is EVICTED (OutputLog::append try_send Full → viewers.remove, REQ-HAZARD-VIEWER-ISOLATION session-protection) must SKIP TO LIVE, not die silently. ROOT (v0.13.0, b4 JIT item 2 = p0_paste + post-b4 a_journaled-Linux, ONE root): serve_attach forwards each frame (read_event→b64decode→re-encode AttachRecord→net_stream_send) SLOWER than the drain fans out under flood → its VIEWER_CHANNEL_DEPTH(256) channel overflows → the drain evicts (viewers.remove drops the ViewerSink → drops tx → viewer_writer's rx.recv() Err → the writer returns WRITING NOTHING) → serve_attach's brain.read_event() just STOPS getting Output (no EOF, no error) → serve_attach blocks forever → the operator receives nothing (attach_received_output=FALSE). Eviction-of-a-hopelessly-behind-viewer is CORRECT session-protection (keep it); SILENT+PERMANENT eviction is the bug. VIEWER-only → B2-SAFE (a viewer never advances delivered_through / is not authoritative / exposes no resume cursor). FIX (doyle-gated, skip-to-live = tail -f reconnect): (1) explicit broker→viewer EVICTION SIGNAL (KIND_VIEWER_EVICTED, written in the viewer_writer thread OFF the log lock, DISTINCT from session-exit EOF so serve must NOT tear down on it); (2) serve_attach re-subscribes from the CURRENT ring floor (skip-to-live, replays nothing, sees the next live burst) — resetting the cold serve-brain's next_seq so the post-eviction forward-jump replay is accepted (the legacy reject-gap path, brain.rs:618-626, would otherwise FATAL the forward jump); (3) HARD constraint NO evict→resubscribe busy-loop: serve_attach rate-limits re-subscribes (RESUBSCRIBE_INTERVAL) so under max-flood the operator sees intermittent LIVE bursts, never a CPU spin. (v0.13.0)

  [OK] REQ-WHOAMI-1  required: [doc, impl, unit]  stages: +doc +impl +unit -int

       `spt whoami` is a thin ALIAS for `spt endpoint list` (full output: the SELF pin + the subnet roster) — the standalone bare-id command is dropped (the `id=$(spt whoami)` capture was never a real pattern: env vars don't persist between agent tool calls). The one new render: the `endpoint list` SELF pin carries the Self endpoint's authored `endpoint description` (info::read_info(...).resources) when present, inline after the liveness state. whoami stays a top-level hot-path verb (parse unchanged, REQ-MSG-9).


Both REQs OK. Confirming the overall exit verdict.


$env:NO_COLOR=1; traceable-reqs check *> $null; "exit=$LASTEXITCODE"; $env:NO_COLOR=1; (traceable-reqs check 2>&1 | Out-String) -match '(?m)^\s*\d+\s+(requirements?|reqs?).*' | Out-Null; if($Matches){$Matches[0]}


exit=0
311 requirements: 311 complete, 0 incomplete, 0 findings


traceable clean (311/311, exit 0). Now xtask docs-drift check (no CLI surface changed, expect clean).


$env:CARGO_BUILD_JOBS=2; cargo run -p xtask -- check 2>&1 | Select-Object -Last 20; "exit=$LASTEXITCODE"


   Compiling spt-net v0.16.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-net)
   Compiling spt-daemon v0.16.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon)
   Compiling xtask v0.16.0 (C:\Users\decid\Documents\projects\spt-core\crates\xtask)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 22.00s
     Running `target\debug\xtask.exe check`
xtask check: OK
exit=0


All gates green: lib suites (126/380/261), new tests pass, clippy clean, traceable 311/311 (exit 0), xtask check OK. Reviewing exactly what to stage (shared tree — selective add).


Bash

git status --short && echo "--- toml hunks ---" && git diff --stat


 M crates/spt-daemon/src/registryhost.rs
 M crates/spt-net/src/net/endpoint.rs
 M crates/spt/src/picker/data.rs
 M traceable-reqs.toml
--- toml hunks ---
 crates/spt-daemon/src/registryhost.rs | 120 ++++++++++++++++--
 crates/spt-net/src/net/endpoint.rs    | 227 +++++++++++++++++++++++++++++++++-
 crates/spt/src/picker/data.rs         |  67 ++++++++++
 traceable-reqs.toml                   |   4 +-
 4 files changed, 404 insertions(+), 14 deletions(-)


Only my 4 files — no concurrent doyle edits. Committing as two wave-scoped commits (splitting the toml hunks). W4 first.


git add crates/spt-daemon/src/registryhost.rs crates/spt/src/picker/data.rs && printf 'n\ny\n' | git add -p traceable-reqs.toml && echo "=== staged toml hunk (should be PRESENCE only) ===" && git diff --cached traceable-reqs.toml


diff --git a/traceable-reqs.toml b/traceable-reqs.toml
index c5c02c0..8f5aad5 100644
--- a/traceable-reqs.toml
+++ b/traceable-reqs.toml
@@ -1459,7 +1459,7 @@ required_stages = ["doc"]  # doc = ADR-0030 + docs/design/robust-wan-subnet-join
 [[requirements]]
 id = "REQ-NET-FAMILY-GATE"
 title = "spt-core binds only IP families that are actually REACHABLE (ADR-0030), so a half-broken family (e.g. IPv6 whose AAAA resolves but whose path is dead) never silently consumes connection/discovery time. At NetEndpoint::bind (endpoint.rs), probe each family's reachability ONCE and bind only the working ones: dual-stack when both healthy; IPv4-only when IPv6 is dead; IPv6-only when IPv4 is dead (drop the DEAD family — NOT a fixed prefer-IPv4; IPv6-only networks must keep working). Re-evaluated on daemon restart (once-at-bind, no live re-eval in v1). Explicit overrides SPT_DISABLE_IPV6 / SPT_DISABLE_IPV4 force a family off (escape hatch + determinism + testing, mirroring SPT_NTP_SERVER); a forced-off family is never bound regardless of the probe. Reusable beyond join — every spt connection benefits. (next milestone)"
-required_stages = ["doc"]  # doc = ADR-0030. impl+unit activate at the build milestone: per-family reachability probe at bind + bind-only-working-families + SPT_DISABLE_IPV6/IPV4 overrides; impl-verify the iroh single-family bind API + whether iroh hole-punch already fast-fails. unit = family-selection logic (both/v4-dead/v6-dead/forced) without live sockets.
+required_stages = ["doc", "impl", "unit"]  # doc = ADR-0030. ACTIVATED v0.17.0 W1: impl = per-family reachability probe at NetEndpoint::bind (production scope) + bind-only-working-families (clear_ip_transports + single bind_addr) + SPT_DISABLE_IPV6/IPV4 overrides. iroh single-family bind API verified (clear_ip_transports + one bind_addr, the existing BindScope mechanism). unit = select_families (both/v4-dead/v6-dead/forced, no live sockets) + flag parser.
 [[requirements]]
 id = "REQ-JOIN-DIAGNOSTICS"
 title = "`spt subnet join` never fails SILENTLY (ADR-0030; the field incident showed no output at all). (a) LIVE progress during the meet (replace the one-shot \"Searching…\" cli.rs:6268 with periodic elapsed/deadline) so silence ≠ hang; (b) DETAILED failure on meet-exhaustion — rendezvous candidates + families attempted (IPv4/IPv6) + relay-vs-direct + the last concrete error — surfaced BEFORE any code prompt (a dead subnet must not make the user fetch a code); connect_seed_holder (pairhost.rs:437) and dial_via_rendezvous (meet.rs:281) currently swallow per-attempt errors — thread the last error up with attempt context; (c) PROPAGATE the terminal event — brain.rs:1024 `_ => continue` must deliver a daemon NoSeedHolder/PairFail to the CLI as a printed error (this is WHY the user saw nothing); (d) `--verbose`/`SPT_LOG` discovery TRACE (per-probe derived id, discovery path mDNS/n0-DNS/relay, per-family timeouts), opt-in — no such knob exists today. (next milestone)"
(1/2) Stage this hunk [y,n,q,a,d,j,J,g,/,e,?]? @@ -1471,7 +1471,7 @@ required_stages = ["doc"]  # doc = ADR-0030. impl+unit activate at the build mil
 [[requirements]]
 id = "REQ-PRESENCE-LIVENESS-TRUTH"
 title = "A node's gossiped per-endpoint registry Status reflects real liveness, so a remote viewer never paints a DEAD endpoint as ONLINE. ROOT (confirmed + gated vs CONTEXT.md): registryhost.rs:397-405 advertises a NOT-alive perch as Status::Dormant (the `else` of is_perch_alive), re-stamped every gossip round (never ages to Offline). Design intent GATED vs resting.rs + CONTEXT.md: active/dormant is the MULTI-INSTANCE routing differentiator (active = the bare-id routing target; dormant = a WARM non-target sibling — 'driving ling@laptop makes ling@desktop dormant', resting.rs:97; transitions active→dormant on AttentionShift/Detach). suspended = COLD (session closed, resumable-on-wake) while its NODE is UP. offline = NODE DOWN — NEVER self-gossiped (RestState has no Offline; a live node only ever gossips active/dormant/suspended), remote-inferred via epoch-lease eviction. So labeling a NOT-running perch Dormant is the DEFECT (dormant requires warm/running). PRIMARY FIX (registryhost `else`, not-bound-alive): live-but-UNBOUND (has a live broker session; is_perch_alive is bound-gated) → Active/Dormant (still warm); else (cold, no live session, but its node is up because this very daemon is gossiping) → SUSPENDED — NOT Dormant, NOT Offline (the node is UP; Offline is never self-gossiped). This alone removes the false-ONLINE. dormant keeps gossiping (routing/MRA needs it) but RENDERS as its online flavor (no distinct glyph; the dormant→suspended auto-suspend timer disambiguates recency). The DISPLAY of these states (incl Suspended=gray-filled) is REQ-SUBNET-DISPLAY-PARITY. Design: docs/design/subnet-presence-display.md §A. (next milestone)"
-required_stages = []  # SEED (rule 5). next-milestone build: registryhost not-bound-alive → {Active/Dormant if unbound-live (warm), else Suspended (cold, node up)} — never the blanket Dormant, never self-gossiped Offline. unit = status-derivation over (alive / unbound-live-warm / cold-no-session). int = a dead perch reads NOT-online on a REMOTE node's picker (home-vs-remote agree). [display palette → REQ-SUBNET-DISPLAY-PARITY]
+required_stages = ["impl", "unit", "int"]  # ACTIVATED v0.17.0 W4. impl = registryhost advertised_status (not-bound-alive → {Dormant if unbound-live (warm), else Suspended (cold, node up)} — never the blanket Dormant, never self-gossiped Offline). unit = status-derivation over (alive / unbound-live-warm / cold-no-session). int = a dead perch reads NOT-online on a REMOTE node's picker (home-vs-remote agree). [display palette → REQ-SUBNET-DISPLAY-PARITY]
 [[requirements]]
 id = "REQ-SUBNET-DISPLAY-PARITY"
 title = "The `spt endpoint run` picker renders endpoint state IDENTICALLY for local and remote (subnet) rows — bound/unbound, controlled (+driver node), and harness-only are all visible across the subnet, not local-only. Today data.rs:293-294 reduces a remote row to plain green-filled/gray-hollow because those facts aren't propagated. (1) GOSSIP additive per-Instance fields: bound/unbound, controlled + driver-node, harness_only (Offline is never gossiped — node-down is remote-inferred). (2) Derive the full EpDisplay for subnet rows from the gossiped fields, same path as local (remove the remote-reduction). (3) PALETTE rework (fill = ACTIONABLE: filled=can act now (rc-control if online, WAKE if suspended-on-live-node) / hollow=cannot (no control seat / node gone)): green-filled=online+bound+free; blue-filled=online+controlled (desc shows `controlled by <node>`); RED-filled=online+UNBOUND (controlled-or-not; controlled-ness shown via available options not glyph) — replaces green-hollow Unbound + absorbs the dropped UnboundControlled; AMBER-HOLLOW=online+harness-only (no broker seat → can't rc) — was amber-FILLED; GRAY-FILLED=Suspended (cold, node up — wakeable) NEW; gray-hollow=Offline (node down) now REMOTE-ONLY. EpDisplay: drop UnboundControlled, Unbound→red-filled, HarnessOnly→amber-hollow, add Suspended(gray-filled). Picker maps Active|Dormant→online flavor, Suspended→gray-filled, Offline→gray-hollow. (next milestone)"
(2/2) Stage this hunk [y,n,q,a,d,K,g,/,e,?]?
=== staged toml hunk (should be PRESENCE only) ===
diff --git a/traceable-reqs.toml b/traceable-reqs.toml
index c5c02c0..39a3052 100644
--- a/traceable-reqs.toml
+++ b/traceable-reqs.toml
@@ -1471,7 +1471,7 @@ required_stages = ["doc"]  # doc = ADR-0030. impl+unit activate at the build mil
 [[requirements]]
 id = "REQ-PRESENCE-LIVENESS-TRUTH"
 title = "A node's gossiped per-endpoint registry Status reflects real liveness, so a remote viewer never paints a DEAD endpoint as ONLINE. ROOT (confirmed + gated vs CONTEXT.md): registryhost.rs:397-405 advertises a NOT-alive perch as Status::Dormant (the `else` of is_perch_alive), re-stamped every gossip round (never ages to Offline). Design intent GATED vs resting.rs + CONTEXT.md: active/dormant is the MULTI-INSTANCE routing differentiator (active = the bare-id routing target; dormant = a WARM non-target sibling — 'driving ling@laptop makes ling@desktop dormant', resting.rs:97; transitions active→dormant on AttentionShift/Detach). suspended = COLD (session closed, resumable-on-wake) while its NODE is UP. offline = NODE DOWN — NEVER self-gossiped (RestState has no Offline; a live node only ever gossips active/dormant/suspended), remote-inferred via epoch-lease eviction. So labeling a NOT-running perch Dormant is the DEFECT (dormant requires warm/running). PRIMARY FIX (registryhost `else`, not-bound-alive): live-but-UNBOUND (has a live broker session; is_perch_alive is bound-gated) → Active/Dormant (still warm); else (cold, no live session, but its node is up because this very daemon is gossiping) → SUSPENDED — NOT Dormant, NOT Offline (the node is UP; Offline is never self-gossiped). This alone removes the false-ONLINE. dormant keeps gossiping (routing/MRA needs it) but RENDERS as its online flavor (no distinct glyph; the dormant→suspended auto-suspend timer disambiguates recency). The DISPLAY of these states (incl Suspended=gray-filled) is REQ-SUBNET-DISPLAY-PARITY. Design: docs/design/subnet-presence-display.md §A. (next milestone)"
-required_stages = []  # SEED (rule 5). next-milestone build: registryhost not-bound-alive → {Active/Dormant if unbound-live (warm), else Suspended (cold, node up)} — never the blanket Dormant, never self-gossiped Offline. unit = status-derivation over (alive / unbound-live-warm / cold-no-session). int = a dead perch reads NOT-online on a REMOTE node's picker (home-vs-remote agree). [display palette → REQ-SUBNET-DISPLAY-PARITY]
+required_stages = ["impl", "unit", "int"]  # ACTIVATED v0.17.0 W4. impl = registryhost advertised_status (not-bound-alive → {Dormant if unbound-live (warm), else Suspended (cold, node up)} — never the blanket Dormant, never self-gossiped Offline). unit = status-derivation over (alive / unbound-live-warm / cold-no-session). int = a dead perch reads NOT-online on a REMOTE node's picker (home-vs-remote agree). [display palette → REQ-SUBNET-DISPLAY-PARITY]
 [[requirements]]
 id = "REQ-SUBNET-DISPLAY-PARITY"
 title = "The `spt endpoint run` picker renders endpoint state IDENTICALLY for local and remote (subnet) rows — bound/unbound, controlled (+driver node), and harness-only are all visible across the subnet, not local-only. Today data.rs:293-294 reduces a remote row to plain green-filled/gray-hollow because those facts aren't propagated. (1) GOSSIP additive per-Instance fields: bound/unbound, controlled + driver-node, harness_only (Offline is never gossiped — node-down is remote-inferred). (2) Derive the full EpDisplay for subnet rows from the gossiped fields, same path as local (remove the remote-reduction). (3) PALETTE rework (fill = ACTIONABLE: filled=can act now (rc-control if online, WAKE if suspended-on-live-node) / hollow=cannot (no control seat / node gone)): green-filled=online+bound+free; blue-filled=online+controlled (desc shows `controlled by <node>`); RED-filled=online+UNBOUND (controlled-or-not; controlled-ness shown via available options not glyph) — replaces green-hollow Unbound + absorbs the dropped UnboundControlled; AMBER-HOLLOW=online+harness-only (no broker seat → can't rc) — was amber-FILLED; GRAY-FILLED=Suspended (cold, node up — wakeable) NEW; gray-hollow=Offline (node down) now REMOTE-ONLY. EpDisplay: drop UnboundControlled, Unbound→red-filled, HarnessOnly→amber-hollow, add Suspended(gray-filled). Picker maps Active|Dormant→online flavor, Suspended→gray-filled, Offline→gray-hollow. (next milestone)"


Staged toml hunk is PRESENCE-only — correct. Committing W4.


git commit -q -F - <<'EOF'
fix(registry): advertise cold perch as Suspended not Dormant (v0.17.0 W4)

REQ-PRESENCE-LIVENESS-TRUTH. A node self-gossiped any not-bound-alive perch as
Status::Dormant; a remote picker maps Dormant->Online, so a DEAD endpoint
painted green ONLINE remotely while its home node read it Offline.

Split the registryhost self-gossip `else` (extracted as advertised_status):
live-but-UNBOUND (live broker session; is_perch_alive is bound-gated) stays warm
-> Dormant (a valid routing fallback); cold (no live session) -> Suspended
(cold-but-node-up, resumable-on-wake) — never the blanket Dormant, never
self-gossiped Offline (a live node never gossips node-down). The picker already
maps Suspended -> offline, so this alone removes the false-ONLINE.

- unit (spt-daemon): advertised_status over alive / unbound-warm / cold-dead.
- int (spt picker): a Suspended remote perch reads NOT online through the real
  load_snapshots -> resource_projection -> gather pipeline, while a warm Dormant
  sibling stays online (home-vs-remote agree).

Activated REQ-PRESENCE-LIVENESS-TRUTH impl+unit+int.

Co-authored by: todlando
EOF
git log --oneline -1


867c7ab fix(registry): advertise cold perch as Suspended not Dormant (v0.17.0 W4)


Now W1 — stage endpoint.rs + the remaining FAMILY toml hunk.


git add crates/spt-net/src/net/endpoint.rs traceable-reqs.toml && echo "=== staged files ===" && git diff --cached --stat && echo "=== unstaged (should be empty) ===" && git status --short


=== staged files ===
 crates/spt-net/src/net/endpoint.rs | 227 ++++++++++++++++++++++++++++++++++++-
 traceable-reqs.toml                |   2 +-
 2 files changed, 227 insertions(+), 2 deletions(-)
=== unstaged (should be empty) ===
M  crates/spt-net/src/net/endpoint.rs
M  traceable-reqs.toml


Clean — both files fully staged, nothing unstaged. Committing W1.


git commit -q -F - <<'EOF'
feat(net): per-family bind gate — drop a dead IP family at bind (v0.17.0 W1)

REQ-NET-FAMILY-GATE. iroh binds dual-stack; a half-broken family (IPv6 whose
AAAA resolves but whose path is dead) silently burns the 30s rendezvous window,
so a WAN subnet join fails with no error. Confirmed field incident (SCELTOUIN):
fixed live by disabling IPv6.

At NetEndpoint::bind (production BindScope::All only), probe each family's
reachability once and bind only the working ones: dual-stack when both healthy,
IPv4-only when IPv6 is dead, IPv6-only when IPv4 is dead (drop the DEAD family —
never a fixed prefer-IPv4; IPv6-only nets must keep working). Re-evaluated on
daemon restart.

- Probe: a bounded TCP connect to a per-family anycast :443 (std::net on a
  blocking thread — tokio's `net` feature is off here), run concurrently; short
  timeout so a healthy bind is not slowed. Mirrors the field discriminator
  (Test-NetConnection :443) — catches a dead PATH, not just a missing route.
- select_families is pure: both-dead/both-healthy fall back to dual-stack so an
  inconclusive probe (locked-down egress, offline host) never strands the node.
- SPT_DISABLE_IPV6 / SPT_DISABLE_IPV4 force a family off regardless of probe
  (escape hatch + determinism + testing), mirroring SPT_NTP_SERVER; both forced
  off is an error.
- Single-family bind uses the existing clear_ip_transports + one bind_addr
  mechanism (verified against the BindScope path); hermetic Loopback/Port scopes
  keep dual-stack untouched (no WAN probe, no firewall surprise).
- unit: select_families over both/v4-dead/v6-dead/forced + the flag parser, no
  live sockets.

Activated REQ-NET-FAMILY-GATE impl+unit (doc was ADR-0030).

Co-authored by: todlando
EOF
git log --oneline -3


7cddaba feat(net): per-family bind gate — drop a dead IP family at bind (v0.17.0 W1)
867c7ab fix(registry): advertise cold perch as Suspended not Dormant (v0.17.0 W4)
1b4fced docs(v0.17.0): unified subnet endpoint-state display palette (grill outcome)