2840	                (r, online)
2841	            })
2842	            .collect();
2843	        out.push_str(&render_node_rows(&sub.name, &settled));
2844	    }
2845	    if out.is_empty() {
2846	        out.push_str(standalone_text(daemon_running));
2847	    }
2848	    if let Some(w) = stall {
2849	        out.push_str(&w);
2850	    }
2851	    if let Some(w) = inbound {
2852	        out.push_str(&w);
2853	    }
2854	    if hints {
2855	        out.push_str(HINT_FOOTER);
2856	    }
2857	    print!("{out}");
2858	    let _ = std::io::stdout().flush();
2859	    0
2860	}
2861
2862	/// The leave/prune elevation verdict (REQ-SUBNET-6): both verbs mutate
2863	/// trust material, the same class as join's machine enrollment — fail-safe,
2864	/// only confirmed elevation proceeds.
2865	// [impl->REQ-SUBNET-6]
2866	fn trust_mutation_refusal(elevation: Elevation, what: &str) -> Option<String> {
2867	    match elevation::gate_for(elevation) {
2868	        Gate::Show => None,
2869	        Gate::Fallback => Some(format!(
2870	            "ELEVATION_REQUIRED: {what} mutates this machine's trust material — re-run \
2871	             elevated (run as administrator / root)"
2872	        )),
2873	    }
2874	}
2875
2876	/// `spt subnet leave <NAME>` — membership exit (M8 decision 6, REQ-SUBNET-6):
2877	/// drop the subnet's seed, its trust rows, its serve-state row, and its
2878	/// registry snapshot on THIS node. Elevation-gated, gate-first (refused =
2879	/// nothing read, nothing removed). The remaining members keep the old seed —
2880	/// revoking this machine from the subnet is their seed rotation
2881	/// (REQ-HAZARD-PAIR-SEED-ROTATION), not something leave can do from here.
2882	// [impl->REQ-SUBNET-6]
2883	fn cmd_subnet_leave(name: &str) -> i32 {
2884	    if let Some(msg) = trust_mutation_refusal(elevation::current(), "leaving a subnet") {
2885	        if let Some(code) = try_auto_elevate() {
2886	            return code;
2887	        }
2888	        eprintln!("{}", with_elevation_hint(msg));
2889	        return EXIT_NOT_ELEVATED;
2890	    }
2891	    let mut subnets = SubnetStore::load();
2892	    if subnets.find(name).is_none() {
2893	        eprintln!("NO_SUCH_SUBNET:{name}");
2894	        return 2;
2895	    }
2896	    // The seed first (the membership fact), then the dependent material —
2897	    // each step is atomic on its own file; a failure mid-way leaves only
2898	    // harmless residue (roster rows without a subnet never dial: the pump
2899	    // iterates subnets, not roster rows).
2900	    subnets.remove(name);
2901	    if let Err(e) = subnets.save() {
2902	        eprintln!("LEAVE_FAIL:{name}: {e}");
2903	        return 1;
2904	    }
2905	    // Drop this subnet's roster (members + tombstones) — a local wipe, not a
2906	    // grow-set tombstone: we are removing the whole subnet here, and with no
2907	    // seed left the pump never re-merges it (Mesh-D6 — the roster is membership).
2908	    let mut roster = spt_store::roster::RosterStore::load();
2909	    let before = roster.members.len();
2910	    roster.members.retain(|m| m.subnet != name);
2911	    roster.tombstones.retain(|t| t.subnet != name);
2912	    let dropped = before - roster.members.len();
2913	    if let Err(e) = roster.save() {
2914	        eprintln!("LEAVE_ROSTER_FAIL:{name}: {e} (membership already removed)");
2915	        return 1;
2916	    }
2917	    let mut att = spt_store::attachment::AttachmentStore::load();
2918	    att.set_detached(name, false);
2919	    let _ = att.save();
2920	    let snap = perch::identity_dir()
2921	        .join("registry")
2922	        .join(format!("{name}.json"));
2923	    let _ = std::fs::remove_file(snap);
2924	    eprintln!(
2925	        "LEFT:{name} (seed removed, {dropped} roster member(s) dropped). The remaining \
2926	         members still hold the old seed — rotate it there if this machine must not rejoin."
2927	    );
2928	    0
2929	}
2930
2931	/// The prune target resolution (pure, REQ-SUBNET-6): distinct member
2932	/// pubkeys matching a full hex, a hex prefix, or a node label
2933	/// (case-insensitive) — several distinct matches refuse with candidates,
2934	/// never a guess (the `@node` qualifier stance).
2935	// [impl->REQ-SUBNET-6]
2936	fn prune_candidates(roster: &spt_store::roster::RosterStore, query: &str) -> Vec<String> {
2937	    let q = query.to_ascii_lowercase();
2938	    let mut out: Vec<String> = Vec::new();
2939	    for m in &roster.members {
2940	        let hit = m.pubkey_hex == q
2941	            || m.pubkey_hex.starts_with(&q)
2942	            || (!m.label.is_empty() && m.label.eq_ignore_ascii_case(query));
2943	        if hit && !out.contains(&m.pubkey_hex) {
2944	            out.push(m.pubkey_hex.clone());
2945	        }
2946	    }
2947	    out
2948	}
2949
2950	/// `spt subnet prune <node>` — locally tombstone a dead identity's roster
2951	/// membership + clear its registry snapshot rows (M8 decision 7, REQ-SUBNET-6).
2952	/// The pump dials roster members every tick, so a re-paired/retired machine's
2953	/// stale identity costs a failed dial forever until pruned. The tombstone is
2954	/// LOCAL (Mesh-D6): it drops the `is_member` gate and a later merge cannot
2955	/// re-admit it here — but it does NOT propagate or rotate the seed (the
2956	/// fleet-wide, seed-rotating revoke is `spt subnet revoke`, Mesh-D7).
2957	/// Elevation-gated, gate-first. Own identity refuses (leave owns that).
2958	// [impl->REQ-SUBNET-6]
2959	fn cmd_subnet_prune(node: &str) -> i32 {
2960	    if let Some(msg) = trust_mutation_refusal(elevation::current(), "pruning a node's roster rows") {
2961	        if let Some(code) = try_auto_elevate() {
2962	            return code;
2963	        }
2964	        eprintln!("{}", with_elevation_hint(msg));
2965	        return EXIT_NOT_ELEVATED;
2966	    }
2967	    let mut roster = spt_store::roster::RosterStore::load();
2968	    let candidates = prune_candidates(&roster, node);
2969	    let hex = match candidates.as_slice() {
2970	        [] => {
2971	            eprintln!("NO_MATCH:{node} — no member identity matches (hex, prefix, or label)");
2972	            return 1;
2973	        }
2974	        [one] => one.clone(),
2975	        several => {
2976	            let pfx: Vec<String> = several
2977	                .iter()
2978	                .map(|h| format!("{}…", spt_net::net::registry::key_prefix(h)))
2979	                .collect();
2980	            eprintln!(
2981	                "AMBIGUOUS:{node} — several identities match: {} (use a longer prefix)",
2982	                pfx.join(", ")
2983	            );
2984	            return 1;
2985	        }
2986	    };
2987	    if let Ok(own) = spt_store::nodeid::load_or_create() {
2988	        if own.public_key().to_hex() == hex {
2989	            eprintln!("OWN_IDENTITY:{node} — prune removes OTHER nodes' rows; use `spt subnet leave` to exit a subnet");
2990	            return 1;
2991	        }
2992	    }
2993	    // Tombstone (not delete) every subnet the identity is a member of — a bare
2994	    // remove would re-merge from any peer that still lists the key; the
2995	    // tombstone dominates + suppresses reinsert (a fresh re-pair clears it).
2996	    let subnets_hit: Vec<String> = roster
2997	        .members
2998	        .iter()
2999	        .filter(|m| m.pubkey_hex == hex)
3000	        .map(|m| m.subnet.clone())
3001	        .collect();
3002	    let stamp = (now_ms() / 1000).to_string();
3003	    for sub in &subnets_hit {
3004	        roster.tombstone(sub, &hex, &stamp);
3005	    }
3006	    let dropped = subnets_hit.len();
3007	    if let Err(e) = roster.save() {
3008	        eprintln!("PRUNE_FAIL:{node}: {e}");
3009	        return 1;
3010	    }
3011	    // Registry snapshot rows: the dead identity's advertised rows would
3012	    // otherwise linger until the 4.10 eviction window; prune is explicit,
3013	    // so clear them now (every snapshot — the identity is dead everywhere).
3014	    let snap_dir = perch::identity_dir().join("registry");
3015	    let mut evicted = 0usize;
3016	    for (name, mut reg) in crate::wansend::load_snapshots(&snap_dir) {
3017	        // Prune is an EXPLICIT forget: drop the dead identity's routable rows
3018	        // AND its node-level label (silence-decay keeps the label, but a
3019	        // pruned identity is gone for good — REQ-SUBNET-3 / REQ-SUBNET-6).
3020	        let n = reg.evict_nodes(|node| node == hex) + reg.evict_node_labels(|node| node == hex);
3021	        if n > 0 {
3022	            evicted += n;
3023	            if let Ok(json) = serde_json::to_string_pretty(&reg) {
3024	                let _ = spt_store::atomic::atomic_write_string(
3025	                    &snap_dir.join(format!("{name}.json")),
3026	                    &json,
3027	                );
3028	            }
3029	        }
3030	    }
3031	    eprintln!(
3032	        "PRUNED:{}… ({dropped} roster tombstone(s) [{}], {evicted} registry row(s)). \
3033	         Note: a local tombstone is not revocation — it does not propagate or \
3034	         rotate the seed (the machine still holds it). Use `spt subnet revoke` \
3035	         to revoke fleet-wide and rotate the seed.",
3036	        spt_net::net::registry::key_prefix(&hex),
3037	        subnets_hit.join(", "),
3038	    );
3039	    0
3040	}
3041
3042	/// `spt subnet detach|attach <NAME> [--save]` — the per-subnet serve-state
3043	/// flip (M8 decision 6, REQ-SUBNET-5). Ungated: a serve-state toggle
3044	/// mutates no trust material (unlike `leave`/`prune`). The daemon's pump,
3045	/// pairing responder, and meet rotation re-read the live state per round,
3046	/// so the flip takes effect within one cadence without a restart. `--save`
3047	/// also writes the startup default into `daemon.json` (an unsaved flip
3048	/// does not survive a daemon restart — the boot reset re-seeds from
3049	/// config).

                (r, online)
            })
            .collect();
        out.push_str(&render_node_rows(&sub.name, &settled));
    }
    if out.is_empty() {
        out.push_str(standalone_text(daemon_running));
    }
    if let Some(w) = stall {
        out.push_str(&w);
    }
    if let Some(w) = inbound {
        out.push_str(&w);
    }
    if hints {
        out.push_str(HINT_FOOTER);
    }
    print!("{out}");
    let _ = std::io::stdout().flush();
    0
}

/// The leave/prune elevation verdict (REQ-SUBNET-6): both verbs mutate
/// trust material, the same class as join's machine enrollment — fail-safe,
/// only confirmed elevation proceeds.
// [impl->REQ-SUBNET-6]
fn trust_mutation_refusal(elevation: Elevation, what: &str) -> Option<String> {
    match elevation::gate_for(elevation) {
        Gate::Show => None,
        Gate::Fallback => Some(format!(
            "ELEVATION_REQUIRED: {what} mutates this machine's trust material — re-run \
             elevated (run as administrator / root)"
        )),
    }
}

/// `spt subnet leave <NAME>` — membership exit (M8 decision 6, REQ-SUBNET-6):
/// drop the subnet's seed, its trust rows, its serve-state row, and its
/// registry snapshot on THIS node. Elevation-gated, gate-first (refused =
/// nothing read, nothing removed). The remaining members keep the old seed —
/// revoking this machine from the subnet is their seed rotation
/// (REQ-HAZARD-PAIR-SEED-ROTATION), not something leave can do from here.
// [impl->REQ-SUBNET-6]
fn cmd_subnet_leave(name: &str) -> i32 {
    if let Some(msg) = trust_mutation_refusal(elevation::current(), "leaving a subnet") {
        if let Some(code) = try_auto_elevate() {
            return code;
        }
        eprintln!("{}", with_elevation_hint(msg));
        return EXIT_NOT_ELEVATED;
    }
    let mut subnets = SubnetStore::load();
    if subnets.find(name).is_none() {
        eprintln!("NO_SUCH_SUBNET:{name}");
        return 2;
    }
    // The seed first (the membership fact), then the dependent material —
    // each step is atomic on its own file; a failure mid-way leaves only
    // harmless residue (roster rows without a subnet never dial: the pump
    // iterates subnets, not roster rows).
    subnets.remove(name);
    if let Err(e) = subnets.save() {
        eprintln!("LEAVE_FAIL:{name}: {e}");
        return 1;
    }
    // Drop this subnet's roster (members + tombstones) — a local wipe, not a
    // grow-set tombstone: we are removing the whole subnet here, and with no
    // seed left the pump never re-merges it (Mesh-D6 — the roster is membership).
    let mut roster = spt_store::roster::RosterStore::load();
    let before = roster.members.len();
    roster.members.retain(|m| m.subnet != name);
    roster.tombstones.retain(|t| t.subnet != name);
    let dropped = before - roster.members.len();
    if let Err(e) = roster.save() {
        eprintln!("LEAVE_ROSTER_FAIL:{name}: {e} (membership already removed)");
        return 1;
    }
    let mut att = spt_store::attachment::AttachmentStore::load();
    att.set_detached(name, false);
    let _ = att.save();
    let snap = perch::identity_dir()
        .join("registry")
        .join(format!("{name}.json"));
    let _ = std::fs::remove_file(snap);
    eprintln!(
        "LEFT:{name} (seed removed, {dropped} roster member(s) dropped). The remaining \
         members still hold the old seed — rotate it there if this machine must not rejoin."
    );
    0
}

/// The prune target resolution (pure, REQ-SUBNET-6): distinct member
/// pubkeys matching a full hex, a hex prefix, or a node label
/// (case-insensitive) — several distinct matches refuse with candidates,
/// never a guess (the `@node` qualifier stance).
// [impl->REQ-SUBNET-6]
fn prune_candidates(roster: &spt_store::roster::RosterStore, query: &str) -> Vec<String> {
    let q = query.to_ascii_lowercase();
    let mut out: Vec<String> = Vec::new();
    for m in &roster.members {
        let hit = m.pubkey_hex == q
            || m.pubkey_hex.starts_with(&q)
            || (!m.label.is_empty() && m.label.eq_ignore_ascii_case(query));
        if hit && !out.contains(&m.pubkey_hex) {
            out.push(m.pubkey_hex.clone());
        }
    }
    out
}

/// `spt subnet prune <node>` — locally tombstone a dead identity's roster
/// membership + clear its registry snapshot rows (M8 decision 7, REQ-SUBNET-6).
/// The pump dials roster members every tick, so a re-paired/retired machine's
/// stale identity costs a failed dial forever until pruned. The tombstone is
/// LOCAL (Mesh-D6): it drops the `is_member` gate and a later merge cannot
/// re-admit it here — but it does NOT propagate or rotate the seed (the
/// fleet-wide, seed-rotating revoke is `spt subnet revoke`, Mesh-D7).
/// Elevation-gated, gate-first. Own identity refuses (leave owns that).
// [impl->REQ-SUBNET-6]
fn cmd_subnet_prune(node: &str) -> i32 {
    if let Some(msg) = trust_mutation_refusal(elevation::current(), "pruning a node's roster rows") {
        if let Some(code) = try_auto_elevate() {
            return code;
        }
        eprintln!("{}", with_elevation_hint(msg));
        return EXIT_NOT_ELEVATED;
    }
    let mut roster = spt_store::roster::RosterStore::load();
    let candidates = prune_candidates(&roster, node);
    let hex = match candidates.as_slice() {
        [] => {
            eprintln!("NO_MATCH:{node} — no member identity matches (hex, prefix, or label)");
            return 1;
        }
        [one] => one.clone(),
        several => {
            let pfx: Vec<String> = several
                .iter()
                .map(|h| format!("{}…", spt_net::net::registry::key_prefix(h)))
                .collect();
            eprintln!(
                "AMBIGUOUS:{node} — several identities match: {} (use a longer prefix)",
                pfx.join(", ")
            );
            return 1;
        }
    };
    if let Ok(own) = spt_store::nodeid::load_or_create() {
        if own.public_key().to_hex() == hex {
            eprintln!("OWN_IDENTITY:{node} — prune removes OTHER nodes' rows; use `spt subnet leave` to exit a subnet");
            return 1;
        }
    }
    // Tombstone (not delete) every subnet the identity is a member of — a bare
    // remove would re-merge from any peer that still lists the key; the
    // tombstone dominates + suppresses reinsert (a fresh re-pair clears it).
    let subnets_hit: Vec<String> = roster
        .members
        .iter()
        .filter(|m| m.pubkey_hex == hex)
        .map(|m| m.subnet.clone())
        .collect();
    let stamp = (now_ms() / 1000).to_string();
    for sub in &subnets_hit {
        roster.tombstone(sub, &hex, &stamp);
    }
    let dropped = subnets_hit.len();
    if let Err(e) = roster.save() {
        eprintln!("PRUNE_FAIL:{node}: {e}");
        return 1;
    }
    // Registry snapshot rows: the dead identity's advertised rows would
    // otherwise linger until the 4.10 eviction window; prune is explicit,
    // so clear them now (every snapshot — the identity is dead everywhere).
    let snap_dir = perch::identity_dir().join("registry");
    let mut evicted = 0usize;
    for (name, mut reg) in crate::wansend::load_snapshots(&snap_dir) {
        // Prune is an EXPLICIT forget: drop the dead identity's routable rows
        // AND its node-level label (silence-decay keeps the label, but a
        // pruned identity is gone for good — REQ-SUBNET-3 / REQ-SUBNET-6).
        let n = reg.evict_nodes(|node| node == hex) + reg.evict_node_labels(|node| node == hex);
        if n > 0 {
            evicted += n;
            if let Ok(json) = serde_json::to_string_pretty(&reg) {
                let _ = spt_store::atomic::atomic_write_string(
                    &snap_dir.join(format!("{name}.json")),
                    &json,
                );
            }
        }
    }
    eprintln!(
        "PRUNED:{}… ({dropped} roster tombstone(s) [{}], {evicted} registry row(s)). \
         Note: a local tombstone is not revocation — it does not propagate or \
         rotate the seed (the machine still holds it). Use `spt subnet revoke` \
         to revoke fleet-wide and rotate the seed.",
        spt_net::net::registry::key_prefix(&hex),
        subnets_hit.join(", "),
    );
    0
}

/// `spt subnet detach|attach <NAME> [--save]` — the per-subnet serve-state
/// flip (M8 decision 6, REQ-SUBNET-5). Ungated: a serve-state toggle
/// mutates no trust material (unlike `leave`/`prune`). The daemon's pump,
/// pairing responder, and meet rotation re-read the live state per round,
/// so the flip takes effect within one cadence without a restart. `--save`
/// also writes the startup default into `daemon.json` (an unsaved flip
/// does not survive a daemon restart — the boot reset re-seeds from
/// config).


535	enum SubnetCmd {
536	    /// Show subnet membership: name, paired nodes, endpoints.
537	    ///
538	    /// Never prints seeds, epochs, or pairing codes. Bare `spt subnet` is
539	    /// the same view.
540	    Status {
541	        /// Limit to one subnet (all member subnets otherwise).
542	        name: Option<String>,
543	        /// Per-node rows: label, online/offline, [online endpoints/total].
544	        #[arg(long)]
545	        nodes: bool,
546	    },
547	    /// Mint a fresh subnet and print its joining material.
548	    ///
549	    /// This node becomes the sole seed-holder. Prints the current 6-digit
550	    /// code, the `otpauth://` provisioning URI, and a terminal QR of it.
551	    /// Gated behind OS elevation (the seed-reveal path).
552	    Create {
553	        /// The new subnet's name.
554	        name: String,
555	    },
556	    /// Show a subnet's current 6-digit pairing code (+ URI and QR).
557	    ///
558	    /// The re-provisioning surface: prints the same joining material as
559	    /// `create` — current code, `otpauth://` URI, terminal QR, expiry.
560	    /// Gated behind OS elevation (or read the code from your authenticator
561	    /// app). With no name the node's sole subnet is used; if it holds
562	    /// several, the name is required (never guessed).
563	    ShowCode {
564	        /// Which subnet's code to show. Required only when the node holds several.
565	        name: Option<String>,
566	    },
567	    /// Pair this machine into an existing subnet (guided).
568	    ///
569	    /// Finds a member machine over LAN + relay rendezvous and runs the
570	    /// code-authenticated pairing ceremony against it. Prompts for the
571	    /// name and code when omitted (interactive terminals). Gated behind
572	    /// OS elevation — joining enrolls this whole machine.
573	    Join {
574	        /// The subnet to join (as named on the member machine).
575	        name: Option<String>,
576	        /// The current 6-digit code (`spt subnet show-code` on a member
577	        /// machine, or your authenticator app).
578	        #[arg(long)]
579	        code: Option<String>,
580	    },
581	    /// Exit a subnet: drop its membership and trust material from this node.
582	    ///
583	    /// Removes the subnet's seed, its trust rows, its serve-state, and its
584	    /// registry snapshot here. Gated behind OS elevation (membership exit
585	    /// destroys trust material). The remaining members still hold the old
586	    /// seed — rotate it there if this machine should not rejoin.
587	    Leave {
588	        /// The held subnet to leave.
589	        name: String,
590	    },
591	    /// Remove a dead node identity's trust rows (and registry rows).
592	    ///
593	    /// The cleanup verb for a machine that re-paired under a new identity
594	    /// or is gone for good: its stale trust rows cost a dial every pump
595	    /// tick. Takes a full pubkey hex, an unambiguous prefix, or a node
596	    /// label. Gated behind OS elevation (trust mutation).
597	    Prune {
598	        /// The dead identity: pubkey hex, unambiguous prefix, or label.
599	        node: String,
600	    },
601	    /// Stop serving a held subnet (the daemon keeps running).
602	    ///
603	    /// The membership (seed) stays on disk, but this node neither
604	    /// advertises into nor connects to the subnet — pairing responder,
605	    /// rendezvous meet, and registry gossip all skip it. Takes effect
606	    /// within one pump cadence; `spt subnet attach` reverses it.
607	    Detach {
608	        /// The held subnet to stop serving.
609	        name: String,
610	        /// Also persist as the startup default (survives daemon restarts).
611	        #[arg(long)]
612	        save: bool,
613	    },
614	    /// Resume serving a detached subnet.
615	    ///
616	    /// Advertising + connecting restart within one pump cadence.
617	    Attach {
618	        /// The held subnet to serve again.
619	        name: String,
620	        /// Also persist as the startup default (survives daemon restarts).
621	        #[arg(long)]
622	        save: bool,
623	    },
624	    /// Issue a subnet-wide user notification.
625	    ///
626	    /// Produced into the replicated notification spool and first-fired at
627	    /// the user's most-recently-active endpoint in that subnet. Body from
628	    /// the trailing arg, or stdin when omitted. Targets the calling
629	    /// endpoint's HOME subnet unless `--target` names another (M8
630	    /// decision 25: no resolvable home + no `--target` = refuse).
631	    Notify {
632	        /// Notification body (read from stdin when omitted).
633	        body: Option<String>,
634	        /// Target subnet (defaults to the calling endpoint's home subnet).
635	        #[arg(long)]
636	        target: Option<String>,
637	        /// Issuer endpoint id (auto-detected from session if omitted).
638	        #[arg(long)]
639	        from: Option<String>,
640	    },
641	}
642
643	/// Top-level commands an agent is meant to run from its own session; every
644	/// other visible command lands in the operator-facing "User commands" group.
645	/// Grouping is render-time only (clap has no native subcommand help
646	/// headings); `help_groups_cover_every_command` keeps this set honest.
647	const AGENT_COMMANDS: &[&str] = &[
648	    "api", "endpoint", "how-to", "ready", "ring", "send", "shell", "whoami",
649	];
650
651	/// Build the top-level help template: the default clap layout, with the flat
652	/// `Commands:` block replaced by two alphabetized groups (user-facing first,
653	/// agent-facing second). Entries are generated from the derived `Command`
654	/// itself, so abouts can never drift from the list.

enum SubnetCmd {
    /// Show subnet membership: name, paired nodes, endpoints.
    ///
    /// Never prints seeds, epochs, or pairing codes. Bare `spt subnet` is
    /// the same view.
    Status {
        /// Limit to one subnet (all member subnets otherwise).
        name: Option<String>,
        /// Per-node rows: label, online/offline, [online endpoints/total].
        #[arg(long)]
        nodes: bool,
    },
    /// Mint a fresh subnet and print its joining material.
    ///
    /// This node becomes the sole seed-holder. Prints the current 6-digit
    /// code, the `otpauth://` provisioning URI, and a terminal QR of it.
    /// Gated behind OS elevation (the seed-reveal path).
    Create {
        /// The new subnet's name.
        name: String,
    },
    /// Show a subnet's current 6-digit pairing code (+ URI and QR).
    ///
    /// The re-provisioning surface: prints the same joining material as
    /// `create` — current code, `otpauth://` URI, terminal QR, expiry.
    /// Gated behind OS elevation (or read the code from your authenticator
    /// app). With no name the node's sole subnet is used; if it holds
    /// several, the name is required (never guessed).
    ShowCode {
        /// Which subnet's code to show. Required only when the node holds several.
        name: Option<String>,
    },
    /// Pair this machine into an existing subnet (guided).
    ///
    /// Finds a member machine over LAN + relay rendezvous and runs the
    /// code-authenticated pairing ceremony against it. Prompts for the
    /// name and code when omitted (interactive terminals). Gated behind
    /// OS elevation — joining enrolls this whole machine.
    Join {
        /// The subnet to join (as named on the member machine).
        name: Option<String>,
        /// The current 6-digit code (`spt subnet show-code` on a member
        /// machine, or your authenticator app).
        #[arg(long)]
        code: Option<String>,
    },
    /// Exit a subnet: drop its membership and trust material from this node.
    ///
    /// Removes the subnet's seed, its trust rows, its serve-state, and its
    /// registry snapshot here. Gated behind OS elevation (membership exit
    /// destroys trust material). The remaining members still hold the old
    /// seed — rotate it there if this machine should not rejoin.
    Leave {
        /// The held subnet to leave.
        name: String,
    },
    /// Remove a dead node identity's trust rows (and registry rows).
    ///
    /// The cleanup verb for a machine that re-paired under a new identity
    /// or is gone for good: its stale trust rows cost a dial every pump
    /// tick. Takes a full pubkey hex, an unambiguous prefix, or a node
    /// label. Gated behind OS elevation (trust mutation).
    Prune {
        /// The dead identity: pubkey hex, unambiguous prefix, or label.
        node: String,
    },
    /// Stop serving a held subnet (the daemon keeps running).
    ///
    /// The membership (seed) stays on disk, but this node neither
    /// advertises into nor connects to the subnet — pairing responder,
    /// rendezvous meet, and registry gossip all skip it. Takes effect
    /// within one pump cadence; `spt subnet attach` reverses it.
    Detach {
        /// The held subnet to stop serving.
        name: String,
        /// Also persist as the startup default (survives daemon restarts).
        #[arg(long)]
        save: bool,
    },
    /// Resume serving a detached subnet.
    ///
    /// Advertising + connecting restart within one pump cadence.
    Attach {
        /// The held subnet to serve again.
        name: String,
        /// Also persist as the startup default (survives daemon restarts).
        #[arg(long)]
        save: bool,
    },
    /// Issue a subnet-wide user notification.
    ///
    /// Produced into the replicated notification spool and first-fired at
    /// the user's most-recently-active endpoint in that subnet. Body from
    /// the trailing arg, or stdin when omitted. Targets the calling
    /// endpoint's HOME subnet unless `--target` names another (M8
    /// decision 25: no resolvable home + no `--target` = refuse).
    Notify {
        /// Notification body (read from stdin when omitted).
        body: Option<String>,
        /// Target subnet (defaults to the calling endpoint's home subnet).
        #[arg(long)]
        target: Option<String>,
        /// Issuer endpoint id (auto-detected from session if omitted).
        #[arg(long)]
        from: Option<String>,
    },
}

/// Top-level commands an agent is meant to run from its own session; every
/// other visible command lands in the operator-facing "User commands" group.
/// Grouping is render-time only (clap has no native subcommand help
/// headings); `help_groups_cover_every_command` keeps this set honest.
const AGENT_COMMANDS: &[&str] = &[
    "api", "endpoint", "how-to", "ready", "ring", "send", "shell", "whoami",
];

/// Build the top-level help template: the default clap layout, with the flat
/// `Commands:` block replaced by two alphabetized groups (user-facing first,
/// agent-facing second). Entries are generated from the derived `Command`
/// itself, so abouts can never drift from the list.


traceable-reqs.toml:708:[Omitted long matching line]
crates\spt\src\cli.rs:731:                Some(SubnetCmd::Leave { name }) => cmd_subnet_leave(&name),
crates\spt\src\cli.rs:732:                Some(SubnetCmd::Prune { node }) => cmd_subnet_prune(&node),
crates\spt\src\cli.rs:2959:fn cmd_subnet_prune(node: &str) -> i32 {
crates\spt\src\cli.rs:6322:            Some(SubnetCmd::Leave { name }) if name == "home"
crates\spt\src\cli.rs:6326:            Some(SubnetCmd::Prune { node }) if node == "09ef831e"


720	        Cmd::Subnet { action } => {
721	            match action {
722	                // Bare `spt subnet` = the flagless status view WITH the hint
723	                // footer; explicit `status` drops it (M8 decision 12).
724	                None => cmd_subnet_status(None, false, true),
725	                Some(SubnetCmd::Status { name, nodes }) => cmd_subnet_status(name, nodes, false),
726	                Some(SubnetCmd::Create { name }) => cmd_subnet_create(name),
727	                Some(SubnetCmd::ShowCode { name }) => cmd_subnet_show_code(name),
728	                Some(SubnetCmd::Join { name, code }) => cmd_subnet_join(name, code),
729	                Some(SubnetCmd::Detach { name, save }) => cmd_subnet_attachment(&name, true, save),
730	                Some(SubnetCmd::Attach { name, save }) => cmd_subnet_attachment(&name, false, save),
731	                Some(SubnetCmd::Leave { name }) => cmd_subnet_leave(&name),
732	                Some(SubnetCmd::Prune { node }) => cmd_subnet_prune(&node),
733	                // notify's M8 home (decision 4); the hot path above stays
734	                // flat — moved verbs have no top-level shims (decision 9).
735	                // [impl->REQ-CLI-3]
736	                Some(SubnetCmd::Notify { body, target, from }) => cmd_notify(body, target, from),
737	            }
738	        }
739	        Cmd::HowTo { topic } => cmd_how_to(topic.as_deref()),
740	        // The endpoint noun (M8 decisions 1–2): bare = the merged list.
741	        // [impl->REQ-CLI-1]
742	        Cmd::Endpoint { action } => match action.unwrap_or(EndpointCmd::List {
743	            local: false,
744	            subnet: None,

        Cmd::Subnet { action } => {
            match action {
                // Bare `spt subnet` = the flagless status view WITH the hint
                // footer; explicit `status` drops it (M8 decision 12).
                None => cmd_subnet_status(None, false, true),
                Some(SubnetCmd::Status { name, nodes }) => cmd_subnet_status(name, nodes, false),
                Some(SubnetCmd::Create { name }) => cmd_subnet_create(name),
                Some(SubnetCmd::ShowCode { name }) => cmd_subnet_show_code(name),
                Some(SubnetCmd::Join { name, code }) => cmd_subnet_join(name, code),
                Some(SubnetCmd::Detach { name, save }) => cmd_subnet_attachment(&name, true, save),
                Some(SubnetCmd::Attach { name, save }) => cmd_subnet_attachment(&name, false, save),
                Some(SubnetCmd::Leave { name }) => cmd_subnet_leave(&name),
                Some(SubnetCmd::Prune { node }) => cmd_subnet_prune(&node),
                // notify's M8 home (decision 4); the hot path above stays
                // flat — moved verbs have no top-level shims (decision 9).
                // [impl->REQ-CLI-3]
                Some(SubnetCmd::Notify { body, target, from }) => cmd_notify(body, target, from),
            }
        }
        Cmd::HowTo { topic } => cmd_how_to(topic.as_deref()),
        // The endpoint noun (M8 decisions 1–2): bare = the merged list.
        // [impl->REQ-CLI-1]
        Cmd::Endpoint { action } => match action.unwrap_or(EndpointCmd::List {
            local: false,
            subnet: None,


Now the pump. Read peerloop.rs structure (PumpPaths, run_peer_pump, eviction sweep).


1	//! Outbound peer pump (D9-1-2) — the production trigger loops every D5–D8
2	//! slice deferred ("rides D9 with the daemon lifecycle").
3	//!
4	//! One daemon-hosted loop drives three cadenced pumps against every roster
5	//! member of every member subnet, each a thin call over an already-shipped,
6	//! idempotent driver — the pump owns *when*, the drivers own *how*:
7	//!
8	//! - **registry push** ([`RegistryHost::advertise_local`] → one feed stream
9	//!   per peer): the node re-advertises its own rows; peers reconverge within
10	//!   one cadence (replay-is-recovery, the D4d/D8 push posture). Mesh-D6 widened
11	//!   the push target from the directly-paired set to ALL roster members — a
12	//!   wider DIRECT fan-out, never a relay (REQ-MESH-3). Own rows only — a
13	//!   relayed third-party row would carry an origin the receiver cannot gate
14	//!   honestly (KNOWN-HAZARDS 7.5/4.10), so every row still arrives from its
15	//!   author over a handshake.
16	//! - **notif push** ([`crate::notifsync::emit_notif_feed`]): the full
17	//!   semilattice spool per subnet; the receiver's join dedups.
18	//! - **sync pull** ([`crate::sync::request_sync`]): anti-entropy for the
19	//!   minds. Want-refs are the union of [`crate::sync::select_refs`] (locally
20	//!   tracked branches the policy allows toward the peer) and
21	//!   **registry-derived** `a-<id>` refs (endpoints known in the shared
22	//!   subnets — how a fresh instance bootstraps a mind it has never held),
23	//!   both gated by the same [`SyncPolicy::allows`] the responder enforces
24	//!   (symmetric scoping).
25	//!
26	//! Conflicts a pull surfaces are reported through [`PumpHooks::on_pull`] —
27	//! the default production hook logs them loud. The bounded Psyche reconcile
28	//! turn itself still runs at the activation paths (ADR-0013): the daemon
29	//! does not yet host per-agent manifest runtimes (the 7.4 fan-out), so an
30	//! in-pump turn has no runtime to ride; conflict artifacts persist safely
31	//! meanwhile (REQ-HAZARD-CONFLICT-BOTH-PRESERVED).
32	//!
33	//! ## Wire discipline (inherited from the test suites, now production)
34	//!
35	//! Every pump write is fire-and-forget (`op_id: None`) — a journaled
36	//! `net_stream_send` blocks on its ack and DISCARDS interleaved events
37	//! (gotcha #1). Stream opens for push feeds carry no op either: the feeds
38	//! are idempotent by construction. The sync pull's `open_op` IS durable
39	//! (exactly-once open identity), minted from a dedicated [`EpochSource`]
40	//! file so a daemon restart can never reuse one.
41	// [impl->REQ-INST-5]
42	// [impl->REQ-INST-7]
43	// [impl->REQ-NOTIF-1]
44
45	use std::collections::HashMap;
46	use std::io;
47	use std::path::PathBuf;
48	use std::sync::atomic::{AtomicBool, Ordering};
49	use std::sync::Arc;
50	use std::thread;
51	use std::time::{Duration, Instant};
52
53	use spt_net::net::endpoint::addr_for_node_hex;
54	use spt_store::contextstore::ContextStore;
55	use spt_store::epoch::EpochSource;
56	use spt_store::notif::NotifStore;
57	use spt_store::perch;
58	use spt_store::subnet::SubnetStore;
59	use spt_store::visibility::VisibilityStore;
60
61	use crate::brain::Brain;
62	use crate::config::DaemonConfig;
63	use crate::consent::{decide, most_recently_active};
64	use crate::notif::{produce_consent_notif, NotifSurfacePolicy};
65	use crate::notifsync::emit_notif_feed;
66	use crate::propagate::{request_update, UpdatePullOutcome};
67	use crate::registryhost::RegistryHost;
68	use crate::relcache::ReleaseCache;
69	use crate::release::VerifyPolicy;
70	use crate::sync::{request_sync, select_refs, SyncPolicy, SyncPullReport};
71	use crate::update::BrokerAbi;
72
73	/// The pump's tick granularity — each cadence fires when its period elapsed.
74	const TICK: Duration = Duration::from_millis(200);
75
76	/// Heartbeat write throttle (M8 decision 23, REQ-DAEMON-5): the stall
77	/// warning reads 3-cadence staleness, so a few seconds of heartbeat
78	/// granularity is invisible to it — and 5 writes/sec of disk churn is not.
79	const HEARTBEAT_PERIOD: Duration = Duration::from_secs(5);
80
81	/// Supervisor backoff floor: the first restart after a pump death.
82	const SUPERVISE_BACKOFF_BASE: Duration = Duration::from_secs(5);
83
84	/// Supervisor backoff cap (M8 decision 23): a persistently-crashing pump
85	/// retries at most every 5 minutes — visible, bounded, never silent.
86	const SUPERVISE_BACKOFF_CAP: Duration = Duration::from_secs(300);
87
88	/// A run that survived this long before dying counts as healthy — the next
89	/// backoff resets to the floor instead of doubling (a once-a-day panic must
90	/// not ratchet the pump toward permanent 5-minute outages).
91	const SUPERVISE_HEALTHY_RUN: Duration = Duration::from_secs(60);
92
93	/// Resolve a trusted peer's stable name (lowercase-hex pubkey) to a dialable
94	/// address (the serde JSON `net-dial` carries). Production resolution lets
95	/// discovery do the work ([`production_resolver`]); hermetic tests inject a
96	/// concrete address map (loopback endpoints run no discovery).
97	pub type PeerResolver = Box<dyn Fn(&str) -> Option<serde_json::Value> + Send>;
98
99	/// The production resolver: an id-only iroh address — mDNS on the LAN, n0
100	/// DNS on the WAN (`addr_for_node_hex`).
101	pub fn production_resolver() -> PeerResolver {
102	    Box::new(|hex| addr_for_node_hex(hex).and_then(|a| serde_json::to_value(a).ok()))
103	}
104
105	/// Where this node's pump-side stores live. Production = the canonical
106	/// SPT_HOME layout; the loopback E2E gives its in-process "node" distinct
107	/// roots so two nodes can share one test process.
108	pub struct PumpPaths {
109	    /// The perch tree (registry advertisement scan).
110	    pub owlery: PathBuf,
111	    /// The context store root ([`ContextStore::open_or_init_in`]).
112	    pub ctx_root: PathBuf,
113	    /// The notif spool DB ([`NotifStore::open_at`]).
114	    pub notif_db: PathBuf,
115	    /// Transient pull scratch.
116	    pub scratch: PathBuf,
117	    /// `subnet.json` ([`SubnetStore::load_from`]).
118	    pub subnets: PathBuf,
119	    /// `visibility.json` ([`VisibilityStore::load_from`]).
120	    pub vis: PathBuf,
121	    /// The pump's durable open-op counter (its own file — colliding with the
122	    /// registry's advertisement epochs would break neither, but a shared
123	    /// in-memory cursor pair over one file double-mints; separate files keep
124	    /// each `EpochSource` the sole writer of its counter).
125	    pub ops: PathBuf,
126	    /// The staged-release cache dir ([`ReleaseCache::open`]).
127	    pub releases: PathBuf,
128	    /// `release-keys.json` ([`VerifyPolicy::load_from`]).
129	    pub release_keys: PathBuf,
130	    /// The wake-edge freshness-pull marker (D9-2, REQ-INST-3): a
131	    /// dormant→active transition drops it; the pump consumes it and forces a
132	    /// sync round NOW instead of waiting out `cadence.sync`
133	    /// ([`crate::resting::PULL_MARKER_FILE`]).
134	    pub pull_marker: PathBuf,
135	    /// The event-driven advertisement marker (M8 decision 15, REQ-CONV-2):
136	    /// an endpoint online/offline transition drops it; the pump consumes it
137	    /// and forces a registry round NOW instead of waiting out
138	    /// `cadence.registry` ([`crate::registryhost::ADVERTISE_MARKER_FILE`]).
139	    pub advertise_marker: PathBuf,
140	    /// `peer-addrs.json` — last-known peer addresses (M8 decisions 14/20,
141	    /// REQ-CONV-1): consulted before id-only discovery, written back after
142	    /// every successful dial ([`spt_store::peeraddrs::PeerAddrStore`]).
143	    pub peer_addrs: PathBuf,
144	    /// The pump's last-tick heartbeat (M8 decision 23, REQ-DAEMON-5):
145	    /// epoch-ms written while the loop is alive, rendered by
146	    /// `spt daemon status` / the `subnet status` stall warning
147	    /// ([`crate::endpoint::pump_heartbeat_path`]).
148	    pub heartbeat: PathBuf,
149	}
150
151	impl PumpPaths {
152	    /// The canonical SPT_HOME layout.
153	    pub fn production() -> PumpPaths {
154	        let home = perch::spt_home();
155	        let identity = perch::identity_dir();
156	        PumpPaths {
157	            owlery: perch::owlery_dir(),
158	            ctx_root: home.join("context"),
159	            notif_db: identity.join("notifs.db"),
160	            scratch: home.join("scratch").join("pump"),

//! Outbound peer pump (D9-1-2) — the production trigger loops every D5–D8
//! slice deferred ("rides D9 with the daemon lifecycle").
//!
//! One daemon-hosted loop drives three cadenced pumps against every roster
//! member of every member subnet, each a thin call over an already-shipped,
//! idempotent driver — the pump owns *when*, the drivers own *how*:
//!
//! - **registry push** ([`RegistryHost::advertise_local`] → one feed stream
//!   per peer): the node re-advertises its own rows; peers reconverge within
//!   one cadence (replay-is-recovery, the D4d/D8 push posture). Mesh-D6 widened
//!   the push target from the directly-paired set to ALL roster members — a
//!   wider DIRECT fan-out, never a relay (REQ-MESH-3). Own rows only — a
//!   relayed third-party row would carry an origin the receiver cannot gate
//!   honestly (KNOWN-HAZARDS 7.5/4.10), so every row still arrives from its
//!   author over a handshake.
//! - **notif push** ([`crate::notifsync::emit_notif_feed`]): the full
//!   semilattice spool per subnet; the receiver's join dedups.
//! - **sync pull** ([`crate::sync::request_sync`]): anti-entropy for the
//!   minds. Want-refs are the union of [`crate::sync::select_refs`] (locally
//!   tracked branches the policy allows toward the peer) and
//!   **registry-derived** `a-<id>` refs (endpoints known in the shared
//!   subnets — how a fresh instance bootstraps a mind it has never held),
//!   both gated by the same [`SyncPolicy::allows`] the responder enforces
//!   (symmetric scoping).
//!
//! Conflicts a pull surfaces are reported through [`PumpHooks::on_pull`] —
//! the default production hook logs them loud. The bounded Psyche reconcile
//! turn itself still runs at the activation paths (ADR-0013): the daemon
//! does not yet host per-agent manifest runtimes (the 7.4 fan-out), so an
//! in-pump turn has no runtime to ride; conflict artifacts persist safely
//! meanwhile (REQ-HAZARD-CONFLICT-BOTH-PRESERVED).
//!
//! ## Wire discipline (inherited from the test suites, now production)
//!
//! Every pump write is fire-and-forget (`op_id: None`) — a journaled
//! `net_stream_send` blocks on its ack and DISCARDS interleaved events
//! (gotcha #1). Stream opens for push feeds carry no op either: the feeds
//! are idempotent by construction. The sync pull's `open_op` IS durable
//! (exactly-once open identity), minted from a dedicated [`EpochSource`]
//! file so a daemon restart can never reuse one.
// [impl->REQ-INST-5]
// [impl->REQ-INST-7]
// [impl->REQ-NOTIF-1]

use std::collections::HashMap;
use std::io;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::thread;
use std::time::{Duration, Instant};

use spt_net::net::endpoint::addr_for_node_hex;
use spt_store::contextstore::ContextStore;
use spt_store::epoch::EpochSource;
use spt_store::notif::NotifStore;
use spt_store::perch;
use spt_store::subnet::SubnetStore;
use spt_store::visibility::VisibilityStore;

use crate::brain::Brain;
use crate::config::DaemonConfig;
use crate::consent::{decide, most_recently_active};
use crate::notif::{produce_consent_notif, NotifSurfacePolicy};
use crate::notifsync::emit_notif_feed;
use crate::propagate::{request_update, UpdatePullOutcome};
use crate::registryhost::RegistryHost;
use crate::relcache::ReleaseCache;
use crate::release::VerifyPolicy;
use crate::sync::{request_sync, select_refs, SyncPolicy, SyncPullReport};
use crate::update::BrokerAbi;

/// The pump's tick granularity — each cadence fires when its period elapsed.
const TICK: Duration = Duration::from_millis(200);

/// Heartbeat write throttle (M8 decision 23, REQ-DAEMON-5): the stall
/// warning reads 3-cadence staleness, so a few seconds of heartbeat
/// granularity is invisible to it — and 5 writes/sec of disk churn is not.
const HEARTBEAT_PERIOD: Duration = Duration::from_secs(5);

/// Supervisor backoff floor: the first restart after a pump death.
const SUPERVISE_BACKOFF_BASE: Duration = Duration::from_secs(5);

/// Supervisor backoff cap (M8 decision 23): a persistently-crashing pump
/// retries at most every 5 minutes — visible, bounded, never silent.
const SUPERVISE_BACKOFF_CAP: Duration = Duration::from_secs(300);

/// A run that survived this long before dying counts as healthy — the next
/// backoff resets to the floor instead of doubling (a once-a-day panic must
/// not ratchet the pump toward permanent 5-minute outages).
const SUPERVISE_HEALTHY_RUN: Duration = Duration::from_secs(60);

/// Resolve a trusted peer's stable name (lowercase-hex pubkey) to a dialable
/// address (the serde JSON `net-dial` carries). Production resolution lets
/// discovery do the work ([`production_resolver`]); hermetic tests inject a
/// concrete address map (loopback endpoints run no discovery).
pub type PeerResolver = Box<dyn Fn(&str) -> Option<serde_json::Value> + Send>;

/// The production resolver: an id-only iroh address — mDNS on the LAN, n0
/// DNS on the WAN (`addr_for_node_hex`).
pub fn production_resolver() -> PeerResolver {
    Box::new(|hex| addr_for_node_hex(hex).and_then(|a| serde_json::to_value(a).ok()))
}

/// Where this node's pump-side stores live. Production = the canonical
/// SPT_HOME layout; the loopback E2E gives its in-process "node" distinct
/// roots so two nodes can share one test process.
pub struct PumpPaths {
    /// The perch tree (registry advertisement scan).
    pub owlery: PathBuf,
    /// The context store root ([`ContextStore::open_or_init_in`]).
    pub ctx_root: PathBuf,
    /// The notif spool DB ([`NotifStore::open_at`]).
    pub notif_db: PathBuf,
    /// Transient pull scratch.
    pub scratch: PathBuf,
    /// `subnet.json` ([`SubnetStore::load_from`]).
    pub subnets: PathBuf,
    /// `visibility.json` ([`VisibilityStore::load_from`]).
    pub vis: PathBuf,
    /// The pump's durable open-op counter (its own file — colliding with the
    /// registry's advertisement epochs would break neither, but a shared
    /// in-memory cursor pair over one file double-mints; separate files keep
    /// each `EpochSource` the sole writer of its counter).
    pub ops: PathBuf,
    /// The staged-release cache dir ([`ReleaseCache::open`]).
    pub releases: PathBuf,
    /// `release-keys.json` ([`VerifyPolicy::load_from`]).
    pub release_keys: PathBuf,
    /// The wake-edge freshness-pull marker (D9-2, REQ-INST-3): a
    /// dormant→active transition drops it; the pump consumes it and forces a
    /// sync round NOW instead of waiting out `cadence.sync`
    /// ([`crate::resting::PULL_MARKER_FILE`]).
    pub pull_marker: PathBuf,
    /// The event-driven advertisement marker (M8 decision 15, REQ-CONV-2):
    /// an endpoint online/offline transition drops it; the pump consumes it
    /// and forces a registry round NOW instead of waiting out
    /// `cadence.registry` ([`crate::registryhost::ADVERTISE_MARKER_FILE`]).
    pub advertise_marker: PathBuf,
    /// `peer-addrs.json` — last-known peer addresses (M8 decisions 14/20,
    /// REQ-CONV-1): consulted before id-only discovery, written back after
    /// every successful dial ([`spt_store::peeraddrs::PeerAddrStore`]).
    pub peer_addrs: PathBuf,
    /// The pump's last-tick heartbeat (M8 decision 23, REQ-DAEMON-5):
    /// epoch-ms written while the loop is alive, rendered by
    /// `spt daemon status` / the `subnet status` stall warning
    /// ([`crate::endpoint::pump_heartbeat_path`]).
    pub heartbeat: PathBuf,
}

impl PumpPaths {
    /// The canonical SPT_HOME layout.
    pub fn production() -> PumpPaths {
        let home = perch::spt_home();
        let identity = perch::identity_dir();
        PumpPaths {
            owlery: perch::owlery_dir(),
            ctx_root: home.join("context"),
            notif_db: identity.join("notifs.db"),
            scratch: home.join("scratch").join("pump"),


160	            scratch: home.join("scratch").join("pump"),
161	            subnets: identity.join("subnet.json"),
162	            vis: identity.join("visibility.json"),
163	            ops: identity.join("pump-ops.json"),
164	            releases: home.join("releases"),
165	            release_keys: identity.join("release-keys.json"),
166	            pull_marker: identity.join(crate::resting::PULL_MARKER_FILE),
167	            advertise_marker: identity.join(crate::registryhost::ADVERTISE_MARKER_FILE),
168	            peer_addrs: identity.join("peer-addrs.json"),
169	            heartbeat: crate::endpoint::pump_heartbeat_path(),
170	        }
171	    }
172	}
173
174	/// Pump cadences (config-driven in production: [`DaemonConfig`]).
175	#[derive(Debug, Clone, Copy)]
176	pub struct PumpCadence {
177	    pub registry: Duration,
178	    pub notif: Duration,
179	    pub sync: Duration,
180	    pub update: Duration,
181	    /// Silent-peer registry eviction window (KNOWN-HAZARDS 4.10): swept each
182	    /// registry tick, before re-advertisement.
183	    pub registry_evict_after: Duration,
184	}
185
186	impl PumpCadence {
187	    /// From the daemon config knobs.
188	    pub fn from_config(cfg: &DaemonConfig) -> PumpCadence {
189	        PumpCadence {
190	            registry: cfg.registry_pump_period,
191	            notif: cfg.notif_pump_period,
192	            sync: cfg.sync_pull_period,
193	            update: cfg.update_check_period,
194	            registry_evict_after: cfg.registry_evict_after,
195	        }
196	    }
197	}
198
199	/// The pump's resolved knobs: cadences + the REQ-UPD-4 consent stance.
200	#[derive(Debug, Clone, Copy)]
201	pub struct PumpConfig {
202	    pub cadence: PumpCadence,
203	    /// `true` = the explicit opt-in to prompt-free updates; default `false`
204	    /// surfaces every staged release as a consent notif.
205	    pub full_auto_update: bool,
206	}
207
208	impl PumpConfig {
209	    /// From the daemon config knobs.
210	    pub fn from_config(cfg: &DaemonConfig) -> PumpConfig {
211	        PumpConfig {
212	            cadence: PumpCadence::from_config(cfg),
213	            full_auto_update: cfg.full_auto_update,
214	        }
215	    }
216	}
217
218	/// A pull observer: `(peer_hex, report)` after every completed sync pull.
219	pub type OnPull = Box<dyn FnMut(&str, &SyncPullReport) + Send>;
220
221	/// Observation hooks. `on_pull` fires after every completed sync pull —
222	/// the conflict surface (ADR-0013) and the E2E's probe.
223	pub struct PumpHooks {
224	    pub on_pull: OnPull,
225	}
226
227	impl Default for PumpHooks {
228	    /// The production hook: conflicts are loud, never silent — the artifacts
229	    /// already persist (hazard 6.6); the reconcile turn runs at activation.
230	    fn default() -> PumpHooks {
231	        PumpHooks {
232	            on_pull: Box::new(|peer, report| {
233	                for (branch, file) in report.conflicts() {
234	                    eprintln!("SYNC_CONFLICT:{peer}:{branch}:{file}");
235	                }
236	            }),
237	        }
238	    }
239	}
240
241	/// Run the pump until `stop` is set. Owns one brain IPC connection and a
242	/// per-peer connection cache (an IO failure against a peer drops its cache
243	/// row; the next tick redials — conns are rebuilt freely, nothing leaks
244	/// because the broker's closed-watcher reaps dead conns).
245	pub fn run_peer_pump(
246	    broker_name: &str,
247	    registry: Arc<RegistryHost>,
248	    paths: &PumpPaths,
249	    resolver: PeerResolver,
250	    config: PumpConfig,
251	    mut hooks: PumpHooks,
252	    stop: &AtomicBool,
253	) -> io::Result<()> {
254	    let PumpConfig {
255	        cadence,
256	        full_auto_update,
257	    } = config;
258	    let mut brain = connect(broker_name)?;
259	    let mut ops = EpochSource::load_from(&paths.ops);
260	    let mut conns: HashMap<String, u64> = HashMap::new();
261	    // Stagger from "everything due now" — the first tick primes peers.
262	    // `None` = never run yet → due immediately. Avoids the `Instant -
263	    // Duration` underflow panic on hosts whose uptime is below the offset
264	    // (Windows monotonic clock can't represent an instant before boot).
265	    let (mut last_reg, mut last_notif, mut last_sync, mut last_upd): (
266	        Option<Instant>,
267	        Option<Instant>,
268	        Option<Instant>,
269	        Option<Instant>,
270	    ) = (None, None, None, None);
271	    let mut last_hb: Option<Instant> = None;
272
273	    while !stop.load(Ordering::Relaxed) {
274	        let now = Instant::now();
275	        // Liveness heartbeat (M8 decision 23, REQ-DAEMON-5): written while
276	        // the loop is ALIVE — including idle ticks — never per work round,
277	        // so a wedged peer_io and a healthy quiet pump are distinguishable.
278	        // Throttled: the stall warning reads 3-cadence staleness, sub-second
279	        // precision buys nothing but disk churn.
280	        // [impl->REQ-DAEMON-5]
281	        if due(last_hb, now, HEARTBEAT_PERIOD) {
282	            write_heartbeat(&paths.heartbeat);
283	            last_hb = Some(now);
284	        }
285	        // Event-driven advertisement (M8 decision 15, REQ-CONV-2): an
286	        // endpoint online/offline transition dropped the marker — consume it
287	        // and run the registry leg NOW. A wake of THIS loop, not a second
288	        // advertisement path: the forced round is the same advertise_local +
289	        // push code, so the epoch lease and visibility gates ride unchanged.
290	        // [impl->REQ-CONV-2]
291	        let wake_adv = crate::registryhost::take_advertise_now(&paths.advertise_marker);
292	        let due_reg = wake_adv || due(last_reg, now, cadence.registry);
293	        let due_notif = due(last_notif, now, cadence.notif);
294	        // The wake edge's freshness pull (D9-2, REQ-INST-3): a dormant→active
295	        // transition dropped the marker — consume it and pull NOW, the
296	        // catch-up-on-activation latency is the point. The take is the remove,
297	        // so one wake claims exactly one forced round.
298	        // [impl->REQ-INST-3]
299	        let wake_pull = crate::resting::take_freshness_pull(&paths.pull_marker);
300	        let due_sync = wake_pull || due(last_sync, now, cadence.sync);
301	        let due_upd = due(last_upd, now, cadence.update);
302	        if !(due_reg || due_notif || due_sync || due_upd) {
303	            thread::sleep(TICK);
304	            continue;
305	        }
306
307	        // Re-load config-ish stores per round (the access-gate freshness
308	        // stance — edits take effect without a restart).
309	        let subnets = SubnetStore::load_from(&paths.subnets);
310	        // Serve-state (M8 decision 6, REQ-SUBNET-5): a detached subnet is
311	        // neither advertised into nor connected to — skipped wholesale in
312	        // the per-subnet loop below. Re-read per round like every other
313	        // config-ish store, so a flip takes effect within one tick.
314	        // [impl->REQ-SUBNET-5]
315	        let attachment = spt_store::attachment::AttachmentStore::load_from(
316	            &paths
317	                .subnets
318	                .parent()
319	                .map(|p| p.join("attachment.json"))
320	                .unwrap_or_else(|| PathBuf::from("attachment.json")),
321	        );
322	        // THE WIDEN (Mesh-D6, REQ-MESH-3): the push target is the full roster
323	        // (all members), not the directly-paired set — a wider DIRECT fan-out.
324	        // The one roster loaded here feeds both the push (who we dial) and the
325	        // sync gate (who we authorize), so they read one store.
326	        let roster_path = paths
327	            .subnets
328	            .parent()
329	            .map(|p| p.join("roster.json"))
330	            .unwrap_or_else(|| PathBuf::from("roster.json"));
331	        let policy = SyncPolicy {
332	            subnets: subnets
333	                .subnets
334	                .iter()
335	                .map(|s| (s.name.clone(), s.hide_new_endpoints))
336	                .collect(),
337	            roster: spt_store::roster::RosterStore::load_from(&roster_path),
338	            vis: VisibilityStore::load_from(&paths.vis),
339	        };
340	        let adverts = if due_reg {
341	            // Consume the re-pair eviction marker FIRST (M8 decision 13,
342	            // REQ-SUBNET-7): a superseded identity's in-memory rows go
343	            // before this tick's snapshot mirror can resurrect the pairing
344	            // host's disk-side eviction.
345	            // [impl->REQ-SUBNET-7]
346	            registry.consume_repair_evictions();
347	            // Sweep silent-node ghost rows BEFORE re-advertising (KNOWN-
348	            // HAZARDS 4.10): a dead node identity's rows decay instead of
349	            // poisoning bare-id resolution forever; a live peer re-stamps its
350	            // heard entry with every admitted feed and is never touched.
351	            // [impl->REQ-HAZARD-REGISTRY-GHOST-ROWS]
352	            registry.evict_silent_peers(cadence.registry_evict_after);
353	            registry.advertise_local(&paths.owlery)
354	        } else {
355	            Vec::new()
356	        };
357
358	        for sub in &subnets.subnets {
359	            if attachment.is_detached(&sub.name) {
360	                continue; // detached: neither advertise into nor connect to (REQ-SUBNET-5)
361	            }
362	            // [impl->REQ-MESH-3] the widened fan-out: every roster member of
363	            // this subnet (tombstoned entries + self excluded), reusing the
364	            // roster already loaded for the sync gate.
365	            for peer_hex in push_targets(&policy.roster, &sub.name, registry.node_hex()) {
366	                let Some(conn_id) = ensure_conn(
367	                    &mut brain,
368	                    &mut conns,
369	                    &resolver,
370	                    &peer_hex,
371	                    &paths.peer_addrs,
372	                ) else {
373	                    continue; // unresolvable/undialable now — next tick retries
374	                };
375
376	                let mut peer_io = || -> io::Result<()> {
377	                    if due_reg {
378	                        let lines: Vec<u8> = adverts
379	                            .iter()
380	                            .filter(|u| u.subnet() == sub.name)
381	                            .flat_map(|u| u.encode_line())
382	                            .collect();
383	                        if !lines.is_empty() {
384	                            push_feed(&mut brain, conn_id, &lines)?;
385	                        }
386	                    }
387	                    if due_notif {
388	                        let store = NotifStore::open_at(&paths.notif_db)
389	                            .map_err(|e| io::Error::other(e.to_string()))?;
390	                        let feed = emit_notif_feed(&store, &sub.name).map_err(io::Error::other)?;
391	                        if !feed.is_empty() {
392	                            push_feed(&mut brain, conn_id, &feed)?;
393	                        }
394	                    }
395	                    if due_sync {
396	                        let cs = ContextStore::open_or_init_in(&paths.ctx_root)?;
397	                        let mut want = select_refs(&policy, &cs, &peer_hex)?;
398	                        // Registry-derived bootstrap refs: minds named in the
399	                        // shared subnet that this node never held locally.
400	                        if let Some(snap) = registry.snapshot(&sub.name) {
401	                            for id in snap.endpoint_ids() {
402	                                let r = format!("a-{id}");
403	                                if !want.contains(&r) && policy.allows(&r, &peer_hex) {
404	                                    want.push(r);
405	                                }
406	                            }
407	                        }
408	                        if !want.is_empty() {
409	                            let open_op = ops.next_epoch()?;
410	                            let report = request_sync(
411	                                &mut brain,
412	                                conn_id,
413	                                &want,
414	                                open_op,
415	                                &cs,
416	                                &paths.scratch,
417	                            )?;
418	                            (hooks.on_pull)(&peer_hex, &report);
419	                        }

            scratch: home.join("scratch").join("pump"),
            subnets: identity.join("subnet.json"),
            vis: identity.join("visibility.json"),
            ops: identity.join("pump-ops.json"),
            releases: home.join("releases"),
            release_keys: identity.join("release-keys.json"),
            pull_marker: identity.join(crate::resting::PULL_MARKER_FILE),
            advertise_marker: identity.join(crate::registryhost::ADVERTISE_MARKER_FILE),
            peer_addrs: identity.join("peer-addrs.json"),
            heartbeat: crate::endpoint::pump_heartbeat_path(),
        }
    }
}

/// Pump cadences (config-driven in production: [`DaemonConfig`]).
#[derive(Debug, Clone, Copy)]
pub struct PumpCadence {
    pub registry: Duration,
    pub notif: Duration,
    pub sync: Duration,
    pub update: Duration,
    /// Silent-peer registry eviction window (KNOWN-HAZARDS 4.10): swept each
    /// registry tick, before re-advertisement.
    pub registry_evict_after: Duration,
}

impl PumpCadence {
    /// From the daemon config knobs.
    pub fn from_config(cfg: &DaemonConfig) -> PumpCadence {
        PumpCadence {
            registry: cfg.registry_pump_period,
            notif: cfg.notif_pump_period,
            sync: cfg.sync_pull_period,
            update: cfg.update_check_period,
            registry_evict_after: cfg.registry_evict_after,
        }
    }
}

/// The pump's resolved knobs: cadences + the REQ-UPD-4 consent stance.
#[derive(Debug, Clone, Copy)]
pub struct PumpConfig {
    pub cadence: PumpCadence,
    /// `true` = the explicit opt-in to prompt-free updates; default `false`
    /// surfaces every staged release as a consent notif.
    pub full_auto_update: bool,
}

impl PumpConfig {
    /// From the daemon config knobs.
    pub fn from_config(cfg: &DaemonConfig) -> PumpConfig {
        PumpConfig {
            cadence: PumpCadence::from_config(cfg),
            full_auto_update: cfg.full_auto_update,
        }
    }
}

/// A pull observer: `(peer_hex, report)` after every completed sync pull.
pub type OnPull = Box<dyn FnMut(&str, &SyncPullReport) + Send>;

/// Observation hooks. `on_pull` fires after every completed sync pull —
/// the conflict surface (ADR-0013) and the E2E's probe.
pub struct PumpHooks {
    pub on_pull: OnPull,
}

impl Default for PumpHooks {
    /// The production hook: conflicts are loud, never silent — the artifacts
    /// already persist (hazard 6.6); the reconcile turn runs at activation.
    fn default() -> PumpHooks {
        PumpHooks {
            on_pull: Box::new(|peer, report| {
                for (branch, file) in report.conflicts() {
                    eprintln!("SYNC_CONFLICT:{peer}:{branch}:{file}");
                }
            }),
        }
    }
}

/// Run the pump until `stop` is set. Owns one brain IPC connection and a
/// per-peer connection cache (an IO failure against a peer drops its cache
/// row; the next tick redials — conns are rebuilt freely, nothing leaks
/// because the broker's closed-watcher reaps dead conns).
pub fn run_peer_pump(
    broker_name: &str,
    registry: Arc<RegistryHost>,
    paths: &PumpPaths,
    resolver: PeerResolver,
    config: PumpConfig,
    mut hooks: PumpHooks,
    stop: &AtomicBool,
) -> io::Result<()> {
    let PumpConfig {
        cadence,
        full_auto_update,
    } = config;
    let mut brain = connect(broker_name)?;
    let mut ops = EpochSource::load_from(&paths.ops);
    let mut conns: HashMap<String, u64> = HashMap::new();
    // Stagger from "everything due now" — the first tick primes peers.
    // `None` = never run yet → due immediately. Avoids the `Instant -
    // Duration` underflow panic on hosts whose uptime is below the offset
    // (Windows monotonic clock can't represent an instant before boot).
    let (mut last_reg, mut last_notif, mut last_sync, mut last_upd): (
        Option<Instant>,
        Option<Instant>,
        Option<Instant>,
        Option<Instant>,
    ) = (None, None, None, None);
    let mut last_hb: Option<Instant> = None;

    while !stop.load(Ordering::Relaxed) {
        let now = Instant::now();
        // Liveness heartbeat (M8 decision 23, REQ-DAEMON-5): written while
        // the loop is ALIVE — including idle ticks — never per work round,
        // so a wedged peer_io and a healthy quiet pump are distinguishable.
        // Throttled: the stall warning reads 3-cadence staleness, sub-second
        // precision buys nothing but disk churn.
        // [impl->REQ-DAEMON-5]
        if due(last_hb, now, HEARTBEAT_PERIOD) {
            write_heartbeat(&paths.heartbeat);
            last_hb = Some(now);
        }
        // Event-driven advertisement (M8 decision 15, REQ-CONV-2): an
        // endpoint online/offline transition dropped the marker — consume it
        // and run the registry leg NOW. A wake of THIS loop, not a second
        // advertisement path: the forced round is the same advertise_local +
        // push code, so the epoch lease and visibility gates ride unchanged.
        // [impl->REQ-CONV-2]
        let wake_adv = crate::registryhost::take_advertise_now(&paths.advertise_marker);
        let due_reg = wake_adv || due(last_reg, now, cadence.registry);
        let due_notif = due(last_notif, now, cadence.notif);
        // The wake edge's freshness pull (D9-2, REQ-INST-3): a dormant→active
        // transition dropped the marker — consume it and pull NOW, the
        // catch-up-on-activation latency is the point. The take is the remove,
        // so one wake claims exactly one forced round.
        // [impl->REQ-INST-3]
        let wake_pull = crate::resting::take_freshness_pull(&paths.pull_marker);
        let due_sync = wake_pull || due(last_sync, now, cadence.sync);
        let due_upd = due(last_upd, now, cadence.update);
        if !(due_reg || due_notif || due_sync || due_upd) {
            thread::sleep(TICK);
            continue;
        }

        // Re-load config-ish stores per round (the access-gate freshness
        // stance — edits take effect without a restart).
        let subnets = SubnetStore::load_from(&paths.subnets);
        // Serve-state (M8 decision 6, REQ-SUBNET-5): a detached subnet is
        // neither advertised into nor connected to — skipped wholesale in
        // the per-subnet loop below. Re-read per round like every other
        // config-ish store, so a flip takes effect within one tick.
        // [impl->REQ-SUBNET-5]
        let attachment = spt_store::attachment::AttachmentStore::load_from(
            &paths
                .subnets
                .parent()
                .map(|p| p.join("attachment.json"))
                .unwrap_or_else(|| PathBuf::from("attachment.json")),
        );
        // THE WIDEN (Mesh-D6, REQ-MESH-3): the push target is the full roster
        // (all members), not the directly-paired set — a wider DIRECT fan-out.
        // The one roster loaded here feeds both the push (who we dial) and the
        // sync gate (who we authorize), so they read one store.
        let roster_path = paths
            .subnets
            .parent()
            .map(|p| p.join("roster.json"))
            .unwrap_or_else(|| PathBuf::from("roster.json"));
        let policy = SyncPolicy {
            subnets: subnets
                .subnets
                .iter()
                .map(|s| (s.name.clone(), s.hide_new_endpoints))
                .collect(),
            roster: spt_store::roster::RosterStore::load_from(&roster_path),
            vis: VisibilityStore::load_from(&paths.vis),
        };
        let adverts = if due_reg {
            // Consume the re-pair eviction marker FIRST (M8 decision 13,
            // REQ-SUBNET-7): a superseded identity's in-memory rows go
            // before this tick's snapshot mirror can resurrect the pairing
            // host's disk-side eviction.
            // [impl->REQ-SUBNET-7]
            registry.consume_repair_evictions();
            // Sweep silent-node ghost rows BEFORE re-advertising (KNOWN-
            // HAZARDS 4.10): a dead node identity's rows decay instead of
            // poisoning bare-id resolution forever; a live peer re-stamps its
            // heard entry with every admitted feed and is never touched.
            // [impl->REQ-HAZARD-REGISTRY-GHOST-ROWS]
            registry.evict_silent_peers(cadence.registry_evict_after);
            registry.advertise_local(&paths.owlery)
        } else {
            Vec::new()
        };

        for sub in &subnets.subnets {
            if attachment.is_detached(&sub.name) {
                continue; // detached: neither advertise into nor connect to (REQ-SUBNET-5)
            }
            // [impl->REQ-MESH-3] the widened fan-out: every roster member of
            // this subnet (tombstoned entries + self excluded), reusing the
            // roster already loaded for the sync gate.
            for peer_hex in push_targets(&policy.roster, &sub.name, registry.node_hex()) {
                let Some(conn_id) = ensure_conn(
                    &mut brain,
                    &mut conns,
                    &resolver,
                    &peer_hex,
                    &paths.peer_addrs,
                ) else {
                    continue; // unresolvable/undialable now — next tick retries
                };

                let mut peer_io = || -> io::Result<()> {
                    if due_reg {
                        let lines: Vec<u8> = adverts
                            .iter()
                            .filter(|u| u.subnet() == sub.name)
                            .flat_map(|u| u.encode_line())
                            .collect();
                        if !lines.is_empty() {
                            push_feed(&mut brain, conn_id, &lines)?;
                        }
                    }
                    if due_notif {
                        let store = NotifStore::open_at(&paths.notif_db)
                            .map_err(|e| io::Error::other(e.to_string()))?;
                        let feed = emit_notif_feed(&store, &sub.name).map_err(io::Error::other)?;
                        if !feed.is_empty() {
                            push_feed(&mut brain, conn_id, &feed)?;
                        }
                    }
                    if due_sync {
                        let cs = ContextStore::open_or_init_in(&paths.ctx_root)?;
                        let mut want = select_refs(&policy, &cs, &peer_hex)?;
                        // Registry-derived bootstrap refs: minds named in the
                        // shared subnet that this node never held locally.
                        if let Some(snap) = registry.snapshot(&sub.name) {
                            for id in snap.endpoint_ids() {
                                let r = format!("a-{id}");
                                if !want.contains(&r) && policy.allows(&r, &peer_hex) {
                                    want.push(r);
                                }
                            }
                        }
                        if !want.is_empty() {
                            let open_op = ops.next_epoch()?;
                            let report = request_sync(
                                &mut brain,
                                conn_id,
                                &want,
                                open_op,
                                &cs,
                                &paths.scratch,
                            )?;
                            (hooks.on_pull)(&peer_hex, &report);
                        }


419	                        }
420	                    }
421	                    if due_upd {
422	                        // The peer-propagation pull (REQ-UPD-1) under the
423	                        // PRODUCTION gate: keys + channel pin from
424	                        // release-keys.json (fail-closed when unprovisioned),
425	                        // version floor = the staged release (rollback gate),
426	                        // wall clock for expiry only.
427	                        let cache = ReleaseCache::open(&paths.releases);
428	                        let base_policy = VerifyPolicy::load_from(&paths.release_keys, 0, now_ms());
429	                        let current = if cache.staged_channel().as_deref()
430	                            == Some(base_policy.pinned_channel.as_str())
431	                        {
432	                            cache.staged_version().unwrap_or(0)
433	                        } else {
434	                            0
435	                        };
436	                        let policy = VerifyPolicy {
437	                            current_version: current,
438	                            ..base_policy
439	                        };
440	                        if !policy.trusted_keys.is_empty() {
441	                            let open_op = ops.next_epoch()?;
442	                            match request_update(
443	                                &mut brain,
444	                                conn_id,
445	                                open_op,
446	                                &BrokerAbi::current(),
447	                                &policy,
448	                                &cache,
449	                                &paths.scratch,
450	                            )? {
451	                                UpdatePullOutcome::Updated(plan) => {
452	                                    let version = cache
453	                                        .staged_version()
454	                                        .map(|v| v.to_string())
455	                                        .unwrap_or_else(|| "?".to_string());
456	                                    eprintln!(
457	                                        "UPDATE_STAGED:{version}:{:?} (from {})",
458	                                        plan.class, peer_hex
459	                                    );
460	                                    // Convergence fact (M8 decision 19): the
461	                                    // status query reports this as the last
462	                                    // typed outcome.
463	                                    // [impl->REQ-UPD-6]
464	                                    let _ = cache.record_last_outcome("staged");
465	                                    // REQ-UPD-4: gated on user confirmation by
466	                                    // default — the staged release surfaces as
467	                                    // a consent notif (D8's producer); apply
468	                                    // rides the existing self-update machinery
469	                                    // once confirmed. Full-auto writes nothing.
470	                                    let decision = decide(
471	                                        full_auto_update,
472	                                        most_recently_active(&paths.owlery),
473	                                    );
474	                                    let surface = NotifSurfacePolicy {
475	                                        subnets: subnets
476	                                            .subnets
477	                                            .iter()
478	                                            .map(|s| (s.name.clone(), s.hide_new_endpoints))
479	                                            .collect(),
480	                                        vis: VisibilityStore::load_from(&paths.vis),
481	                                    };
482	                                    let store = NotifStore::open_at(&paths.notif_db)
483	                                        .map_err(|e| io::Error::other(e.to_string()))?;
484	                                    // The consent notif's id MUST mint from the
485	                                    // node's CANONICAL epoch (shared with the
486	                                    // registry leg + the CLI notify front
487	                                    // door), NOT `ops` (the stream-open op
488	                                    // counter, a separate file) — else two
489	                                    // counters issue the same `node:epoch`
490	                                    // notif_id and collide (the rig
491	                                    // presence-rung UNIQUE failure).
492	                                    // [impl->REQ-HAZARD-REGISTRY-EPOCH-LEASE]
493	                                    let produced = registry.with_epoch(|ep| {
494	                                        produce_consent_notif(
495	                                            &decision,
496	                                            &version,
497	                                            &store,
498	                                            registry.node_hex(),
499	                                            ep,
500	                                            &sub.name,
501	                                            &surface,
502	                                            &paths.owlery,
503	                                            now_ms(),
504	                                        )
505	                                    });
506	                                    if let Err(e) = produced {
507	                                        eprintln!("UPDATE_NOTIF_FAIL:{e}");
508	                                    }
509	                                }
510	                                UpdatePullOutcome::UpToDate => {}
511	                                UpdatePullOutcome::Rejected(reason) => {
512	                                    // Loud + typed, never silent (ADR-0004 §D).
513	                                    eprintln!("UPDATE_REJECTED:{}:{reason}", peer_hex);
514	                                    // Convergence fact (M8 decision 19) —
515	                                    // `Rejected{reason}` in the table.
516	                                    // [impl->REQ-UPD-6]
517	                                    let _ =
518	                                        cache.record_last_outcome(&format!("rejected:{reason}"));
519	                                }
520	                            }
521	                        }
522	                    }
523	                    Ok(())
524	                };
525	                if let Err(e) = peer_io() {
526	                    eprintln!("PUMP_PEER_FAIL:{}:{e}", peer_hex);
527	                    conns.remove(&peer_hex); // redial next tick
528	                }
529	            }
530	        }
531
532	        let now = Instant::now();
533	        if due_reg {
534	            last_reg = Some(now);
535	        }
536	        if due_notif {
537	            last_notif = Some(now);
538	        }

                        }
                    }
                    if due_upd {
                        // The peer-propagation pull (REQ-UPD-1) under the
                        // PRODUCTION gate: keys + channel pin from
                        // release-keys.json (fail-closed when unprovisioned),
                        // version floor = the staged release (rollback gate),
                        // wall clock for expiry only.
                        let cache = ReleaseCache::open(&paths.releases);
                        let base_policy = VerifyPolicy::load_from(&paths.release_keys, 0, now_ms());
                        let current = if cache.staged_channel().as_deref()
                            == Some(base_policy.pinned_channel.as_str())
                        {
                            cache.staged_version().unwrap_or(0)
                        } else {
                            0
                        };
                        let policy = VerifyPolicy {
                            current_version: current,
                            ..base_policy
                        };
                        if !policy.trusted_keys.is_empty() {
                            let open_op = ops.next_epoch()?;
                            match request_update(
                                &mut brain,
                                conn_id,
                                open_op,
                                &BrokerAbi::current(),
                                &policy,
                                &cache,
                                &paths.scratch,
                            )? {
                                UpdatePullOutcome::Updated(plan) => {
                                    let version = cache
                                        .staged_version()
                                        .map(|v| v.to_string())
                                        .unwrap_or_else(|| "?".to_string());
                                    eprintln!(
                                        "UPDATE_STAGED:{version}:{:?} (from {})",
                                        plan.class, peer_hex
                                    );
                                    // Convergence fact (M8 decision 19): the
                                    // status query reports this as the last
                                    // typed outcome.
                                    // [impl->REQ-UPD-6]
                                    let _ = cache.record_last_outcome("staged");
                                    // REQ-UPD-4: gated on user confirmation by
                                    // default — the staged release surfaces as
                                    // a consent notif (D8's producer); apply
                                    // rides the existing self-update machinery
                                    // once confirmed. Full-auto writes nothing.
                                    let decision = decide(
                                        full_auto_update,
                                        most_recently_active(&paths.owlery),
                                    );
                                    let surface = NotifSurfacePolicy {
                                        subnets: subnets
                                            .subnets
                                            .iter()
                                            .map(|s| (s.name.clone(), s.hide_new_endpoints))
                                            .collect(),
                                        vis: VisibilityStore::load_from(&paths.vis),
                                    };
                                    let store = NotifStore::open_at(&paths.notif_db)
                                        .map_err(|e| io::Error::other(e.to_string()))?;
                                    // The consent notif's id MUST mint from the
                                    // node's CANONICAL epoch (shared with the
                                    // registry leg + the CLI notify front
                                    // door), NOT `ops` (the stream-open op
                                    // counter, a separate file) — else two
                                    // counters issue the same `node:epoch`
                                    // notif_id and collide (the rig
                                    // presence-rung UNIQUE failure).
                                    // [impl->REQ-HAZARD-REGISTRY-EPOCH-LEASE]
                                    let produced = registry.with_epoch(|ep| {
                                        produce_consent_notif(
                                            &decision,
                                            &version,
                                            &store,
                                            registry.node_hex(),
                                            ep,
                                            &sub.name,
                                            &surface,
                                            &paths.owlery,
                                            now_ms(),
                                        )
                                    });
                                    if let Err(e) = produced {
                                        eprintln!("UPDATE_NOTIF_FAIL:{e}");
                                    }
                                }
                                UpdatePullOutcome::UpToDate => {}
                                UpdatePullOutcome::Rejected(reason) => {
                                    // Loud + typed, never silent (ADR-0004 §D).
                                    eprintln!("UPDATE_REJECTED:{}:{reason}", peer_hex);
                                    // Convergence fact (M8 decision 19) —
                                    // `Rejected{reason}` in the table.
                                    // [impl->REQ-UPD-6]
                                    let _ =
                                        cache.record_last_outcome(&format!("rejected:{reason}"));
                                }
                            }
                        }
                    }
                    Ok(())
                };
                if let Err(e) = peer_io() {
                    eprintln!("PUMP_PEER_FAIL:{}:{e}", peer_hex);
                    conns.remove(&peer_hex); // redial next tick
                }
            }
        }

        let now = Instant::now();
        if due_reg {
            last_reg = Some(now);
        }
        if due_notif {
            last_notif = Some(now);
        }


I now have the full picture. Let me read the existing seed-proof integration test rig — it's the template for the grace int test.


1	//! Mesh-D2 — connect-time seed-proof, the integration evidence (REQ-MESH-1 int).
2	//!
3	//! Two in-process `NetHost`s dial over hermetic loopback QUIC with the
4	//! membership source wired. A connection establishes (and caches the proven
5	//! subnet set on both `ConnEntry`s) **only when both sides prove current-epoch
6	//! seed-knowledge of a shared subnet**; a non-prover (wrong seed, or no shared
7	//! subnet) is dropped before the connection ever joins the table. This is the
8	//! same two-host hermetic shape as `nethost`'s `two_hosts_dial_over_loopback`,
9	//! plus the seed-proof.
10
11	use std::collections::HashSet;
12	use std::sync::Arc;
13	use std::time::Duration;
14
15	use spt_daemon::nethost::{NetConfig, NetHost};
16	use spt_daemon::seedproofx::{MembershipSource, SubnetCred};
17	use spt_net::net::endpoint::{BindScope, LocalDiscovery, RelayPolicy};
18	use spt_proto::identity::Identity;
19
20	/// A 20-byte subnet seed from a single filler byte (test material).
21	fn seed(b: u8) -> Vec<u8> {
22	    vec![b; 20]
23	}
24
25	/// A membership source returning a fixed credential list (clones per connect,
26	/// mirroring the live re-read of the production loader).
27	fn membership(creds: Vec<SubnetCred>) -> MembershipSource {
28	    Arc::new(move || creds.clone())
29	}
30
31	fn host(identity: Identity, creds: Vec<SubnetCred>) -> NetHost {
32	    NetHost::start(NetConfig {
33	        identity,
34	        relay: RelayPolicy::Disabled,
35	        discovery: LocalDiscovery::Off,
36	        scope: BindScope::Loopback,
37	        membership: Some(membership(creds)),
38	    })
39	    .expect("net host start")
40	}
41
42	/// Poll until `f()` holds (the acceptor registers its inbound conn async), or
43	/// give up after ~2s and return the last value.
44	fn wait_until(mut f: impl FnMut() -> bool) -> bool {
45	    for _ in 0..400 {
46	        if f() {
47	            return true;
48	        }
49	        std::thread::sleep(Duration::from_millis(5));
50	    }
51	    false
52	}
53
54	fn cred(name: &str, s: u8, epoch: u64) -> SubnetCred {
55	    SubnetCred {
56	        name: name.to_string(),
57	        seed: seed(s),
58	        epoch,
59	    }
60	}
61
62	fn set(names: &[&str]) -> HashSet<String> {
63	    names.iter().map(|s| s.to_string()).collect()
64	}
65
66	// [int->REQ-MESH-1] two members sharing a subnet (same seed + epoch) connect:
67	// the conn registers on BOTH sides and each caches the shared subnet as proven —
68	// the mesh property (a connection establishes only when both prove).
69	#[test]
70	fn both_members_prove_and_cache_the_shared_subnet() {
71	    let a = host(Identity::generate(), vec![cred("home", 7, 1)]);
72	    let b_id = Identity::generate();
73	    let b = host(b_id, vec![cred("home", 7, 1)]);
74
75	    let (conn_id, _remote) = a.dial(b.addr()).expect("dial member");
76	    assert_eq!(
77	        a.conn_proven_subnets(conn_id),
78	        set(&["home"]),
79	        "dialer caches the proven subnet"
80	    );
81	    assert!(
82	        wait_until(|| b.conn_count() == 1),
83	        "acceptor registered the proven inbound conn"
84	    );
85	    assert_eq!(
86	        b.conn_proven_subnets(1),
87	        set(&["home"]),
88	        "acceptor caches the proven subnet"
89	    );
90	}
91
92	// [int->REQ-MESH-1] the proven verdict is a SET, not a bool: two nodes sharing
93	// {home, work} but not the dialer-only `solo` prove exactly the intersection on
94	// both sides.
95	#[test]
96	fn proven_verdict_is_the_shared_set() {
97	    let a = host(
98	        Identity::generate(),
99	        vec![cred("home", 1, 1), cred("work", 2, 1), cred("solo", 9, 1)],
100	    );
101	    let b = host(
102	        Identity::generate(),
103	        vec![cred("home", 1, 1), cred("work", 2, 1)],
104	    );
105
106	    let (conn_id, _) = a.dial(b.addr()).expect("dial");
107	    assert_eq!(
108	        a.conn_proven_subnets(conn_id),
109	        set(&["home", "work"]),
110	        "dialer proves the intersection, not solo"
111	    );
112	    assert!(wait_until(|| b.conn_count() == 1), "acceptor registered");
113	    assert_eq!(b.conn_proven_subnets(1), set(&["home", "work"]));
114	}
115
116	// [int->REQ-MESH-1] a non-prover is dropped: same subnet NAME but a different
117	// seed (an outsider who can't derive the membership key) fails the mutual proof,
118	// so the dial errors and the acceptor never registers the connection.
119	#[test]
120	fn wrong_seed_is_dropped() {
121	    let member = host(Identity::generate(), vec![cred("home", 7, 1)]);
122	    let impostor = host(Identity::generate(), vec![cred("home", 8, 1)]); // wrong seed
123
124	    let res = impostor.dial(member.addr());
125	    assert!(res.is_err(), "dial fails when the peer rejects our proof");
126	    // The acceptor must not have a usable conn (it dropped the failed proof).
127	    assert!(
128	        !wait_until(|| member.conn_count() == 1),
129	        "acceptor dropped the non-prover; no conn in the table"
130	    );
131	}
132
133	// [int->REQ-MESH-1] no shared subnet ⇒ empty intersection ⇒ dropped. Two real
134	// members of DIFFERENT subnets have nothing to mesh, so the connection is not
135	// established.
136	#[test]
137	fn no_shared_subnet_is_dropped() {
138	    let a = host(Identity::generate(), vec![cred("alpha", 1, 1)]);
139	    let b = host(Identity::generate(), vec![cred("beta", 2, 1)]);
140
141	    let res = a.dial(b.addr());
142	    assert!(res.is_err(), "dial fails with no shared subnet");
143	    assert!(
144	        !wait_until(|| b.conn_count() == 1),
145	        "acceptor dropped the no-overlap conn"
146	    );
147	}
148

//! Mesh-D2 — connect-time seed-proof, the integration evidence (REQ-MESH-1 int).
//!
//! Two in-process `NetHost`s dial over hermetic loopback QUIC with the
//! membership source wired. A connection establishes (and caches the proven
//! subnet set on both `ConnEntry`s) **only when both sides prove current-epoch
//! seed-knowledge of a shared subnet**; a non-prover (wrong seed, or no shared
//! subnet) is dropped before the connection ever joins the table. This is the
//! same two-host hermetic shape as `nethost`'s `two_hosts_dial_over_loopback`,
//! plus the seed-proof.

use std::collections::HashSet;
use std::sync::Arc;
use std::time::Duration;

use spt_daemon::nethost::{NetConfig, NetHost};
use spt_daemon::seedproofx::{MembershipSource, SubnetCred};
use spt_net::net::endpoint::{BindScope, LocalDiscovery, RelayPolicy};
use spt_proto::identity::Identity;

/// A 20-byte subnet seed from a single filler byte (test material).
fn seed(b: u8) -> Vec<u8> {
    vec![b; 20]
}

/// A membership source returning a fixed credential list (clones per connect,
/// mirroring the live re-read of the production loader).
fn membership(creds: Vec<SubnetCred>) -> MembershipSource {
    Arc::new(move || creds.clone())
}

fn host(identity: Identity, creds: Vec<SubnetCred>) -> NetHost {
    NetHost::start(NetConfig {
        identity,
        relay: RelayPolicy::Disabled,
        discovery: LocalDiscovery::Off,
        scope: BindScope::Loopback,
        membership: Some(membership(creds)),
    })
    .expect("net host start")
}

/// Poll until `f()` holds (the acceptor registers its inbound conn async), or
/// give up after ~2s and return the last value.
fn wait_until(mut f: impl FnMut() -> bool) -> bool {
    for _ in 0..400 {
        if f() {
            return true;
        }
        std::thread::sleep(Duration::from_millis(5));
    }
    false
}

fn cred(name: &str, s: u8, epoch: u64) -> SubnetCred {
    SubnetCred {
        name: name.to_string(),
        seed: seed(s),
        epoch,
    }
}

fn set(names: &[&str]) -> HashSet<String> {
    names.iter().map(|s| s.to_string()).collect()
}

// [int->REQ-MESH-1] two members sharing a subnet (same seed + epoch) connect:
// the conn registers on BOTH sides and each caches the shared subnet as proven —
// the mesh property (a connection establishes only when both prove).
#[test]
fn both_members_prove_and_cache_the_shared_subnet() {
    let a = host(Identity::generate(), vec![cred("home", 7, 1)]);
    let b_id = Identity::generate();
    let b = host(b_id, vec![cred("home", 7, 1)]);

    let (conn_id, _remote) = a.dial(b.addr()).expect("dial member");
    assert_eq!(
        a.conn_proven_subnets(conn_id),
        set(&["home"]),
        "dialer caches the proven subnet"
    );
    assert!(
        wait_until(|| b.conn_count() == 1),
        "acceptor registered the proven inbound conn"
    );
    assert_eq!(
        b.conn_proven_subnets(1),
        set(&["home"]),
        "acceptor caches the proven subnet"
    );
}

// [int->REQ-MESH-1] the proven verdict is a SET, not a bool: two nodes sharing
// {home, work} but not the dialer-only `solo` prove exactly the intersection on
// both sides.
#[test]
fn proven_verdict_is_the_shared_set() {
    let a = host(
        Identity::generate(),
        vec![cred("home", 1, 1), cred("work", 2, 1), cred("solo", 9, 1)],
    );
    let b = host(
        Identity::generate(),
        vec![cred("home", 1, 1), cred("work", 2, 1)],
    );

    let (conn_id, _) = a.dial(b.addr()).expect("dial");
    assert_eq!(
        a.conn_proven_subnets(conn_id),
        set(&["home", "work"]),
        "dialer proves the intersection, not solo"
    );
    assert!(wait_until(|| b.conn_count() == 1), "acceptor registered");
    assert_eq!(b.conn_proven_subnets(1), set(&["home", "work"]));
}

// [int->REQ-MESH-1] a non-prover is dropped: same subnet NAME but a different
// seed (an outsider who can't derive the membership key) fails the mutual proof,
// so the dial errors and the acceptor never registers the connection.
#[test]
fn wrong_seed_is_dropped() {
    let member = host(Identity::generate(), vec![cred("home", 7, 1)]);
    let impostor = host(Identity::generate(), vec![cred("home", 8, 1)]); // wrong seed

    let res = impostor.dial(member.addr());
    assert!(res.is_err(), "dial fails when the peer rejects our proof");
    // The acceptor must not have a usable conn (it dropped the failed proof).
    assert!(
        !wait_until(|| member.conn_count() == 1),
        "acceptor dropped the non-prover; no conn in the table"
    );
}

// [int->REQ-MESH-1] no shared subnet ⇒ empty intersection ⇒ dropped. Two real
// members of DIFFERENT subnets have nothing to mesh, so the connection is not
// established.
#[test]
fn no_shared_subnet_is_dropped() {
    let a = host(Identity::generate(), vec![cred("alpha", 1, 1)]);
    let b = host(Identity::generate(), vec![cred("beta", 2, 1)]);

    let res = a.dial(b.addr());
    assert!(res.is_err(), "dial fails with no shared subnet");
    assert!(
        !wait_until(|| b.conn_count() == 1),
        "acceptor dropped the no-overlap conn"
    );
}


I have the complete picture now. This is a large, security-critical phase. Let me set up task tracking and decide the key gray areas, then implement layer by layer (stores → crypto grading → CLI → pump → tests).

Key design decisions (resolving the psyche's flagged gray areas):
- **Re-seed delivery = inline during the proof exchange** (mirrors the D4 roster leg), not a separate dispatcher-gated stream. A purely-reseed conn carries no full subnet → drops → stale node re-dials full. This is *stronger* fail-closed than a restricted dispatcher and needs zero broker-IPC plumbing (which D5 explicitly deferred).
- **Crypto needs the previous-generation seed retained one-deep** — `rotate_seed` keeps `prev_seed_hex`; both sides send current+prev proofs so a stale peer can verify a fresh one.
- **Revokee lockout = tombstone (app gate) + rotation (connect gate)**; force-drop of lingering conns deferred (not needed for the security property or the acceptance test).


Bash

cargo --version && ls crates/spt-store/src/ | head -40

Confirm toolchain + store module layout


cargo 1.93.0 (083ac5135 2025-12-15)
access.rs
atomic.rs
attachment.rs
branchstore.rs
contextmark.rs
contextstore.rs
db.rs
epoch.rs
gitrun.rs
grants.rs
history.rs
home.rs
hostlabel.rs
info.rs
lib.rs
liveness.rs
nodeid.rs
notif.rs
peeraddrs.rs
perch.rs
proc.rs
project.rs
registry.rs
rename.rs
roster.rs
seed.rs
shellinfo.rs
spool.rs
subnet.rs
syncmerge.rs
visibility.rs
xfer.rs