100	        let reg = Arc::new(SeedRegistry::new());
101
102	        // Boot housekeeping (KNOWN-HAZARDS 1.3): sweep stale `index.lock`
103	        // wedges out of the git-touching runtime the daemon hosts — the
104	        // context store's seed repo + its agent/project worktrees (a crashed
105	        // git leaves a 0-byte lock that blocks every later commit forever).
106	        // Path composed via the layout authority without opening the full
107	        // ContextStore on the boot path; a lock-less (or store-less) home
108	        // no-ops.
109	        // [impl->REQ-HAZARD-STALE-INDEX-LOCK]
110	        for lock in spt_store::branchstore::sweep_stale_index_locks(
111	            &spt_store::contextstore::seed_git_dir(&spt_store::perch::tracked_dir()),
112	            spt_store::branchstore::STALE_LOCK_AGE,
113	        ) {
114	            eprintln!("STALE_LOCK_SWEPT:{}", lock.display());
115	        }
116
117	        // The broker's network host (D4a, ADR-0004 §B): the node's iroh endpoint
118	        // + live QUIC connections live in the broker so they survive a brain
119	        // restart. Identity is the durable node.key (stable name across daemon
120	        // restarts); relay stance comes from daemon.json; mDNS rides the
121	        // endpoint (closing the §B mDNS/relay-session row by construction). A
122	        // net failure degrades to a net-less broker — it must not cost PTY
123	        // hosting (mirrors the broker-bind soft-fail below).
124	        // [impl->REQ-DAEMON-2]
125	        let mut node_hex: Option<String> = None;
126	        let net = {
127	            let cfg = DaemonConfig::load();
128	            let relay = match cfg.relay {
129	                RelayChoice::N0 => RelayPolicy::N0Default,
130	                RelayChoice::Disabled => RelayPolicy::Disabled,
131	            };
132	            match spt_store::nodeid::load_or_create() {
133	                Ok(identity) => {
134	                    node_hex = Some(identity.public_key().to_hex());
135	                    let net_cfg = NetConfig {
136	                        identity,
137	                        relay,
138	                        discovery: LocalDiscovery::Mdns,
139	                        scope: BindScope::All, // production: real interfaces
140	                    };
141	                    match NetHost::start(net_cfg) {
142	                        Ok(host) => Some(host),
143	                        Err(e) => {
144	                            eprintln!("NET_BIND_FAIL: {e} — broker runs net-less");
145	                            None
146	                        }
147	                    }
148	                }
149	                Err(e) => {
150	                    eprintln!("NODE_KEY_FAIL: {e} — broker runs net-less");
151	                    None
152	                }
153	            }
154	        };
155
156	        // The broker hosts PTYs (B1/B2) + the net endpoint (D4a). Serve it on a
157	        // background thread; a bind failure there must not stop the seed channel
158	        // (the B6 surface) — the PTY-hosting consumers arrive in B7/B8.
159	        let net_up = net.is_some();
160	        match Broker::bind_in_with_net(&broker_socket_name(), default_journal_path(), net) {
161	            Ok(broker) => {
162	                thread::spawn(move || {
163	                    let _ = broker.serve();
164	                });
165	                // Inbound net dispatch + outbound peer pump (D9-1): the
166	                // production trigger loops. Only meaningful with a net host —
167	                // a net-less broker accepts nothing and dials nobody.
168	                if net_up {
169	                    let hex = node_hex.clone().unwrap_or_default();
170	                    let registry = Arc::new(crate::registryhost::RegistryHost::new(&hex));
171	                    let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
172	                    let _ = crate::dispatch::spawn_dispatcher(
173	                        broker_socket_name(),
174	                        Arc::clone(&registry),
175	                        Arc::clone(&stop),
176	                    );
177	                    let cfg = DaemonConfig::load();
178	                    if cfg.peer_pump {
179	                        let _ = crate::peerloop::spawn_peer_pump(

        let reg = Arc::new(SeedRegistry::new());

        // Boot housekeeping (KNOWN-HAZARDS 1.3): sweep stale `index.lock`
        // wedges out of the git-touching runtime the daemon hosts — the
        // context store's seed repo + its agent/project worktrees (a crashed
        // git leaves a 0-byte lock that blocks every later commit forever).
        // Path composed via the layout authority without opening the full
        // ContextStore on the boot path; a lock-less (or store-less) home
        // no-ops.
        // [impl->REQ-HAZARD-STALE-INDEX-LOCK]
        for lock in spt_store::branchstore::sweep_stale_index_locks(
            &spt_store::contextstore::seed_git_dir(&spt_store::perch::tracked_dir()),
            spt_store::branchstore::STALE_LOCK_AGE,
        ) {
            eprintln!("STALE_LOCK_SWEPT:{}", lock.display());
        }

        // The broker's network host (D4a, ADR-0004 §B): the node's iroh endpoint
        // + live QUIC connections live in the broker so they survive a brain
        // restart. Identity is the durable node.key (stable name across daemon
        // restarts); relay stance comes from daemon.json; mDNS rides the
        // endpoint (closing the §B mDNS/relay-session row by construction). A
        // net failure degrades to a net-less broker — it must not cost PTY
        // hosting (mirrors the broker-bind soft-fail below).
        // [impl->REQ-DAEMON-2]
        let mut node_hex: Option<String> = None;
        let net = {
            let cfg = DaemonConfig::load();
            let relay = match cfg.relay {
                RelayChoice::N0 => RelayPolicy::N0Default,
                RelayChoice::Disabled => RelayPolicy::Disabled,
            };
            match spt_store::nodeid::load_or_create() {
                Ok(identity) => {
                    node_hex = Some(identity.public_key().to_hex());
                    let net_cfg = NetConfig {
                        identity,
                        relay,
                        discovery: LocalDiscovery::Mdns,
                        scope: BindScope::All, // production: real interfaces
                    };
                    match NetHost::start(net_cfg) {
                        Ok(host) => Some(host),
                        Err(e) => {
                            eprintln!("NET_BIND_FAIL: {e} — broker runs net-less");
                            None
                        }
                    }
                }
                Err(e) => {
                    eprintln!("NODE_KEY_FAIL: {e} — broker runs net-less");
                    None
                }
            }
        };

        // The broker hosts PTYs (B1/B2) + the net endpoint (D4a). Serve it on a
        // background thread; a bind failure there must not stop the seed channel
        // (the B6 surface) — the PTY-hosting consumers arrive in B7/B8.
        let net_up = net.is_some();
        match Broker::bind_in_with_net(&broker_socket_name(), default_journal_path(), net) {
            Ok(broker) => {
                thread::spawn(move || {
                    let _ = broker.serve();
                });
                // Inbound net dispatch + outbound peer pump (D9-1): the
                // production trigger loops. Only meaningful with a net host —
                // a net-less broker accepts nothing and dials nobody.
                if net_up {
                    let hex = node_hex.clone().unwrap_or_default();
                    let registry = Arc::new(crate::registryhost::RegistryHost::new(&hex));
                    let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
                    let _ = crate::dispatch::spawn_dispatcher(
                        broker_socket_name(),
                        Arc::clone(&registry),
                        Arc::clone(&stop),
                    );
                    let cfg = DaemonConfig::load();
                    if cfg.peer_pump {
                        let _ = crate::peerloop::spawn_peer_pump(


Edit

// The broker's network host (D4a, ADR-0004 §B): the node's iroh endpoint

        // Serve-state boot reset (M8 decision 6, REQ-SUBNET-5): the live
        // attachment state starts from the config's startup defaults — an
        // unsaved attach/detach flip deliberately does not survive a daemon
        // restart (`--save` is the persistence path).
        // [impl->REQ-SUBNET-5]
        {
            let att = spt_store::attachment::AttachmentStore::reset_to(
                &DaemonConfig::load().detached_subnets,
            );
            if let Err(e) = att.save() {
                eprintln!("ATTACHMENT_RESET_FAIL: {e} — serving with all subnets attached");
            }
            for name in &att.detached {
                eprintln!("SUBNET_DETACHED:{name} (startup default — `spt subnet attach {name}`)");
            }
        }

        // The broker's network host (D4a, ADR-0004 §B): the node's iroh endpoint


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

        // Serve-state boot reset (M8 decision 6, REQ-SUBNET-5): the live
        // attachment state starts from the config's startup defaults — an
        // unsaved attach/detach flip deliberately does not survive a daemon
        // restart (`--save` is the persistence path).
        // [impl->REQ-SUBNET-5]
        {
            let att = spt_store::attachment::AttachmentStore::reset_to(
                &DaemonConfig::load().detached_subnets,
            );
            if let Err(e) = att.save() {
                eprintln!("ATTACHMENT_RESET_FAIL: {e} — serving with all subnets attached");
            }
            for name in &att.detached {
                eprintln!("SUBNET_DETACHED:{name} (startup default — `spt subnet attach {name}`)");
            }
        }

        // The broker's network host (D4a, ADR-0004 §B): the node's iroh endpoint

+        // Serve-state boot reset (M8 decision 6, REQ-SUBNET-5): the live

+        // attachment state starts from the config's startup defaults — an

+        // unsaved attach/detach flip deliberately does not survive a daemon

+        // restart (`--save` is the persistence path).

+            let att = spt_store::attachment::AttachmentStore::reset_to(

+                &DaemonConfig::load().detached_subnets,

+            if let Err(e) = att.save() {

+                eprintln!("ATTACHMENT_RESET_FAIL: {e} — serving with all subnets attached");

+            for name in &att.detached {

+                eprintln!("SUBNET_DETACHED:{name} (startup default — `spt subnet attach {name}`)");

// + live QUIC connections live in the broker so they survive a brain

// restart. Identity is the durable node.key (stable name across daemon


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

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

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

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

D1 — endpoint + daemon nouns (breaking CLI reorg)

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

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

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

D3 — install & elevation hardening

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

D4 — convergence polish + pump resilience

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

D5 — docs + CI + acceptance prep

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


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


Update CLI flags for endpoint and subnet commands


Pump gating:


        // 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);
        let trust = TrustStore::load_from(&paths.trust);

        // 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")),
        );
        let trust = TrustStore::load_from(&paths.trust);


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

        // 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);
        let trust = TrustStore::load_from(&paths.trust);

        // 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")),
        );
        let trust = TrustStore::load_from(&paths.trust);

+        // 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.

+        let attachment = spt_store::attachment::AttachmentStore::load_from(

+                .map(|p| p.join("attachment.json"))

+                .unwrap_or_else(|| PathBuf::from("attachment.json")),


        for sub in &subnets.subnets {
            for peer in trust.peers_in(&sub.name) {
                if peer.pubkey_hex == registry.node_hex() {
                    continue; // never pump at ourselves
                }

        for sub in &subnets.subnets {
            if attachment.is_detached(&sub.name) {
                continue; // detached: neither advertise into nor connect to (REQ-SUBNET-5)
            }
            for peer in trust.peers_in(&sub.name) {
                if peer.pubkey_hex == registry.node_hex() {
                    continue; // never pump at ourselves
                }


        for sub in &subnets.subnets {
            for peer in trust.peers_in(&sub.name) {
                if peer.pubkey_hex == registry.node_hex() {
                    continue; // never pump at ourselves
                }

        for sub in &subnets.subnets {
            if attachment.is_detached(&sub.name) {
                continue; // detached: neither advertise into nor connect to (REQ-SUBNET-5)
            }
            for peer in trust.peers_in(&sub.name) {
                if peer.pubkey_hex == registry.node_hex() {
                    continue; // never pump at ourselves
                }

+            if attachment.is_detached(&sub.name) {

+                continue; // detached: neither advertise into nor connect to (REQ-SUBNET-5)


Pairhost gating:


    let Ok(mut rate) = rate.try_lock() else {
        conn.close(1u32.into(), b"pairing busy");
        return;
    };
    let subnets = SubnetStore::load();
    let mut trust = TrustStore::load();

    let Ok(mut rate) = rate.try_lock() else {
        conn.close(1u32.into(), b"pairing busy");
        return;
    };
    // Serve-state filter (M8 decision 6, REQ-SUBNET-5): a detached subnet
    // is not served — the responder sees a store WITHOUT it, so a joiner
    // gets the same answer as for a subnet this node does not hold. The
    // membership record on disk is untouched.
    // [impl->REQ-SUBNET-5]
    let mut subnets = SubnetStore::load();
    spt_store::attachment::AttachmentStore::load().filter_serving(&mut subnets);
    let mut trust = TrustStore::load();


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

    let Ok(mut rate) = rate.try_lock() else {
        conn.close(1u32.into(), b"pairing busy");
        return;
    };
    let subnets = SubnetStore::load();
    let mut trust = TrustStore::load();

    let Ok(mut rate) = rate.try_lock() else {
        conn.close(1u32.into(), b"pairing busy");
        return;
    };
    // Serve-state filter (M8 decision 6, REQ-SUBNET-5): a detached subnet
    // is not served — the responder sees a store WITHOUT it, so a joiner
    // gets the same answer as for a subnet this node does not hold. The
    // membership record on disk is untouched.
    // [impl->REQ-SUBNET-5]
    let mut subnets = SubnetStore::load();
    spt_store::attachment::AttachmentStore::load().filter_serving(&mut subnets);
    let mut trust = TrustStore::load();

+    // Serve-state filter (M8 decision 6, REQ-SUBNET-5): a detached subnet

+    // is not served — the responder sees a store WITHOUT it, so a joiner

+    // gets the same answer as for a subnet this node does not hold. The

+    // membership record on disk is untouched.

+    let mut subnets = SubnetStore::load();

+    spt_store::attachment::AttachmentStore::load().filter_serving(&mut subnets);


        loop {
            let step = time_step(now_secs());
            let names: Vec<String> = SubnetStore::load()
                .subnets
                .iter()
                .map(|s| s.name.clone())
                .collect();

        loop {
            let step = time_step(now_secs());
            // Serve-state filter (REQ-SUBNET-5): a detached subnet's meet
            // listener does not rotate — a joiner cannot rendezvous with a
            // membership this node deliberately is not serving. Re-read per
            // window like the subnet store itself.
            let attachment = spt_store::attachment::AttachmentStore::load();
            let names: Vec<String> = SubnetStore::load()
                .subnets
                .iter()
                .map(|s| s.name.clone())
                .filter(|n| !attachment.is_detached(n))
                .collect();


        loop {
            let step = time_step(now_secs());
            let names: Vec<String> = SubnetStore::load()
                .subnets
                .iter()
                .map(|s| s.name.clone())
                .collect();

        loop {
            let step = time_step(now_secs());
            // Serve-state filter (REQ-SUBNET-5): a detached subnet's meet
            // listener does not rotate — a joiner cannot rendezvous with a
            // membership this node deliberately is not serving. Re-read per
            // window like the subnet store itself.
            let attachment = spt_store::attachment::AttachmentStore::load();
            let names: Vec<String> = SubnetStore::load()
                .subnets
                .iter()
                .map(|s| s.name.clone())
                .filter(|n| !attachment.is_detached(n))
                .collect();

let mut prev_tasks: Vec<tokio::task::JoinHandle<()>> = Vec::new();

+            // Serve-state filter (REQ-SUBNET-5): a detached subnet's meet

+            // listener does not rotate — a joiner cannot rendezvous with a

+            // membership this node deliberately is not serving. Re-read per

+            // window like the subnet store itself.

+            let attachment = spt_store::attachment::AttachmentStore::load();

+                .filter(|n| !attachment.is_detached(n))

// One listener task per subnet for this window's lifetime.


CLI verbs + banner states:


    /// 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 {

    /// 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 {


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

    /// 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 {

+    /// 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.

+        /// The held subnet to stop serving.

+        /// Also persist as the startup default (survives daemon restarts).

+    /// Resume serving a detached subnet.

+    /// Advertising + connecting restart within one pump cadence.

+        /// The held subnet to serve again.


Some(SubnetCmd::Join { name, code }) => cmd_subnet_join(name, code),

                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::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::Create { name }) => cmd_subnet_create(name),

Some(SubnetCmd::ShowCode { name }) => cmd_subnet_show_code(name),

+                Some(SubnetCmd::Detach { name, save }) => cmd_subnet_attachment(&name, true, save),

+                Some(SubnetCmd::Attach { name, save }) => cmd_subnet_attachment(&name, false, save),

// notify's M8 home (decision 4); the hot path above stays

// flat — moved verbs have no top-level shims (decision 9).


/// The join command's elevation verdict — pure over the probed level

/// `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).
// [impl->REQ-SUBNET-5]
fn cmd_subnet_attachment(name: &str, detach: bool, save: bool) -> i32 {
    if SubnetStore::load().find(name).is_none() {
        eprintln!("NO_SUCH_SUBNET:{name}");
        return 2;
    }
    let mut att = spt_store::attachment::AttachmentStore::load();
    let edge = att.set_detached(name, detach);
    if let Err(e) = att.save() {
        eprintln!("ATTACHMENT_SAVE_FAIL:{name}: {e}");
        return 1;
    }
    let verb = if detach { "DETACHED" } else { "ATTACHED" };
    if edge {
        eprintln!("{verb}:{name} (takes effect within one pump cadence)");
    } else {
        eprintln!("NO_EDGE:{name} (already {})", verb.to_lowercase());
    }
    if save {
        let path = spt_daemon::DaemonConfig::default_path();
        let mut cfg = spt_daemon::DaemonConfig::load();
        cfg.detached_subnets.retain(|n| n != name);
        if detach {
            cfg.detached_subnets.push(name.to_string());
        }
        match cfg.save_to(&path) {
            Ok(()) => eprintln!("SAVED:{name} startup default = {}", verb.to_lowercase()),
            Err(e) => {
                eprintln!("CONFIG_SAVE_FAIL:{name}: {e}");
                return 1;
            }
        }
    }
    0
}

/// The join command's elevation verdict — pure over the probed level


/// `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).
// [impl->REQ-SUBNET-5]
fn cmd_subnet_attachment(name: &str, detach: bool, save: bool) -> i32 {
    if SubnetStore::load().find(name).is_none() {
        eprintln!("NO_SUCH_SUBNET:{name}");
        return 2;
    }
    let mut att = spt_store::attachment::AttachmentStore::load();
    let edge = att.set_detached(name, detach);
    if let Err(e) = att.save() {
        eprintln!("ATTACHMENT_SAVE_FAIL:{name}: {e}");
        return 1;
    }
    let verb = if detach { "DETACHED" } else { "ATTACHED" };
    if edge {
        eprintln!("{verb}:{name} (takes effect within one pump cadence)");
    } else {
        eprintln!("NO_EDGE:{name} (already {})", verb.to_lowercase());
    }
    if save {
        let path = spt_daemon::DaemonConfig::default_path();
        let mut cfg = spt_daemon::DaemonConfig::load();
        cfg.detached_subnets.retain(|n| n != name);
        if detach {
            cfg.detached_subnets.push(name.to_string());
        }
        match cfg.save_to(&path) {
            Ok(()) => eprintln!("SAVED:{name} startup default = {}", verb.to_lowercase()),
            Err(e) => {
                eprintln!("CONFIG_SAVE_FAIL:{name}: {e}");
                return 1;
            }
        }
    }
    0
}

/// The join command's elevation verdict — pure over the probed level

+/// `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

+fn cmd_subnet_attachment(name: &str, detach: bool, save: bool) -> i32 {

+    if SubnetStore::load().find(name).is_none() {

+        eprintln!("NO_SUCH_SUBNET:{name}");

+    let mut att = spt_store::attachment::AttachmentStore::load();

+    let edge = att.set_detached(name, detach);

+        eprintln!("ATTACHMENT_SAVE_FAIL:{name}: {e}");

+    let verb = if detach { "DETACHED" } else { "ATTACHED" };

+        eprintln!("{verb}:{name} (takes effect within one pump cadence)");

+        eprintln!("NO_EDGE:{name} (already {})", verb.to_lowercase());

+        let path = spt_daemon::DaemonConfig::default_path();

+        let mut cfg = spt_daemon::DaemonConfig::load();

+        cfg.detached_subnets.retain(|n| n != name);

+            cfg.detached_subnets.push(name.to_string());

+            Ok(()) => eprintln!("SAVED:{name} startup default = {}", verb.to_lowercase()),

+                eprintln!("CONFIG_SAVE_FAIL:{name}: {e}");

/// (REQ-SUBNET-4): joining enrolls the MACHINE into a subnet's trust

/// fabric, a mutation an unprivileged process must not perform (an


D3 — install & elevation hardening

D5 — docs + CI + acceptance prep


Banner per-subnet states:


    // Attachment states: every held subnet is attached while the daemon is
    // up (CONTEXT §subnet attachment — detach verbs are deferred); a
    // net-less daemon (endpoint bind failed) serves nothing: no connection.
    let net_up = spt_daemon::brain::Brain::cold_start(&spt_daemon::broker_socket_name(), now_ms())
        .ok()
        .and_then(|mut b| b.net_status().ok())
        .map(|s| s.enabled)
        .unwrap_or(true); // can't ask ⇒ assume serving rather than alarm
    let state = if net_up { "attached" } else { "no connection" };
    let rows: Vec<(String, &'static str)> = SubnetStore::load()
        .subnets
        .iter()
        .map(|s| (s.name.clone(), state))
        .collect();
    eprint!("{}", render_subnets_joined(&rows));
    Ok(())
}

    // Attachment states per subnet (M8 decision 6, REQ-SUBNET-5): detached
    // is a chosen per-subnet state; a net-less daemon (endpoint bind
    // failed) serves nothing — "no connection", never conflated with a
    // deliberate detach (CONTEXT §subnet attachment).
    let net_up = spt_daemon::brain::Brain::cold_start(&spt_daemon::broker_socket_name(), now_ms())
        .ok()
        .and_then(|mut b| b.net_status().ok())
        .map(|s| s.enabled)
        .unwrap_or(true); // can't ask ⇒ assume serving rather than alarm
    let att = spt_store::attachment::AttachmentStore::load();
    let rows: Vec<(String, &'static str)> = SubnetStore::load()
        .subnets
        .iter()
        .map(|s| {
            let state = if att.is_detached(&s.name) {
                "detached"
            } else if net_up {
                "attached"
            } else {
                "no connection"
            };
            (s.name.clone(), state)
        })
        .collect();
    eprint!("{}", render_subnets_joined(&rows));
    Ok(())
}


    // Attachment states: every held subnet is attached while the daemon is
    // up (CONTEXT §subnet attachment — detach verbs are deferred); a
    // net-less daemon (endpoint bind failed) serves nothing: no connection.
    let net_up = spt_daemon::brain::Brain::cold_start(&spt_daemon::broker_socket_name(), now_ms())
        .ok()
        .and_then(|mut b| b.net_status().ok())
        .map(|s| s.enabled)
        .unwrap_or(true); // can't ask ⇒ assume serving rather than alarm
    let state = if net_up { "attached" } else { "no connection" };
    let rows: Vec<(String, &'static str)> = SubnetStore::load()
        .subnets
        .iter()
        .map(|s| (s.name.clone(), state))
        .collect();
    eprint!("{}", render_subnets_joined(&rows));
    Ok(())
}

    // Attachment states per subnet (M8 decision 6, REQ-SUBNET-5): detached
    // is a chosen per-subnet state; a net-less daemon (endpoint bind
    // failed) serves nothing — "no connection", never conflated with a
    // deliberate detach (CONTEXT §subnet attachment).
    let net_up = spt_daemon::brain::Brain::cold_start(&spt_daemon::broker_socket_name(), now_ms())
        .ok()
        .and_then(|mut b| b.net_status().ok())
        .map(|s| s.enabled)
        .unwrap_or(true); // can't ask ⇒ assume serving rather than alarm
    let att = spt_store::attachment::AttachmentStore::load();
    let rows: Vec<(String, &'static str)> = SubnetStore::load()
        .subnets
        .iter()
        .map(|s| {
            let state = if att.is_detached(&s.name) {
                "detached"
            } else if net_up {
                "attached"
            } else {
                "no connection"
            };
            (s.name.clone(), state)
        })
        .collect();
    eprint!("{}", render_subnets_joined(&rows));
    Ok(())
}

eprintln!("spt-core daemon: online - {ident}");

-    // Attachment states: every held subnet is attached while the daemon is

-    // up (CONTEXT §subnet attachment — detach verbs are deferred); a

-    // net-less daemon (endpoint bind failed) serves nothing: no connection.

+    // Attachment states per subnet (M8 decision 6, REQ-SUBNET-5): detached

+    // is a chosen per-subnet state; a net-less daemon (endpoint bind

+    // failed) serves nothing — "no connection", never conflated with a

+    // deliberate detach (CONTEXT §subnet attachment).

-    let state = if net_up { "attached" } else { "no connection" };

+    let att = spt_store::attachment::AttachmentStore::load();

-        .map(|s| (s.name.clone(), state))

+            let state = if att.is_detached(&s.name) {


Grep

fn subnet_.*parse|"subnet", "join"|"subnet", "create"


5879:    fn subnet_arg_shapes_parse() {
5900:            subnet_cmd(&["spt", "subnet", "create", "home"]),
5904:            parse(&["spt", "subnet", "create"]).is_err(),
5918:            subnet_cmd(&["spt", "subnet", "join"]),
5925:            subnet_cmd(&["spt", "subnet", "join", "home"]),
5929:            subnet_cmd(&["spt", "subnet", "join", "home", "--code", "123456"]),


5860	                .has_branch(&agent_branch("ling-cold"))
5861	                .unwrap(),
5862	            "the deleting fork's own mind stands"
5863	        );
5864	    }
5865
5866	    // ── subnet namespace ────────────────────────────────────────────────────────
5867
5868	    fn subnet_cmd(args: &[&str]) -> Option<SubnetCmd> {
5869	        match parse(args).unwrap().cmd {
5870	            Cmd::Subnet { action } => action,
5871	            _ => panic!("expected Subnet"),
5872	        }
5873	    }
5874
5875	    // [unit->REQ-SUBNET-1] [unit->REQ-PAIR-3] the subnet arg shapes parse:
5876	    // bare (= status), status [NAME], create <NAME>, show-code [NAME] — all
5877	    // positional names, no --subnet stutter (M7 plan decision 3).
5878	    #[test]
5879	    fn subnet_arg_shapes_parse() {
5880	        assert!(
5881	            subnet_cmd(&["spt", "subnet"]).is_none(),
5882	            "bare subnet = status view"
5883	        );
5884	        assert!(matches!(
5885	            subnet_cmd(&["spt", "subnet", "status"]),
5886	            Some(SubnetCmd::Status {
5887	                name: None,
5888	                nodes: false
5889	            })
5890	        ));
5891	        assert!(matches!(
5892	            subnet_cmd(&["spt", "subnet", "status", "home"]),
5893	            Some(SubnetCmd::Status { name: Some(n), .. }) if n == "home"
5894	        ));
5895	        assert!(matches!(
5896	            subnet_cmd(&["spt", "subnet", "status", "home", "--nodes"]),
5897	            Some(SubnetCmd::Status { nodes: true, .. })
5898	        ));
5899	        assert!(matches!(
5900	            subnet_cmd(&["spt", "subnet", "create", "home"]),
5901	            Some(SubnetCmd::Create { name }) if name == "home"
5902	        ));
5903	        assert!(
5904	            parse(&["spt", "subnet", "create"]).is_err(),
5905	            "create needs a name"
5906	        );
5907	        assert!(matches!(
5908	            subnet_cmd(&["spt", "subnet", "show-code"]),
5909	            Some(SubnetCmd::ShowCode { name: None })
5910	        ));
5911	        assert!(matches!(
5912	            subnet_cmd(&["spt", "subnet", "show-code", "home"]),
5913	            Some(SubnetCmd::ShowCode { name: Some(n) }) if n == "home"
5914	        ));
5915	        // [unit->REQ-SUBNET-2] the join shapes: fully interactive (bare),
5916	        // name-only (prompts the code), and fully non-interactive.
5917	        assert!(matches!(
5918	            subnet_cmd(&["spt", "subnet", "join"]),
5919	            Some(SubnetCmd::Join {
5920	                name: None,
5921	                code: None
5922	            })
5923	        ));
5924	        assert!(matches!(
5925	            subnet_cmd(&["spt", "subnet", "join", "home"]),
5926	            Some(SubnetCmd::Join { name: Some(n), code: None }) if n == "home"
5927	        ));
5928	        assert!(matches!(
5929	            subnet_cmd(&["spt", "subnet", "join", "home", "--code", "123456"]),
5930	            Some(SubnetCmd::Join { name: Some(_), code: Some(c) }) if c == "123456"
5931	        ));
5932	    }
5933
5934	    // [unit->REQ-SUBNET-4] join is elevation-gated with the same fail-safe
5935	    // posture as the seed-reveal paths: only CONFIRMED elevation proceeds;
5936	    // NotElevated and the undeterminable Unknown both refuse (before any
5937	    // prompt or network work — the command checks this gate first).
5938	    #[test]
5939	    fn join_gate_refuses_without_confirmed_elevation() {

                .has_branch(&agent_branch("ling-cold"))
                .unwrap(),
            "the deleting fork's own mind stands"
        );
    }

    // ── subnet namespace ────────────────────────────────────────────────────────

    fn subnet_cmd(args: &[&str]) -> Option<SubnetCmd> {
        match parse(args).unwrap().cmd {
            Cmd::Subnet { action } => action,
            _ => panic!("expected Subnet"),
        }
    }

    // [unit->REQ-SUBNET-1] [unit->REQ-PAIR-3] the subnet arg shapes parse:
    // bare (= status), status [NAME], create <NAME>, show-code [NAME] — all
    // positional names, no --subnet stutter (M7 plan decision 3).
    #[test]
    fn subnet_arg_shapes_parse() {
        assert!(
            subnet_cmd(&["spt", "subnet"]).is_none(),
            "bare subnet = status view"
        );
        assert!(matches!(
            subnet_cmd(&["spt", "subnet", "status"]),
            Some(SubnetCmd::Status {
                name: None,
                nodes: false
            })
        ));
        assert!(matches!(
            subnet_cmd(&["spt", "subnet", "status", "home"]),
            Some(SubnetCmd::Status { name: Some(n), .. }) if n == "home"
        ));
        assert!(matches!(
            subnet_cmd(&["spt", "subnet", "status", "home", "--nodes"]),
            Some(SubnetCmd::Status { nodes: true, .. })
        ));
        assert!(matches!(
            subnet_cmd(&["spt", "subnet", "create", "home"]),
            Some(SubnetCmd::Create { name }) if name == "home"
        ));
        assert!(
            parse(&["spt", "subnet", "create"]).is_err(),
            "create needs a name"
        );
        assert!(matches!(
            subnet_cmd(&["spt", "subnet", "show-code"]),
            Some(SubnetCmd::ShowCode { name: None })
        ));
        assert!(matches!(
            subnet_cmd(&["spt", "subnet", "show-code", "home"]),
            Some(SubnetCmd::ShowCode { name: Some(n) }) if n == "home"
        ));
        // [unit->REQ-SUBNET-2] the join shapes: fully interactive (bare),
        // name-only (prompts the code), and fully non-interactive.
        assert!(matches!(
            subnet_cmd(&["spt", "subnet", "join"]),
            Some(SubnetCmd::Join {
                name: None,
                code: None
            })
        ));
        assert!(matches!(
            subnet_cmd(&["spt", "subnet", "join", "home"]),
            Some(SubnetCmd::Join { name: Some(n), code: None }) if n == "home"
        ));
        assert!(matches!(
            subnet_cmd(&["spt", "subnet", "join", "home", "--code", "123456"]),
            Some(SubnetCmd::Join { name: Some(_), code: Some(c) }) if c == "123456"
        ));
    }

    // [unit->REQ-SUBNET-4] join is elevation-gated with the same fail-safe
    // posture as the seed-reveal paths: only CONFIRMED elevation proceeds;
    // NotElevated and the undeterminable Unknown both refuse (before any
    // prompt or network work — the command checks this gate first).
    #[test]
    fn join_gate_refuses_without_confirmed_elevation() {


Edit

        assert!(matches!(
            subnet_cmd(&["spt", "subnet", "join", "home", "--code", "123456"]),
            Some(SubnetCmd::Join { name: Some(_), code: Some(c) }) if c == "123456"
        ));
    }

        assert!(matches!(
            subnet_cmd(&["spt", "subnet", "join", "home", "--code", "123456"]),
            Some(SubnetCmd::Join { name: Some(_), code: Some(c) }) if c == "123456"
        ));
        // [unit->REQ-SUBNET-5] attach/detach: positional name + --save
        // (M8 decision 6; --save renamed from --auto per decision 25).
        assert!(matches!(
            subnet_cmd(&["spt", "subnet", "detach", "home"]),
            Some(SubnetCmd::Detach { name, save: false }) if name == "home"
        ));
        assert!(matches!(
            subnet_cmd(&["spt", "subnet", "attach", "home", "--save"]),
            Some(SubnetCmd::Attach { name, save: true }) if name == "home"
        ));
        assert!(
            parse(&["spt", "subnet", "detach"]).is_err(),
            "detach needs a name"
        );
    }


        assert!(matches!(
            subnet_cmd(&["spt", "subnet", "join", "home", "--code", "123456"]),
            Some(SubnetCmd::Join { name: Some(_), code: Some(c) }) if c == "123456"
        ));
    }

        assert!(matches!(
            subnet_cmd(&["spt", "subnet", "join", "home", "--code", "123456"]),
            Some(SubnetCmd::Join { name: Some(_), code: Some(c) }) if c == "123456"
        ));
        // [unit->REQ-SUBNET-5] attach/detach: positional name + --save
        // (M8 decision 6; --save renamed from --auto per decision 25).
        assert!(matches!(
            subnet_cmd(&["spt", "subnet", "detach", "home"]),
            Some(SubnetCmd::Detach { name, save: false }) if name == "home"
        ));
        assert!(matches!(
            subnet_cmd(&["spt", "subnet", "attach", "home", "--save"]),
            Some(SubnetCmd::Attach { name, save: true }) if name == "home"
        ));
        assert!(
            parse(&["spt", "subnet", "detach"]).is_err(),
            "detach needs a name"
        );
    }

+        // [unit->REQ-SUBNET-5] attach/detach: positional name + --save

+        // (M8 decision 6; --save renamed from --auto per decision 25).

+            subnet_cmd(&["spt", "subnet", "detach", "home"]),

+            Some(SubnetCmd::Detach { name, save: false }) if name == "home"

+            subnet_cmd(&["spt", "subnet", "attach", "home", "--save"]),

+            Some(SubnetCmd::Attach { name, save: true }) if name == "home"

+            parse(&["spt", "subnet", "detach"]).is_err(),

// [unit->REQ-SUBNET-4] join is elevation-gated with the same fail-safe


PowerShell

cargo build -q -p spt -p spt-daemon -p spt-store 2>&1 | Select-Object -First 40

Build after attach/detach changes


(PowerShell completed with no output)


cargo test -q -p spt-store attachment 2>&1 | Select-Object -Last 4; cargo test -q -p spt-daemon --lib config 2>&1 | Select-Object -Last 4

Run attachment + config tests


running 3 tests
...
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 139 filtered out; finished in 0.01s

running 10 tests
..........
test result: ok. 10 passed; 0 failed; 0 ignored; 0 measured; 185 filtered out; finished in 0.10s


Serve-state leg green. Now leave + prune. Check trust store + registry snapshot shapes.


Grep

pub fn (peers_in|record|revoke|remove)|pub struct (TrustStore|PeerRecord)|pub pubkey_hex|pub label|pub subnet


92:    pub pubkey_hex: String,
94:    pub subnet: String,
98:    pub label: Option<String>,
108:pub struct TrustStore {
158:    pub fn peers_in<'a>(&'a self, subnet: &'a str) -> impl Iterator<Item = &'a TrustedPeer> {
171:    pub fn record(
232:    pub fn revoke(&mut self, subnet: &str, pubkey_hex: &str) -> bool {


107:    /// Where snapshots land (`identity/registry/<subnet>.json`).
418:    pub fn snapshot(&self, subnet: &str) -> Option<SubnetRegistry> {
422:    /// Mirror every subnet's registry to `identity/registry/<subnet>.json`
438:            let _ = atomic_write(&self.snapshot_dir.join(format!("{subnet}.json")), &json);
441:        // The heard-map mirror (M7 D2 hybrid liveness). `.meta`, not `.json`:
442:        // the snapshot reader globs `<subnet>.json`, so this can never shadow
453:    pub fn snapshot_path(dir: &Path, subnet: &str) -> PathBuf {
454:        dir.join(format!("{subnet}.json"))
524:            EpochSource::load_from(&dir.join("epoch.json")),
559:    // the endpoint's own info.json blurb wins; an endpoint without one
578:            // The node seed: daemon.json under the canonical home.


85	}
86
87	/// One authorized peer: its pinned node pubkey, the subnet it's trusted within, an
88	/// optional human label (the warn-on-change anchor), the cross-user seam, and a stamp.
89	#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
90	pub struct TrustedPeer {
91	    /// The peer's Ed25519 node pubkey, lowercase hex (the iroh NodeId). The identity.
92	    pub pubkey_hex: String,
93	    /// The subnet this peer paired into / is trusted within.
94	    pub subnet: String,
95	    /// Optional human node label. When present it is the stable handle warn-on-change
96	    /// keys against; absent (v1 default) means the pubkey is the sole identity.
97	    #[serde(skip_serializing_if = "Option::is_none")]
98	    pub label: Option<String>,
99	    /// Owning user — the cross-user seam. `None` = the single implicit v1 user.
100	    #[serde(skip_serializing_if = "Option::is_none")]
101	    pub user: Option<String>,
102	    /// First-trust stamp (epoch-seconds string; opaque).
103	    pub paired: String,
104	}
105
106	/// The whole `peers.json` document: every peer this node trusts, across all subnets.
107	#[derive(Debug, Clone, Default, Serialize, Deserialize)]
108	pub struct TrustStore {
109	    /// All trusted peers (multi-subnet — one node trusts peers across N subnets).
110	    #[serde(default)]
111	    pub peers: Vec<TrustedPeer>,
112	}
113
114	impl TrustStore {
115	    /// Load from the canonical [`trust_file`]. A missing file is the empty store (an
116	    /// unpaired node) — never an error.
117	    pub fn load() -> TrustStore {
118	        Self::load_from(&trust_file())
119	    }
120
121	    /// Load from an explicit path (the testable core). Absent ⇒ empty; an unreadable or
122	    /// corrupt file also degrades to empty rather than panicking — a damaged trust file
123	    /// must not brick the node (re-pair to rebuild it), the same stance as
124	    /// [`SubnetStore`](crate::subnet::SubnetStore).
125	    pub fn load_from(path: &Path) -> TrustStore {
126	        match std::fs::read_to_string(path) {
127	            Ok(s) => serde_json::from_str(&s).unwrap_or_default(),
128	            Err(_) => TrustStore::default(),
129	        }
130	    }
131
132	    /// Persist to the canonical [`trust_file`], creating `identity/trust/` if needed.
133	    pub fn save(&self) -> Result<(), TrustError> {
134	        self.save_to(&trust_file())
135	    }
136
137	    /// Persist to an explicit path (atomic; creates the parent dir). Atomic write so a
138	    /// concurrent reader never sees a torn trust file (the [`atomic`](crate::atomic)
139	    /// invariant).
140	    pub fn save_to(&self, path: &Path) -> Result<(), TrustError> {
141	        if let Some(parent) = path.parent() {
142	            std::fs::create_dir_all(parent).map_err(|e| TrustError::Io(e.to_string()))?;
143	        }
144	        let json = serde_json::to_string_pretty(self).map_err(|e| TrustError::Io(e.to_string()))?;
145	        atomic_write_string(path, &json).map_err(|e| TrustError::Io(e.to_string()))
146	    }
147
148	    /// Whether `pubkey_hex` is a trusted peer in `subnet` — the connect-time gate (an
149	    /// inbound peer the runtime hasn't pinned is not one of the user's own nodes).
150	    // [impl->REQ-PAIR-2]
151	    pub fn is_trusted(&self, subnet: &str, pubkey_hex: &str) -> bool {
152	        self.peers
153	            .iter()
154	            .any(|p| p.subnet == subnet && p.pubkey_hex == pubkey_hex)
155	    }
156
157	    /// All peers trusted in `subnet`.
158	    pub fn peers_in<'a>(&'a self, subnet: &'a str) -> impl Iterator<Item = &'a TrustedPeer> {
159	        self.peers.iter().filter(move |p| p.subnet == subnet)
160	    }
161
162	    /// Record (pin) a peer after a successful pairing ceremony — the TOFU write.
163	    ///
164	    /// Returns the [`TrustOutcome`]: [`Pinned`](TrustOutcome::Pinned) for a first use,
165	    /// [`AlreadyTrusted`](TrustOutcome::AlreadyTrusted) for an idempotent re-pair, or
166	    /// [`KeyChanged`](TrustOutcome::KeyChanged) if a peer under the same `(subnet,
167	    /// label)` already holds a different key — in which case **nothing is written** and
168	    /// the caller must warn + decide ([`repin`](Self::repin)). The `(subnet, user)`
169	    /// scope is honored (a label is only an anchor within its own user scope).
170	    // [impl->REQ-PAIR-2]
171	    pub fn record(
172	        &mut self,
173	        subnet: &str,
174	        pubkey_hex: &str,
175	        label: Option<&str>,
176	        user: Option<&str>,
177	    ) -> TrustOutcome {
178	        // Exact identity already trusted → idempotent (a key isn't "changed" against
179	        // itself, even when a label is present).
180	        if self.is_trusted(subnet, pubkey_hex) {
181	            return TrustOutcome::AlreadyTrusted;
182	        }
183	        // Warn-on-change: a label already bound to a DIFFERENT key in this scope.
184	        if let Some(lbl) = label {
185	            if let Some(existing) = self.peers.iter().find(|p| {
186	                p.subnet == subnet && p.user.as_deref() == user && p.label.as_deref() == Some(lbl)
187	            }) {
188	                return TrustOutcome::KeyChanged {
189	                    existing_pubkey_hex: existing.pubkey_hex.clone(),
190	                };
191	            }
192	        }
193	        self.peers.push(TrustedPeer {
194	            pubkey_hex: pubkey_hex.to_string(),
195	            subnet: subnet.to_string(),
196	            label: label.map(str::to_string),
197	            user: user.map(str::to_string),
198	            paired: now_stamp(),
199	        });
200	        TrustOutcome::Pinned
201	    }
202
203	    /// Apply an **accepted** key change for a labelled peer: replace the pinned key for
204	    /// `(subnet, label)` with `new_pubkey_hex`. Call this only after a
205	    /// [`KeyChanged`](TrustOutcome::KeyChanged) outcome was surfaced and the operator
206	    /// confirmed the rotation (otherwise a MITM's key would be trusted). Errors if no
207	    /// peer holds that label in scope.
208	    // [impl->REQ-PAIR-2]
209	    pub fn repin(
210	        &mut self,
211	        subnet: &str,
212	        label: &str,
213	        new_pubkey_hex: &str,
214	        user: Option<&str>,
215	    ) -> Result<(), TrustError> {
216	        let peer = self
217	            .peers
218	            .iter_mut()
219	            .find(|p| {
220	                p.subnet == subnet && p.user.as_deref() == user && p.label.as_deref() == Some(label)
221	            })
222	            .ok_or_else(|| TrustError::NotFound(format!("{subnet}/{label}")))?;
223	        peer.pubkey_hex = new_pubkey_hex.to_string();
224	        peer.paired = now_stamp();
225	        Ok(())
226	    }
227
228	    /// Revoke a trusted peer (the node-removal trust-delete). Returns whether a peer was
229	    /// removed. Pair this with a [seed rotation](crate::subnet::SubnetStore::rotate_seed)
230	    /// — a trust-delete alone is not revocation while the seed is still replicated (#10).
231	    // [impl->REQ-PAIR-2]
232	    pub fn revoke(&mut self, subnet: &str, pubkey_hex: &str) -> bool {
233	        let before = self.peers.len();
234	        self.peers
235	            .retain(|p| !(p.subnet == subnet && p.pubkey_hex == pubkey_hex));
236	        self.peers.len() != before
237	    }
238	}
239
240	/// Current unix-seconds as an opaque stamp string (matches [`SubnetStore`](crate::subnet)).
241	fn now_stamp() -> String {
242	    std::time::SystemTime::now()
243	        .duration_since(std::time::UNIX_EPOCH)
244	        .map(|d| d.as_secs().to_string())
245	        .unwrap_or_else(|_| "0".to_string())
246	}
247
248	#[cfg(test)]
249	mod tests {
250	    use super::*;
251
252	    // [unit->REQ-PAIR-2] TOFU first-use: a fresh peer pins (Pinned) and is then
253	    // trusted; re-recording the SAME key is idempotent (AlreadyTrusted), not a
254	    // duplicate — the post-ceremony accept path.
255	    #[test]
256	    fn first_use_pins_then_is_trusted_idempotently() {
257	        let mut store = TrustStore::default();
258	        assert_eq!(
259	            store.record("home", "aa11", None, None),

}

/// One authorized peer: its pinned node pubkey, the subnet it's trusted within, an
/// optional human label (the warn-on-change anchor), the cross-user seam, and a stamp.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TrustedPeer {
    /// The peer's Ed25519 node pubkey, lowercase hex (the iroh NodeId). The identity.
    pub pubkey_hex: String,
    /// The subnet this peer paired into / is trusted within.
    pub subnet: String,
    /// Optional human node label. When present it is the stable handle warn-on-change
    /// keys against; absent (v1 default) means the pubkey is the sole identity.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub label: Option<String>,
    /// Owning user — the cross-user seam. `None` = the single implicit v1 user.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub user: Option<String>,
    /// First-trust stamp (epoch-seconds string; opaque).
    pub paired: String,
}

/// The whole `peers.json` document: every peer this node trusts, across all subnets.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct TrustStore {
    /// All trusted peers (multi-subnet — one node trusts peers across N subnets).
    #[serde(default)]
    pub peers: Vec<TrustedPeer>,
}

impl TrustStore {
    /// Load from the canonical [`trust_file`]. A missing file is the empty store (an
    /// unpaired node) — never an error.
    pub fn load() -> TrustStore {
        Self::load_from(&trust_file())
    }

    /// Load from an explicit path (the testable core). Absent ⇒ empty; an unreadable or
    /// corrupt file also degrades to empty rather than panicking — a damaged trust file
    /// must not brick the node (re-pair to rebuild it), the same stance as
    /// [`SubnetStore`](crate::subnet::SubnetStore).
    pub fn load_from(path: &Path) -> TrustStore {
        match std::fs::read_to_string(path) {
            Ok(s) => serde_json::from_str(&s).unwrap_or_default(),
            Err(_) => TrustStore::default(),
        }
    }

    /// Persist to the canonical [`trust_file`], creating `identity/trust/` if needed.
    pub fn save(&self) -> Result<(), TrustError> {
        self.save_to(&trust_file())
    }

    /// Persist to an explicit path (atomic; creates the parent dir). Atomic write so a
    /// concurrent reader never sees a torn trust file (the [`atomic`](crate::atomic)
    /// invariant).
    pub fn save_to(&self, path: &Path) -> Result<(), TrustError> {
        if let Some(parent) = path.parent() {
            std::fs::create_dir_all(parent).map_err(|e| TrustError::Io(e.to_string()))?;
        }
        let json = serde_json::to_string_pretty(self).map_err(|e| TrustError::Io(e.to_string()))?;
        atomic_write_string(path, &json).map_err(|e| TrustError::Io(e.to_string()))
    }

    /// Whether `pubkey_hex` is a trusted peer in `subnet` — the connect-time gate (an
    /// inbound peer the runtime hasn't pinned is not one of the user's own nodes).
    // [impl->REQ-PAIR-2]
    pub fn is_trusted(&self, subnet: &str, pubkey_hex: &str) -> bool {
        self.peers
            .iter()
            .any(|p| p.subnet == subnet && p.pubkey_hex == pubkey_hex)
    }

    /// All peers trusted in `subnet`.
    pub fn peers_in<'a>(&'a self, subnet: &'a str) -> impl Iterator<Item = &'a TrustedPeer> {
        self.peers.iter().filter(move |p| p.subnet == subnet)
    }

    /// Record (pin) a peer after a successful pairing ceremony — the TOFU write.
    ///
    /// Returns the [`TrustOutcome`]: [`Pinned`](TrustOutcome::Pinned) for a first use,
    /// [`AlreadyTrusted`](TrustOutcome::AlreadyTrusted) for an idempotent re-pair, or
    /// [`KeyChanged`](TrustOutcome::KeyChanged) if a peer under the same `(subnet,
    /// label)` already holds a different key — in which case **nothing is written** and
    /// the caller must warn + decide ([`repin`](Self::repin)). The `(subnet, user)`
    /// scope is honored (a label is only an anchor within its own user scope).
    // [impl->REQ-PAIR-2]
    pub fn record(
        &mut self,
        subnet: &str,
        pubkey_hex: &str,
        label: Option<&str>,
        user: Option<&str>,
    ) -> TrustOutcome {
        // Exact identity already trusted → idempotent (a key isn't "changed" against
        // itself, even when a label is present).
        if self.is_trusted(subnet, pubkey_hex) {
            return TrustOutcome::AlreadyTrusted;
        }
        // Warn-on-change: a label already bound to a DIFFERENT key in this scope.
        if let Some(lbl) = label {
            if let Some(existing) = self.peers.iter().find(|p| {
                p.subnet == subnet && p.user.as_deref() == user && p.label.as_deref() == Some(lbl)
            }) {
                return TrustOutcome::KeyChanged {
                    existing_pubkey_hex: existing.pubkey_hex.clone(),
                };
            }
        }
        self.peers.push(TrustedPeer {
            pubkey_hex: pubkey_hex.to_string(),
            subnet: subnet.to_string(),
            label: label.map(str::to_string),
            user: user.map(str::to_string),
            paired: now_stamp(),
        });
        TrustOutcome::Pinned
    }

    /// Apply an **accepted** key change for a labelled peer: replace the pinned key for
    /// `(subnet, label)` with `new_pubkey_hex`. Call this only after a
    /// [`KeyChanged`](TrustOutcome::KeyChanged) outcome was surfaced and the operator
    /// confirmed the rotation (otherwise a MITM's key would be trusted). Errors if no
    /// peer holds that label in scope.
    // [impl->REQ-PAIR-2]
    pub fn repin(
        &mut self,
        subnet: &str,
        label: &str,
        new_pubkey_hex: &str,
        user: Option<&str>,
    ) -> Result<(), TrustError> {
        let peer = self
            .peers
            .iter_mut()
            .find(|p| {
                p.subnet == subnet && p.user.as_deref() == user && p.label.as_deref() == Some(label)
            })
            .ok_or_else(|| TrustError::NotFound(format!("{subnet}/{label}")))?;
        peer.pubkey_hex = new_pubkey_hex.to_string();
        peer.paired = now_stamp();
        Ok(())
    }

    /// Revoke a trusted peer (the node-removal trust-delete). Returns whether a peer was
    /// removed. Pair this with a [seed rotation](crate::subnet::SubnetStore::rotate_seed)
    /// — a trust-delete alone is not revocation while the seed is still replicated (#10).
    // [impl->REQ-PAIR-2]
    pub fn revoke(&mut self, subnet: &str, pubkey_hex: &str) -> bool {
        let before = self.peers.len();
        self.peers
            .retain(|p| !(p.subnet == subnet && p.pubkey_hex == pubkey_hex));
        self.peers.len() != before
    }
}

/// Current unix-seconds as an opaque stamp string (matches [`SubnetStore`](crate::subnet)).
fn now_stamp() -> String {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_secs().to_string())
        .unwrap_or_else(|_| "0".to_string())
}

#[cfg(test)]
mod tests {
    use super::*;

    // [unit->REQ-PAIR-2] TOFU first-use: a fresh peer pins (Pinned) and is then
    // trusted; re-recording the SAME key is idempotent (AlreadyTrusted), not a
    // duplicate — the post-ceremony accept path.
    #[test]
    fn first_use_pins_then_is_trusted_idempotently() {
        let mut store = TrustStore::default();
        assert_eq!(
            store.record("home", "aa11", None, None),


pub fn evict_nodes|pub fn remove|fn load_snapshots


crates\spt\src\wansend.rs:506:pub fn load_snapshots(dir: &Path) -> BTreeMap<String, SubnetRegistry> {
crates\spt\src\wansend.rs-507-    let mut out = BTreeMap::new();
crates\spt\src\wansend.rs-508-    let Ok(entries) = std::fs::read_dir(dir) else {
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\wansend.rs-509-        return out;
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\wansend.rs-510-    };
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\wansend.rs-511-    for entry in entries.flatten() {
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\wansend.rs-512-        let path = entry.path();
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\wansend.rs-513-        if path.extension().and_then(|e| e.to_str()) != Some("json") {
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\wansend.rs-514-            continue;
--
crates\spt\src\wansend.rs:992:    fn load_snapshots_degrades_on_junk() {
crates\spt\src\wansend.rs-993-        let dir = tempfile::tempdir().unwrap();
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\wansend.rs-994-        assert!(load_snapshots(&dir.path().join("nope")).is_empty());
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\wansend.rs-995-        let d = dir.path().join("registry");
crates\spt\src\wansend.rs-996-        std::fs::create_dir_all(&d).unwrap();
crates\spt\src\wansend.rs-997-        std::fs::write(
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\wansend.rs-998-            d.join("good.json"),
crates\spt\src\wansend.rs-999-            serde_json::to_string(&SubnetRegistry::new()).unwrap(),
C:\Users\decid\Documents\projects\spt-core\crates\spt\src\wansend.rs-1000-        )
--
crates\spt-net\src\net\registry.rs:178:    pub fn evict_nodes(&mut self, dead: impl Fn(&str) -> bool) -> usize {
C:\Users\decid\Documents\projects\spt-core\crates\spt-net\src\net\registry.rs-179-        let mut evicted = 0usize;
C:\Users\decid\Documents\projects\spt-core\crates\spt-net\src\net\registry.rs-180-        self.by_id.retain(|_, rows| {
C:\Users\decid\Documents\projects\spt-core\crates\spt-net\src\net\registry.rs-181-            let before = rows.len();
C:\Users\decid\Documents\projects\spt-core\crates\spt-net\src\net\registry.rs-182-            rows.retain(|i| !dead(&i.node));
C:\Users\decid\Documents\projects\spt-core\crates\spt-net\src\net\registry.rs-183-            evicted += before - rows.len();
C:\Users\decid\Documents\projects\spt-core\crates\spt-net\src\net\registry.rs-184-            !rows.is_empty()
C:\Users\decid\Documents\projects\spt-core\crates\spt-net\src\net\registry.rs-185-        });
C:\Users\decid\Documents\projects\spt-core\crates\spt-net\src\net\registry.rs-186-        evicted
--
crates\spt-store\src\contextstore.rs:386:    pub fn remove_endpoint(&self, id: &str) -> std::io::Result<()> {
crates\spt-store\src\contextstore.rs-387-        Self::validated(id)?;
C:\Users\decid\Documents\projects\spt-core\crates\spt-store\src\contextstore.rs-388-        if self.store.has_branch(&agent_branch(id))? {
C:\Users\decid\Documents\projects\spt-core\crates\spt-store\src\contextstore.rs-389-            let wt = self.root.join("agents").join(id);
C:\Users\decid\Documents\projects\spt-core\crates\spt-store\src\contextstore.rs-390-            if wt.join(".git").exists() {
crates\spt-store\src\contextstore.rs-391-                crate::gitrun::run_git_ok(
C:\Users\decid\Documents\projects\spt-core\crates\spt-store\src\contextstore.rs-392-                    &[
C:\Users\decid\Documents\projects\spt-core\crates\spt-store\src\contextstore.rs-393-                        "-C",
C:\Users\decid\Documents\projects\spt-core\crates\spt-store\src\contextstore.rs-394-                        &self.root.join(".seed.git").to_string_lossy(),
--
crates\spt-store\src\subnet.rs:230:    pub fn remove(&mut self, name: &str) -> bool {
C:\Users\decid\Documents\projects\spt-core\crates\spt-store\src\subnet.rs-231-        let before = self.subnets.len();
C:\Users\decid\Documents\projects\spt-core\crates\spt-store\src\subnet.rs-232-        self.subnets.retain(|s| s.name != name);
C:\Users\decid\Documents\projects\spt-core\crates\spt-store\src\subnet.rs-233-        self.subnets.len() != before
C:\Users\decid\Documents\projects\spt-core\crates\spt-store\src\subnet.rs-234-    }
C:\Users\decid\Documents\projects\spt-core\crates\spt-store\src\subnet.rs-235-}
C:\Users\decid\Documents\projects\spt-core\crates\spt-store\src\subnet.rs-236-
C:\Users\decid\Documents\projects\spt-core\crates\spt-store\src\subnet.rs-237-/// Mint 20 cryptographically-random seed bytes. Reuses spt-proto's Ed25519 seed
C:\Users\decid\Documents\projects\spt-core\crates\spt-store\src\subnet.rs-238-/// generator (OsRng) as the CSPRNG — the same no-extra-`rand`-dependency trick used


Add leave + prune verbs.


Edit

    /// 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 {

    /// 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 {


    /// 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 {

+    /// 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.

+    /// 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).

+        /// The dead identity: pubkey hex, unambiguous prefix, or label.


                Some(SubnetCmd::Detach { name, save }) => cmd_subnet_attachment(&name, true, save),
                Some(SubnetCmd::Attach { name, save }) => cmd_subnet_attachment(&name, false, save),

                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),


                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),

Some(SubnetCmd::Join { name, code }) => cmd_subnet_join(name, code),

+                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).


/// `spt subnet detach|attach <NAME> [--save]` — the per-subnet serve-state

/// 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 (trust rows without a subnet never dial: the pump
    // iterates subnets, not trust rows).
    subnets.remove(name);
    if let Err(e) = subnets.save() {
        eprintln!("LEAVE_FAIL:{name}: {e}");
        return 1;
    }
    let mut trust = spt_store::trust::TrustStore::load();
    let before = trust.peers.len();
    trust.peers.retain(|p| p.subnet != name);
    let dropped = before - trust.peers.len();
    if let Err(e) = trust.save() {
        eprintln!("LEAVE_TRUST_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} trust row(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 trusted
/// 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(trust: &spt_store::trust::TrustStore, query: &str) -> Vec<String> {
    let q = query.to_ascii_lowercase();
    let mut out: Vec<String> = Vec::new();
    for p in &trust.peers {
        let hit = p.pubkey_hex == q
            || p.pubkey_hex.starts_with(&q)
            || p.label
                .as_deref()
                .is_some_and(|l| l.eq_ignore_ascii_case(query));
        if hit && !out.contains(&p.pubkey_hex) {
            out.push(p.pubkey_hex.clone());
        }
    }
    out
}

/// `spt subnet prune <node>` — remove a dead identity's trust rows + its
/// registry snapshot rows (M8 decision 7, REQ-SUBNET-6). The pump dials
/// trust rows every tick, so a re-paired/retired machine's stale identity
/// costs a failed dial forever until pruned. Elevation-gated, gate-first.
/// Own identity refuses (leave/identity tooling owns that, not prune).
// [impl->REQ-SUBNET-6]
fn cmd_subnet_prune(node: &str) -> i32 {
    if let Some(msg) = trust_mutation_refusal(elevation::current(), "pruning a node's trust rows") {
        if let Some(code) = try_auto_elevate() {
            return code;
        }
        eprintln!("{}", with_elevation_hint(msg));
        return EXIT_NOT_ELEVATED;
    }
    let mut trust = spt_store::trust::TrustStore::load();
    let candidates = prune_candidates(&trust, node);
    let hex = match candidates.as_slice() {
        [] => {
            eprintln!("NO_MATCH:{node} — no trusted 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;
        }
    }
    let before = trust.peers.len();
    let subnets_hit: Vec<String> = trust
        .peers
        .iter()
        .filter(|p| p.pubkey_hex == hex)
        .map(|p| p.subnet.clone())
        .collect();
    trust.peers.retain(|p| p.pubkey_hex != hex);
    let dropped = before - trust.peers.len();
    if let Err(e) = trust.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) {
        let n = reg.evict_nodes(|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} trust row(s) [{}], {evicted} registry row(s)). \
         Note: trust-delete alone is not revocation — rotate the seed if the \
         machine itself is untrusted (it still holds the subnet seed).",
        spt_net::net::registry::key_prefix(&hex),
        subnets_hit.join(", "),
    );
    0
}

/// `spt subnet detach|attach <NAME> [--save]` — the per-subnet serve-state


/// 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 (trust rows without a subnet never dial: the pump
    // iterates subnets, not trust rows).
    subnets.remove(name);
    if let Err(e) = subnets.save() {
        eprintln!("LEAVE_FAIL:{name}: {e}");
        return 1;
    }
    let mut trust = spt_store::trust::TrustStore::load();
    let before = trust.peers.len();
    trust.peers.retain(|p| p.subnet != name);
    let dropped = before - trust.peers.len();
    if let Err(e) = trust.save() {
        eprintln!("LEAVE_TRUST_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} trust row(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 trusted
/// 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(trust: &spt_store::trust::TrustStore, query: &str) -> Vec<String> {
    let q = query.to_ascii_lowercase();
    let mut out: Vec<String> = Vec::new();
    for p in &trust.peers {
        let hit = p.pubkey_hex == q
            || p.pubkey_hex.starts_with(&q)
            || p.label
                .as_deref()
                .is_some_and(|l| l.eq_ignore_ascii_case(query));
        if hit && !out.contains(&p.pubkey_hex) {
            out.push(p.pubkey_hex.clone());
        }
    }
    out
}

/// `spt subnet prune <node>` — remove a dead identity's trust rows + its
/// registry snapshot rows (M8 decision 7, REQ-SUBNET-6). The pump dials
/// trust rows every tick, so a re-paired/retired machine's stale identity
/// costs a failed dial forever until pruned. Elevation-gated, gate-first.
/// Own identity refuses (leave/identity tooling owns that, not prune).
// [impl->REQ-SUBNET-6]
fn cmd_subnet_prune(node: &str) -> i32 {
    if let Some(msg) = trust_mutation_refusal(elevation::current(), "pruning a node's trust rows") {
        if let Some(code) = try_auto_elevate() {
            return code;
        }
        eprintln!("{}", with_elevation_hint(msg));
        return EXIT_NOT_ELEVATED;
    }
    let mut trust = spt_store::trust::TrustStore::load();
    let candidates = prune_candidates(&trust, node);
    let hex = match candidates.as_slice() {
        [] => {
            eprintln!("NO_MATCH:{node} — no trusted 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;
        }
    }
    let before = trust.peers.len();
    let subnets_hit: Vec<String> = trust
        .peers
        .iter()
        .filter(|p| p.pubkey_hex == hex)
        .map(|p| p.subnet.clone())
        .collect();
    trust.peers.retain(|p| p.pubkey_hex != hex);
    let dropped = before - trust.peers.len();
    if let Err(e) = trust.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) {
        let n = reg.evict_nodes(|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} trust row(s) [{}], {evicted} registry row(s)). \
         Note: trust-delete alone is not revocation — rotate the seed if the \
         machine itself is untrusted (it still holds the subnet seed).",
        spt_net::net::registry::key_prefix(&hex),
        subnets_hit.join(", "),
    );
    0
}

/// `spt subnet detach|attach <NAME> [--save]` — the per-subnet serve-state

+/// The leave/prune elevation verdict (REQ-SUBNET-6): both verbs mutate

+/// trust material, the same class as join's machine enrollment — fail-safe,

+fn trust_mutation_refusal(elevation: Elevation, what: &str) -> Option<String> {

+    match elevation::gate_for(elevation) {

+        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.

+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() {

+        eprintln!("{}", with_elevation_hint(msg));

+    let mut subnets = SubnetStore::load();

+        eprintln!("NO_SUCH_SUBNET:{name}");

+    // 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 (trust rows without a subnet never dial: the pump

+    // iterates subnets, not trust rows).

+        eprintln!("LEAVE_FAIL:{name}: {e}");

+    let mut trust = spt_store::trust::TrustStore::load();

+    trust.peers.retain(|p| p.subnet != name);

+    let dropped = before - trust.peers.len();

+        eprintln!("LEAVE_TRUST_FAIL:{name}: {e} (membership already removed)");

+    let mut att = spt_store::attachment::AttachmentStore::load();

+    let snap = perch::identity_dir().join("registry").join(format!("{name}.json"));

+    let _ = std::fs::remove_file(snap);

+        "LEFT:{name} (seed removed, {dropped} trust row(s) dropped). The remaining \

+         members still hold the old seed — rotate it there if this machine must not rejoin."

+/// The prune target resolution (pure, REQ-SUBNET-6): distinct trusted

+/// 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).

+fn prune_candidates(trust: &spt_store::trust::TrustStore, query: &str) -> Vec<String> {

+    let q = query.to_ascii_lowercase();

+    let mut out: Vec<String> = Vec::new();

+            || p.pubkey_hex.starts_with(&q)

+                .is_some_and(|l| l.eq_ignore_ascii_case(query));

+        if hit && !out.contains(&p.pubkey_hex) {

+            out.push(p.pubkey_hex.clone());

+/// `spt subnet prune <node>` — remove a dead identity's trust rows + its

+/// registry snapshot rows (M8 decision 7, REQ-SUBNET-6). The pump dials

+/// trust rows every tick, so a re-paired/retired machine's stale identity

+/// costs a failed dial forever until pruned. Elevation-gated, gate-first.

+/// Own identity refuses (leave/identity tooling owns that, not prune).

+fn cmd_subnet_prune(node: &str) -> i32 {

+    if let Some(msg) = trust_mutation_refusal(elevation::current(), "pruning a node's trust rows") {

+        if let Some(code) = try_auto_elevate() {

+    let candidates = prune_candidates(&trust, node);

+    let hex = match candidates.as_slice() {

+            eprintln!("NO_MATCH:{node} — no trusted identity matches (hex, prefix, or label)");

+            let pfx: Vec<String> = several

+                .map(|h| format!("{}…", spt_net::net::registry::key_prefix(h)))

+                "AMBIGUOUS:{node} — several identities match: {} (use a longer prefix)",

+    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");

+    let subnets_hit: Vec<String> = trust

+        .filter(|p| p.pubkey_hex == hex)

+    trust.peers.retain(|p| p.pubkey_hex != hex);

+        eprintln!("PRUNE_FAIL:{node}: {e}");

+    // 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");

+    for (name, mut reg) in crate::wansend::load_snapshots(&snap_dir) {

+        let n = reg.evict_nodes(|node| node == hex);

+            if let Ok(json) = serde_json::to_string_pretty(&reg) {

+                let _ = spt_store::atomic::atomic_write_string(

+                    &snap_dir.join(format!("{name}.json")),

+        "PRUNED:{}… ({dropped} trust row(s) [{}], {evicted} registry row(s)). \

+         Note: trust-delete alone is not revocation — rotate the seed if the \

+         machine itself is untrusted (it still holds the subnet seed).",

+        spt_net::net::registry::key_prefix(&hex),

/// flip (M8 decision 6, REQ-SUBNET-5). Ungated: a serve-state toggle

/// mutates no trust material (unlike `leave`/`prune`). The daemon's pump,


        // [unit->REQ-SUBNET-5] attach/detach: positional name + --save
        // (M8 decision 6; --save renamed from --auto per decision 25).
        assert!(matches!(
            subnet_cmd(&["spt", "subnet", "detach", "home"]),
            Some(SubnetCmd::Detach { name, save: false }) if name == "home"
        ));
        assert!(matches!(
            subnet_cmd(&["spt", "subnet", "attach", "home", "--save"]),
            Some(SubnetCmd::Attach { name, save: true }) if name == "home"
        ));
        assert!(
            parse(&["spt", "subnet", "detach"]).is_err(),
            "detach needs a name"
        );
    }

        // [unit->REQ-SUBNET-5] attach/detach: positional name + --save
        // (M8 decision 6; --save renamed from --auto per decision 25).
        assert!(matches!(
            subnet_cmd(&["spt", "subnet", "detach", "home"]),
            Some(SubnetCmd::Detach { name, save: false }) if name == "home"
        ));
        assert!(matches!(
            subnet_cmd(&["spt", "subnet", "attach", "home", "--save"]),
            Some(SubnetCmd::Attach { name, save: true }) if name == "home"
        ));
        assert!(
            parse(&["spt", "subnet", "detach"]).is_err(),
            "detach needs a name"
        );
        // [unit->REQ-SUBNET-6] leave/prune: positional target, no flags.
        assert!(matches!(
            subnet_cmd(&["spt", "subnet", "leave", "home"]),
            Some(SubnetCmd::Leave { name }) if name == "home"
        ));
        assert!(matches!(
            subnet_cmd(&["spt", "subnet", "prune", "09ef831e"]),
            Some(SubnetCmd::Prune { node }) if node == "09ef831e"
        ));
        assert!(
            parse(&["spt", "subnet", "leave"]).is_err(),
            "leave needs a name"
        );
        assert!(
            parse(&["spt", "subnet", "prune"]).is_err(),
            "prune needs a node"
        );
    }

    // [unit->REQ-SUBNET-6] leave/prune are elevation-gated with the join
    // gate's fail-safe posture: NotElevated and Unknown both refuse.
    #[test]
    fn trust_mutation_gate_refuses_without_confirmed_elevation() {
        assert!(trust_mutation_refusal(Elevation::Elevated, "leaving").is_none());
        assert!(trust_mutation_refusal(Elevation::NotElevated, "leaving").is_some());
        assert!(trust_mutation_refusal(Elevation::Unknown, "leaving").is_some());
    }

    // [unit->REQ-SUBNET-6] prune target resolution: full hex, unambiguous
    // prefix, and label all resolve; a shared prefix yields BOTH candidates
    // (the caller refuses); unknown queries match nothing.
    #[test]
    fn prune_candidates_resolve_never_guess() {
        let mut trust = spt_store::trust::TrustStore::default();
        trust.record("bignet", "09ef831e00112233", None, None);
        trust.record("bignet", "09ef831e00112233", None, None); // idempotent
        trust.record("worknet", "09ef831e00112233", None, None); // same id, 2nd subnet
        trust.record("bignet", "09aa000011223344", Some("GRAVITY"), None);

        // Full hex + unambiguous prefix + label each resolve to one identity.
        assert_eq!(
            prune_candidates(&trust, "09ef831e00112233"),
            vec!["09ef831e00112233".to_string()]
        );
        assert_eq!(
            prune_candidates(&trust, "09ef"),
            vec!["09ef831e00112233".to_string()],
            "cross-subnet rows of ONE identity are one candidate"
        );
        assert_eq!(
            prune_candidates(&trust, "gravity"),
            vec!["09aa000011223344".to_string()],
            "label resolves case-insensitively"
        );
        // A shared prefix surfaces both — the command refuses, never guesses.
        assert_eq!(prune_candidates(&trust, "09").len(), 2);
        // Unknown query matches nothing.
        assert!(prune_candidates(&trust, "ff00").is_empty());
    }