640	            if inst.node != joiner_hex
641	                && inst.node_label.as_deref() == Some(intro.label.as_str())
642	                && inst.machine_id.as_deref() == Some(intro.machine_id.as_str())
643	                && !out.contains(&inst.node)
644	            {
645	                out.push(inst.node.clone());
646	            }
647	        }
648	    }
649	    out
650	}
651
652	/// The re-pair overwrite's effect leg (M8 decision 13, REQ-SUBNET-7), run
653	/// by the pairing host AFTER a completed ceremony: resolve the superseded
654	/// identities from the subnet's snapshot, drop their trust rows AND tombstone
655	/// their roster entries (this subnet's scope — the ceremony proved control of
656	/// THIS subnet's code, nothing about others), rewrite the disk snapshot, and
657	/// note the hexes in the eviction marker so the pump clears the in-memory rows
658	/// on its next tick. The caller persists both `trust` and `roster`.
659	///
660	/// The roster tombstone is the load-bearing eviction since Mesh-D5a: the
661	/// inbound gates authorize on [`RosterStore::is_member`], not trust, so a
662	/// re-keyed identity that kept its roster entry would stay a member. Dropping
663	/// only the (no-longer-consulted) trust row would leave the superseded key
664	/// authorized — the gap this tombstone closes. Tombstone (not a bare remove)
665	/// because the roster is a grow-set: a plain delete re-merges from any peer
666	/// that still lists the dead key. The D3 tombstone primitive dominates +
667	/// suppresses reinsert; a fresh re-pair of that key would clear it (decision
668	/// 10), exactly as the joiner's own key is cleared at the call site.
669	// [impl->REQ-SUBNET-7]
670	// [impl->REQ-MESH-5]
671	pub fn repair_evict_superseded(
672	    trust: &mut TrustStore,
673	    roster: &mut spt_store::roster::RosterStore,
674	    subnet: &str,
675	    joiner_hex: &str,
676	    intro: &spt_net::net::pairing::wire::NodeIntro,
677	    snap_dir: &Path,
678	) {
679	    let snap_path = RegistryHost::snapshot_path(snap_dir, subnet);
680	    let Some(mut reg) = std::fs::read_to_string(&snap_path)
681	        .ok()
682	        .and_then(|s| serde_json::from_str::<SubnetRegistry>(&s).ok())
683	    else {
684	        return; // no gossip yet — nothing to supersede
685	    };
686	    let dead = superseded_identities(&reg, intro, joiner_hex);
687	    if dead.is_empty() {
688	        return;
689	    }
690	    for hex in &dead {
691	        let dropped = trust.revoke(subnet, hex);
692	        // The gate-effective eviction (Mesh-D5a moved the gate to the roster).
693	        roster.tombstone(subnet, hex, &wall_ms().to_string());
694	        eprintln!(
695	            "REPAIR_SUPERSEDED:{} (label '{}' re-paired as {}…; trust row {}, roster tombstoned)",
696	            hex,
697	            intro.label,
698	            spt_net::net::registry::key_prefix(joiner_hex),
699	            if dropped { "dropped" } else { "absent" },
700	        );
701	    }
702	    reg.evict_nodes(|node| dead.iter().any(|d| d == node));
703	    if let Ok(json) = serde_json::to_vec_pretty(&reg) {
704	        let _ = atomic_write(&snap_path, &json);
705	    }
706	    // Merge into the marker (several re-pairs can land between pump ticks).
707	    let marker = RegistryHost::repair_evict_path(snap_dir);
708	    let mut all: std::collections::BTreeSet<String> = std::fs::read_to_string(&marker)
709	        .ok()
710	        .and_then(|s| serde_json::from_str(&s).ok())
711	        .unwrap_or_default();
712	    all.extend(dead);
713	    if let Ok(json) = serde_json::to_vec_pretty(&all) {
714	        let _ = atomic_write(&marker, &json);
715	    }
716	}
717
718	/// The event-driven advertisement marker file (M8 decision 15, REQ-CONV-2),
719	/// under `identity/` — the [`crate::resting::PULL_MARKER_FILE`] idiom applied
720	/// to the registry leg. An endpoint online/offline transition (ready-listener
721	/// start/stop, a rest edge) drops it; the pump consumes it and forces a
722	/// registry round NOW (advertise_local + peer push) instead of waiting out
723	/// the cadence. A WAKE of the existing loop, never a second advertisement
724	/// path: the forced round takes the exact same code path, so the epoch lease
725	/// and visibility gates ride unchanged. File-based because the transitions
726	/// happen in other processes (`spt ready`, `spt endpoint stop`, a wake CLI)
727	/// than the pump's daemon.
728	pub const ADVERTISE_MARKER_FILE: &str = "advertise-now";
729
730	/// Drop the advertise-now marker at the canonical identity dir (idempotent —
731	/// a second transition before the pump's next tick re-requests the same
732	/// round). Best-effort: a failed drop costs one cadence, never an error.
733	// [impl->REQ-CONV-2]
734	pub fn request_advertise_now() {
735	    let marker = spt_store::perch::identity_dir().join(ADVERTISE_MARKER_FILE);
736	    if let Some(parent) = marker.parent() {
737	        let _ = std::fs::create_dir_all(parent);
738	    }
739	    let _ = std::fs::write(&marker, b"");
740	}
741
742	/// Consume the advertise-now marker: `true` exactly once per drop (the
743	/// remove is the take — two pump ticks can never both claim one transition).
744	// [impl->REQ-CONV-2]
745	pub fn take_advertise_now(marker_path: &Path) -> bool {
746	    std::fs::remove_file(marker_path).is_ok()
747	}
748
749	/// Wall-clock ms (the heard-map stamp; display/recency only — never a merge
750	/// key, the epoch lease owns ordering).
751	fn wall_ms() -> u64 {
752	    std::time::SystemTime::now()
753	        .duration_since(std::time::UNIX_EPOCH)
754	        .map(|d| d.as_millis() as u64)
755	        .unwrap_or(0)
756	}
757
758	/// tmp-write + atomic rename with the 5.2 Windows-EBUSY retry posture.
759	fn atomic_write(path: &Path, bytes: &[u8]) -> io::Result<()> {
760	    let tmp = path.with_extension("json.tmp");
761	    std::fs::write(&tmp, bytes)?;
762	    for attempt in 0..5 {
763	        match std::fs::rename(&tmp, path) {
764	            Ok(()) => return Ok(()),
765	            Err(_) if attempt < 4 => {
766	                std::thread::sleep(std::time::Duration::from_millis(20 << attempt));
767	            }
768	            Err(e) => return Err(e),
769	        }
770	    }
771	    unreachable!("loop returns on every path")
772	}
773
774	#[cfg(test)]
775	mod tests {
776	    use super::*;
777	    use crate::test_home::with_home;
778	    use spt_store::info::{self, InfoJson};
779
780	    fn upd(subnet: &str, id: &str, node: &str, epoch: u64) -> RegistryUpdate {
781	        RegistryUpdate {
782	            subnet: subnet.to_string(),
783	            endpoint_id: id.to_string(),
784	            instance: Instance {
785	                node: node.to_string(),
786	                status: Status::Active,
787	                epoch,
788	                resources: None,
789	                last_active_ms: None,
790	                shell_adapters: Vec::new(),
791	                node_label: None,
792	                machine_id: None,
793	            },
794	        }
795	    }
796
797	    /// A roster entry for `pk` in `subnet` — the Mesh-D5 gate seed (replaces a
798	    /// `TrustStore::record` pin; `is_member` keys on `(subnet, pubkey)`).
799	    fn member(subnet: &str, pk: &str) -> spt_store::roster::RosterEntry {
800	        spt_store::roster::RosterEntry {
801	            pubkey_hex: pk.to_string(),
802	            subnet: subnet.to_string(),
803	            label: String::new(),
804	            machine_id: String::new(),
805	            address: None,
806	            last_seen: String::new(),
807	            lease_epoch: 1,
808	        }
809	    }
810
811	    fn policy(subnet: &str, trusted: &str) -> RegistryGatePolicy {
812	        let mut roster = RosterStore::default();
813	        roster.merge_entry(member(subnet, trusted));
814	        RegistryGatePolicy {
815	            subnets: vec![subnet.to_string()],
816	            roster,
817	        }
818	    }
819
820	    fn host(dir: &Path) -> RegistryHost {
821	        RegistryHost::new_at(
822	            "aa11",
823	            EpochSource::load_from(&dir.join("epoch.json")),
824	            dir.join("registry"),
825	        )
826	    }
827
828	    // [unit->REQ-HAZARD-REGISTRY-EPOCH-LEASE] the consent-notif producer
829	    // shares the node's ONE canonical epoch with the registry leg — the fix
830	    // for the rig presence-rung `UNIQUE constraint failed: notifs.notif_id`.
831	    // `with_epoch` is the canonical (identity/epoch.json) cursor: ids minted
832	    // through it continue the SAME sequence registry rows use and persist to
833	    // the SAME file — so a consent notif on a fresh node can never reissue a
834	    // low `node:epoch` that a notify/presence producer (which loads this same
835	    // file) already used.
836	    #[test]
837	    fn with_epoch_is_the_one_canonical_counter() {
838	        let dir = tempfile::tempdir().unwrap();
839	        let epoch_path = dir.path().join("epoch.json");
840	        let h = RegistryHost::new_at(
841	            "aa11",
842	            EpochSource::load_from(&epoch_path),
843	            dir.path().join("registry"),
844	        );
845	        // Registry leg advances the canonical counter (stand-in for
846	        // advertise_local stamping rows).
847	        let r1 = h.with_epoch(|e| e.next_epoch().unwrap());
848	        let r2 = h.with_epoch(|e| e.next_epoch().unwrap());
849	        assert_eq!((r1, r2), (1, 2));
850	        // A consent notif minting through the SAME accessor continues the
851	        // sequence — never restarts at 1 (the pump-ops.json bug).
852	        let consent = h.with_epoch(|e| e.next_epoch().unwrap());
853	        assert_eq!(consent, 3, "consent shares the registry counter");
854	        // And it is the canonical FILE: a fresh load (the CLI notify front
855	        // door's view) sees the advanced value and mints strictly above it —
856	        // no cross-producer id reuse.
857	        let mut front_door = EpochSource::load_from(&epoch_path);
858	        assert_eq!(front_door.current(), 3, "persisted to the canonical file");
859	        assert_eq!(front_door.next_epoch().unwrap(), 4);
860	    }
861
862	    /// An instance row stamped with a label + machine id (the M8 re-pair
863	    /// anchor pair).
864	    fn identified(node: &str, label: &str, mid: &str, epoch: u64) -> Instance {
865	        Instance {
866	            node: node.to_string(),
867	            status: Status::Active,
868	            epoch,
869	            resources: None,
870	            last_active_ms: None,
871	            shell_adapters: Vec::new(),
872	            node_label: Some(label.to_string()),
873	            machine_id: Some(mid.to_string()),
874	        }
875	    }
876
877	    // [unit->REQ-SUBNET-7] superseded-identity resolution: BOTH label and
878	    // machine id must match a DIFFERENT key — label alone (hostname
879	    // collision) and machine id alone never supersede; the joiner's own
880	    // rows never match.
881	    #[test]
882	    fn superseded_requires_label_and_machine_id_under_other_key() {
883	        let mut reg = SubnetRegistry::new();
884	        reg.merge_instance("ling", identified("old-key", "BOX", "mid-1", 5));
885	        reg.merge_instance("oak", identified("label-only", "BOX", "mid-9", 5));
886	        reg.merge_instance("ivy", identified("mid-only", "OTHER", "mid-1", 5));
887	        reg.merge_instance("elm", identified("new-key", "BOX", "mid-1", 5));
888	        let intro = spt_net::net::pairing::wire::NodeIntro {
889	            label: "BOX".into(),
890	            machine_id: "mid-1".into(),
891	        };
892	        assert_eq!(
893	            superseded_identities(&reg, &intro, "new-key"),
894	            vec!["old-key".to_string()],
895	            "only the same-(label,mid) different-key identity supersedes"
896	        );
897	    }
898
899	    // [unit->REQ-SUBNET-7] [unit->REQ-MESH-5] the effect leg: a completed
900	    // re-pair drops the superseded identity's trust row AND tombstones its
901	    // roster entry (this subnet only) — the roster tombstone is the
902	    // gate-effective eviction since D5a moved the gate to `is_member`;
903	    // dropping trust alone would leave the re-keyed identity authorized.
904	    // Rewrites the disk snapshot without its rows, notes it in the marker;
905	    // the host's consume then clears the in-memory rows + the heard entry.
906	    #[test]
907	    fn repair_evict_drops_trust_snapshot_and_memory() {
908	        let dir = tempfile::tempdir().unwrap();
909	        let snap_dir = dir.path().join("registry");
910	        let h = RegistryHost::new_at(
911	            "self",
912	            EpochSource::load_from(&dir.path().join("epoch.json")),
913	            snap_dir.clone(),
914	        );
915	        // Gossip state: the old identity advertises in home + a second subnet.
916	        let p = policy("home", "old-key");
917	        h.apply_feed(
918	            "old-key",
919	            &[RegistryUpdate {
920	                subnet: "home".into(),
921	                endpoint_id: "ling".into(),
922	                instance: identified("old-key", "BOX", "mid-1", 5),
923	            }],
924	            &p,
925	        );
926	        // Snapshot mirrored to disk by the merge.
927	        assert!(RegistryHost::snapshot_path(&snap_dir, "home").exists());
928
929	        let mut trust = TrustStore::default();
930	        trust.record("home", "old-key", None, None);
931	        trust.record("worknet", "old-key", None, None);
932	        // Roster membership in both subnets — the gate-relevant state (D5a).
933	        let mut roster = RosterStore::default();
934	        roster.merge_entry(member("home", "old-key"));
935	        roster.merge_entry(member("worknet", "old-key"));
936	        assert!(roster.is_member("home", "old-key"), "member before re-pair");
937	        let intro = spt_net::net::pairing::wire::NodeIntro {
938	            label: "BOX".into(),
939	            machine_id: "mid-1".into(),
940	        };
941	        repair_evict_superseded(&mut trust, &mut roster, "home", "new-key", &intro, &snap_dir);
942
943	        assert!(
944	            !trust.is_trusted("home", "old-key"),
945	            "ceremony subnet's trust row dropped"
946	        );
947	        assert!(
948	            trust.is_trusted("worknet", "old-key"),
949	            "other subnets untouched — the ceremony proved THIS subnet's code only"
950	        );
951	        // The gate-effective eviction: tombstoned in the ceremony subnet,
952	        // still a member elsewhere (the ceremony proved THIS subnet only).
953	        assert!(
954	            !roster.is_member("home", "old-key"),
955	            "superseded identity no longer passes the is_member gate"
956	        );
957	        assert!(
958	            roster.is_member("worknet", "old-key"),
959	            "other subnets' roster membership untouched"
960	        );
961	        let on_disk: SubnetRegistry = serde_json::from_str(
962	            &std::fs::read_to_string(RegistryHost::snapshot_path(&snap_dir, "home")).unwrap(),
963	        )
964	        .unwrap();
965	        assert!(
966	            on_disk.instances("ling").is_empty(),
967	            "superseded rows gone from the disk snapshot"
968	        );
969	        // The marker holds the hex; consume clears the IN-MEMORY rows too
970	        // (else the next snapshot mirror would resurrect the disk eviction).
971	        assert!(RegistryHost::repair_evict_path(&snap_dir).exists());
972	        assert_eq!(h.consume_repair_evictions(), 1, "in-memory row evicted");
973	        assert!(h.rows("home", "ling").is_empty());
974	        assert!(
975	            !RegistryHost::repair_evict_path(&snap_dir).exists(),
976	            "marker consumed exactly once"
977	        );
978	        assert_eq!(h.consume_repair_evictions(), 0, "second consume no-ops");
979
980	        // No-match intro: nothing evicts, no marker minted.
981	        let mut t2 = TrustStore::default();
982	        t2.record("home", "unrelated", None, None);
983	        let mut r2 = RosterStore::default();
984	        r2.merge_entry(member("home", "unrelated"));
985	        let other = spt_net::net::pairing::wire::NodeIntro {
986	            label: "ELSEWHERE".into(),
987	            machine_id: "mid-x".into(),
988	        };
989	        repair_evict_superseded(&mut t2, &mut r2, "home", "new-key", &other, &snap_dir);
990	        assert!(t2.is_trusted("home", "unrelated"));
991	        assert!(r2.is_member("home", "unrelated"), "no-match leaves roster intact");
992	        assert!(!RegistryHost::repair_evict_path(&snap_dir).exists());
993	    }
994
995	    // [unit->REQ-INST-7] inbound feeds gate fail-closed: non-member subnet
996	    // and untrusted origin write nothing; an admitted row merges and the
997	    // epoch lease still drops a stale follow-up.
998	    #[test]
999	    fn apply_feed_gates_and_merges_under_the_lease() {

            if inst.node != joiner_hex
                && inst.node_label.as_deref() == Some(intro.label.as_str())
                && inst.machine_id.as_deref() == Some(intro.machine_id.as_str())
                && !out.contains(&inst.node)
            {
                out.push(inst.node.clone());
            }
        }
    }
    out
}

/// The re-pair overwrite's effect leg (M8 decision 13, REQ-SUBNET-7), run
/// by the pairing host AFTER a completed ceremony: resolve the superseded
/// identities from the subnet's snapshot, drop their trust rows AND tombstone
/// their roster entries (this subnet's scope — the ceremony proved control of
/// THIS subnet's code, nothing about others), rewrite the disk snapshot, and
/// note the hexes in the eviction marker so the pump clears the in-memory rows
/// on its next tick. The caller persists both `trust` and `roster`.
///
/// The roster tombstone is the load-bearing eviction since Mesh-D5a: the
/// inbound gates authorize on [`RosterStore::is_member`], not trust, so a
/// re-keyed identity that kept its roster entry would stay a member. Dropping
/// only the (no-longer-consulted) trust row would leave the superseded key
/// authorized — the gap this tombstone closes. Tombstone (not a bare remove)
/// because the roster is a grow-set: a plain delete re-merges from any peer
/// that still lists the dead key. The D3 tombstone primitive dominates +
/// suppresses reinsert; a fresh re-pair of that key would clear it (decision
/// 10), exactly as the joiner's own key is cleared at the call site.
// [impl->REQ-SUBNET-7]
// [impl->REQ-MESH-5]
pub fn repair_evict_superseded(
    trust: &mut TrustStore,
    roster: &mut spt_store::roster::RosterStore,
    subnet: &str,
    joiner_hex: &str,
    intro: &spt_net::net::pairing::wire::NodeIntro,
    snap_dir: &Path,
) {
    let snap_path = RegistryHost::snapshot_path(snap_dir, subnet);
    let Some(mut reg) = std::fs::read_to_string(&snap_path)
        .ok()
        .and_then(|s| serde_json::from_str::<SubnetRegistry>(&s).ok())
    else {
        return; // no gossip yet — nothing to supersede
    };
    let dead = superseded_identities(&reg, intro, joiner_hex);
    if dead.is_empty() {
        return;
    }
    for hex in &dead {
        let dropped = trust.revoke(subnet, hex);
        // The gate-effective eviction (Mesh-D5a moved the gate to the roster).
        roster.tombstone(subnet, hex, &wall_ms().to_string());
        eprintln!(
            "REPAIR_SUPERSEDED:{} (label '{}' re-paired as {}…; trust row {}, roster tombstoned)",
            hex,
            intro.label,
            spt_net::net::registry::key_prefix(joiner_hex),
            if dropped { "dropped" } else { "absent" },
        );
    }
    reg.evict_nodes(|node| dead.iter().any(|d| d == node));
    if let Ok(json) = serde_json::to_vec_pretty(&reg) {
        let _ = atomic_write(&snap_path, &json);
    }
    // Merge into the marker (several re-pairs can land between pump ticks).
    let marker = RegistryHost::repair_evict_path(snap_dir);
    let mut all: std::collections::BTreeSet<String> = std::fs::read_to_string(&marker)
        .ok()
        .and_then(|s| serde_json::from_str(&s).ok())
        .unwrap_or_default();
    all.extend(dead);
    if let Ok(json) = serde_json::to_vec_pretty(&all) {
        let _ = atomic_write(&marker, &json);
    }
}

/// The event-driven advertisement marker file (M8 decision 15, REQ-CONV-2),
/// under `identity/` — the [`crate::resting::PULL_MARKER_FILE`] idiom applied
/// to the registry leg. An endpoint online/offline transition (ready-listener
/// start/stop, a rest edge) drops it; the pump consumes it and forces a
/// registry round NOW (advertise_local + peer push) instead of waiting out
/// the cadence. A WAKE of the existing loop, never a second advertisement
/// path: the forced round takes the exact same code path, so the epoch lease
/// and visibility gates ride unchanged. File-based because the transitions
/// happen in other processes (`spt ready`, `spt endpoint stop`, a wake CLI)
/// than the pump's daemon.
pub const ADVERTISE_MARKER_FILE: &str = "advertise-now";

/// Drop the advertise-now marker at the canonical identity dir (idempotent —
/// a second transition before the pump's next tick re-requests the same
/// round). Best-effort: a failed drop costs one cadence, never an error.
// [impl->REQ-CONV-2]
pub fn request_advertise_now() {
    let marker = spt_store::perch::identity_dir().join(ADVERTISE_MARKER_FILE);
    if let Some(parent) = marker.parent() {
        let _ = std::fs::create_dir_all(parent);
    }
    let _ = std::fs::write(&marker, b"");
}

/// Consume the advertise-now marker: `true` exactly once per drop (the
/// remove is the take — two pump ticks can never both claim one transition).
// [impl->REQ-CONV-2]
pub fn take_advertise_now(marker_path: &Path) -> bool {
    std::fs::remove_file(marker_path).is_ok()
}

/// Wall-clock ms (the heard-map stamp; display/recency only — never a merge
/// key, the epoch lease owns ordering).
fn wall_ms() -> u64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_millis() as u64)
        .unwrap_or(0)
}

/// tmp-write + atomic rename with the 5.2 Windows-EBUSY retry posture.
fn atomic_write(path: &Path, bytes: &[u8]) -> io::Result<()> {
    let tmp = path.with_extension("json.tmp");
    std::fs::write(&tmp, bytes)?;
    for attempt in 0..5 {
        match std::fs::rename(&tmp, path) {
            Ok(()) => return Ok(()),
            Err(_) if attempt < 4 => {
                std::thread::sleep(std::time::Duration::from_millis(20 << attempt));
            }
            Err(e) => return Err(e),
        }
    }
    unreachable!("loop returns on every path")
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::test_home::with_home;
    use spt_store::info::{self, InfoJson};

    fn upd(subnet: &str, id: &str, node: &str, epoch: u64) -> RegistryUpdate {
        RegistryUpdate {
            subnet: subnet.to_string(),
            endpoint_id: id.to_string(),
            instance: Instance {
                node: node.to_string(),
                status: Status::Active,
                epoch,
                resources: None,
                last_active_ms: None,
                shell_adapters: Vec::new(),
                node_label: None,
                machine_id: None,
            },
        }
    }

    /// A roster entry for `pk` in `subnet` — the Mesh-D5 gate seed (replaces a
    /// `TrustStore::record` pin; `is_member` keys on `(subnet, pubkey)`).
    fn member(subnet: &str, pk: &str) -> spt_store::roster::RosterEntry {
        spt_store::roster::RosterEntry {
            pubkey_hex: pk.to_string(),
            subnet: subnet.to_string(),
            label: String::new(),
            machine_id: String::new(),
            address: None,
            last_seen: String::new(),
            lease_epoch: 1,
        }
    }

    fn policy(subnet: &str, trusted: &str) -> RegistryGatePolicy {
        let mut roster = RosterStore::default();
        roster.merge_entry(member(subnet, trusted));
        RegistryGatePolicy {
            subnets: vec![subnet.to_string()],
            roster,
        }
    }

    fn host(dir: &Path) -> RegistryHost {
        RegistryHost::new_at(
            "aa11",
            EpochSource::load_from(&dir.join("epoch.json")),
            dir.join("registry"),
        )
    }

    // [unit->REQ-HAZARD-REGISTRY-EPOCH-LEASE] the consent-notif producer
    // shares the node's ONE canonical epoch with the registry leg — the fix
    // for the rig presence-rung `UNIQUE constraint failed: notifs.notif_id`.
    // `with_epoch` is the canonical (identity/epoch.json) cursor: ids minted
    // through it continue the SAME sequence registry rows use and persist to
    // the SAME file — so a consent notif on a fresh node can never reissue a
    // low `node:epoch` that a notify/presence producer (which loads this same
    // file) already used.
    #[test]
    fn with_epoch_is_the_one_canonical_counter() {
        let dir = tempfile::tempdir().unwrap();
        let epoch_path = dir.path().join("epoch.json");
        let h = RegistryHost::new_at(
            "aa11",
            EpochSource::load_from(&epoch_path),
            dir.path().join("registry"),
        );
        // Registry leg advances the canonical counter (stand-in for
        // advertise_local stamping rows).
        let r1 = h.with_epoch(|e| e.next_epoch().unwrap());
        let r2 = h.with_epoch(|e| e.next_epoch().unwrap());
        assert_eq!((r1, r2), (1, 2));
        // A consent notif minting through the SAME accessor continues the
        // sequence — never restarts at 1 (the pump-ops.json bug).
        let consent = h.with_epoch(|e| e.next_epoch().unwrap());
        assert_eq!(consent, 3, "consent shares the registry counter");
        // And it is the canonical FILE: a fresh load (the CLI notify front
        // door's view) sees the advanced value and mints strictly above it —
        // no cross-producer id reuse.
        let mut front_door = EpochSource::load_from(&epoch_path);
        assert_eq!(front_door.current(), 3, "persisted to the canonical file");
        assert_eq!(front_door.next_epoch().unwrap(), 4);
    }

    /// An instance row stamped with a label + machine id (the M8 re-pair
    /// anchor pair).
    fn identified(node: &str, label: &str, mid: &str, epoch: u64) -> Instance {
        Instance {
            node: node.to_string(),
            status: Status::Active,
            epoch,
            resources: None,
            last_active_ms: None,
            shell_adapters: Vec::new(),
            node_label: Some(label.to_string()),
            machine_id: Some(mid.to_string()),
        }
    }

    // [unit->REQ-SUBNET-7] superseded-identity resolution: BOTH label and
    // machine id must match a DIFFERENT key — label alone (hostname
    // collision) and machine id alone never supersede; the joiner's own
    // rows never match.
    #[test]
    fn superseded_requires_label_and_machine_id_under_other_key() {
        let mut reg = SubnetRegistry::new();
        reg.merge_instance("ling", identified("old-key", "BOX", "mid-1", 5));
        reg.merge_instance("oak", identified("label-only", "BOX", "mid-9", 5));
        reg.merge_instance("ivy", identified("mid-only", "OTHER", "mid-1", 5));
        reg.merge_instance("elm", identified("new-key", "BOX", "mid-1", 5));
        let intro = spt_net::net::pairing::wire::NodeIntro {
            label: "BOX".into(),
            machine_id: "mid-1".into(),
        };
        assert_eq!(
            superseded_identities(&reg, &intro, "new-key"),
            vec!["old-key".to_string()],
            "only the same-(label,mid) different-key identity supersedes"
        );
    }

    // [unit->REQ-SUBNET-7] [unit->REQ-MESH-5] the effect leg: a completed
    // re-pair drops the superseded identity's trust row AND tombstones its
    // roster entry (this subnet only) — the roster tombstone is the
    // gate-effective eviction since D5a moved the gate to `is_member`;
    // dropping trust alone would leave the re-keyed identity authorized.
    // Rewrites the disk snapshot without its rows, notes it in the marker;
    // the host's consume then clears the in-memory rows + the heard entry.
    #[test]
    fn repair_evict_drops_trust_snapshot_and_memory() {
        let dir = tempfile::tempdir().unwrap();
        let snap_dir = dir.path().join("registry");
        let h = RegistryHost::new_at(
            "self",
            EpochSource::load_from(&dir.path().join("epoch.json")),
            snap_dir.clone(),
        );
        // Gossip state: the old identity advertises in home + a second subnet.
        let p = policy("home", "old-key");
        h.apply_feed(
            "old-key",
            &[RegistryUpdate {
                subnet: "home".into(),
                endpoint_id: "ling".into(),
                instance: identified("old-key", "BOX", "mid-1", 5),
            }],
            &p,
        );
        // Snapshot mirrored to disk by the merge.
        assert!(RegistryHost::snapshot_path(&snap_dir, "home").exists());

        let mut trust = TrustStore::default();
        trust.record("home", "old-key", None, None);
        trust.record("worknet", "old-key", None, None);
        // Roster membership in both subnets — the gate-relevant state (D5a).
        let mut roster = RosterStore::default();
        roster.merge_entry(member("home", "old-key"));
        roster.merge_entry(member("worknet", "old-key"));
        assert!(roster.is_member("home", "old-key"), "member before re-pair");
        let intro = spt_net::net::pairing::wire::NodeIntro {
            label: "BOX".into(),
            machine_id: "mid-1".into(),
        };
        repair_evict_superseded(&mut trust, &mut roster, "home", "new-key", &intro, &snap_dir);

        assert!(
            !trust.is_trusted("home", "old-key"),
            "ceremony subnet's trust row dropped"
        );
        assert!(
            trust.is_trusted("worknet", "old-key"),
            "other subnets untouched — the ceremony proved THIS subnet's code only"
        );
        // The gate-effective eviction: tombstoned in the ceremony subnet,
        // still a member elsewhere (the ceremony proved THIS subnet only).
        assert!(
            !roster.is_member("home", "old-key"),
            "superseded identity no longer passes the is_member gate"
        );
        assert!(
            roster.is_member("worknet", "old-key"),
            "other subnets' roster membership untouched"
        );
        let on_disk: SubnetRegistry = serde_json::from_str(
            &std::fs::read_to_string(RegistryHost::snapshot_path(&snap_dir, "home")).unwrap(),
        )
        .unwrap();
        assert!(
            on_disk.instances("ling").is_empty(),
            "superseded rows gone from the disk snapshot"
        );
        // The marker holds the hex; consume clears the IN-MEMORY rows too
        // (else the next snapshot mirror would resurrect the disk eviction).
        assert!(RegistryHost::repair_evict_path(&snap_dir).exists());
        assert_eq!(h.consume_repair_evictions(), 1, "in-memory row evicted");
        assert!(h.rows("home", "ling").is_empty());
        assert!(
            !RegistryHost::repair_evict_path(&snap_dir).exists(),
            "marker consumed exactly once"
        );
        assert_eq!(h.consume_repair_evictions(), 0, "second consume no-ops");

        // No-match intro: nothing evicts, no marker minted.
        let mut t2 = TrustStore::default();
        t2.record("home", "unrelated", None, None);
        let mut r2 = RosterStore::default();
        r2.merge_entry(member("home", "unrelated"));
        let other = spt_net::net::pairing::wire::NodeIntro {
            label: "ELSEWHERE".into(),
            machine_id: "mid-x".into(),
        };
        repair_evict_superseded(&mut t2, &mut r2, "home", "new-key", &other, &snap_dir);
        assert!(t2.is_trusted("home", "unrelated"));
        assert!(r2.is_member("home", "unrelated"), "no-match leaves roster intact");
        assert!(!RegistryHost::repair_evict_path(&snap_dir).exists());
    }

    // [unit->REQ-INST-7] inbound feeds gate fail-closed: non-member subnet
    // and untrusted origin write nothing; an admitted row merges and the
    // epoch lease still drops a stale follow-up.
    #[test]
    fn apply_feed_gates_and_merges_under_the_lease() {


1	//! `spt-store` — the persistence commodity layer for spt-core.
2	//!
3	//! Sits directly above `spt-proto` (R-ARCH-1 layering): it serializes proto
4	//! types to disk (spool, registry, info.json) through a single atomic-write
5	//! primitive. Stable on-disk formats are copied verbatim from the sister
6	//! project (ADR-0001); the EBUSY-retry hardening on the write path is the
7	//! clean-room addition demanded by KNOWN-HAZARDS 5.2.
8	//!
9	// Second layer of the acyclically-layered crate stack (R-ARCH-1): spt-store
10	// depends only on spt-proto (one layer down), never the reverse — the
11	// dependency edge is one-way, so the workspace graph stays acyclic.
12	// [impl->REQ-ARCH-1]
13
14	pub mod access;
15	pub mod atomic;
16	pub mod attachment;
17	pub mod branchstore;
18	pub mod contextmark;
19	pub mod contextstore;
20	mod db;
21	pub mod epoch;
22	pub mod gitrun;
23	pub mod grants;
24	pub mod history;
25	pub mod home;
26	pub mod hostlabel;
27	pub mod info;
28	pub mod liveness;
29	pub mod nodeid;
30	pub mod notif;
31	pub mod peeraddrs;
32	pub mod perch;
33	pub mod proc;
34	pub mod project;
35	pub mod registry;
36	pub mod rename;
37	pub mod roster;
38	pub mod seed;
39	pub mod shellinfo;
40	pub mod spool;
41	pub mod subnet;
42	pub mod syncmerge;
43	pub mod trust;
44	pub mod visibility;
45	pub mod xfer;
46

//! `spt-store` — the persistence commodity layer for spt-core.
//!
//! Sits directly above `spt-proto` (R-ARCH-1 layering): it serializes proto
//! types to disk (spool, registry, info.json) through a single atomic-write
//! primitive. Stable on-disk formats are copied verbatim from the sister
//! project (ADR-0001); the EBUSY-retry hardening on the write path is the
//! clean-room addition demanded by KNOWN-HAZARDS 5.2.
//!
// Second layer of the acyclically-layered crate stack (R-ARCH-1): spt-store
// depends only on spt-proto (one layer down), never the reverse — the
// dependency edge is one-way, so the workspace graph stays acyclic.
// [impl->REQ-ARCH-1]

pub mod access;
pub mod atomic;
pub mod attachment;
pub mod branchstore;
pub mod contextmark;
pub mod contextstore;
mod db;
pub mod epoch;
pub mod gitrun;
pub mod grants;
pub mod history;
pub mod home;
pub mod hostlabel;
pub mod info;
pub mod liveness;
pub mod nodeid;
pub mod notif;
pub mod peeraddrs;
pub mod perch;
pub mod proc;
pub mod project;
pub mod registry;
pub mod rename;
pub mod roster;
pub mod seed;
pub mod shellinfo;
pub mod spool;
pub mod subnet;
pub mod syncmerge;
pub mod trust;
pub mod visibility;
pub mod xfer;


1	//! Mesh-D0 — the staggered + star subnet-mesh repro, RED.
2	//!
3	//! These two tests assert the *desired* full-mesh convergence the subnet does
4	//! NOT yet have (`SUBNET-MESH-PLAN.md`, ADR-0017). They are `#[ignore]`d: the
5	//! acceptance that flips green at **Mesh-D6** when the registry push target
6	//! widens from directly-paired peers to the full roster. No production change
7	//! lands with this file — it locks the bug as an executable repro. See
8	//! `MESH-D0-PLAN.md` for the fidelity decision.
9	//!
10	//! The bug, in one seam: `peerloop.rs` pushes a node's own rows to
11	//! `trust.peers_in(subnet)` — *directly-paired* peers only, no relay. So when
12	//! `C` joins `B` (C pins B, not A) and `A` joins `B`, A and C never pin each
13	//! other; C's pump never targets A and A's never targets C. They stay mutually
14	//! invisible even when both are online. The mesh restores reach by widening
15	//! that push target to every roster member (Mesh-D6).
16	//!
17	//! Fidelity: the **push side is the real loop** (`run_peer_pump` with explicit
18	//! per-node `PumpPaths` whose `trust` is the pairwise pin) — so the D6
19	//! target-widening flips these green untouched. The **gate side runs the real
20	//! dispatch loop** (`run_dispatch_loop`) under a permissive canonical
21	//! `SPT_HOME` trust, isolating the red strictly to the push-target seam (the
22	//! "subnet = pairing graph, not mesh" bug); the gate swap is Mesh-D5's own
23	//! unit. Addresses are omniscient (a node still only *pushes* where its
24	//! push-target says, so this does not mask the bug); address *discovery* is the
25	//! roster's job, tested at D3/D4.
26	//!
27	//! Refinement note: once the roster store exists (D3/D4) this harness seeds
28	//! rosters the way it seeds trust today; the `#[ignore]` lifts at D6 and the
29	//! file becomes the REQ-MESH-3 `int` evidence (tagged then, not now — D0
30	//! activates no REQ).
31
32	use std::collections::HashMap;
33	use std::path::PathBuf;
34	use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
35	use std::sync::{Arc, Mutex, OnceLock};
36	use std::thread;
37	use std::time::Duration;
38
39	use spt_daemon::brain::Brain;
40	use spt_daemon::broker::Broker;
41	use spt_daemon::dispatch::{run_dispatch_loop, DispatchPaths};
42	use spt_daemon::nethost::{NetConfig, NetHost};
43	use spt_daemon::peerloop::{
44	    run_peer_pump, PeerResolver, PumpCadence, PumpConfig, PumpHooks, PumpPaths,
45	};
46	use spt_daemon::registryhost::RegistryHost;
47	use spt_net::net::endpoint::{BindScope, LocalDiscovery, RelayPolicy};
48	use spt_proto::identity::Identity;
49	use spt_store::epoch::EpochSource;
50	use spt_store::info::{self, InfoJson};
51	use spt_store::perch::{self, ParentHint};
52	use spt_store::subnet::SubnetStore;
53	use spt_store::trust::TrustStore;
54	use tempfile::TempDir;
55
56	const SUBNET: &str = "mesh0";
57
58	// One canonical SPT_HOME for the binary; the lock serializes the two tests
59	// (SPT_HOME is process-global). Per-node registries/owleries live under
60	// fresh per-test tempdirs, so the only shared canonical state is the subnet
61	// record + the permissive gate trust — both monotonic supersets, safe to
62	// re-establish per test.
63	static HOME: OnceLock<TempDir> = OnceLock::new();
64	static LOCK: Mutex<()> = Mutex::new(());
65	fn init_home() {
66	    HOME.get_or_init(|| {
67	        let dir = TempDir::new().expect("temp home");
68	        std::env::set_var("SPT_HOME", dir.path());
69	        std::env::set_var("SPT_NTP_SERVER", "off");
70	        dir
71	    });
72	}
73
74	static SEQ: AtomicU32 = AtomicU32::new(0);
75	fn unique_name() -> String {
76	    let n = SEQ.fetch_add(1, Ordering::Relaxed);
77	    format!("spt-daemon-mesh-{}-{}.sock", std::process::id(), n)
78	}
79
80	fn hermetic() -> NetConfig {
81	    NetConfig {
82	        identity: Identity::generate(),
83	        relay: RelayPolicy::Disabled,
84	        discovery: LocalDiscovery::Off,
85	        scope: BindScope::Loopback,
86	        membership: None,
87	    }
88	}
89
90	fn net_broker(name: &str, dir: &std::path::Path) -> Arc<Broker> {
91	    let host = NetHost::start(hermetic()).expect("net host start");
92	    let broker =
93	        Broker::bind_in_with_net(name, dir.join("effects.log"), Some(host)).expect("bind broker");
94	    let serve = Arc::clone(&broker);
95	    thread::spawn(move || {
96	        let _ = serve.serve();
97	    });
98	    broker
99	}
100
101	fn connect_retry(name: &str) -> Brain {
102	    for _ in 0..300 {
103	        if let Ok(b) = Brain::cold_start(name, 1) {
104	            return b;
105	        }
106	        thread::sleep(Duration::from_millis(10));
107	    }
108	    panic!("brain could not connect");
109	}
110
111	/// Poll `probe` until true; panic with `what` on timeout (15s — green paths
112	/// exit early, so the long bound only pays on the *red* mesh assertions).
113	fn converge(what: &str, mut probe: impl FnMut() -> bool) {
114	    for _ in 0..600 {
115	        if probe() {
116	            return;
117	        }
118	        thread::sleep(Duration::from_millis(25));
119	    }
120	    panic!("never converged: {what}");
121	}
122
123	/// The inert/registry-only cadence: only registry feeds flow (notif/sync/
124	/// update are pushed far enough out to never fire), so the shared canonical
125	/// notif/context stores stay untouched across nodes.
126	fn cadence() -> PumpCadence {
127	    PumpCadence {
128	        registry: Duration::from_millis(100),
129	        notif: Duration::from_secs(3_000),
130	        sync: Duration::from_secs(3_000),
131	        update: Duration::from_secs(3_000),
132	        registry_evict_after: Duration::from_secs(3_000),
133	    }
134	}
135
136	/// One mesh node: its broker + registry + on-disk pump roots. `perch_id` is
137	/// the node's sole Self perch (the row peers must come to see).
138	struct MeshNode {
139	    name: String,
140	    hex: String,
141	    addr: serde_json::Value,
142	    perch_id: String,
143	    registry: Arc<RegistryHost>,
144	    paths: PumpPaths,
145	    dispatch: DispatchPaths,
146	}
147
148	impl MeshNode {
149	    /// Stand up a node under `root`: broker, a single advertised perch, a
150	    /// registry, and pump paths whose `trust` pins exactly `paired` (the
151	    /// pairwise reality — NOT a full mesh).
152	    fn new(root: &std::path::Path, perch_id: &str, paired: &[&str], dir: &std::path::Path) -> Self {
153	        let name = unique_name();
154	        let _broker = net_broker(&name, &root.join("broker"));
155	        let mut probe = connect_retry(&name);
156	        let status = probe.net_status().expect("status");
157	        let hex = status.node_id_hex.expect("net id");
158	        let addr = status.addr;
159
160	        let owlery = root.join("owlery");
161	        let perch_path = perch::resolve_perch_path_in(&owlery, perch_id, ParentHint::Infer);
162	        std::fs::create_dir_all(&perch_path).unwrap();
163	        let rec = InfoJson::new(perch_id, "now", std::process::id(), "s", "ready_agent");
164	        info::write_info(&perch_path, &rec).unwrap();
165
166	        let paths = PumpPaths {
167	            owlery: owlery.clone(),
168	            ctx_root: root.join("context"),
169	            notif_db: root.join("notifs.db"),
170	            scratch: root.join("scratch"),
171	            subnets: root.join("subnet.json"),
172	            trust: root.join("trust.json"),
173	            vis: root.join("visibility.json"),
174	            ops: root.join("pump-ops.json"),
175	            releases: root.join("releases"),
176	            release_keys: root.join("release-keys.json"),
177	            pull_marker: root.join("sync-pull-now"),
178	            advertise_marker: root.join("advertise-now"),
179	            peer_addrs: root.join("peer-addrs.json"),
180	            heartbeat: root.join("pump-heartbeat.json"),
181	        };
182	        std::fs::create_dir_all(root).unwrap();
183	        let mut subnets = SubnetStore::default();
184	        subnets.create_subnet(SUBNET).expect("node subnet");
185	        subnets.save_to(&paths.subnets).expect("save node subnet");
186	        let mut trust = TrustStore::default();
187	        for p in paired {
188	            trust.record(SUBNET, p, None, None);
189	        }
190	        trust.save_to(&paths.trust).expect("save node trust");
191
192	        let registry = Arc::new(RegistryHost::new_at(
193	            &hex,
194	            EpochSource::load_from(&root.join("epoch.json")),
195	            root.join("registry"),
196	        ));
197	        let dispatch = DispatchPaths {
198	            owlery,
199	            releases: paths.releases.clone(),
200	            scratch: dir.join(format!("disp-{perch_id}")),
201	        };
202	        MeshNode {
203	            name,
204	            hex,
205	            addr,
206	            perch_id: perch_id.to_string(),
207	            registry,
208	            paths,
209	            dispatch,
210	        }
211	    }
212
213	    /// Spawn this node's production push + dispatch loops, gated on a fresh
214	    /// stop flag (the returned `Arc` — set it to take the node "offline").
215	    fn online(&self, resolver: PeerResolver) -> Arc<AtomicBool> {
216	        let stop = Arc::new(AtomicBool::new(false));
217	        {
218	            let (name, reg, paths, flag) = (
219	                self.name.clone(),
220	                Arc::clone(&self.registry),
221	                clone_paths(&self.paths),
222	                Arc::clone(&stop),
223	            );
224	            thread::spawn(move || {
225	                let config = PumpConfig {
226	                    cadence: cadence(),
227	                    full_auto_update: false,
228	                };
229	                let _ = run_peer_pump(
230	                    &name,
231	                    reg,
232	                    &paths,
233	                    resolver,
234	                    config,
235	                    PumpHooks::default(),
236	                    &flag,
237	                );
238	            });
239	        }
240	        {
241	            let (name, disp, reg, flag) = (
242	                self.name.clone(),
243	                self.dispatch.clone(),
244	                Arc::clone(&self.registry),
245	                Arc::clone(&stop),
246	            );
247	            thread::spawn(move || {
248	                let _ = run_dispatch_loop(&name, &disp, reg, &flag, Duration::from_millis(25));
249	            });
250	        }
251	        stop
252	    }
253
254	    /// Does this node's registry hold a row for `endpoint_id` authored by
255	    /// node `node_hex`? (Visibility — and, with an address for `node_hex`,
256	    /// reach.)
257	    fn sees(&self, endpoint_id: &str, node_hex: &str) -> bool {
258	        self.registry
259	            .rows(SUBNET, endpoint_id)
260	            .iter()
261	            .any(|i| i.node == node_hex)
262	    }
263	}
264
265	/// Establish the permissive canonical gate trust: every member trusts every
266	/// other in `SUBNET`, so the dispatch-side `RegistryGatePolicy::load()` admits
267	/// any member feed. The pairwise pin that reproduces the bug lives in each
268	/// node's *explicit* `paths.trust` (the push target), not here.
269	fn canonical_full_trust(hexes: &[&str]) {
270	    let mut subnets = SubnetStore::load();
271	    let _ = subnets.create_subnet(SUBNET); // idempotent across tests
272	    subnets.save().expect("save canonical subnet");
273	    let mut trust = TrustStore::load();
274	    for h in hexes {
275	        trust.record(SUBNET, h, None, None);
276	    }
277	    trust.save().expect("save canonical trust");
278	}
279
280	/// An omniscient resolver over the whole fleet (hex → loopback addr).
281	fn resolver_over(addrs: &HashMap<String, serde_json::Value>) -> PeerResolver {
282	    let map = addrs.clone();
283	    Box::new(move |hex: &str| map.get(hex).cloned())
284	}
285
286	/// Field-by-field copy — `PumpPaths` is not `Clone` (mirrors `peerloop.rs`).
287	fn clone_paths(p: &PumpPaths) -> PumpPaths {
288	    PumpPaths {
289	        owlery: p.owlery.clone(),
290	        ctx_root: p.ctx_root.clone(),
291	        notif_db: p.notif_db.clone(),
292	        scratch: p.scratch.clone(),
293	        subnets: p.subnets.clone(),
294	        trust: p.trust.clone(),
295	        vis: p.vis.clone(),
296	        ops: p.ops.clone(),
297	        releases: p.releases.clone(),
298	        release_keys: p.release_keys.clone(),
299	        pull_marker: p.pull_marker.clone(),
300	        advertise_marker: p.advertise_marker.clone(),
301	        peer_addrs: p.peer_addrs.clone(),
302	        heartbeat: p.heartbeat.clone(),
303	    }
304	}
305
306	// ── The staggered repro (the acceptance) ────────────────────────────────────
307	// A joins B, A offline, C joins B, B offline, A online. At the end A and C are
308	// both online and B is not — the exact grill repro. The mesh must converge
309	// A↔C (see AND reach) with B absent. RED today: A pins only B, C pins only B,
310	// so neither ever pushes to the other. Flips green at Mesh-D6.
311	#[test]
312	#[ignore = "mesh lands at Mesh-D6 — this is the Mesh-D0 red repro"]
313	fn staggered_offliner_still_meshes() {
314	    init_home();
315	    let _g = LOCK.lock().unwrap_or_else(|p| p.into_inner());
316	    let dir = TempDir::new().unwrap();
317
318	    // Pairwise pins: A↔B, B↔C. A and C never pin each other.
319	    let a = MeshNode::new(&dir.path().join("a"), "perchA", &[], dir.path());
320	    let b = MeshNode::new(&dir.path().join("b"), "perchB", &[], dir.path());
321	    let c = MeshNode::new(&dir.path().join("c"), "perchC", &[], dir.path());
322	    // Re-pin now that hexes are known: A pins B; B pins A,C; C pins B.
323	    repin(&a, &[&b.hex]);
324	    repin(&b, &[&a.hex, &c.hex]);
325	    repin(&c, &[&b.hex]);
326
327	    canonical_full_trust(&[&a.hex, &b.hex, &c.hex]);
328	    let addrs: HashMap<String, serde_json::Value> = [
329	        (a.hex.clone(), a.addr.clone()),
330	        (b.hex.clone(), b.addr.clone()),
331	        (c.hex.clone(), c.addr.clone()),
332	    ]
333	    .into_iter()
334	    .collect();
335
336	    // Phase 1 — A joins B (both online): B comes to see A, A to see B.
337	    let a_stop = a.online(resolver_over(&addrs));
338	    let b_stop = b.online(resolver_over(&addrs));
339	    converge("B sees A's perch", || b.sees("perchA", &a.hex));
340	    converge("A sees B's perch", || a.sees("perchB", &b.hex));
341	    a_stop.store(true, Ordering::Relaxed); // A offline
342
343	    // Phase 2 — C joins B (B still online): B comes to see C.
344	    let c_stop = c.online(resolver_over(&addrs));
345	    converge("B sees C's perch", || b.sees("perchC", &c.hex));
346	    b_stop.store(true, Ordering::Relaxed); // B offline
347
348	    // Phase 3 — A online again; C still online; B offline. The mesh must
349	    // converge A↔C with B gone. This is the red assertion.
350	    let a_stop2 = a.online(resolver_over(&addrs));
351	    converge("A meshes to C (sees perchC)", || a.sees("perchC", &c.hex));
352	    converge("C meshes to A (sees perchA)", || c.sees("perchA", &a.hex));
353
354	    // Reach: each end holds a dialable address for the other.
355	    assert!(addrs.contains_key(&c.hex) && addrs.contains_key(&a.hex));
356
357	    a_stop2.store(true, Ordering::Relaxed);
358	    c_stop.store(true, Ordering::Relaxed);
359	}
360
361	// ── The all-online star (B never relays) ─────────────────────────────────────
362	// A, B, C all online; pairwise A↔B, B↔C only. A must reach C and C reach A —
363	// directly, with B as a non-relaying hub (B emits only its OWN rows, so an
364	// A-row reaching C can only have come straight from A; KH 4.10/7.5 preserved).
365	// RED today: A pushes only to B, C only to B; B forwards neither.
366	#[test]
367	#[ignore = "mesh lands at Mesh-D6 — this is the Mesh-D0 red repro"]
368	fn all_online_star_a_reaches_c_b_never_relays() {
369	    init_home();
370	    let _g = LOCK.lock().unwrap_or_else(|p| p.into_inner());
371	    let dir = TempDir::new().unwrap();
372
373	    let a = MeshNode::new(&dir.path().join("a"), "perchA", &[], dir.path());
374	    let b = MeshNode::new(&dir.path().join("b"), "perchB", &[], dir.path());
375	    let c = MeshNode::new(&dir.path().join("c"), "perchC", &[], dir.path());
376	    repin(&a, &[&b.hex]);
377	    repin(&b, &[&a.hex, &c.hex]);
378	    repin(&c, &[&b.hex]);
379
380	    canonical_full_trust(&[&a.hex, &b.hex, &c.hex]);
381	    let addrs: HashMap<String, serde_json::Value> = [
382	        (a.hex.clone(), a.addr.clone()),
383	        (b.hex.clone(), b.addr.clone()),
384	        (c.hex.clone(), c.addr.clone()),
385	    ]
386	    .into_iter()
387	    .collect();
388
389	    let a_stop = a.online(resolver_over(&addrs));
390	    let b_stop = b.online(resolver_over(&addrs));
391	    let c_stop = c.online(resolver_over(&addrs));
392
393	    // The hub sees both spokes (the directly-paired baseline — green today).
394	    converge("B sees A", || b.sees("perchA", &a.hex));
395	    converge("B sees C", || b.sees("perchC", &c.hex));
396
397	    // The mesh: spokes see each other WITHOUT B relaying. Red today.
398	    converge("A sees C directly", || a.sees("perchC", &c.hex));
399	    converge("C sees A directly", || c.sees("perchA", &a.hex));
400
401	    a_stop.store(true, Ordering::Relaxed);
402	    b_stop.store(true, Ordering::Relaxed);
403	    c_stop.store(true, Ordering::Relaxed);
404	}
405
406	/// Overwrite a node's explicit push-target trust to pin exactly `paired`.
407	fn repin(node: &MeshNode, paired: &[&str]) {
408	    let mut trust = TrustStore::default();
409	    for p in paired {
410	        trust.record(SUBNET, p, None, None);
411	    }
412	    trust.save_to(&node.paths.trust).expect("re-pin trust");
413	}
414
415	// Silence unused-field warnings: these are part of the node model the harness
416	// documents even where a given test does not read them.
417	#[allow(dead_code)]
418	fn _touch(node: &MeshNode) -> (&str, &str) {
419	    (&node.perch_id, node.paths.owlery.to_str().unwrap_or(""))
420	}
421	#[allow(dead_code)]
422	type _Pb = PathBuf;
423

//! Mesh-D0 — the staggered + star subnet-mesh repro, RED.
//!
//! These two tests assert the *desired* full-mesh convergence the subnet does
//! NOT yet have (`SUBNET-MESH-PLAN.md`, ADR-0017). They are `#[ignore]`d: the
//! acceptance that flips green at **Mesh-D6** when the registry push target
//! widens from directly-paired peers to the full roster. No production change
//! lands with this file — it locks the bug as an executable repro. See
//! `MESH-D0-PLAN.md` for the fidelity decision.
//!
//! The bug, in one seam: `peerloop.rs` pushes a node's own rows to
//! `trust.peers_in(subnet)` — *directly-paired* peers only, no relay. So when
//! `C` joins `B` (C pins B, not A) and `A` joins `B`, A and C never pin each
//! other; C's pump never targets A and A's never targets C. They stay mutually
//! invisible even when both are online. The mesh restores reach by widening
//! that push target to every roster member (Mesh-D6).
//!
//! Fidelity: the **push side is the real loop** (`run_peer_pump` with explicit
//! per-node `PumpPaths` whose `trust` is the pairwise pin) — so the D6
//! target-widening flips these green untouched. The **gate side runs the real
//! dispatch loop** (`run_dispatch_loop`) under a permissive canonical
//! `SPT_HOME` trust, isolating the red strictly to the push-target seam (the
//! "subnet = pairing graph, not mesh" bug); the gate swap is Mesh-D5's own
//! unit. Addresses are omniscient (a node still only *pushes* where its
//! push-target says, so this does not mask the bug); address *discovery* is the
//! roster's job, tested at D3/D4.
//!
//! Refinement note: once the roster store exists (D3/D4) this harness seeds
//! rosters the way it seeds trust today; the `#[ignore]` lifts at D6 and the
//! file becomes the REQ-MESH-3 `int` evidence (tagged then, not now — D0
//! activates no REQ).

use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use std::thread;
use std::time::Duration;

use spt_daemon::brain::Brain;
use spt_daemon::broker::Broker;
use spt_daemon::dispatch::{run_dispatch_loop, DispatchPaths};
use spt_daemon::nethost::{NetConfig, NetHost};
use spt_daemon::peerloop::{
    run_peer_pump, PeerResolver, PumpCadence, PumpConfig, PumpHooks, PumpPaths,
};
use spt_daemon::registryhost::RegistryHost;
use spt_net::net::endpoint::{BindScope, LocalDiscovery, RelayPolicy};
use spt_proto::identity::Identity;
use spt_store::epoch::EpochSource;
use spt_store::info::{self, InfoJson};
use spt_store::perch::{self, ParentHint};
use spt_store::subnet::SubnetStore;
use spt_store::trust::TrustStore;
use tempfile::TempDir;

const SUBNET: &str = "mesh0";

// One canonical SPT_HOME for the binary; the lock serializes the two tests
// (SPT_HOME is process-global). Per-node registries/owleries live under
// fresh per-test tempdirs, so the only shared canonical state is the subnet
// record + the permissive gate trust — both monotonic supersets, safe to
// re-establish per test.
static HOME: OnceLock<TempDir> = OnceLock::new();
static LOCK: Mutex<()> = Mutex::new(());
fn init_home() {
    HOME.get_or_init(|| {
        let dir = TempDir::new().expect("temp home");
        std::env::set_var("SPT_HOME", dir.path());
        std::env::set_var("SPT_NTP_SERVER", "off");
        dir
    });
}

static SEQ: AtomicU32 = AtomicU32::new(0);
fn unique_name() -> String {
    let n = SEQ.fetch_add(1, Ordering::Relaxed);
    format!("spt-daemon-mesh-{}-{}.sock", std::process::id(), n)
}

fn hermetic() -> NetConfig {
    NetConfig {
        identity: Identity::generate(),
        relay: RelayPolicy::Disabled,
        discovery: LocalDiscovery::Off,
        scope: BindScope::Loopback,
        membership: None,
    }
}

fn net_broker(name: &str, dir: &std::path::Path) -> Arc<Broker> {
    let host = NetHost::start(hermetic()).expect("net host start");
    let broker =
        Broker::bind_in_with_net(name, dir.join("effects.log"), Some(host)).expect("bind broker");
    let serve = Arc::clone(&broker);
    thread::spawn(move || {
        let _ = serve.serve();
    });
    broker
}

fn connect_retry(name: &str) -> Brain {
    for _ in 0..300 {
        if let Ok(b) = Brain::cold_start(name, 1) {
            return b;
        }
        thread::sleep(Duration::from_millis(10));
    }
    panic!("brain could not connect");
}

/// Poll `probe` until true; panic with `what` on timeout (15s — green paths
/// exit early, so the long bound only pays on the *red* mesh assertions).
fn converge(what: &str, mut probe: impl FnMut() -> bool) {
    for _ in 0..600 {
        if probe() {
            return;
        }
        thread::sleep(Duration::from_millis(25));
    }
    panic!("never converged: {what}");
}

/// The inert/registry-only cadence: only registry feeds flow (notif/sync/
/// update are pushed far enough out to never fire), so the shared canonical
/// notif/context stores stay untouched across nodes.
fn cadence() -> PumpCadence {
    PumpCadence {
        registry: Duration::from_millis(100),
        notif: Duration::from_secs(3_000),
        sync: Duration::from_secs(3_000),
        update: Duration::from_secs(3_000),
        registry_evict_after: Duration::from_secs(3_000),
    }
}

/// One mesh node: its broker + registry + on-disk pump roots. `perch_id` is
/// the node's sole Self perch (the row peers must come to see).
struct MeshNode {
    name: String,
    hex: String,
    addr: serde_json::Value,
    perch_id: String,
    registry: Arc<RegistryHost>,
    paths: PumpPaths,
    dispatch: DispatchPaths,
}

impl MeshNode {
    /// Stand up a node under `root`: broker, a single advertised perch, a
    /// registry, and pump paths whose `trust` pins exactly `paired` (the
    /// pairwise reality — NOT a full mesh).
    fn new(root: &std::path::Path, perch_id: &str, paired: &[&str], dir: &std::path::Path) -> Self {
        let name = unique_name();
        let _broker = net_broker(&name, &root.join("broker"));
        let mut probe = connect_retry(&name);
        let status = probe.net_status().expect("status");
        let hex = status.node_id_hex.expect("net id");
        let addr = status.addr;

        let owlery = root.join("owlery");
        let perch_path = perch::resolve_perch_path_in(&owlery, perch_id, ParentHint::Infer);
        std::fs::create_dir_all(&perch_path).unwrap();
        let rec = InfoJson::new(perch_id, "now", std::process::id(), "s", "ready_agent");
        info::write_info(&perch_path, &rec).unwrap();

        let paths = PumpPaths {
            owlery: owlery.clone(),
            ctx_root: root.join("context"),
            notif_db: root.join("notifs.db"),
            scratch: root.join("scratch"),
            subnets: root.join("subnet.json"),
            trust: root.join("trust.json"),
            vis: root.join("visibility.json"),
            ops: root.join("pump-ops.json"),
            releases: root.join("releases"),
            release_keys: root.join("release-keys.json"),
            pull_marker: root.join("sync-pull-now"),
            advertise_marker: root.join("advertise-now"),
            peer_addrs: root.join("peer-addrs.json"),
            heartbeat: root.join("pump-heartbeat.json"),
        };
        std::fs::create_dir_all(root).unwrap();
        let mut subnets = SubnetStore::default();
        subnets.create_subnet(SUBNET).expect("node subnet");
        subnets.save_to(&paths.subnets).expect("save node subnet");
        let mut trust = TrustStore::default();
        for p in paired {
            trust.record(SUBNET, p, None, None);
        }
        trust.save_to(&paths.trust).expect("save node trust");

        let registry = Arc::new(RegistryHost::new_at(
            &hex,
            EpochSource::load_from(&root.join("epoch.json")),
            root.join("registry"),
        ));
        let dispatch = DispatchPaths {
            owlery,
            releases: paths.releases.clone(),
            scratch: dir.join(format!("disp-{perch_id}")),
        };
        MeshNode {
            name,
            hex,
            addr,
            perch_id: perch_id.to_string(),
            registry,
            paths,
            dispatch,
        }
    }

    /// Spawn this node's production push + dispatch loops, gated on a fresh
    /// stop flag (the returned `Arc` — set it to take the node "offline").
    fn online(&self, resolver: PeerResolver) -> Arc<AtomicBool> {
        let stop = Arc::new(AtomicBool::new(false));
        {
            let (name, reg, paths, flag) = (
                self.name.clone(),
                Arc::clone(&self.registry),
                clone_paths(&self.paths),
                Arc::clone(&stop),
            );
            thread::spawn(move || {
                let config = PumpConfig {
                    cadence: cadence(),
                    full_auto_update: false,
                };
                let _ = run_peer_pump(
                    &name,
                    reg,
                    &paths,
                    resolver,
                    config,
                    PumpHooks::default(),
                    &flag,
                );
            });
        }
        {
            let (name, disp, reg, flag) = (
                self.name.clone(),
                self.dispatch.clone(),
                Arc::clone(&self.registry),
                Arc::clone(&stop),
            );
            thread::spawn(move || {
                let _ = run_dispatch_loop(&name, &disp, reg, &flag, Duration::from_millis(25));
            });
        }
        stop
    }

    /// Does this node's registry hold a row for `endpoint_id` authored by
    /// node `node_hex`? (Visibility — and, with an address for `node_hex`,
    /// reach.)
    fn sees(&self, endpoint_id: &str, node_hex: &str) -> bool {
        self.registry
            .rows(SUBNET, endpoint_id)
            .iter()
            .any(|i| i.node == node_hex)
    }
}

/// Establish the permissive canonical gate trust: every member trusts every
/// other in `SUBNET`, so the dispatch-side `RegistryGatePolicy::load()` admits
/// any member feed. The pairwise pin that reproduces the bug lives in each
/// node's *explicit* `paths.trust` (the push target), not here.
fn canonical_full_trust(hexes: &[&str]) {
    let mut subnets = SubnetStore::load();
    let _ = subnets.create_subnet(SUBNET); // idempotent across tests
    subnets.save().expect("save canonical subnet");
    let mut trust = TrustStore::load();
    for h in hexes {
        trust.record(SUBNET, h, None, None);
    }
    trust.save().expect("save canonical trust");
}

/// An omniscient resolver over the whole fleet (hex → loopback addr).
fn resolver_over(addrs: &HashMap<String, serde_json::Value>) -> PeerResolver {
    let map = addrs.clone();
    Box::new(move |hex: &str| map.get(hex).cloned())
}

/// Field-by-field copy — `PumpPaths` is not `Clone` (mirrors `peerloop.rs`).
fn clone_paths(p: &PumpPaths) -> PumpPaths {
    PumpPaths {
        owlery: p.owlery.clone(),
        ctx_root: p.ctx_root.clone(),
        notif_db: p.notif_db.clone(),
        scratch: p.scratch.clone(),
        subnets: p.subnets.clone(),
        trust: p.trust.clone(),
        vis: p.vis.clone(),
        ops: p.ops.clone(),
        releases: p.releases.clone(),
        release_keys: p.release_keys.clone(),
        pull_marker: p.pull_marker.clone(),
        advertise_marker: p.advertise_marker.clone(),
        peer_addrs: p.peer_addrs.clone(),
        heartbeat: p.heartbeat.clone(),
    }
}

// ── The staggered repro (the acceptance) ────────────────────────────────────
// A joins B, A offline, C joins B, B offline, A online. At the end A and C are
// both online and B is not — the exact grill repro. The mesh must converge
// A↔C (see AND reach) with B absent. RED today: A pins only B, C pins only B,
// so neither ever pushes to the other. Flips green at Mesh-D6.
#[test]
#[ignore = "mesh lands at Mesh-D6 — this is the Mesh-D0 red repro"]
fn staggered_offliner_still_meshes() {
    init_home();
    let _g = LOCK.lock().unwrap_or_else(|p| p.into_inner());
    let dir = TempDir::new().unwrap();

    // Pairwise pins: A↔B, B↔C. A and C never pin each other.
    let a = MeshNode::new(&dir.path().join("a"), "perchA", &[], dir.path());
    let b = MeshNode::new(&dir.path().join("b"), "perchB", &[], dir.path());
    let c = MeshNode::new(&dir.path().join("c"), "perchC", &[], dir.path());
    // Re-pin now that hexes are known: A pins B; B pins A,C; C pins B.
    repin(&a, &[&b.hex]);
    repin(&b, &[&a.hex, &c.hex]);
    repin(&c, &[&b.hex]);

    canonical_full_trust(&[&a.hex, &b.hex, &c.hex]);
    let addrs: HashMap<String, serde_json::Value> = [
        (a.hex.clone(), a.addr.clone()),
        (b.hex.clone(), b.addr.clone()),
        (c.hex.clone(), c.addr.clone()),
    ]
    .into_iter()
    .collect();

    // Phase 1 — A joins B (both online): B comes to see A, A to see B.
    let a_stop = a.online(resolver_over(&addrs));
    let b_stop = b.online(resolver_over(&addrs));
    converge("B sees A's perch", || b.sees("perchA", &a.hex));
    converge("A sees B's perch", || a.sees("perchB", &b.hex));
    a_stop.store(true, Ordering::Relaxed); // A offline

    // Phase 2 — C joins B (B still online): B comes to see C.
    let c_stop = c.online(resolver_over(&addrs));
    converge("B sees C's perch", || b.sees("perchC", &c.hex));
    b_stop.store(true, Ordering::Relaxed); // B offline

    // Phase 3 — A online again; C still online; B offline. The mesh must
    // converge A↔C with B gone. This is the red assertion.
    let a_stop2 = a.online(resolver_over(&addrs));
    converge("A meshes to C (sees perchC)", || a.sees("perchC", &c.hex));
    converge("C meshes to A (sees perchA)", || c.sees("perchA", &a.hex));

    // Reach: each end holds a dialable address for the other.
    assert!(addrs.contains_key(&c.hex) && addrs.contains_key(&a.hex));

    a_stop2.store(true, Ordering::Relaxed);
    c_stop.store(true, Ordering::Relaxed);
}

// ── The all-online star (B never relays) ─────────────────────────────────────
// A, B, C all online; pairwise A↔B, B↔C only. A must reach C and C reach A —
// directly, with B as a non-relaying hub (B emits only its OWN rows, so an
// A-row reaching C can only have come straight from A; KH 4.10/7.5 preserved).
// RED today: A pushes only to B, C only to B; B forwards neither.
#[test]
#[ignore = "mesh lands at Mesh-D6 — this is the Mesh-D0 red repro"]
fn all_online_star_a_reaches_c_b_never_relays() {
    init_home();
    let _g = LOCK.lock().unwrap_or_else(|p| p.into_inner());
    let dir = TempDir::new().unwrap();

    let a = MeshNode::new(&dir.path().join("a"), "perchA", &[], dir.path());
    let b = MeshNode::new(&dir.path().join("b"), "perchB", &[], dir.path());
    let c = MeshNode::new(&dir.path().join("c"), "perchC", &[], dir.path());
    repin(&a, &[&b.hex]);
    repin(&b, &[&a.hex, &c.hex]);
    repin(&c, &[&b.hex]);

    canonical_full_trust(&[&a.hex, &b.hex, &c.hex]);
    let addrs: HashMap<String, serde_json::Value> = [
        (a.hex.clone(), a.addr.clone()),
        (b.hex.clone(), b.addr.clone()),
        (c.hex.clone(), c.addr.clone()),
    ]
    .into_iter()
    .collect();

    let a_stop = a.online(resolver_over(&addrs));
    let b_stop = b.online(resolver_over(&addrs));
    let c_stop = c.online(resolver_over(&addrs));

    // The hub sees both spokes (the directly-paired baseline — green today).
    converge("B sees A", || b.sees("perchA", &a.hex));
    converge("B sees C", || b.sees("perchC", &c.hex));

    // The mesh: spokes see each other WITHOUT B relaying. Red today.
    converge("A sees C directly", || a.sees("perchC", &c.hex));
    converge("C sees A directly", || c.sees("perchA", &a.hex));

    a_stop.store(true, Ordering::Relaxed);
    b_stop.store(true, Ordering::Relaxed);
    c_stop.store(true, Ordering::Relaxed);
}

/// Overwrite a node's explicit push-target trust to pin exactly `paired`.
fn repin(node: &MeshNode, paired: &[&str]) {
    let mut trust = TrustStore::default();
    for p in paired {
        trust.record(SUBNET, p, None, None);
    }
    trust.save_to(&node.paths.trust).expect("re-pin trust");
}

// Silence unused-field warnings: these are part of the node model the harness
// documents even where a given test does not read them.
#[allow(dead_code)]
fn _touch(node: &MeshNode) -> (&str, &str) {
    (&node.perch_id, node.paths.owlery.to_str().unwrap_or(""))
}
#[allow(dead_code)]
type _Pb = PathBuf;


2280	/// Build the status rows from the subnet store + registry snapshots + the
2281	/// trust store. Pure over its inputs so the view is unit-tested hermetically.
2282	///
2283	/// Node membership is TRUST-derived (self + pinned peers — the durable
2284	/// pairing truth), with registry-row nodes unioned in as enrichment: a
2285	/// member node with zero advertised endpoints (fresh box, empty owlery) is
2286	/// still a member, and an offline peer whose rows the 4.10 eviction decayed
2287	/// must not vanish from its own subnet's view. Endpoint counts stay
2288	/// gossip-derived and stale-tolerant (they catch up as gossip lands).
2289	// [impl->REQ-SUBNET-1]
2290	fn subnet_status_rows(
2291	    store: &SubnetStore,
2292	    regs: &std::collections::BTreeMap<String, spt_net::net::registry::SubnetRegistry>,
2293	    trust: &spt_store::trust::TrustStore,
2294	    own_hex: Option<&str>,
2295	    only: Option<&str>,
2296	) -> Vec<SubnetStatusRow> {
2297	    store
2298	        .subnets
2299	        .iter()
2300	        .filter(|s| only.is_none_or(|n| n == s.name))
2301	        .map(|s| {
2302	            let (mut nodes, mut endpoints) = (std::collections::BTreeSet::new(), 0usize);
2303	            if let Some(own) = own_hex {
2304	                nodes.insert(own.to_string());
2305	            }
2306	            for peer in trust.peers_in(&s.name) {
2307	                nodes.insert(peer.pubkey_hex.clone());
2308	            }
2309	            if let Some(reg) = regs.get(&s.name) {
2310	                for id in reg.endpoint_ids() {
2311	                    endpoints += 1;
2312	                    for inst in reg.instances(id) {
2313	                        nodes.insert(inst.node.clone());
2314	                    }
2315	                }
2316	            }
2317	            // This node is a member even when its identity could not load.
2318	            SubnetStatusRow {
2319	                name: s.name.clone(),
2320	                nodes: nodes.len().max(1),
2321	                endpoints,
2322	            }
2323	        })
2324	        .collect()
2325	}
2326
2327	/// Render the status view (M7 plan decision 5, amended by M8 decisions
2328	/// 11–12): aligned rows; **daemon-aware** standalone text on zero subnets
2329	/// (never implies messaging works while the daemon is down); the hint
2330	/// footer rides only the bare `spt subnet` view (`hints`).
2331	// [impl->REQ-SUBNET-1]
2332	// [impl->REQ-SUBNET-8]
2333	fn render_subnet_status(rows: &[SubnetStatusRow], daemon_running: bool, hints: bool) -> String {
2334	    let mut out = String::new();
2335	    if rows.is_empty() {
2336	        out.push_str(standalone_text(daemon_running));
2337	    } else {
2338	        let pad = rows
2339	            .iter()

/// Build the status rows from the subnet store + registry snapshots + the
/// trust store. Pure over its inputs so the view is unit-tested hermetically.
///
/// Node membership is TRUST-derived (self + pinned peers — the durable
/// pairing truth), with registry-row nodes unioned in as enrichment: a
/// member node with zero advertised endpoints (fresh box, empty owlery) is
/// still a member, and an offline peer whose rows the 4.10 eviction decayed
/// must not vanish from its own subnet's view. Endpoint counts stay
/// gossip-derived and stale-tolerant (they catch up as gossip lands).
// [impl->REQ-SUBNET-1]
fn subnet_status_rows(
    store: &SubnetStore,
    regs: &std::collections::BTreeMap<String, spt_net::net::registry::SubnetRegistry>,
    trust: &spt_store::trust::TrustStore,
    own_hex: Option<&str>,
    only: Option<&str>,
) -> Vec<SubnetStatusRow> {
    store
        .subnets
        .iter()
        .filter(|s| only.is_none_or(|n| n == s.name))
        .map(|s| {
            let (mut nodes, mut endpoints) = (std::collections::BTreeSet::new(), 0usize);
            if let Some(own) = own_hex {
                nodes.insert(own.to_string());
            }
            for peer in trust.peers_in(&s.name) {
                nodes.insert(peer.pubkey_hex.clone());
            }
            if let Some(reg) = regs.get(&s.name) {
                for id in reg.endpoint_ids() {
                    endpoints += 1;
                    for inst in reg.instances(id) {
                        nodes.insert(inst.node.clone());
                    }
                }
            }
            // This node is a member even when its identity could not load.
            SubnetStatusRow {
                name: s.name.clone(),
                nodes: nodes.len().max(1),
                endpoints,
            }
        })
        .collect()
}

/// Render the status view (M7 plan decision 5, amended by M8 decisions
/// 11–12): aligned rows; **daemon-aware** standalone text on zero subnets
/// (never implies messaging works while the daemon is down); the hint
/// footer rides only the bare `spt subnet` view (`hints`).
// [impl->REQ-SUBNET-1]
// [impl->REQ-SUBNET-8]
fn render_subnet_status(rows: &[SubnetStatusRow], daemon_running: bool, hints: bool) -> String {
    let mut out = String::new();
    if rows.is_empty() {
        out.push_str(standalone_text(daemon_running));
    } else {
        let pad = rows
            .iter()


1290	                    );
1291	                }
1292	            }
1293	            None => println!("peer pump: no heartbeat recorded"),
1294	        }
1295	    } else {
1296	        println!("daemon: not running (`spt daemon run`, or any spt command auto-starts it)");
1297	    }
1298
1299	    // Member subnets (names + counts — the bare-subnet status rows).
1300	    let store = SubnetStore::load();
1301	    if store.subnets.is_empty() {
1302	        println!("subnets: (none — this node is standalone)");
1303	    } else {
1304	        let regs = crate::wansend::load_snapshots(&perch::identity_dir().join("registry"));
1305	        let trust = spt_store::trust::TrustStore::load();
1306	        let own_hex = spt_store::nodeid::load_or_create()
1307	            .map(|i| i.public_key().to_hex())
1308	            .unwrap_or_default();
1309	        println!("subnets:");
1310	        for row in subnet_status_rows(
1311	            &store,
1312	            &regs,
1313	            &trust,
1314	            Some(&own_hex).filter(|h| !h.is_empty()).map(|h| h.as_str()),
1315	            None,
1316	        ) {
1317	            println!(
1318	                "  {}  nodes={} endpoints={}",
1319	                row.name, row.nodes, row.endpoints
1320	            );
1321	        }
1322	    }
1323
1324	    // Local endpoints (the perch roster).
1325	    let perches = roster::enumerate();
1326	    if perches.is_empty() {
1327	        println!("local endpoints: (none)");
1328	    } else {
1329	        println!("local endpoints:");

                    );
                }
            }
            None => println!("peer pump: no heartbeat recorded"),
        }
    } else {
        println!("daemon: not running (`spt daemon run`, or any spt command auto-starts it)");
    }

    // Member subnets (names + counts — the bare-subnet status rows).
    let store = SubnetStore::load();
    if store.subnets.is_empty() {
        println!("subnets: (none — this node is standalone)");
    } else {
        let regs = crate::wansend::load_snapshots(&perch::identity_dir().join("registry"));
        let trust = spt_store::trust::TrustStore::load();
        let own_hex = spt_store::nodeid::load_or_create()
            .map(|i| i.public_key().to_hex())
            .unwrap_or_default();
        println!("subnets:");
        for row in subnet_status_rows(
            &store,
            &regs,
            &trust,
            Some(&own_hex).filter(|h| !h.is_empty()).map(|h| h.as_str()),
            None,
        ) {
            println!(
                "  {}  nodes={} endpoints={}",
                row.name, row.nodes, row.endpoints
            );
        }
    }

    // Local endpoints (the perch roster).
    let perches = roster::enumerate();
    if perches.is_empty() {
        println!("local endpoints: (none)");
    } else {
        println!("local endpoints:");