1471	fn run_listen(id: &str, once: bool, subnet: Option<&str>) -> i32 {
1472	    let (agent, backlog) = match ReadyAgent::start_homed(id, subnet) {
1473	        Ok(pair) => pair,
1474	        Err(e) => {
1475	            eprintln!("READY_FAIL:{}: {}", id, e);
1476	            return 1;
1477	        }
1478	    };
1479	    ok_line(format!("READY:{}", id));
1480	    for msg in backlog {
1481	        emit(&msg);
1482	    }
1483
1484	    if once {
1485	        if let Some(msg) = agent.recv() {
1486	            emit(&msg);
1487	        }
1488	        for msg in agent.drain_spool() {
1489	            emit(&msg);
1490	        }
1491	        return 0;
1492	    }
1493
1494	    loop {
1495	        if let Some(msg) = agent.recv() {
1496	            emit(&msg);
1497	        }
1498	        for msg in agent.drain_spool() {
1499	            emit(&msg);
1500	        }
1501	    }
1502	}
1503
1504	/// Emit one delivery to the listener stream: render the raw frame into its
1505	/// `<EVENT …>` envelope line(s) (typed envelopes pass through; oversized
1506	/// lines chunk into EVENT-PART) and print + flush. One delivery per emit.
1507	// [impl->REQ-MSG-4]
1508	fn emit(msg: &str) {
1509	    for line in spt_msg::emit::render_event_lines(msg) {
1510	        println!("{}", line);
1511	    }
1512	    let _ = std::io::stdout().flush();
1513	}
1514
1515	fn cmd_list() -> i32 {
1516	    let perches = roster::enumerate();
1517	    if perches.is_empty() {
1518	        eprintln!("(no perches)");
1519	        return 0;
1520	    }
1521	    for p in perches {
1522	        println!(
1523	            "{:<28} {:<14} {:<22} ready={} alive={}",
1524	            p.id,
1525	            p.state,
1526	            p.address.as_deref().unwrap_or("-"),
1527	            p.ready,
1528	            p.alive
1529	        );
1530	    }
1531	    0
1532	}
1533
1534	fn cmd_stop(id: &str) -> i32 {
1535	    let owlery = perch::owlery_dir();
1536	    // Soft-cleanup (HAZARD-SOFT-CLEANUP 6.2): remove the ready marker +
1537	    // unregister the address; spool.db and info.json are preserved.
1538	    let removed = std::fs::remove_file(perch::resolve_ready_file(id, ParentHint::Infer)).is_ok();
1539	    let _ = registry::unregister_address(id, &owlery);
1540	    if removed {
1541	        eprintln!("STOPPED:{}", id);
1542	    } else {
1543	        eprintln!("STOPPED:{} (no ready marker; address unregistered)", id);
1544	    }
1545	    0
1546	}
1547
1548	fn cmd_whoami() -> i32 {
1549	    match roster::detect_self_id() {
1550	        Some(id) => {
1551	            println!("{}", id);
1552	            0
1553	        }
1554	        None => {
1555	            eprintln!("UNKNOWN: no perch matches this session ($OWL_SESSION_ID / $SPT_AGENT_ID)");
1556	            1
1557	        }
1558	    }
1559	}
1560
1561	/// What `show-code` resolved its arguments to against the node's subnet store — the
1562	/// platform-independent half of the command, decided before any OS elevation call so
1563	/// it can be unit-tested hermetically.
1564	enum Resolved {
1565	    /// A subnet to surface: its name + raw seed bytes. `minted` = freshly created by
1566	    /// `subnet create` (persist it only once the code is actually shown).
1567	    Subnet {
1568	        name: String,
1569	        seed: [u8; spt_store::subnet::TOTP_SEED_LEN],
1570	        minted: bool,
1571	    },
1572	    /// Several subnets and no `--subnet` — refuse rather than guess.
1573	    Ambiguous(Vec<String>),
1574	    /// The node belongs to no subnet at all.
1575	    Empty,
1576	    /// A named subnet is not in the store.
1577	    NotFound(String),
1578	    /// `subnet create <name>` for a subnet that already exists.
1579	    Exists(String),
1580	    /// The stored seed is unreadable (corrupt hex/length) — re-pair to re-provision.
1581	    Corrupt(String),
1582	}
1583
1584	/// The rendered outcome of `show-code`: an exit code, the optional stdout block (the
1585	/// code + URI — present ONLY on the elevated show path, never leaked otherwise), a
1586	/// stderr status line, and whether to persist a freshly-minted subnet. Pure over its
1587	/// inputs (no IO, no OS calls) so every branch is unit-tested.
1588	struct ShowResult {
1589	    code: i32,
1590	    out: Option<String>,
1591	    msg: String,
1592	    save: bool,
1593	}
1594
1595	/// Resolve `show-code`/`create` arguments against the store (mutating it iff a create
1596	/// mints). The mint is in-memory only; the caller persists it just on the show path.
1597	// [impl->REQ-PAIR-3]
1598	fn resolve(
1599	    store: &mut SubnetStore,
1600	    subnet: Option<String>,

fn run_listen(id: &str, once: bool, subnet: Option<&str>) -> i32 {
    let (agent, backlog) = match ReadyAgent::start_homed(id, subnet) {
        Ok(pair) => pair,
        Err(e) => {
            eprintln!("READY_FAIL:{}: {}", id, e);
            return 1;
        }
    };
    ok_line(format!("READY:{}", id));
    for msg in backlog {
        emit(&msg);
    }

    if once {
        if let Some(msg) = agent.recv() {
            emit(&msg);
        }
        for msg in agent.drain_spool() {
            emit(&msg);
        }
        return 0;
    }

    loop {
        if let Some(msg) = agent.recv() {
            emit(&msg);
        }
        for msg in agent.drain_spool() {
            emit(&msg);
        }
    }
}

/// Emit one delivery to the listener stream: render the raw frame into its
/// `<EVENT …>` envelope line(s) (typed envelopes pass through; oversized
/// lines chunk into EVENT-PART) and print + flush. One delivery per emit.
// [impl->REQ-MSG-4]
fn emit(msg: &str) {
    for line in spt_msg::emit::render_event_lines(msg) {
        println!("{}", line);
    }
    let _ = std::io::stdout().flush();
}

fn cmd_list() -> i32 {
    let perches = roster::enumerate();
    if perches.is_empty() {
        eprintln!("(no perches)");
        return 0;
    }
    for p in perches {
        println!(
            "{:<28} {:<14} {:<22} ready={} alive={}",
            p.id,
            p.state,
            p.address.as_deref().unwrap_or("-"),
            p.ready,
            p.alive
        );
    }
    0
}

fn cmd_stop(id: &str) -> i32 {
    let owlery = perch::owlery_dir();
    // Soft-cleanup (HAZARD-SOFT-CLEANUP 6.2): remove the ready marker +
    // unregister the address; spool.db and info.json are preserved.
    let removed = std::fs::remove_file(perch::resolve_ready_file(id, ParentHint::Infer)).is_ok();
    let _ = registry::unregister_address(id, &owlery);
    if removed {
        eprintln!("STOPPED:{}", id);
    } else {
        eprintln!("STOPPED:{} (no ready marker; address unregistered)", id);
    }
    0
}

fn cmd_whoami() -> i32 {
    match roster::detect_self_id() {
        Some(id) => {
            println!("{}", id);
            0
        }
        None => {
            eprintln!("UNKNOWN: no perch matches this session ($OWL_SESSION_ID / $SPT_AGENT_ID)");
            1
        }
    }
}

/// What `show-code` resolved its arguments to against the node's subnet store — the
/// platform-independent half of the command, decided before any OS elevation call so
/// it can be unit-tested hermetically.
enum Resolved {
    /// A subnet to surface: its name + raw seed bytes. `minted` = freshly created by
    /// `subnet create` (persist it only once the code is actually shown).
    Subnet {
        name: String,
        seed: [u8; spt_store::subnet::TOTP_SEED_LEN],
        minted: bool,
    },
    /// Several subnets and no `--subnet` — refuse rather than guess.
    Ambiguous(Vec<String>),
    /// The node belongs to no subnet at all.
    Empty,
    /// A named subnet is not in the store.
    NotFound(String),
    /// `subnet create <name>` for a subnet that already exists.
    Exists(String),
    /// The stored seed is unreadable (corrupt hex/length) — re-pair to re-provision.
    Corrupt(String),
}

/// The rendered outcome of `show-code`: an exit code, the optional stdout block (the
/// code + URI — present ONLY on the elevated show path, never leaked otherwise), a
/// stderr status line, and whether to persist a freshly-minted subnet. Pure over its
/// inputs (no IO, no OS calls) so every branch is unit-tested.
struct ShowResult {
    code: i32,
    out: Option<String>,
    msg: String,
    save: bool,
}

/// Resolve `show-code`/`create` arguments against the store (mutating it iff a create
/// mints). The mint is in-memory only; the caller persists it just on the show path.
// [impl->REQ-PAIR-3]
fn resolve(
    store: &mut SubnetStore,
    subnet: Option<String>,


1983	fn subnet_status_rows(
1984	    store: &SubnetStore,
1985	    regs: &std::collections::BTreeMap<String, spt_net::net::registry::SubnetRegistry>,
1986	    trust: &spt_store::trust::TrustStore,
1987	    own_hex: Option<&str>,
1988	    only: Option<&str>,
1989	) -> Vec<SubnetStatusRow> {
1990	    store
1991	        .subnets
1992	        .iter()
1993	        .filter(|s| only.is_none_or(|n| n == s.name))
1994	        .map(|s| {
1995	            let (mut nodes, mut endpoints) = (std::collections::BTreeSet::new(), 0usize);
1996	            if let Some(own) = own_hex {
1997	                nodes.insert(own.to_string());
1998	            }
1999	            for peer in trust.peers_in(&s.name) {
2000	                nodes.insert(peer.pubkey_hex.clone());
2001	            }
2002	            if let Some(reg) = regs.get(&s.name) {
2003	                for id in reg.endpoint_ids() {
2004	                    endpoints += 1;
2005	                    for inst in reg.instances(id) {
2006	                        nodes.insert(inst.node.clone());
2007	                    }
2008	                }
2009	            }
2010	            // This node is a member even when its identity could not load.
2011	            SubnetStatusRow {
2012	                name: s.name.clone(),
2013	                nodes: nodes.len().max(1),
2014	                endpoints,
2015	            }
2016	        })
2017	        .collect()
2018	}
2019
2020	/// Render the status view (M7 plan decision 5): aligned rows, the hint footer
2021	/// always, explanatory standalone-node text on zero subnets. D2 adds
2022	/// `--nodes` per-node rows + hybrid liveness; D3 adds the join hint.
2023	// [impl->REQ-SUBNET-1]
2024	fn render_subnet_status(rows: &[SubnetStatusRow]) -> String {
2025	    let mut out = String::new();
2026	    if rows.is_empty() {
2027	        out.push_str(
2028	            "This node belongs to no subnet — a standalone node. Local messaging \
2029	             works without one; a subnet links your machines into one fabric.\n",
2030	        );
2031	    } else {
2032	        let pad = rows
2033	            .iter()
2034	            .map(|r| r.name.len())
2035	            .max()
2036	            .unwrap_or(0)
2037	            .max("SUBNET".len());
2038	        out.push_str(&format!("{:<pad$}  NODES  ENDPOINTS\n", "SUBNET"));
2039	        for r in rows {
2040	            out.push_str(&format!(
2041	                "{:<pad$}  {:<5}  {}\n",
2042	                r.name, r.nodes, r.endpoints
2043	            ));
2044	        }
2045	    }
2046	    out.push_str(HINT_FOOTER);
2047	    out
2048	}
2049
2050	/// The status views' standing hint footer (M7 plan decision 5: always
2051	/// printed — the next verb is never a doc lookup away).
2052	const HINT_FOOTER: &str = "\nhint: `spt subnet create <name>` mints a new subnet on this machine\n\
2053	     hint: `spt subnet join <name>` pairs this machine into one (elevated)\n\
2054	     hint: `spt subnet show-code [name]` shows a subnet's joining code (elevated)\n";
2055
2056	/// One node's `status --nodes` row (M7 plan decision 6): identity, advertised
2057	/// label, endpoint counts, and the gossip-recency liveness verdict.
2058	struct NodeRow {
2059	    node: String,
2060	    label: Option<String>,
2061	    /// Active (listening-now) endpoint rows on this node.
2062	    active: usize,
2063	    /// All endpoint rows on this node.
2064	    total: usize,
2065	    liveness: NodeLiveness,
2066	}
2067
2068	/// The gossip-recency half of the hybrid liveness decision (M7 plan decision
2069	/// 6): the cheap signals settle most rows; only [`Probe`](NodeLiveness::Probe)
2070	/// rows cost a dial.
2071	#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2072	enum NodeLiveness {
2073	    Online,
2074	    Offline,
2075	    /// Gossip can't say (not heard recently, rows not all-Offline) — dial it.
2076	    Probe,
2077	}
2078
2079	/// A node's render form: `LABEL (keypfx…)` when labeled, `keypfx…` bare
2080	/// (REQ-SUBNET-3 — e.g. `HFENDULEAM (bcead52b…)`).
2081	fn node_display(node: &str, label: Option<&str>) -> String {
2082	    let pfx = spt_net::net::registry::key_prefix(node);
2083	    match label {
2084	        Some(l) => format!("{l} ({pfx}…)"),
2085	        None => format!("{pfx}…"),
2086	    }
2087	}
2088
2089	/// Build one subnet's per-node rows with the gossip-recency verdict. Pure
2090	/// over its inputs (the probe is the caller's impure follow-up).
2091	///
2092	/// `members` seeds the row set — `(node_hex, label)` for self + every
2093	/// trust-pinned peer — so a member with zero registry rows still renders
2094	/// (`[0/0]`): a fresh box has no endpoints yet, and an offline peer's rows
2095	/// decay under the 4.10 eviction; neither may vanish from the view.
2096	/// Liveness: own node → online (we are running); rows all-Offline →
2097	/// offline; heard within `stale_after_ms` → online; otherwise (including
2098	/// a rowless member — absence is not evidence) → probe.
2099	// [impl->REQ-SUBNET-1]
2100	fn node_status_rows(
2101	    reg: &spt_net::net::registry::SubnetRegistry,
2102	    members: &[(String, Option<String>)],
2103	    heard: &std::collections::BTreeMap<String, u64>,
2104	    own_hex: &str,
2105	    now_ms: u64,
2106	    stale_after_ms: u64,
2107	) -> Vec<NodeRow> {
2108	    use spt_net::net::registry::Status;
2109	    // node → (label, active, total, any_routable)
2110	    let mut by_node: std::collections::BTreeMap<String, (Option<String>, usize, usize, bool)> =
2111	        std::collections::BTreeMap::new();
2112	    for (node, label) in members {
2113	        let e = by_node.entry(node.clone()).or_default();
2114	        if e.0.is_none() {
2115	            e.0 = label.clone();
2116	        }
2117	    }
2118	    for id in reg.endpoint_ids() {
2119	        for inst in reg.instances(id) {
2120	            let e = by_node.entry(inst.node.clone()).or_default();
2121	            if e.0.is_none() {
2122	                e.0 = inst.node_label.clone();
2123	            }
2124	            e.1 += usize::from(inst.status == Status::Active);
2125	            e.2 += 1;
2126	            e.3 |= inst.status.routable();
2127	        }
2128	    }
2129	    by_node
2130	        .into_iter()
2131	        .map(|(node, (label, active, total, any_routable))| {
2132	            let liveness = if node == own_hex {
2133	                NodeLiveness::Online
2134	            } else if total > 0 && !any_routable {
2135	                NodeLiveness::Offline
2136	            } else if heard
2137	                .get(&node)
2138	                .is_some_and(|&at| now_ms.saturating_sub(at) <= stale_after_ms)
2139	            {
2140	                NodeLiveness::Online
2141	            } else {
2142	                NodeLiveness::Probe
2143	            };
2144	            NodeRow {
2145	                node,
2146	                label,
2147	                active,
2148	                total,
2149	                liveness,
2150	            }
2151	        })
2152	        .collect()
2153	}
2154
2155	/// Render one subnet's `--nodes` table from probe-settled rows.
2156	// [impl->REQ-SUBNET-1] [impl->REQ-SUBNET-3]
2157	fn render_node_rows(subnet: &str, rows: &[(NodeRow, bool)]) -> String {
2158	    let mut out = format!("SUBNET {subnet}\n");
2159	    if rows.is_empty() {
2160	        out.push_str("  (no gossip yet — pair another machine, or wait one pump cadence)\n");
2161	        return out;
2162	    }
2163	    let names: Vec<String> = rows
2164	        .iter()
2165	        .map(|(r, _)| node_display(&r.node, r.label.as_deref()))
2166	        .collect();
2167	    let pad = names
2168	        .iter()
2169	        .map(|n| n.chars().count())
2170	        .max()
2171	        .unwrap_or(0)
2172	        .max("NODE".len());
2173	    out.push_str(&format!("  {:<pad$}  STATUS   ENDPOINTS\n", "NODE"));
2174	    for ((row, online), name) in rows.iter().zip(&names) {
2175	        // Pad by char count, not byte len ('…' is multi-byte).
2176	        let fill = " ".repeat(pad - name.chars().count());
2177	        out.push_str(&format!(
2178	            "  {name}{fill}  {:<7}  [{}/{}]\n",
2179	            if *online { "online" } else { "offline" },
2180	            row.active,
2181	            row.total,
2182	        ));
2183	    }
2184	    out
2185	}
2186
2187	/// Probe a stale node: dial it over the broker's QUIC endpoint (the hybrid
2188	/// liveness's expensive half — spent only on rows gossip could not settle).
2189	/// Any failure (daemon down, no address, dial refused/timed out) reads
2190	/// offline — fail-quiet, this is a status view.
2191	fn probe_node(node_hex: &str) -> bool {
2192	    crate::wansend::probe_node(node_hex)
2193	}
2194
2195	/// Render the coming-online banner's "spt subnets joined" section + the
2196	/// concluding divider: header, underline, one `NAME: <state>` row per held
2197	/// subnet (`(none)` when empty), divider. Pure for tests.
2198	// [impl->REQ-DAEMON-3]
2199	fn render_subnets_joined(rows: &[(String, &'static str)]) -> String {
2200	    let mut out = String::from("spt subnets joined\n──────────────────\n");
2201	    if rows.is_empty() {
2202	        out.push_str("  (none)\n");
2203	    } else {
2204	        for (name, state) in rows {
2205	            out.push_str(&format!("  {name}: {state}\n"));
2206	        }
2207	    }
2208	    out.push_str("────────────────────────────────────────\n");
2209	    out
2210	}
2211
2212	/// Ensure the per-machine daemon is up for a daemon-dependent command —
2213	/// silent when it already runs; otherwise announce the transition on
2214	/// stderr (stdout stays payload-pure) and conclude with a divider before
2215	/// the command's own output (grill 2026-06-06). The membership-creating
2216	/// verbs call this because **membership implies reachability** (CONTEXT
2217	/// §subnet attachment): a sole seed-holder with no responder daemon is a
2218	/// contradiction of the subnet's purpose.
2219	// [impl->REQ-SUBNET-2] [impl->REQ-DAEMON-3]
2220	fn ensure_daemon_announced() -> Result<(), i32> {
2221	    if spt_daemon::is_running() {
2222	        return Ok(());
2223	    }
2224	    eprintln!("spt-core daemon is offline - coming online...");
2225	    if let Err(e) = spt_daemon::ensure_running() {
2226	        eprintln!("DAEMON_START_FAIL: {e}");
2227	        return Err(1);
2228	    }
2229	    let ident = match (
2230	        spt_store::hostlabel::os_hostname(),
2231	        spt_store::nodeid::load_or_create()
2232	            .ok()
2233	            .map(|i| i.public_key().to_hex()),
2234	    ) {
2235	        (Some(l), Some(hex)) => {
2236	            format!("{l} ({}…)", spt_net::net::registry::key_prefix(&hex))
2237	        }
2238	        (None, Some(hex)) => format!("{}…", spt_net::net::registry::key_prefix(&hex)),
2239	        _ => "this node".to_string(),
2240	    };
2241	    eprintln!("spt-core daemon: online - {ident}");
2242	    // Attachment states: every held subnet is attached while the daemon is
2243	    // up (CONTEXT §subnet attachment — detach verbs are deferred); a
2244	    // net-less daemon (endpoint bind failed) serves nothing: no connection.
2245	    let net_up = spt_daemon::brain::Brain::cold_start(&spt_daemon::broker_socket_name(), now_ms())
2246	        .ok()
2247	        .and_then(|mut b| b.net_status().ok())
2248	        .map(|s| s.enabled)
2249	        .unwrap_or(true); // can't ask ⇒ assume serving rather than alarm
2250	    let state = if net_up { "attached" } else { "no connection" };
2251	    let rows: Vec<(String, &'static str)> = SubnetStore::load()
2252	        .subnets
2253	        .iter()
2254	        .map(|s| (s.name.clone(), state))
2255	        .collect();
2256	    eprint!("{}", render_subnets_joined(&rows));
2257	    Ok(())
2258	}
2259
2260	/// `spt subnet [status [NAME] [--nodes]]` — the membership view (REQ-SUBNET-1,
2261	/// M7 plan decisions 5 + 6). Read-only and ungated: prints membership shape,
2262	/// never secrets.
2263	// [impl->REQ-SUBNET-1]
2264	fn cmd_subnet_status(name: Option<String>, nodes: bool) -> i32 {
2265	    let store = SubnetStore::load();
2266	    if let Some(n) = &name {
2267	        if store.find(n).is_none() {
2268	            eprintln!("NO_SUCH_SUBNET:{n}");
2269	            return 2;
2270	        }
2271	    }
2272	    let snap_dir = perch::identity_dir().join("registry");
2273	    let regs = crate::wansend::load_snapshots(&snap_dir);
2274	    let trust = spt_store::trust::TrustStore::load();
2275	    let own_hex = spt_store::nodeid::load_or_create()
2276	        .map(|i| i.public_key().to_hex())
2277	        .unwrap_or_default();
2278	    if !nodes {
2279	        let rows = subnet_status_rows(
2280	            &store,
2281	            &regs,
2282	            &trust,
2283	            Some(&own_hex).filter(|h| !h.is_empty()).map(|h| h.as_str()),
2284	            name.as_deref(),
2285	        );
2286	        print!("{}", render_subnet_status(&rows));
2287	        let _ = std::io::stdout().flush();
2288	        return 0;
2289	    }
2290
2291	    // --nodes: per-node rows under the hybrid liveness (decision 6).
2292	    let heard: std::collections::BTreeMap<String, u64> = std::fs::read(
2293	        spt_daemon::registryhost::RegistryHost::heard_path(&snap_dir),
2294	    )
2295	    .ok()
2296	    .and_then(|b| serde_json::from_slice(&b).ok())
2297	    .unwrap_or_default();
2298	    // Stale = silent for 3 advertisement cadences (the daemon re-gossips
2299	    // every pump period; missing a couple is jitter, missing three is news).
2300	    let stale_after_ms = 3 * spt_daemon::DaemonConfig::load()
2301	        .registry_pump_period
2302	        .as_millis() as u64;
2303	    let mut out = String::new();
2304	    for sub in &store.subnets {
2305	        if name.as_deref().is_some_and(|n| n != sub.name) {
2306	            continue;
2307	        }
2308	        let empty = spt_net::net::registry::SubnetRegistry::new();
2309	        let reg = regs.get(&sub.name).unwrap_or(&empty);
2310	        // Membership seed: self + trust-pinned peers (rowless members still
2311	        // render — fresh boxes and eviction-decayed offline peers alike).
2312	        let mut members: Vec<(String, Option<String>)> = Vec::new();
2313	        if !own_hex.is_empty() {
2314	            members.push((own_hex.clone(), spt_store::hostlabel::os_hostname()));
2315	        }
2316	        for peer in trust.peers_in(&sub.name) {
2317	            members.push((peer.pubkey_hex.clone(), peer.label.clone()));
2318	        }
2319	        let rows = node_status_rows(reg, &members, &heard, &own_hex, now_ms(), stale_after_ms);
2320	        let settled: Vec<(NodeRow, bool)> = rows
2321	            .into_iter()
2322	            .map(|r| {
2323	                let online = match r.liveness {
2324	                    NodeLiveness::Online => true,
2325	                    NodeLiveness::Offline => false,
2326	                    NodeLiveness::Probe => probe_node(&r.node),
2327	                };
2328	                (r, online)
2329	            })
2330	            .collect();
2331	        out.push_str(&render_node_rows(&sub.name, &settled));
2332	    }
2333	    if out.is_empty() {
2334	        out.push_str(
2335	            "This node belongs to no subnet — a standalone node. Local messaging \
2336	             works without one; a subnet links your machines into one fabric.\n",
2337	        );
2338	    }
2339	    out.push_str(HINT_FOOTER);
2340	    print!("{out}");
2341	    let _ = std::io::stdout().flush();
2342	    0
2343	}
2344
2345	/// The join command's elevation verdict — pure over the probed level
2346	/// (REQ-SUBNET-4): joining enrolls the MACHINE into a subnet's trust
2347	/// fabric, a mutation an unprivileged process must not perform (an
2348	/// attacker-run joiner would enroll the box into the attacker's subnet).
2349	/// Same fail-safe posture as the seed-reveal gates: only confirmed
2350	/// elevation proceeds.
2351	// [impl->REQ-SUBNET-4]
2352	fn join_elevation_refusal(elevation: Elevation) -> Option<String> {
2353	    match elevation::gate_for(elevation) {
2354	        Gate::Show => None,
2355	        Gate::Fallback => Some(
2356	            "ELEVATION_REQUIRED: joining a subnet enrolls this machine into its trust \
2357	             fabric — re-run elevated (run as administrator / root)"
2358	                .to_string(),
2359	        ),
2360	    }
2361	}
2362
2363	/// Read one interactive line from the terminal (prompts on stderr so stdout
2364	/// stays clean). `None` when stdin is not a terminal or the line is empty —
2365	/// the caller falls back to a usage error instead of hanging a pipe.
2366	fn prompt_line(label: &str) -> Option<String> {
2367	    use std::io::IsTerminal;
2368	    if !std::io::stdin().is_terminal() {
2369	        return None;
2370	    }
2371	    eprint!("{label}");
2372	    let _ = std::io::stderr().flush();
2373	    let mut s = String::new();
2374	    std::io::stdin().read_line(&mut s).ok()?;
2375	    let s = s.trim().to_string();
2376	    (!s.is_empty()).then_some(s)
2377	}
2378
2379	/// `spt subnet join [NAME] [--code <CODE>]` — the guided join flow (M7 plan
2380	/// decision 10, REQ-SUBNET-2): elevation gate → prompt the missing inputs →
2381	/// the daemon runs the rendezvous + ceremony on the node-identity endpoint →
2382	/// wrong codes re-prompt (codes rotate; bounded attempts), rate limits
2383	/// surface honestly, and a missing seed-holder gets actionable hints.
2384	// [impl->REQ-SUBNET-2] [impl->REQ-SUBNET-4]
2385	fn cmd_subnet_join(name: Option<String>, code: Option<String>) -> i32 {
2386	    // The gate runs FIRST — before any prompt or network syllable
2387	    // (REQ-SUBNET-4: refused = nothing typed, nothing dialed, nothing saved).
2388	    if let Some(msg) = join_elevation_refusal(elevation::current()) {
2389	        if let Some(code) = try_auto_elevate() {
2390	            return code;
2391	        }
2392	        eprintln!("{}", with_elevation_hint(msg));
2393	        return EXIT_NOT_ELEVATED;
2394	    }
2395	    let Some(name) = name.or_else(|| prompt_line("Subnet name: ")) else {
2396	        eprintln!(
2397	            "USAGE: a subnet name is required — `spt subnet join <name>` (or run interactively)"
2398	        );
2399	        return 2;
2400	    };
2401	    if SubnetStore::load().find(&name).is_some() {
2402	        eprintln!("ALREADY_MEMBER:{name} — this machine already holds the subnet");

fn subnet_status_rows(
    store: &SubnetStore,
    regs: &std::collections::BTreeMap<String, spt_net::net::registry::SubnetRegistry>,
    trust: &spt_store::trust::TrustStore,
    own_hex: Option<&str>,
    only: Option<&str>,
) -> Vec<SubnetStatusRow> {
    store
        .subnets
        .iter()
        .filter(|s| only.is_none_or(|n| n == s.name))
        .map(|s| {
            let (mut nodes, mut endpoints) = (std::collections::BTreeSet::new(), 0usize);
            if let Some(own) = own_hex {
                nodes.insert(own.to_string());
            }
            for peer in trust.peers_in(&s.name) {
                nodes.insert(peer.pubkey_hex.clone());
            }
            if let Some(reg) = regs.get(&s.name) {
                for id in reg.endpoint_ids() {
                    endpoints += 1;
                    for inst in reg.instances(id) {
                        nodes.insert(inst.node.clone());
                    }
                }
            }
            // This node is a member even when its identity could not load.
            SubnetStatusRow {
                name: s.name.clone(),
                nodes: nodes.len().max(1),
                endpoints,
            }
        })
        .collect()
}

/// Render the status view (M7 plan decision 5): aligned rows, the hint footer
/// always, explanatory standalone-node text on zero subnets. D2 adds
/// `--nodes` per-node rows + hybrid liveness; D3 adds the join hint.
// [impl->REQ-SUBNET-1]
fn render_subnet_status(rows: &[SubnetStatusRow]) -> String {
    let mut out = String::new();
    if rows.is_empty() {
        out.push_str(
            "This node belongs to no subnet — a standalone node. Local messaging \
             works without one; a subnet links your machines into one fabric.\n",
        );
    } else {
        let pad = rows
            .iter()
            .map(|r| r.name.len())
            .max()
            .unwrap_or(0)
            .max("SUBNET".len());
        out.push_str(&format!("{:<pad$}  NODES  ENDPOINTS\n", "SUBNET"));
        for r in rows {
            out.push_str(&format!(
                "{:<pad$}  {:<5}  {}\n",
                r.name, r.nodes, r.endpoints
            ));
        }
    }
    out.push_str(HINT_FOOTER);
    out
}

/// The status views' standing hint footer (M7 plan decision 5: always
/// printed — the next verb is never a doc lookup away).
const HINT_FOOTER: &str = "\nhint: `spt subnet create <name>` mints a new subnet on this machine\n\
     hint: `spt subnet join <name>` pairs this machine into one (elevated)\n\
     hint: `spt subnet show-code [name]` shows a subnet's joining code (elevated)\n";

/// One node's `status --nodes` row (M7 plan decision 6): identity, advertised
/// label, endpoint counts, and the gossip-recency liveness verdict.
struct NodeRow {
    node: String,
    label: Option<String>,
    /// Active (listening-now) endpoint rows on this node.
    active: usize,
    /// All endpoint rows on this node.
    total: usize,
    liveness: NodeLiveness,
}

/// The gossip-recency half of the hybrid liveness decision (M7 plan decision
/// 6): the cheap signals settle most rows; only [`Probe`](NodeLiveness::Probe)
/// rows cost a dial.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum NodeLiveness {
    Online,
    Offline,
    /// Gossip can't say (not heard recently, rows not all-Offline) — dial it.
    Probe,
}

/// A node's render form: `LABEL (keypfx…)` when labeled, `keypfx…` bare
/// (REQ-SUBNET-3 — e.g. `HFENDULEAM (bcead52b…)`).
fn node_display(node: &str, label: Option<&str>) -> String {
    let pfx = spt_net::net::registry::key_prefix(node);
    match label {
        Some(l) => format!("{l} ({pfx}…)"),
        None => format!("{pfx}…"),
    }
}

/// Build one subnet's per-node rows with the gossip-recency verdict. Pure
/// over its inputs (the probe is the caller's impure follow-up).
///
/// `members` seeds the row set — `(node_hex, label)` for self + every
/// trust-pinned peer — so a member with zero registry rows still renders
/// (`[0/0]`): a fresh box has no endpoints yet, and an offline peer's rows
/// decay under the 4.10 eviction; neither may vanish from the view.
/// Liveness: own node → online (we are running); rows all-Offline →
/// offline; heard within `stale_after_ms` → online; otherwise (including
/// a rowless member — absence is not evidence) → probe.
// [impl->REQ-SUBNET-1]
fn node_status_rows(
    reg: &spt_net::net::registry::SubnetRegistry,
    members: &[(String, Option<String>)],
    heard: &std::collections::BTreeMap<String, u64>,
    own_hex: &str,
    now_ms: u64,
    stale_after_ms: u64,
) -> Vec<NodeRow> {
    use spt_net::net::registry::Status;
    // node → (label, active, total, any_routable)
    let mut by_node: std::collections::BTreeMap<String, (Option<String>, usize, usize, bool)> =
        std::collections::BTreeMap::new();
    for (node, label) in members {
        let e = by_node.entry(node.clone()).or_default();
        if e.0.is_none() {
            e.0 = label.clone();
        }
    }
    for id in reg.endpoint_ids() {
        for inst in reg.instances(id) {
            let e = by_node.entry(inst.node.clone()).or_default();
            if e.0.is_none() {
                e.0 = inst.node_label.clone();
            }
            e.1 += usize::from(inst.status == Status::Active);
            e.2 += 1;
            e.3 |= inst.status.routable();
        }
    }
    by_node
        .into_iter()
        .map(|(node, (label, active, total, any_routable))| {
            let liveness = if node == own_hex {
                NodeLiveness::Online
            } else if total > 0 && !any_routable {
                NodeLiveness::Offline
            } else if heard
                .get(&node)
                .is_some_and(|&at| now_ms.saturating_sub(at) <= stale_after_ms)
            {
                NodeLiveness::Online
            } else {
                NodeLiveness::Probe
            };
            NodeRow {
                node,
                label,
                active,
                total,
                liveness,
            }
        })
        .collect()
}

/// Render one subnet's `--nodes` table from probe-settled rows.
// [impl->REQ-SUBNET-1] [impl->REQ-SUBNET-3]
fn render_node_rows(subnet: &str, rows: &[(NodeRow, bool)]) -> String {
    let mut out = format!("SUBNET {subnet}\n");
    if rows.is_empty() {
        out.push_str("  (no gossip yet — pair another machine, or wait one pump cadence)\n");
        return out;
    }
    let names: Vec<String> = rows
        .iter()
        .map(|(r, _)| node_display(&r.node, r.label.as_deref()))
        .collect();
    let pad = names
        .iter()
        .map(|n| n.chars().count())
        .max()
        .unwrap_or(0)
        .max("NODE".len());
    out.push_str(&format!("  {:<pad$}  STATUS   ENDPOINTS\n", "NODE"));
    for ((row, online), name) in rows.iter().zip(&names) {
        // Pad by char count, not byte len ('…' is multi-byte).
        let fill = " ".repeat(pad - name.chars().count());
        out.push_str(&format!(
            "  {name}{fill}  {:<7}  [{}/{}]\n",
            if *online { "online" } else { "offline" },
            row.active,
            row.total,
        ));
    }
    out
}

/// Probe a stale node: dial it over the broker's QUIC endpoint (the hybrid
/// liveness's expensive half — spent only on rows gossip could not settle).
/// Any failure (daemon down, no address, dial refused/timed out) reads
/// offline — fail-quiet, this is a status view.
fn probe_node(node_hex: &str) -> bool {
    crate::wansend::probe_node(node_hex)
}

/// Render the coming-online banner's "spt subnets joined" section + the
/// concluding divider: header, underline, one `NAME: <state>` row per held
/// subnet (`(none)` when empty), divider. Pure for tests.
// [impl->REQ-DAEMON-3]
fn render_subnets_joined(rows: &[(String, &'static str)]) -> String {
    let mut out = String::from("spt subnets joined\n──────────────────\n");
    if rows.is_empty() {
        out.push_str("  (none)\n");
    } else {
        for (name, state) in rows {
            out.push_str(&format!("  {name}: {state}\n"));
        }
    }
    out.push_str("────────────────────────────────────────\n");
    out
}

/// Ensure the per-machine daemon is up for a daemon-dependent command —
/// silent when it already runs; otherwise announce the transition on
/// stderr (stdout stays payload-pure) and conclude with a divider before
/// the command's own output (grill 2026-06-06). The membership-creating
/// verbs call this because **membership implies reachability** (CONTEXT
/// §subnet attachment): a sole seed-holder with no responder daemon is a
/// contradiction of the subnet's purpose.
// [impl->REQ-SUBNET-2] [impl->REQ-DAEMON-3]
fn ensure_daemon_announced() -> Result<(), i32> {
    if spt_daemon::is_running() {
        return Ok(());
    }
    eprintln!("spt-core daemon is offline - coming online...");
    if let Err(e) = spt_daemon::ensure_running() {
        eprintln!("DAEMON_START_FAIL: {e}");
        return Err(1);
    }
    let ident = match (
        spt_store::hostlabel::os_hostname(),
        spt_store::nodeid::load_or_create()
            .ok()
            .map(|i| i.public_key().to_hex()),
    ) {
        (Some(l), Some(hex)) => {
            format!("{l} ({}…)", spt_net::net::registry::key_prefix(&hex))
        }
        (None, Some(hex)) => format!("{}…", spt_net::net::registry::key_prefix(&hex)),
        _ => "this node".to_string(),
    };
    eprintln!("spt-core daemon: online - {ident}");
    // Attachment states: every held subnet is attached while the daemon is
    // up (CONTEXT §subnet attachment — detach verbs are deferred); a
    // net-less daemon (endpoint bind failed) serves nothing: no connection.
    let net_up = spt_daemon::brain::Brain::cold_start(&spt_daemon::broker_socket_name(), now_ms())
        .ok()
        .and_then(|mut b| b.net_status().ok())
        .map(|s| s.enabled)
        .unwrap_or(true); // can't ask ⇒ assume serving rather than alarm
    let state = if net_up { "attached" } else { "no connection" };
    let rows: Vec<(String, &'static str)> = SubnetStore::load()
        .subnets
        .iter()
        .map(|s| (s.name.clone(), state))
        .collect();
    eprint!("{}", render_subnets_joined(&rows));
    Ok(())
}

/// `spt subnet [status [NAME] [--nodes]]` — the membership view (REQ-SUBNET-1,
/// M7 plan decisions 5 + 6). Read-only and ungated: prints membership shape,
/// never secrets.
// [impl->REQ-SUBNET-1]
fn cmd_subnet_status(name: Option<String>, nodes: bool) -> i32 {
    let store = SubnetStore::load();
    if let Some(n) = &name {
        if store.find(n).is_none() {
            eprintln!("NO_SUCH_SUBNET:{n}");
            return 2;
        }
    }
    let snap_dir = perch::identity_dir().join("registry");
    let regs = crate::wansend::load_snapshots(&snap_dir);
    let trust = spt_store::trust::TrustStore::load();
    let own_hex = spt_store::nodeid::load_or_create()
        .map(|i| i.public_key().to_hex())
        .unwrap_or_default();
    if !nodes {
        let rows = subnet_status_rows(
            &store,
            &regs,
            &trust,
            Some(&own_hex).filter(|h| !h.is_empty()).map(|h| h.as_str()),
            name.as_deref(),
        );
        print!("{}", render_subnet_status(&rows));
        let _ = std::io::stdout().flush();
        return 0;
    }

    // --nodes: per-node rows under the hybrid liveness (decision 6).
    let heard: std::collections::BTreeMap<String, u64> = std::fs::read(
        spt_daemon::registryhost::RegistryHost::heard_path(&snap_dir),
    )
    .ok()
    .and_then(|b| serde_json::from_slice(&b).ok())
    .unwrap_or_default();
    // Stale = silent for 3 advertisement cadences (the daemon re-gossips
    // every pump period; missing a couple is jitter, missing three is news).
    let stale_after_ms = 3 * spt_daemon::DaemonConfig::load()
        .registry_pump_period
        .as_millis() as u64;
    let mut out = String::new();
    for sub in &store.subnets {
        if name.as_deref().is_some_and(|n| n != sub.name) {
            continue;
        }
        let empty = spt_net::net::registry::SubnetRegistry::new();
        let reg = regs.get(&sub.name).unwrap_or(&empty);
        // Membership seed: self + trust-pinned peers (rowless members still
        // render — fresh boxes and eviction-decayed offline peers alike).
        let mut members: Vec<(String, Option<String>)> = Vec::new();
        if !own_hex.is_empty() {
            members.push((own_hex.clone(), spt_store::hostlabel::os_hostname()));
        }
        for peer in trust.peers_in(&sub.name) {
            members.push((peer.pubkey_hex.clone(), peer.label.clone()));
        }
        let rows = node_status_rows(reg, &members, &heard, &own_hex, now_ms(), stale_after_ms);
        let settled: Vec<(NodeRow, bool)> = rows
            .into_iter()
            .map(|r| {
                let online = match r.liveness {
                    NodeLiveness::Online => true,
                    NodeLiveness::Offline => false,
                    NodeLiveness::Probe => probe_node(&r.node),
                };
                (r, online)
            })
            .collect();
        out.push_str(&render_node_rows(&sub.name, &settled));
    }
    if out.is_empty() {
        out.push_str(
            "This node belongs to no subnet — a standalone node. Local messaging \
             works without one; a subnet links your machines into one fabric.\n",
        );
    }
    out.push_str(HINT_FOOTER);
    print!("{out}");
    let _ = std::io::stdout().flush();
    0
}

/// The join command's elevation verdict — pure over the probed level
/// (REQ-SUBNET-4): joining enrolls the MACHINE into a subnet's trust
/// fabric, a mutation an unprivileged process must not perform (an
/// attacker-run joiner would enroll the box into the attacker's subnet).
/// Same fail-safe posture as the seed-reveal gates: only confirmed
/// elevation proceeds.
// [impl->REQ-SUBNET-4]
fn join_elevation_refusal(elevation: Elevation) -> Option<String> {
    match elevation::gate_for(elevation) {
        Gate::Show => None,
        Gate::Fallback => Some(
            "ELEVATION_REQUIRED: joining a subnet enrolls this machine into its trust \
             fabric — re-run elevated (run as administrator / root)"
                .to_string(),
        ),
    }
}

/// Read one interactive line from the terminal (prompts on stderr so stdout
/// stays clean). `None` when stdin is not a terminal or the line is empty —
/// the caller falls back to a usage error instead of hanging a pipe.
fn prompt_line(label: &str) -> Option<String> {
    use std::io::IsTerminal;
    if !std::io::stdin().is_terminal() {
        return None;
    }
    eprint!("{label}");
    let _ = std::io::stderr().flush();
    let mut s = String::new();
    std::io::stdin().read_line(&mut s).ok()?;
    let s = s.trim().to_string();
    (!s.is_empty()).then_some(s)
}

/// `spt subnet join [NAME] [--code <CODE>]` — the guided join flow (M7 plan
/// decision 10, REQ-SUBNET-2): elevation gate → prompt the missing inputs →
/// the daemon runs the rendezvous + ceremony on the node-identity endpoint →
/// wrong codes re-prompt (codes rotate; bounded attempts), rate limits
/// surface honestly, and a missing seed-holder gets actionable hints.
// [impl->REQ-SUBNET-2] [impl->REQ-SUBNET-4]
fn cmd_subnet_join(name: Option<String>, code: Option<String>) -> i32 {
    // The gate runs FIRST — before any prompt or network syllable
    // (REQ-SUBNET-4: refused = nothing typed, nothing dialed, nothing saved).
    if let Some(msg) = join_elevation_refusal(elevation::current()) {
        if let Some(code) = try_auto_elevate() {
            return code;
        }
        eprintln!("{}", with_elevation_hint(msg));
        return EXIT_NOT_ELEVATED;
    }
    let Some(name) = name.or_else(|| prompt_line("Subnet name: ")) else {
        eprintln!(
            "USAGE: a subnet name is required — `spt subnet join <name>` (or run interactively)"
        );
        return 2;
    };
    if SubnetStore::load().find(&name).is_some() {
        eprintln!("ALREADY_MEMBER:{name} — this machine already holds the subnet");


resources --> new flag under `spt endpoint list`: `--detail` --- change the `spt subnet attach/detach --auto` flag to `--save`. clearer semantics.


Update CLI flags for endpoint and subnet commands


2536	const HOW_TO_READY: &str = "\
2537	# how-to: ready — receive messages
2538
2539	Pick a short lowercase perch id for yourself (`<id>` below). `spt whoami`
2540	prints it if your session already has one.
2541
2542	Preferred — your harness can host a long-running background task:
2543
2544	    spt ready <id>
2545
2546	- Blocks for the session's life: run it as a background task and read its stdout.
2547	- `READY:<id>` on stderr is the online signal.
2548	- The spooled backlog drains first; then each delivery prints to stdout as it
2549	  arrives.
2550	- Delivery shape on stdout: one envelope line per message —
2551	  `<EVENT type=\"msg\" from=\"<sender>\">body</EVENT>`. The body is
2552	  HTML-escaped with newlines as `<br>` (unescape `< > "` then
2553	  `&` last). Oversized deliveries arrive as `<EVENT-PART seq=\"k/n\"
2554	  id=\"…\">` lines — concatenate the parts in seq order, then parse.
2555
2556	Fallback — no long-running tasks available (the one-shot loop):
2557
2558	    spt ready <id> --once
2559
2560	- Drains the backlog, waits for one live delivery, then exits.
2561	- Re-run it after every exit to stay reachable.
2562
2563	Replying: pipe the body to `spt send --reply-to <sender>` — `<sender>` is the
2564	envelope's `from` attribute (empty = anonymous; no reply possible).
2565
2566	Going offline cleanly: `spt stop <id>` (the spool is preserved; messages sent
2567	while you are away queue and drain on your next `ready`).
2568	";
2569
2570	/// `spt how-to send` — delivery guidance for agents.
2571	const HOW_TO_SEND: &str = "\
2572	# how-to: send — deliver messages
2573
2574	Fire-and-forget (the body is read from stdin):
2575
2576	    spt send <target>
2577
2578	- `SENT:<target>`   — delivered live to a listening agent.
2579	- `QUEUED:<target>` — the target is not listening; the message spooled and
2580	  drains when it next runs `spt ready`. QUEUED is success, not an error — do
2581	  not retry.
2582
2583	Reply to a message you received (body from stdin; prints `REPLIED:<sender>`):
2584
2585	    spt send --reply-to <sender>
2586
2587	Send and block for the reply (body from stdin; the reply body prints to stdout):
2588
2589	    spt ring <target> --timeout 60
2590
2591	- `TIMEOUT` after the wait is exit 0 — the message still landed; the reply will
2592	  arrive on your listener instead.
2593
2594	Your own sender id is auto-detected in most sessions; `--from <id>` overrides.
2595	";
2596
2597	/// The `how-to` topic registry: (name, one-line summary, full text). v1 topics
2598	/// per M7 plan decision 12; bare `how-to` lists this table.
2599	const HOW_TO_TOPICS: &[(&str, &str, &str)] = &[
2600	    (
2601	        "ready",
2602	        "receive messages: listener guidance + the --once fallback loop",
2603	        HOW_TO_READY,
2604	    ),
2605	    (
2606	        "send",
2607	        "deliver messages: send vs ring, replies, SENT vs QUEUED",
2608	        HOW_TO_SEND,
2609	    ),
2610	];
2611
2612	/// `spt how-to [topic]` — in-binary, task-oriented agent instructions
2613	/// (REQ-DOCS-6). Single source = this binary: the docs-site points here and
2614	/// never duplicates the texts (the DOCS-STRATEGY anti-drift posture applied
2615	/// to agent guidance), so what an agent reads cannot disagree with the
2616	/// binary it runs.
2617	// [impl->REQ-DOCS-6]
2618	fn cmd_how_to(topic: Option<&str>) -> i32 {
2619	    let list = || {
2620	        let pad = HOW_TO_TOPICS
2621	            .iter()
2622	            .map(|(n, ..)| n.len())
2623	            .max()
2624	            .unwrap_or(0);
2625	        HOW_TO_TOPICS
2626	            .iter()
2627	            .map(|(n, summary, _)| format!("  {n:<pad$}  {summary}\n"))
2628	            .collect::<String>()
2629	    };
2630	    match topic {
2631	        None => {
2632	            print!("Topics (`spt how-to <topic>`):\n{}", list());
2633	            let _ = std::io::stdout().flush();
2634	            0
2635	        }
2636	        Some(t) => match HOW_TO_TOPICS.iter().find(|(name, ..)| *name == t) {
2637	            Some((_, _, text)) => {
2638	                print!("{text}");
2639	                let _ = std::io::stdout().flush();
2640	                0
2641	            }
2642	            None => {
2643	                eprintln!("NO_SUCH_TOPIC:{t} — topics:\n{}", list());
2644	                2
2645	            }
2646	        },
2647	    }
2648	}
2649
2650	/// Current unix time in whole seconds (the TOTP clock input). A pre-epoch clock
2651	/// degrades to step 0 rather than panicking.
2652	/// `spt rename <old> <new>` — the on-disk rename ripple (REQ-INST-11). Validates
2653	/// the new id (the `:`/`@` reserved-delimiter rule — a qualified string can never
2654	/// become a bare id), then ripples the perch tree via
2655	/// [`spt_store::rename::rename_perch_in`]. Prints each rippled perch id.
2656	// [impl->REQ-INST-11]
2657	/// `spt access allow|revoke|open|list` — operate the endpoint access whitelist
2658	/// (`identity/trust/access.json`, ADR-0009). The daemon's gate re-reads the
2659	/// store per check, so edits take effect on the next inbound — no restart.
2660	// [impl->REQ-SEC-1]
2661	fn cmd_access(action: AccessCmd) -> i32 {
2662	    use spt_store::access::AccessStore;
2663
2664	    /// A node subject must be a 64-char Ed25519 pubkey hex — refuse typos
2665	    /// rather than store a subject that can never match (a silent lockout).

const HOW_TO_READY: &str = "\
# how-to: ready — receive messages

Pick a short lowercase perch id for yourself (`<id>` below). `spt whoami`
prints it if your session already has one.

Preferred — your harness can host a long-running background task:

    spt ready <id>

- Blocks for the session's life: run it as a background task and read its stdout.
- `READY:<id>` on stderr is the online signal.
- The spooled backlog drains first; then each delivery prints to stdout as it
  arrives.
- Delivery shape on stdout: one envelope line per message —
  `<EVENT type=\"msg\" from=\"<sender>\">body</EVENT>`. The body is
  HTML-escaped with newlines as `<br>` (unescape `< > "` then
  `&` last). Oversized deliveries arrive as `<EVENT-PART seq=\"k/n\"
  id=\"…\">` lines — concatenate the parts in seq order, then parse.

Fallback — no long-running tasks available (the one-shot loop):

    spt ready <id> --once

- Drains the backlog, waits for one live delivery, then exits.
- Re-run it after every exit to stay reachable.

Replying: pipe the body to `spt send --reply-to <sender>` — `<sender>` is the
envelope's `from` attribute (empty = anonymous; no reply possible).

Going offline cleanly: `spt stop <id>` (the spool is preserved; messages sent
while you are away queue and drain on your next `ready`).
";

/// `spt how-to send` — delivery guidance for agents.
const HOW_TO_SEND: &str = "\
# how-to: send — deliver messages

Fire-and-forget (the body is read from stdin):

    spt send <target>

- `SENT:<target>`   — delivered live to a listening agent.
- `QUEUED:<target>` — the target is not listening; the message spooled and
  drains when it next runs `spt ready`. QUEUED is success, not an error — do
  not retry.

Reply to a message you received (body from stdin; prints `REPLIED:<sender>`):

    spt send --reply-to <sender>

Send and block for the reply (body from stdin; the reply body prints to stdout):

    spt ring <target> --timeout 60

- `TIMEOUT` after the wait is exit 0 — the message still landed; the reply will
  arrive on your listener instead.

Your own sender id is auto-detected in most sessions; `--from <id>` overrides.
";

/// The `how-to` topic registry: (name, one-line summary, full text). v1 topics
/// per M7 plan decision 12; bare `how-to` lists this table.
const HOW_TO_TOPICS: &[(&str, &str, &str)] = &[
    (
        "ready",
        "receive messages: listener guidance + the --once fallback loop",
        HOW_TO_READY,
    ),
    (
        "send",
        "deliver messages: send vs ring, replies, SENT vs QUEUED",
        HOW_TO_SEND,
    ),
];

/// `spt how-to [topic]` — in-binary, task-oriented agent instructions
/// (REQ-DOCS-6). Single source = this binary: the docs-site points here and
/// never duplicates the texts (the DOCS-STRATEGY anti-drift posture applied
/// to agent guidance), so what an agent reads cannot disagree with the
/// binary it runs.
// [impl->REQ-DOCS-6]
fn cmd_how_to(topic: Option<&str>) -> i32 {
    let list = || {
        let pad = HOW_TO_TOPICS
            .iter()
            .map(|(n, ..)| n.len())
            .max()
            .unwrap_or(0);
        HOW_TO_TOPICS
            .iter()
            .map(|(n, summary, _)| format!("  {n:<pad$}  {summary}\n"))
            .collect::<String>()
    };
    match topic {
        None => {
            print!("Topics (`spt how-to <topic>`):\n{}", list());
            let _ = std::io::stdout().flush();
            0
        }
        Some(t) => match HOW_TO_TOPICS.iter().find(|(name, ..)| *name == t) {
            Some((_, _, text)) => {
                print!("{text}");
                let _ = std::io::stdout().flush();
                0
            }
            None => {
                eprintln!("NO_SUCH_TOPIC:{t} — topics:\n{}", list());
                2
            }
        },
    }
}

/// Current unix time in whole seconds (the TOTP clock input). A pre-epoch clock
/// degrades to step 0 rather than panicking.
/// `spt rename <old> <new>` — the on-disk rename ripple (REQ-INST-11). Validates
/// the new id (the `:`/`@` reserved-delimiter rule — a qualified string can never
/// become a bare id), then ripples the perch tree via
/// [`spt_store::rename::rename_perch_in`]. Prints each rippled perch id.
// [impl->REQ-INST-11]
/// `spt access allow|revoke|open|list` — operate the endpoint access whitelist
/// (`identity/trust/access.json`, ADR-0009). The daemon's gate re-reads the
/// store per check, so edits take effect on the next inbound — no restart.
// [impl->REQ-SEC-1]
fn cmd_access(action: AccessCmd) -> i32 {
    use spt_store::access::AccessStore;

    /// A node subject must be a 64-char Ed25519 pubkey hex — refuse typos
    /// rather than store a subject that can never match (a silent lockout).


3832	mod tests {
3833	    use super::*;
3834
3835	    fn parse(args: &[&str]) -> Result<Cli, clap::Error> {
3836	        Cli::try_parse_from(args)
3837	    }
3838
3839	    // [unit->REQ-MSG-2] the grouped --help contract holds: every visible
3840	    // command is classified (AGENT_COMMANDS names must exist), the rendered
3841	    // template carries both group headings, and every about stays a
3842	    // one-liner short enough that no list entry ever wraps on an 80-col
3843	    // terminal (the name column is 2+pad+2 wide).
3844	    #[test]
3845	    fn help_groups_cover_every_command() {
3846	        use clap::CommandFactory;
3847	        let mut cmd = Cli::command();
3848	        let template = grouped_help_template(&mut cmd);
3849	        assert!(template.contains("User commands:\n"));
3850	        assert!(template.contains("Agent commands:\n"));
3851
3852	        let pad = cmd
3853	            .get_subcommands()
3854	            .filter(|s| !s.is_hide_set())
3855	            .map(|s| s.get_name().len())
3856	            .max()
3857	            .unwrap();
3858	        for sc in cmd.get_subcommands().filter(|s| !s.is_hide_set()) {
3859	            let about = sc
3860	                .get_about()
3861	                .unwrap_or_else(|| panic!("{} needs an about", sc.get_name()))
3862	                .to_string();
3863	            assert!(
3864	                !about.contains('\n'),
3865	                "{}: about must be a one-liner",
3866	                sc.get_name()
3867	            );
3868	            assert!(
3869	                2 + pad + 2 + about.len() <= 80,
3870	                "{}: about too long for an unwrapped 80-col entry ({} chars)",
3871	                sc.get_name(),
3872	                about.len()
3873	            );
3874	            assert!(
3875	                template.contains(&format!("  {:<pad$}  {about}\n", sc.get_name())),
3876	                "{} missing from the rendered template",
3877	                sc.get_name()
3878	            );
3879	        }
3880	        for name in AGENT_COMMANDS {
3881	            assert!(
3882	                cmd.find_subcommand(name).is_some(),
3883	                "AGENT_COMMANDS names unknown command {name}"
3884	            );
3885	        }
3886	    }
3887
3888	    // [unit->REQ-MSG-2] the documented subcommands + arg shapes parse.
3889	    #[test]
3890	    fn subcommands_parse() {
3891	        assert!(matches!(
3892	            parse(&["spt", "send", "bob", "--from", "alice"])
3893	                .unwrap()
3894	                .cmd,
3895	            Cmd::Send { .. }
3896	        ));
3897	        assert!(matches!(
3898	            parse(&["spt", "send", "bob", "--deferred"]).unwrap().cmd,
3899	            Cmd::Send { deferred: true, .. }
3900	        ));
3901	        assert!(matches!(
3902	            parse(&["spt", "ring", "bob", "--timeout", "5"])
3903	                .unwrap()
3904	                .cmd,
3905	            Cmd::Ring { .. }
3906	        ));
3907	        assert!(matches!(
3908	            parse(&["spt", "ready", "bob"]).unwrap().cmd,
3909	            Cmd::Ready { once: false, .. }
3910	        ));
3911	        // [unit->REQ-MSG-2] `ready --once` absorbs the removed `poll`'s
3912	        // drain-then-exit semantics (M7 plan decision 2); `poll` itself is gone.
3913	        assert!(matches!(
3914	            parse(&["spt", "ready", "bob", "--once"]).unwrap().cmd,
3915	            Cmd::Ready { once: true, .. }
3916	        ));
3917	        assert!(
3918	            parse(&["spt", "poll", "bob"]).is_err(),
3919	            "poll is removed (ready --once)"
3920	        );
3921	        assert!(matches!(parse(&["spt", "list"]).unwrap().cmd, Cmd::List));
3922	        assert!(matches!(
3923	            parse(&["spt", "stop", "bob"]).unwrap().cmd,
3924	            Cmd::Stop { .. }
3925	        ));
3926	        assert!(matches!(
3927	            parse(&["spt", "whoami"]).unwrap().cmd,
3928	            Cmd::Whoami
3929	        ));
3930	        // The hidden daemon entry point parses (auto-started by `api`).
3931	        assert!(matches!(
3932	            parse(&["spt", "daemon"]).unwrap().cmd,
3933	            Cmd::Daemon
3934	        ));
3935	        // [unit->REQ-INST-11] the rename surface parses (two positional ids).
3936	        assert!(matches!(
3937	            parse(&["spt", "rename", "ling", "oak"]).unwrap().cmd,
3938	            Cmd::Rename { .. }
3939	        ));
3940	        assert!(
3941	            parse(&["spt", "rename", "ling"]).is_err(),
3942	            "new id required"
3943	        );
3944	        // [unit->REQ-INST-15] the fork surface parses; the target is required.
3945	        assert!(matches!(
3946	            parse(&["spt", "fork", "ling", "ling-work", "--subnet", "work"])
3947	                .unwrap()
3948	                .cmd,
3949	            Cmd::Fork {
3950	                delete_source: false,
3951	                ..
3952	            }
3953	        ));
3954	        assert!(matches!(
3955	            parse(&[
3956	                "spt",
3957	                "fork",
3958	                "ling",
3959	                "l2",
3960	                "--subnet",
3961	                "work",
3962	                "--delete-source"
3963	            ])
3964	            .unwrap()
3965	            .cmd,
3966	            Cmd::Fork {
3967	                delete_source: true,
3968	                ..
3969	            }
3970	        ));
3971	        assert!(
3972	            parse(&["spt", "fork", "ling", "l2"]).is_err(),
3973	            "--subnet required"
3974	        );
3975	        assert!(parse(&["spt", "fork", "ling"]).is_err(), "new id required");
3976	        // [unit->REQ-INST-14] the resources surfaces parse.
3977	        assert!(matches!(
3978	            parse(&["spt", "resources", "set", "rust builds", "--id", "ling"])
3979	                .unwrap()
3980	                .cmd,
3981	            Cmd::Resources {
3982	                action: ResourcesCmd::Set { .. }
3983	            }
3984	        ));
3985	        assert!(matches!(
3986	            parse(&["spt", "resources", "show"]).unwrap().cmd,
3987	            Cmd::Resources {
3988	                action: ResourcesCmd::Show { id: None }
3989	            }
3990	        ));
3991	        assert!(matches!(
3992	            parse(&["spt", "resources", "list", "--subnet", "home"])
3993	                .unwrap()
3994	                .cmd,
3995	            Cmd::Resources {
3996	                action: ResourcesCmd::List { subnet: Some(_) }
3997	            }
3998	        ));
3999	        assert!(
4000	            parse(&["spt", "resources", "set"]).is_err(),
4001	            "blurb text required"
4002	        );
4003	        // [unit->REQ-INST-3] the resting-command surfaces parse (one local id).
4004	        assert!(matches!(
4005	            parse(&["spt", "suspend", "ling"]).unwrap().cmd,
4006	            Cmd::Suspend { .. }
4007	        ));
4008	        assert!(matches!(
4009	            parse(&["spt", "wake", "ling"]).unwrap().cmd,
4010	            Cmd::Wake { .. }
4011	        ));
4012	        // [unit->REQ-SHELL-2] shutdown parses with and without the self-default id.
4013	        assert!(matches!(
4014	            parse(&["spt", "shutdown"]).unwrap().cmd,
4015	            Cmd::Shutdown { id: None }
4016	        ));
4017	        assert!(matches!(
4018	            parse(&["spt", "shutdown", "ling"]).unwrap().cmd,
4019	            Cmd::Shutdown { id: Some(_) }
4020	        ));
4021	        assert!(parse(&["spt", "suspend"]).is_err(), "id required");
4022	        assert!(parse(&["spt", "wake"]).is_err(), "id required");
4023	        // The digest surface + its flags.
4024	        assert!(matches!(
4025	            parse(&["spt", "digest", "doyle"]).unwrap().cmd,
4026	            Cmd::Digest { .. }
4027	        ));
4028	        assert!(matches!(
4029	            parse(&["spt", "digest", "doyle", "--follow", "--json"])
4030	                .unwrap()
4031	                .cmd,
4032	            Cmd::Digest {
4033	                follow: true,
4034	                json: true,
4035	                ..
4036	            }
4037	        ));
4038	    }
4039
4040	    // [unit->REQ-NOTIF-2] the notify/notif surfaces parse: notify takes an
4041	    // optional body + --subnet/--from; notif list/dismiss shapes hold.
4042	    #[test]
4043	    fn notify_surface_parses() {
4044	        assert!(matches!(
4045	            parse(&[
4046	                "spt",
4047	                "notify",
4048	                "build done",
4049	                "--subnet",
4050	                "home",
4051	                "--from",
4052	                "doyle"
4053	            ])
4054	            .unwrap()
4055	            .cmd,
4056	            Cmd::Notify { .. }
4057	        ));
4058	        assert!(matches!(
4059	            parse(&["spt", "notify"]).unwrap().cmd,
4060	            Cmd::Notify { body: None, .. }
4061	        ));
4062	        assert!(matches!(
4063	            parse(&["spt", "notif", "list", "--subnet", "home"])
4064	                .unwrap()
4065	                .cmd,
4066	            Cmd::Notif {
4067	                action: NotifCmd::List { .. }
4068	            }
4069	        ));
4070	        assert!(matches!(
4071	            parse(&["spt", "notif", "dismiss", "cafe:7"]).unwrap().cmd,
4072	            Cmd::Notif {
4073	                action: NotifCmd::Dismiss { .. }
4074	            }
4075	        ));
4076	        assert!(
4077	            parse(&["spt", "notif", "dismiss"]).is_err(),
4078	            "notif id required"
4079	        );
4080	        // [unit->REQ-UPD-4] the update-consent body names this explicit ack verb.
4081	        assert!(matches!(
4082	            parse(&["spt", "update", "apply"]).unwrap().cmd,
4083	            Cmd::Update {
4084	                action: UpdateCmd::Apply
4085	            }
4086	        ));
4087	    }
4088
4089	    // [unit->REQ-NOTIF-2] subnet resolution refuses to guess: explicit
4090	    // non-member refused; zero subnets refused; exactly one auto-selects;
4091	    // several force --subnet.
4092	    #[test]
4093	    fn notif_subnet_resolution_never_guesses() {
4094	        let mut store = SubnetStore::default();
4095	        assert_eq!(
4096	            resolve_notif_subnet(&store, None),
4097	            Err(1),
4098	            "no subnet => refuse"
4099	        );
4100	        assert_eq!(
4101	            resolve_notif_subnet(&store, Some("home".into())),
4102	            Err(1),
4103	            "non-member => refuse"
4104	        );
4105
4106	        store.create_subnet("home").unwrap();
4107	        assert_eq!(
4108	            resolve_notif_subnet(&store, None),
4109	            Ok("home".to_string()),
4110	            "sole subnet"
4111	        );
4112	        assert_eq!(
4113	            resolve_notif_subnet(&store, Some("home".into())),
4114	            Ok("home".to_string())
4115	        );
4116
4117	        store.create_subnet("work").unwrap();
4118	        assert_eq!(
4119	            resolve_notif_subnet(&store, None),
4120	            Err(1),
4121	            "several => qualify"
4122	        );
4123	        assert_eq!(
4124	            resolve_notif_subnet(&store, Some("work".into())),
4125	            Ok("work".to_string())
4126	        );
4127	    }
4128
4129	    // [unit->REQ-SEC-1] the access whitelist surface parses: allow/revoke take
4130	    // (endpoint, node); open takes the endpoint; list takes an optional one.
4131	    #[test]
4132	    fn access_subcommands_parse() {
4133	        assert!(matches!(
4134	            parse(&["spt", "access", "allow", "ling", "aa11"])
4135	                .unwrap()
4136	                .cmd,
4137	            Cmd::Access {
4138	                action: AccessCmd::Allow { .. }
4139	            }
4140	        ));
4141	        assert!(matches!(
4142	            parse(&["spt", "access", "revoke", "ling", "aa11"])
4143	                .unwrap()
4144	                .cmd,
4145	            Cmd::Access {
4146	                action: AccessCmd::Revoke { .. }
4147	            }
4148	        ));
4149	        assert!(matches!(
4150	            parse(&["spt", "access", "open", "ling"]).unwrap().cmd,
4151	            Cmd::Access {
4152	                action: AccessCmd::Open { .. }
4153	            }
4154	        ));
4155	        assert!(matches!(
4156	            parse(&["spt", "access", "list"]).unwrap().cmd,
4157	            Cmd::Access {
4158	                action: AccessCmd::List { endpoint: None }
4159	            }
4160	        ));
4161	        assert!(matches!(
4162	            parse(&["spt", "access", "list", "ling"]).unwrap().cmd,
4163	            Cmd::Access {
4164	                action: AccessCmd::List { endpoint: Some(_) }
4165	            }
4166	        ));
4167	        assert!(
4168	            parse(&["spt", "access", "allow", "ling"]).is_err(),
4169	            "node required"
4170	        );
4171	    }
4172
4173	    // [unit->REQ-CONSENT-1] the grant surface parses: add/revoke take
4174	    // capability + agent with an optional --qualifier; list takes an
4175	    // optional agent filter.
4176	    #[test]
4177	    fn grant_subcommands_parse() {
4178	        match parse(&[
4179	            "spt",
4180	            "grant",
4181	            "add",
4182	            "spawn-shell",
4183	            "ling",
4184	            "--qualifier",
4185	            "OsNotify",
4186	        ])
4187	        .unwrap()
4188	        .cmd
4189	        {
4190	            Cmd::Grant {
4191	                action:
4192	                    GrantCmd::Add {
4193	                        capability,
4194	                        agent,
4195	                        qualifier,
4196	                    },
4197	            } => {
4198	                assert_eq!(capability, "spawn-shell");
4199	                assert_eq!(agent, "ling");
4200	                assert_eq!(qualifier.as_deref(), Some("OsNotify"));
4201	            }
4202	            _ => panic!("expected Grant Add"),
4203	        }
4204	        assert!(matches!(
4205	            parse(&["spt", "grant", "add", "owner-shutdown", "ling"])
4206	                .unwrap()
4207	                .cmd,
4208	            Cmd::Grant {
4209	                action: GrantCmd::Add {
4210	                    qualifier: None,
4211	                    ..
4212	                }
4213	            }
4214	        ));
4215	        assert!(matches!(
4216	            parse(&["spt", "grant", "revoke", "spawn-shell", "ling"])
4217	                .unwrap()
4218	                .cmd,
4219	            Cmd::Grant {
4220	                action: GrantCmd::Revoke { .. }
4221	            }
4222	        ));
4223	        assert!(matches!(
4224	            parse(&["spt", "grant", "list"]).unwrap().cmd,
4225	            Cmd::Grant {
4226	                action: GrantCmd::List { agent: None }
4227	            }
4228	        ));
4229	        assert!(matches!(
4230	            parse(&["spt", "grant", "list", "ling"]).unwrap().cmd,
4231	            Cmd::Grant {
4232	                action: GrantCmd::List { agent: Some(_) }
4233	            }
4234	        ));
4235	        assert!(
4236	            parse(&["spt", "grant", "add", "spawn-shell"]).is_err(),
4237	            "agent required"
4238	        );
4239	    }
4240
4241	    // [unit->REQ-INSTALL-4] the adapter surface parses: add takes a path or
4242	    // --github; remove takes a name + --force; list stands alone.
4243	    #[test]
4244	    fn adapter_subcommands_parse() {
4245	        assert!(matches!(
4246	            parse(&["spt", "adapter", "add", "C:/adapters/mock"])
4247	                .unwrap()
4248	                .cmd,
4249	            Cmd::Adapter {
4250	                action: AdapterCmd::Add {
4251	                    path: Some(_),
4252	                    github: None
4253	                }
4254	            }
4255	        ));
4256	        assert!(matches!(
4257	            parse(&["spt", "adapter", "add", "--github", "user/repo"])
4258	                .unwrap()
4259	                .cmd,
4260	            Cmd::Adapter {
4261	                action: AdapterCmd::Add {
4262	                    path: None,
4263	                    github: Some(_)
4264	                }
4265	            }
4266	        ));
4267	        match parse(&["spt", "adapter", "remove", "mock-shell", "--force"])
4268	            .unwrap()
4269	            .cmd
4270	        {
4271	            Cmd::Adapter {
4272	                action: AdapterCmd::Remove { name, force },
4273	            } => {
4274	                assert_eq!(name, "mock-shell");
4275	                assert!(force);
4276	            }
4277	            _ => panic!("expected Adapter Remove"),
4278	        }
4279	        assert!(matches!(
4280	            parse(&["spt", "adapter", "list"]).unwrap().cmd,
4281	            Cmd::Adapter {

mod tests {
    use super::*;

    fn parse(args: &[&str]) -> Result<Cli, clap::Error> {
        Cli::try_parse_from(args)
    }

    // [unit->REQ-MSG-2] the grouped --help contract holds: every visible
    // command is classified (AGENT_COMMANDS names must exist), the rendered
    // template carries both group headings, and every about stays a
    // one-liner short enough that no list entry ever wraps on an 80-col
    // terminal (the name column is 2+pad+2 wide).
    #[test]
    fn help_groups_cover_every_command() {
        use clap::CommandFactory;
        let mut cmd = Cli::command();
        let template = grouped_help_template(&mut cmd);
        assert!(template.contains("User commands:\n"));
        assert!(template.contains("Agent commands:\n"));

        let pad = cmd
            .get_subcommands()
            .filter(|s| !s.is_hide_set())
            .map(|s| s.get_name().len())
            .max()
            .unwrap();
        for sc in cmd.get_subcommands().filter(|s| !s.is_hide_set()) {
            let about = sc
                .get_about()
                .unwrap_or_else(|| panic!("{} needs an about", sc.get_name()))
                .to_string();
            assert!(
                !about.contains('\n'),
                "{}: about must be a one-liner",
                sc.get_name()
            );
            assert!(
                2 + pad + 2 + about.len() <= 80,
                "{}: about too long for an unwrapped 80-col entry ({} chars)",
                sc.get_name(),
                about.len()
            );
            assert!(
                template.contains(&format!("  {:<pad$}  {about}\n", sc.get_name())),
                "{} missing from the rendered template",
                sc.get_name()
            );
        }
        for name in AGENT_COMMANDS {
            assert!(
                cmd.find_subcommand(name).is_some(),
                "AGENT_COMMANDS names unknown command {name}"
            );
        }
    }

    // [unit->REQ-MSG-2] the documented subcommands + arg shapes parse.
    #[test]
    fn subcommands_parse() {
        assert!(matches!(
            parse(&["spt", "send", "bob", "--from", "alice"])
                .unwrap()
                .cmd,
            Cmd::Send { .. }
        ));
        assert!(matches!(
            parse(&["spt", "send", "bob", "--deferred"]).unwrap().cmd,
            Cmd::Send { deferred: true, .. }
        ));
        assert!(matches!(
            parse(&["spt", "ring", "bob", "--timeout", "5"])
                .unwrap()
                .cmd,
            Cmd::Ring { .. }
        ));
        assert!(matches!(
            parse(&["spt", "ready", "bob"]).unwrap().cmd,
            Cmd::Ready { once: false, .. }
        ));
        // [unit->REQ-MSG-2] `ready --once` absorbs the removed `poll`'s
        // drain-then-exit semantics (M7 plan decision 2); `poll` itself is gone.
        assert!(matches!(
            parse(&["spt", "ready", "bob", "--once"]).unwrap().cmd,
            Cmd::Ready { once: true, .. }
        ));
        assert!(
            parse(&["spt", "poll", "bob"]).is_err(),
            "poll is removed (ready --once)"
        );
        assert!(matches!(parse(&["spt", "list"]).unwrap().cmd, Cmd::List));
        assert!(matches!(
            parse(&["spt", "stop", "bob"]).unwrap().cmd,
            Cmd::Stop { .. }
        ));
        assert!(matches!(
            parse(&["spt", "whoami"]).unwrap().cmd,
            Cmd::Whoami
        ));
        // The hidden daemon entry point parses (auto-started by `api`).
        assert!(matches!(
            parse(&["spt", "daemon"]).unwrap().cmd,
            Cmd::Daemon
        ));
        // [unit->REQ-INST-11] the rename surface parses (two positional ids).
        assert!(matches!(
            parse(&["spt", "rename", "ling", "oak"]).unwrap().cmd,
            Cmd::Rename { .. }
        ));
        assert!(
            parse(&["spt", "rename", "ling"]).is_err(),
            "new id required"
        );
        // [unit->REQ-INST-15] the fork surface parses; the target is required.
        assert!(matches!(
            parse(&["spt", "fork", "ling", "ling-work", "--subnet", "work"])
                .unwrap()
                .cmd,
            Cmd::Fork {
                delete_source: false,
                ..
            }
        ));
        assert!(matches!(
            parse(&[
                "spt",
                "fork",
                "ling",
                "l2",
                "--subnet",
                "work",
                "--delete-source"
            ])
            .unwrap()
            .cmd,
            Cmd::Fork {
                delete_source: true,
                ..
            }
        ));
        assert!(
            parse(&["spt", "fork", "ling", "l2"]).is_err(),
            "--subnet required"
        );
        assert!(parse(&["spt", "fork", "ling"]).is_err(), "new id required");
        // [unit->REQ-INST-14] the resources surfaces parse.
        assert!(matches!(
            parse(&["spt", "resources", "set", "rust builds", "--id", "ling"])
                .unwrap()
                .cmd,
            Cmd::Resources {
                action: ResourcesCmd::Set { .. }
            }
        ));
        assert!(matches!(
            parse(&["spt", "resources", "show"]).unwrap().cmd,
            Cmd::Resources {
                action: ResourcesCmd::Show { id: None }
            }
        ));
        assert!(matches!(
            parse(&["spt", "resources", "list", "--subnet", "home"])
                .unwrap()
                .cmd,
            Cmd::Resources {
                action: ResourcesCmd::List { subnet: Some(_) }
            }
        ));
        assert!(
            parse(&["spt", "resources", "set"]).is_err(),
            "blurb text required"
        );
        // [unit->REQ-INST-3] the resting-command surfaces parse (one local id).
        assert!(matches!(
            parse(&["spt", "suspend", "ling"]).unwrap().cmd,
            Cmd::Suspend { .. }
        ));
        assert!(matches!(
            parse(&["spt", "wake", "ling"]).unwrap().cmd,
            Cmd::Wake { .. }
        ));
        // [unit->REQ-SHELL-2] shutdown parses with and without the self-default id.
        assert!(matches!(
            parse(&["spt", "shutdown"]).unwrap().cmd,
            Cmd::Shutdown { id: None }
        ));
        assert!(matches!(
            parse(&["spt", "shutdown", "ling"]).unwrap().cmd,
            Cmd::Shutdown { id: Some(_) }
        ));
        assert!(parse(&["spt", "suspend"]).is_err(), "id required");
        assert!(parse(&["spt", "wake"]).is_err(), "id required");
        // The digest surface + its flags.
        assert!(matches!(
            parse(&["spt", "digest", "doyle"]).unwrap().cmd,
            Cmd::Digest { .. }
        ));
        assert!(matches!(
            parse(&["spt", "digest", "doyle", "--follow", "--json"])
                .unwrap()
                .cmd,
            Cmd::Digest {
                follow: true,
                json: true,
                ..
            }
        ));
    }

    // [unit->REQ-NOTIF-2] the notify/notif surfaces parse: notify takes an
    // optional body + --subnet/--from; notif list/dismiss shapes hold.
    #[test]
    fn notify_surface_parses() {
        assert!(matches!(
            parse(&[
                "spt",
                "notify",
                "build done",
                "--subnet",
                "home",
                "--from",
                "doyle"
            ])
            .unwrap()
            .cmd,
            Cmd::Notify { .. }
        ));
        assert!(matches!(
            parse(&["spt", "notify"]).unwrap().cmd,
            Cmd::Notify { body: None, .. }
        ));
        assert!(matches!(
            parse(&["spt", "notif", "list", "--subnet", "home"])
                .unwrap()
                .cmd,
            Cmd::Notif {
                action: NotifCmd::List { .. }
            }
        ));
        assert!(matches!(
            parse(&["spt", "notif", "dismiss", "cafe:7"]).unwrap().cmd,
            Cmd::Notif {
                action: NotifCmd::Dismiss { .. }
            }
        ));
        assert!(
            parse(&["spt", "notif", "dismiss"]).is_err(),
            "notif id required"
        );
        // [unit->REQ-UPD-4] the update-consent body names this explicit ack verb.
        assert!(matches!(
            parse(&["spt", "update", "apply"]).unwrap().cmd,
            Cmd::Update {
                action: UpdateCmd::Apply
            }
        ));
    }

    // [unit->REQ-NOTIF-2] subnet resolution refuses to guess: explicit
    // non-member refused; zero subnets refused; exactly one auto-selects;
    // several force --subnet.
    #[test]
    fn notif_subnet_resolution_never_guesses() {
        let mut store = SubnetStore::default();
        assert_eq!(
            resolve_notif_subnet(&store, None),
            Err(1),
            "no subnet => refuse"
        );
        assert_eq!(
            resolve_notif_subnet(&store, Some("home".into())),
            Err(1),
            "non-member => refuse"
        );

        store.create_subnet("home").unwrap();
        assert_eq!(
            resolve_notif_subnet(&store, None),
            Ok("home".to_string()),
            "sole subnet"
        );
        assert_eq!(
            resolve_notif_subnet(&store, Some("home".into())),
            Ok("home".to_string())
        );

        store.create_subnet("work").unwrap();
        assert_eq!(
            resolve_notif_subnet(&store, None),
            Err(1),
            "several => qualify"
        );
        assert_eq!(
            resolve_notif_subnet(&store, Some("work".into())),
            Ok("work".to_string())
        );
    }

    // [unit->REQ-SEC-1] the access whitelist surface parses: allow/revoke take
    // (endpoint, node); open takes the endpoint; list takes an optional one.
    #[test]
    fn access_subcommands_parse() {
        assert!(matches!(
            parse(&["spt", "access", "allow", "ling", "aa11"])
                .unwrap()
                .cmd,
            Cmd::Access {
                action: AccessCmd::Allow { .. }
            }
        ));
        assert!(matches!(
            parse(&["spt", "access", "revoke", "ling", "aa11"])
                .unwrap()
                .cmd,
            Cmd::Access {
                action: AccessCmd::Revoke { .. }
            }
        ));
        assert!(matches!(
            parse(&["spt", "access", "open", "ling"]).unwrap().cmd,
            Cmd::Access {
                action: AccessCmd::Open { .. }
            }
        ));
        assert!(matches!(
            parse(&["spt", "access", "list"]).unwrap().cmd,
            Cmd::Access {
                action: AccessCmd::List { endpoint: None }
            }
        ));
        assert!(matches!(
            parse(&["spt", "access", "list", "ling"]).unwrap().cmd,
            Cmd::Access {
                action: AccessCmd::List { endpoint: Some(_) }
            }
        ));
        assert!(
            parse(&["spt", "access", "allow", "ling"]).is_err(),
            "node required"
        );
    }

    // [unit->REQ-CONSENT-1] the grant surface parses: add/revoke take
    // capability + agent with an optional --qualifier; list takes an
    // optional agent filter.
    #[test]
    fn grant_subcommands_parse() {
        match parse(&[
            "spt",
            "grant",
            "add",
            "spawn-shell",
            "ling",
            "--qualifier",
            "OsNotify",
        ])
        .unwrap()
        .cmd
        {
            Cmd::Grant {
                action:
                    GrantCmd::Add {
                        capability,
                        agent,
                        qualifier,
                    },
            } => {
                assert_eq!(capability, "spawn-shell");
                assert_eq!(agent, "ling");
                assert_eq!(qualifier.as_deref(), Some("OsNotify"));
            }
            _ => panic!("expected Grant Add"),
        }
        assert!(matches!(
            parse(&["spt", "grant", "add", "owner-shutdown", "ling"])
                .unwrap()
                .cmd,
            Cmd::Grant {
                action: GrantCmd::Add {
                    qualifier: None,
                    ..
                }
            }
        ));
        assert!(matches!(
            parse(&["spt", "grant", "revoke", "spawn-shell", "ling"])
                .unwrap()
                .cmd,
            Cmd::Grant {
                action: GrantCmd::Revoke { .. }
            }
        ));
        assert!(matches!(
            parse(&["spt", "grant", "list"]).unwrap().cmd,
            Cmd::Grant {
                action: GrantCmd::List { agent: None }
            }
        ));
        assert!(matches!(
            parse(&["spt", "grant", "list", "ling"]).unwrap().cmd,
            Cmd::Grant {
                action: GrantCmd::List { agent: Some(_) }
            }
        ));
        assert!(
            parse(&["spt", "grant", "add", "spawn-shell"]).is_err(),
            "agent required"
        );
    }

    // [unit->REQ-INSTALL-4] the adapter surface parses: add takes a path or
    // --github; remove takes a name + --force; list stands alone.
    #[test]
    fn adapter_subcommands_parse() {
        assert!(matches!(
            parse(&["spt", "adapter", "add", "C:/adapters/mock"])
                .unwrap()
                .cmd,
            Cmd::Adapter {
                action: AdapterCmd::Add {
                    path: Some(_),
                    github: None
                }
            }
        ));
        assert!(matches!(
            parse(&["spt", "adapter", "add", "--github", "user/repo"])
                .unwrap()
                .cmd,
            Cmd::Adapter {
                action: AdapterCmd::Add {
                    path: None,
                    github: Some(_)
                }
            }
        ));
        match parse(&["spt", "adapter", "remove", "mock-shell", "--force"])
            .unwrap()
            .cmd
        {
            Cmd::Adapter {
                action: AdapterCmd::Remove { name, force },
            } => {
                assert_eq!(name, "mock-shell");
                assert!(force);
            }
            _ => panic!("expected Adapter Remove"),
        }
        assert!(matches!(
            parse(&["spt", "adapter", "list"]).unwrap().cmd,
            Cmd::Adapter {


Full picture. Check cross-references to moved verbs before editing.


crates\spt-daemon\src\daemon.rs:75:                &["daemon".to_string()],
crates\spt-daemon\src\daemon.rs:293:        let args = ["daemon".to_string()];
crates\spt-daemon\src\daemon.rs:327:        cmd.arg("daemon")
crates\spt-daemon\src\daemon.rs:509:        assert_eq!(q("daemon"), "daemon");
crates\spt\src\cli.rs:3932:            parse(&["spt", "daemon"]).unwrap().cmd,


crates\spt-net\src\lib.rs:39://!   D8  subnet notification primitive + `spt notify`           (REQ-NOTIF-1, REQ-NOTIF-2)
crates\spt\tests\shell_sleepwake_e2e.rs:5://! spawn → bind onlines → `spt shutdown <owner>` (graceful self-suspend) →
crates\spt\tests\shell_sleepwake_e2e.rs:149:    // is node-scoped) + the ready marker `spt shutdown` soft-stops.
crates\spt\tests\shell_sleepwake_e2e.rs:210:    // ── END ONE: `spt shutdown` — the graceful self-suspend. Persistent
crates\spt\src\wansend.rs:152:/// What the remote rest leg did — `spt suspend/wake <id@node>`'s exit-path
crates\spt\src\roster.rs:14:/// A snapshot of one perch for `spt list`.
crates\spt-net\src\net\registry.rs:58:    /// resumed-on-wake. Still addressable (`spt wake ling@desktop` must route) —
crates\spt-net\src\net\registry.rs:368:/// (the registry arm of `spt rename <id> <new_id>`, ADR-0006 §2). Collision-checked
crates\spt-live\src\signoff.rs:22:// M2b implements the GRACEFUL teardown paths (spt shutdown / spt suspend /
crates\spt\tests\contract_e2e.rs:251:/// ingested + deleted by spt-core → a graceful `spt shutdown` fires the
crates\spt\src\cli.rs:294:/// `spt access <action>` subcommands (REQ-SEC-1).
crates\spt\src\cli.rs:301:        /// The origin node's pubkey hex (as shown by `spt list` / pairing).
crates\spt\src\cli.rs:450:/// `spt resources <action>` subcommands (D9-4, REQ-INST-14).
crates\spt\src\cli.rs:681:/// `spt digest <id>` — show a daemon-hosted session's live activity buffer
crates\spt\src\cli.rs:740:/// `spt fork <src> <new_id> --subnet <target>` — the cross-subnet move
crates\spt\src\cli.rs:857:/// `spt resources set|show|list` — the subnet resource registry surface
crates\spt\src\cli.rs:957:/// `spt suspend <id>` / `spt wake <id>` — the manual resting-state edges
crates\spt\src\cli.rs:1030:/// `spt shutdown [id]` — the agent's graceful self-suspend verb (M5-D4d,
crates\spt\src\cli.rs:1034:/// listener (drop the ready marker — the session-over half `spt suspend`
crates\spt\src\cli.rs:1041:        eprintln!("NO_SELF: shutdown needs an endpoint (spt shutdown <id> or $OWL_SESSION_ID)");
crates\spt\src\cli.rs:1047:        // `spt suspend <id@node>` covers the remote need.
crates\spt\src\cli.rs:1049:            "LOCAL_ONLY:{id} — shutdown is an endpoint's own verb; use `spt suspend {id}` remotely"
crates\spt\src\cli.rs:1111:/// `spt notify` — agent-issued subnet notif (ADR-0007, REQ-NOTIF-2): produce
crates\spt\src\cli.rs:2566:Going offline cleanly: `spt stop <id>` (the spool is preserved; messages sent
crates\spt\src\cli.rs:2652:/// `spt rename <old> <new>` — the on-disk rename ripple (REQ-INST-11). Validates
crates\spt\src\cli.rs:2657:/// `spt access allow|revoke|open|list` — operate the endpoint access whitelist
crates\spt\src\cli.rs:2711:                         `spt access open {endpoint}` returns it to default-open"
crates\spt\src\cli.rs:3778:                         recover: rerun `spt rename {old_id} {new_id}` after fixing the cause \
crates\spt\src\cli.rs:4630:    // [unit->REQ-SHELL-2] `spt shutdown` — the graceful self-suspend verb:
crates\spt-term\src\digest.rs:28://! byte stream. The manifest-sourcing of the patterns, the `spt digest` pull,
crates\spt-daemon\tests\digest.rs:8://! `spt digest` **snapshot pull** ([`pull_snapshot`]) and a **delta-stream**
crates\spt-daemon\tests\digest.rs:90:    // The digest hub + its control channel (the `spt digest` access surface).
crates\spt-daemon\tests\twohost.rs:730:    // ── M5 rung 11 — notif→toast (THE dogfood demo): A's `spt notify`
crates\spt-daemon\tests\twohost.rs:1010:    // ── M5 rung 8 — remote suspend/wake (drive side): `spt suspend id@node`'s
crates\spt-daemon\tests\twohost.rs:1152:    // `spt notify` at A surfaces as a native render on whichever host the
crates\spt-net\src\net\rest.rs:1://! Remote resting-state records (M5-D5b, REQ-INST-6) — `spt suspend/wake
crates\spt-store\src\contextstore.rs:259:    /// The context-store arm of `spt rename <old> <new>` (REQ-INST-11, the
crates\spt-store\src\contextstore.rs:327:    /// The context arm of `spt fork <src> <new>` (D9-5, REQ-INST-15 —
crates\spt-store\src\contextstore.rs:381:    /// `spt fork` (and nothing else: deletion is explicit, never implied).
crates\spt-store\src\info.rs:101:    /// `spt resources set` ([`set_resources`]) and advertised into every

[Showing results with pagination = limit: 40]


Ratify M8-PLAN.md — fold A–I resolutions in, status DRAFT→RATIFIED

Move resolved decisions A-G + new H/I into ratified table; update D1-D5 deliverable text (NTP, debug-converge, peer-addrs.json, pump liveness, hazard entry); update acceptance; commit.

Mint M8 requirements in traceable-reqs.toml (rule 3)

REQ-CLI-* (endpoint noun, daemon noun, hot-path), REQ-SUBNET-* (attach/detach, prune, re-pair overwrite, status render), REQ-INSTALL-* (Linux elevation leg, firewall, OS-service), NTP/TOTP offset, pump liveness, addr seeding, event advertisement, debug-converge activation (REQ-UPD-6 int), epoch-reset REQ-HAZARD-* (stages=[]). Activate only what D1 starts; rest staged per rule 5.

D1 — endpoint + daemon nouns (breaking CLI reorg)

Namespace skeletons; move fork/suspend/wake/shutdown/rename/stop/digest + access (1:1, decision F) + description under endpoint; merged endpoint list (SELF pinned, subnet-grouped); notify → subnet notify; daemon run|stop|status (status renders pump heartbeat per H); help groups; how-to topics; cli/reference.md regen.

D2 — subnet serve-state, trust lifecycle, status UX

attach/detach machinery + --auto persistence; subnet leave (elevation); subnet prune (elevation, BIGNET 09ef831e fixture); re-pair trust overwrite (machine id per G: OS id hashed, UUID fallback; additive serde field); status render fixes (decisions 11/12). Disband deferred (A).

D3 — install & elevation hardening

Linux: install.sh symlink to /usr/local/bin + first-sudo account election + KH 5.7 verify. Windows: firewall rule (elevated leg) + blocked-inbound self-detection render. OS-service per B: systemd user service + enable-linger; Windows scheduled task at-logon. E2E rungs env-gated.

D4 — convergence polish + pump resilience

peer-addrs.json seeding per E/14 (both cold starts, id-only fallback); event-driven advertisement (decision 15); NTP TOTP-offset per C/C2 (ceremony-time, system-clock fallback); pump supervision per H (panic catch + capped-backoff restart, heartbeat file); xtask debug-converge watcher per D (REQ-UPD-6 int).

D5 — docs + CI + acceptance prep

Docs-site verb sweep; quickstart/how-to deltas; README; llms.txt; reference regen; KNOWN-HAZARDS epoch-reset entry per I; quickstart_e2e + twohost ladder updates; traceable-reqs check + xtask check green.


crates\spt\tests\shell_sleepwake_e2e.rs:218:    let out = spt(&["shutdown", "doyle"]);
crates\spt-daemon\tests\twohost.rs:479:        spt_store::shellinfo::spawn_record(&perch::owlery_dir(), ID_B, "notify", Some(SHELL_ALIAS))
crates\spt\tests\notify_shell_e2e.rs:120:    let out = spt(&["shell", "spawn", "notify", "--owner", "doyle"]);
crates\spt\tests\notify_shell_e2e.rs:150:        "notify",
crates\spt-daemon\src\dispatch.rs:619:            json!({"kind": "request", "endpoint": "ling", "event": "suspend", "op_id": "a:1"});
crates\spt\tests\contract_e2e.rs:391:            "shutdown",
crates\spt\src\cli.rs:559:    "notify",
crates\spt\src\cli.rs:561:    "resources",
crates\spt\src\cli.rs:565:    "shutdown",
crates\spt\src\cli.rs:3937:            parse(&["spt", "rename", "ling", "oak"]).unwrap().cmd,
crates\spt\src\cli.rs:3941:            parse(&["spt", "rename", "ling"]).is_err(),
crates\spt\src\cli.rs:3946:            parse(&["spt", "fork", "ling", "ling-work", "--subnet", "work"])
crates\spt\src\cli.rs:3957:                "fork",
crates\spt\src\cli.rs:3972:            parse(&["spt", "fork", "ling", "l2"]).is_err(),
crates\spt\src\cli.rs:3975:        assert!(parse(&["spt", "fork", "ling"]).is_err(), "new id required");
crates\spt\src\cli.rs:3978:            parse(&["spt", "resources", "set", "rust builds", "--id", "ling"])
crates\spt\src\cli.rs:3986:            parse(&["spt", "resources", "show"]).unwrap().cmd,
crates\spt\src\cli.rs:3992:            parse(&["spt", "resources", "list", "--subnet", "home"])
crates\spt\src\cli.rs:4000:            parse(&["spt", "resources", "set"]).is_err(),
crates\spt\src\cli.rs:4005:            parse(&["spt", "suspend", "ling"]).unwrap().cmd,
crates\spt\src\cli.rs:4009:            parse(&["spt", "wake", "ling"]).unwrap().cmd,
crates\spt\src\cli.rs:4018:            parse(&["spt", "shutdown", "ling"]).unwrap().cmd,
crates\spt\src\cli.rs:4025:            parse(&["spt", "digest", "doyle"]).unwrap().cmd,
crates\spt\src\cli.rs:4029:            parse(&["spt", "digest", "doyle", "--follow", "--json"])
crates\spt\src\cli.rs:4047:                "notify",
crates\spt\src\cli.rs:4134:            parse(&["spt", "access", "allow", "ling", "aa11"])
crates\spt\src\cli.rs:4142:            parse(&["spt", "access", "revoke", "ling", "aa11"])
crates\spt\src\cli.rs:4150:            parse(&["spt", "access", "open", "ling"]).unwrap().cmd,
crates\spt\src\cli.rs:4156:            parse(&["spt", "access", "list"]).unwrap().cmd,
crates\spt\src\cli.rs:4162:            parse(&["spt", "access", "list", "ling"]).unwrap().cmd,
crates\spt\src\cli.rs:4168:            parse(&["spt", "access", "allow", "ling"]).is_err(),
crates\spt\src\cli.rs:4343:            parse(&["spt", "shell", "rename", "GameRobot-0", "Keeper"])
crates\spt\src\cli.rs:4406:            parse(&["spt", "shell", "rename", "GameRobot-0"]).is_err(),
crates\spt\src\cli.rs:5249:        match parse(&["spt", "digest", "doyle"]).unwrap().cmd {


380	        "ingested commune must land in the live context tier"
381	    );
382
383	    // 3. Graceful shutdown fires the echo-commune BEFORE teardown (3.3).
384	    let shutdown = Command::new(&spt_bin)
385	        .args([
386	            "api",
387	            "--adapter",
388	            "mock",
389	            "--manifest",
390	            &manifest_arg,
391	            "shutdown",
392	            id,
393	            "--session-id",
394	            session_id,
395	        ])
396	        .env("SPT_HOME", home.path())
397	        .output()
398	        .expect("run api shutdown");
399	    let sh_err = String::from_utf8_lossy(&shutdown.stderr);
400	    assert!(
401	        shutdown.status.success(),
402	        "api shutdown exit: {:?}\n{sh_err}",
403	        shutdown.status
404	    );

        "ingested commune must land in the live context tier"
    );

    // 3. Graceful shutdown fires the echo-commune BEFORE teardown (3.3).
    let shutdown = Command::new(&spt_bin)
        .args([
            "api",
            "--adapter",
            "mock",
            "--manifest",
            &manifest_arg,
            "shutdown",
            id,
            "--session-id",
            session_id,
        ])
        .env("SPT_HOME", home.path())
        .output()
        .expect("run api shutdown");
    let sh_err = String::from_utf8_lossy(&shutdown.stderr);
    assert!(
        shutdown.status.success(),
        "api shutdown exit: {:?}\n{sh_err}",
        shutdown.status
    );


20:fn spt() -> Command {

20:fn spt() -> Command {


66:        .args(["ready", "sergey"])
117:        .args(["ready", "sergey", "--once"])
133:        .args(args)
149:        .args(args)