75	            trust: TrustStore::load(),
76	        }
77	    }
78
79	    /// member subnet ∧ origin trusted in that subnet — fail-closed on both.
80	    pub fn admits(&self, subnet: &str, origin_node: &str) -> bool {
81	        self.subnets.iter().any(|s| s == subnet) && self.trust.is_trusted(subnet, origin_node)
82	    }
83	}
84
85	/// The daemon's per-subnet registry instances + this node's advertisement
86	/// epoch source. Shared (`Arc`) between the dispatcher's registry arm and
87	/// the peer pump.
88	pub struct RegistryHost {
89	    /// This node's stable name (lowercase-hex pubkey).
90	    node_hex: String,
91	    /// This node's human label (M7 D2, REQ-SUBNET-3): the OS hostname,
92	    /// re-read at daemon startup ([`new`](Self::new)) and stamped onto every
93	    /// advertised row — a hostname change reaches peers as ordinary
94	    /// epoch-bumped updates. Display/addressing sugar; `node_hex` stays the
95	    /// identity.
96	    node_label: Option<String>,
97	    machine_id: None,
98	    /// When each peer node was last *heard* (an admitted inbound feed,
99	    /// wall-clock ms) — the gossip-recency half of the M7 D2 hybrid
100	    /// liveness (REQ-SUBNET-1): a recently-heard node renders online without
101	    /// a probe; only stale rows get dialed. Local-only (never on the wire);
102	    /// mirrored to `heard.meta` beside the snapshots for the CLI reader.
103	    heard: Mutex<BTreeMap<String, u64>>,
104	    regs: Mutex<BTreeMap<String, SubnetRegistry>>,
105	    /// The node's one durable monotonic counter (the D3b lease source) — the
106	    /// epoch on every locally-authored instance row.
107	    epoch: Mutex<EpochSource>,
108	    /// Where snapshots land (`identity/registry/<subnet>.json`).
109	    snapshot_dir: PathBuf,
110	}
111
112	impl RegistryHost {
113	    /// Stand the host up for this node, with the canonical epoch counter and
114	    /// snapshot dir under the spt home. Reads the OS hostname here — daemon
115	    /// startup IS the label's re-check point (REQ-SUBNET-3).
116	    pub fn new(node_hex: &str) -> RegistryHost {
117	        RegistryHost::new_at(
118	            node_hex,
119	            EpochSource::load(),
120	            perch::identity_dir().join("registry"),
121	        )
122	        .with_node_label(spt_store::hostlabel::os_hostname())
123	    }
124
125	    /// Explicit-paths constructor (tests; the pump E2E gives each in-process
126	    /// "node" its own counter + snapshot dir). No label by default — tests
127	    /// stay hostname-independent; chain [`with_node_label`](Self::with_node_label).
128	    pub fn new_at(node_hex: &str, epoch: EpochSource, snapshot_dir: PathBuf) -> RegistryHost {
129	        RegistryHost {
130	            node_hex: node_hex.to_string(),
131	            node_label: None,
132	            machine_id: None,
133	            heard: Mutex::new(BTreeMap::new()),
134	            regs: Mutex::new(BTreeMap::new()),
135	            epoch: Mutex::new(epoch),
136	            snapshot_dir,
137	        }
138	    }
139
140	    /// Set the label this host stamps onto every advertised row.
141	    // [impl->REQ-SUBNET-3]
142	    pub fn with_node_label(mut self, label: Option<String>) -> RegistryHost {
143	        self.node_label = label;
144	        self

            trust: TrustStore::load(),
        }
    }

    /// member subnet ∧ origin trusted in that subnet — fail-closed on both.
    pub fn admits(&self, subnet: &str, origin_node: &str) -> bool {
        self.subnets.iter().any(|s| s == subnet) && self.trust.is_trusted(subnet, origin_node)
    }
}

/// The daemon's per-subnet registry instances + this node's advertisement
/// epoch source. Shared (`Arc`) between the dispatcher's registry arm and
/// the peer pump.
pub struct RegistryHost {
    /// This node's stable name (lowercase-hex pubkey).
    node_hex: String,
    /// This node's human label (M7 D2, REQ-SUBNET-3): the OS hostname,
    /// re-read at daemon startup ([`new`](Self::new)) and stamped onto every
    /// advertised row — a hostname change reaches peers as ordinary
    /// epoch-bumped updates. Display/addressing sugar; `node_hex` stays the
    /// identity.
    node_label: Option<String>,
    machine_id: None,
    /// When each peer node was last *heard* (an admitted inbound feed,
    /// wall-clock ms) — the gossip-recency half of the M7 D2 hybrid
    /// liveness (REQ-SUBNET-1): a recently-heard node renders online without
    /// a probe; only stale rows get dialed. Local-only (never on the wire);
    /// mirrored to `heard.meta` beside the snapshots for the CLI reader.
    heard: Mutex<BTreeMap<String, u64>>,
    regs: Mutex<BTreeMap<String, SubnetRegistry>>,
    /// The node's one durable monotonic counter (the D3b lease source) — the
    /// epoch on every locally-authored instance row.
    epoch: Mutex<EpochSource>,
    /// Where snapshots land (`identity/registry/<subnet>.json`).
    snapshot_dir: PathBuf,
}

impl RegistryHost {
    /// Stand the host up for this node, with the canonical epoch counter and
    /// snapshot dir under the spt home. Reads the OS hostname here — daemon
    /// startup IS the label's re-check point (REQ-SUBNET-3).
    pub fn new(node_hex: &str) -> RegistryHost {
        RegistryHost::new_at(
            node_hex,
            EpochSource::load(),
            perch::identity_dir().join("registry"),
        )
        .with_node_label(spt_store::hostlabel::os_hostname())
    }

    /// Explicit-paths constructor (tests; the pump E2E gives each in-process
    /// "node" its own counter + snapshot dir). No label by default — tests
    /// stay hostname-independent; chain [`with_node_label`](Self::with_node_label).
    pub fn new_at(node_hex: &str, epoch: EpochSource, snapshot_dir: PathBuf) -> RegistryHost {
        RegistryHost {
            node_hex: node_hex.to_string(),
            node_label: None,
            machine_id: None,
            heard: Mutex::new(BTreeMap::new()),
            regs: Mutex::new(BTreeMap::new()),
            epoch: Mutex::new(epoch),
            snapshot_dir,
        }
    }

    /// Set the label this host stamps onto every advertised row.
    // [impl->REQ-SUBNET-3]
    pub fn with_node_label(mut self, label: Option<String>) -> RegistryHost {
        self.node_label = label;
        self


Edit

    node_label: Option<String>,
    machine_id: None,

    node_label: Option<String>,
    /// This node's hashed stable machine identifier (M8 decision 13/22,
    /// REQ-SUBNET-7): stamped onto every advertised row beside the label —
    /// the re-pair overwrite anchor. Already domain-separated-hashed by
    /// [`crate::machineid`]; never the raw id.
    machine_id: Option<String>,


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

    node_label: Option<String>,
    machine_id: None,

    node_label: Option<String>,
    /// This node's hashed stable machine identifier (M8 decision 13/22,
    /// REQ-SUBNET-7): stamped onto every advertised row beside the label —
    /// the re-pair overwrite anchor. Already domain-separated-hashed by
    /// [`crate::machineid`]; never the raw id.
    machine_id: Option<String>,

/// epoch-bumped updates. Display/addressing sugar; `node_hex` stays the

+    /// This node's hashed stable machine identifier (M8 decision 13/22,

+    /// REQ-SUBNET-7): stamped onto every advertised row beside the label —

+    /// the re-pair overwrite anchor. Already domain-separated-hashed by

+    /// [`crate::machineid`]; never the raw id.

/// When each peer node was last *heard* (an admitted inbound feed,

/// wall-clock ms) — the gossip-recency half of the M7 D2 hybrid

/// liveness (REQ-SUBNET-1): a recently-heard node renders online without


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


    pub fn new(node_hex: &str) -> RegistryHost {
        RegistryHost::new_at(
            node_hex,
            EpochSource::load(),
            perch::identity_dir().join("registry"),
        )
        .with_node_label(spt_store::hostlabel::os_hostname())
    }

    pub fn new(node_hex: &str) -> RegistryHost {
        RegistryHost::new_at(
            node_hex,
            EpochSource::load(),
            perch::identity_dir().join("registry"),
        )
        .with_node_label(spt_store::hostlabel::os_hostname())
        .with_machine_id(crate::machineid::machine_id_hash())
    }


    pub fn new(node_hex: &str) -> RegistryHost {
        RegistryHost::new_at(
            node_hex,
            EpochSource::load(),
            perch::identity_dir().join("registry"),
        )
        .with_node_label(spt_store::hostlabel::os_hostname())
    }

    pub fn new(node_hex: &str) -> RegistryHost {
        RegistryHost::new_at(
            node_hex,
            EpochSource::load(),
            perch::identity_dir().join("registry"),
        )
        .with_node_label(spt_store::hostlabel::os_hostname())
        .with_machine_id(crate::machineid::machine_id_hash())
    }

+        .with_machine_id(crate::machineid::machine_id_hash())

/// Explicit-paths constructor (tests; the pump E2E gives each in-process


    /// Set the label this host stamps onto every advertised row.
    // [impl->REQ-SUBNET-3]
    pub fn with_node_label(mut self, label: Option<String>) -> RegistryHost {
        self.node_label = label;
        self

    /// Set the label this host stamps onto every advertised row.
    // [impl->REQ-SUBNET-3]
    pub fn with_node_label(mut self, label: Option<String>) -> RegistryHost {
        self.node_label = label;
        self
    }

    /// Set the hashed machine identifier this host stamps onto every
    /// advertised row (M8 decision 13/22, REQ-SUBNET-7).
    // [impl->REQ-SUBNET-7]
    pub fn with_machine_id(mut self, machine_id: Option<String>) -> RegistryHost {
        self.machine_id = machine_id;
        self


    /// Set the label this host stamps onto every advertised row.
    // [impl->REQ-SUBNET-3]
    pub fn with_node_label(mut self, label: Option<String>) -> RegistryHost {
        self.node_label = label;
        self
    }

    /// Set the hashed machine identifier this host stamps onto every
    /// advertised row (M8 decision 13/22, REQ-SUBNET-7).
    // [impl->REQ-SUBNET-7]
    pub fn with_machine_id(mut self, machine_id: Option<String>) -> RegistryHost {
        self.machine_id = machine_id;
        self

+    /// Set the hashed machine identifier this host stamps onto every

+    /// advertised row (M8 decision 13/22, REQ-SUBNET-7).

+    pub fn with_machine_id(mut self, machine_id: Option<String>) -> RegistryHost {


337-                    last_active_ms,
338-                    shell_adapters: shell_adapters.clone(),
339-                    // The node label rides every row this node authors
340-                    // (REQ-SUBNET-3) — set from the OS hostname at daemon
341-                    // startup, so a rename converges by re-advertisement.
342-                    // [impl->REQ-SUBNET-3]
343:                    node_label: self.node_label.clone(),
344-                    machine_id: None,
345-                };
346-                let reg = regs.entry(sub.name.clone()).or_default();
347-                match advertise_if_visible(
348-                    reg,
349-                    &vis,


                    // [impl->REQ-SUBNET-3]
                    node_label: self.node_label.clone(),
                    machine_id: None,
                };

                    // [impl->REQ-SUBNET-3]
                    node_label: self.node_label.clone(),
                    // The hashed machine id rides beside it (M8 decision
                    // 13/22) — the re-pair overwrite anchor.
                    // [impl->REQ-SUBNET-7]
                    machine_id: self.machine_id.clone(),
                };


                    // [impl->REQ-SUBNET-3]
                    node_label: self.node_label.clone(),
                    machine_id: None,
                };

                    // [impl->REQ-SUBNET-3]
                    node_label: self.node_label.clone(),
                    // The hashed machine id rides beside it (M8 decision
                    // 13/22) — the re-pair overwrite anchor.
                    // [impl->REQ-SUBNET-7]
                    machine_id: self.machine_id.clone(),
                };

// startup, so a rename converges by re-advertisement.

+                    // The hashed machine id rides beside it (M8 decision

+                    // 13/22) — the re-pair overwrite anchor.

+                    // [impl->REQ-SUBNET-7]

+                    machine_id: self.machine_id.clone(),

let reg = regs.entry(sub.name.clone()).or_default();


Now the machineid module:


Write

//! Stable per-machine identifier, **hashed** (M8 decisions 13/22,
//! REQ-SUBNET-7) — the re-pair overwrite anchor.
//!
//! Source: the OS machine id — `/etc/machine-id` (systemd; dbus fallback)
//! on unix, `HKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid` on Windows —
//! the only candidate that survives the exact event the overwrite targets
//! (an spt state-dir wipe / key regeneration). Where the OS id is
//! unreadable, a minted UUID persisted under the identity dir is the
//! fallback (weaker: it dies with a state wipe, but better than nothing).
//!
//! The raw id never leaves the machine: every consumer gets the
//! **domain-separated SHA-256** (`sha256("spt-machine-id:" + raw)`) —
//! systemd documents the machine id as confidential-ish, and the hash is
//! exactly as stable. A cloned-VM id collision is acceptable: the overwrite
//! only fires inside a completed pairing ceremony, so the worst case is
//! visible and recoverable by re-pairing (M8 decision 22).
// [impl->REQ-SUBNET-7]

use crate::release::sha256_hex;

/// The domain-separation prefix — never hash the raw id bare (a bare
/// sha256(machine-id) is a rainbow-table anchor shared with every other
/// product that does the same).
const DOMAIN: &str = "spt-machine-id:";

/// This machine's hashed stable identifier. `None` only when BOTH the OS id
/// and the minted fallback are unavailable (an unwritable identity dir) —
/// callers degrade to "no re-pair overwrite", never an error.
pub fn machine_id_hash() -> Option<String> {
    raw_os_machine_id()
        .or_else(minted_fallback)
        .map(|raw| hash_raw(&raw))
}

/// The domain-separated hash of a raw machine id (the only form that ever
/// rides the wire or a registry row).
pub fn hash_raw(raw: &str) -> String {
    sha256_hex(format!("{DOMAIN}{}", raw.trim()).as_bytes())
}

/// The OS machine id, raw. Unix: `/etc/machine-id`, then the dbus mirror.
#[cfg(unix)]
fn raw_os_machine_id() -> Option<String> {
    for path in ["/etc/machine-id", "/var/lib/dbus/machine-id"] {
        if let Ok(s) = std::fs::read_to_string(path) {
            let s = s.trim().to_string();
            if !s.is_empty() {
                return Some(s);
            }
        }
    }
    None
}

/// The OS machine id, raw. Windows: the Cryptography `MachineGuid` registry
/// value (stable across reboots, NIC switches, and most upgrades).
#[cfg(windows)]
fn raw_os_machine_id() -> Option<String> {
    use windows_sys::Win32::System::Registry::{
        RegGetValueW, HKEY_LOCAL_MACHINE, RRF_RT_REG_SZ,
    };
    let subkey: Vec<u16> = "SOFTWARE\\Microsoft\\Cryptography\0"
        .encode_utf16()
        .collect();
    let value: Vec<u16> = "MachineGuid\0".encode_utf16().collect();
    let mut buf = [0u16; 128];
    let mut len = (buf.len() * 2) as u32;
    // SAFETY: all pointers reference live, NUL-terminated buffers; len is the
    // byte capacity of `buf` and RegGetValueW writes at most that many bytes.
    let rc = unsafe {
        RegGetValueW(
            HKEY_LOCAL_MACHINE,
            subkey.as_ptr(),
            value.as_ptr(),
            RRF_RT_REG_SZ,
            std::ptr::null_mut(),
            buf.as_mut_ptr().cast(),
            &mut len,
        )
    };
    if rc != 0 {
        return None;
    }
    let chars = (len as usize / 2).saturating_sub(1); // drop the NUL
    let s = String::from_utf16_lossy(&buf[..chars.min(buf.len())])
        .trim()
        .to_string();
    (!s.is_empty()).then_some(s)
}

/// The minted fallback: a persisted random id under the identity dir.
/// Load-or-create; `None` when the dir is unwritable.
fn minted_fallback() -> Option<String> {
    let path = spt_store::perch::identity_dir().join("machine-id");
    if let Ok(s) = std::fs::read_to_string(&path) {
        let s = s.trim().to_string();
        if !s.is_empty() {
            return Some(s);
        }
    }
    // Mint 16 random bytes via the identity primitive's CSPRNG (the same
    // no-extra-rand-dep trick the subnet seed mint uses).
    let minted = {
        let id = spt_proto::identity::Identity::generate();
        // The pubkey is derived from CSPRNG seed material — 32 random-ish
        // hex-stable bytes; we only need uniqueness, not secrecy (the
        // value is hashed before any wire use anyway).
        id.public_key().to_hex()
    };
    if let Some(parent) = path.parent() {
        let _ = std::fs::create_dir_all(parent);
    }
    spt_store::atomic::atomic_write_string(&path, &minted).ok()?;
    Some(minted)
}

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

    // [unit->REQ-SUBNET-7] the hash is domain-separated and deterministic:
    // the same raw id always hashes the same; the raw id and a bare sha256
    // of it never appear in the output.
    #[test]
    fn hash_is_domain_separated_and_stable() {
        let a = hash_raw("abc-123");
        let b = hash_raw("abc-123");
        let c = hash_raw("abc-124");
        assert_eq!(a, b, "deterministic");
        assert_ne!(a, c, "distinct ids hash distinct");
        assert_eq!(a.len(), 64, "sha256 hex");
        assert_ne!(
            a,
            sha256_hex(b"abc-123"),
            "domain separation: never the bare hash"
        );
        // Whitespace-tolerant: /etc/machine-id ends with a newline.
        assert_eq!(hash_raw("abc-123\n"), a);
    }

    // [unit->REQ-SUBNET-7] the OS id resolves on real machines (both rig
    // OSes have one); the full chain yields SOME stable hash either way.
    #[test]
    fn machine_id_hash_resolves_and_is_stable() {
        let first = machine_id_hash();
        assert!(first.is_some(), "OS id or minted fallback must resolve");
        assert_eq!(machine_id_hash(), first, "stable across calls");
    }
}


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

//! Stable per-machine identifier, **hashed** (M8 decisions 13/22,
//! REQ-SUBNET-7) — the re-pair overwrite anchor.
//!
//! Source: the OS machine id — `/etc/machine-id` (systemd; dbus fallback)
//! on unix, `HKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid` on Windows —
//! the only candidate that survives the exact event the overwrite targets
//! (an spt state-dir wipe / key regeneration). Where the OS id is
//! unreadable, a minted UUID persisted under the identity dir is the
//! fallback (weaker: it dies with a state wipe, but better than nothing).
//!
//! The raw id never leaves the machine: every consumer gets the
//! **domain-separated SHA-256** (`sha256("spt-machine-id:" + raw)`) —
//! systemd documents the machine id as confidential-ish, and the hash is
//! exactly as stable. A cloned-VM id collision is acceptable: the overwrite
//! only fires inside a completed pairing ceremony, so the worst case is
//! visible and recoverable by re-pairing (M8 decision 22).
// [impl->REQ-SUBNET-7]

use crate::release::sha256_hex;

/// The domain-separation prefix — never hash the raw id bare (a bare
/// sha256(machine-id) is a rainbow-table anchor shared with every other
/// product that does the same).
const DOMAIN: &str = "spt-machine-id:";

/// This machine's hashed stable identifier. `None` only when BOTH the OS id
/// and the minted fallback are unavailable (an unwritable identity dir) —
/// callers degrade to "no re-pair overwrite", never an error.
pub fn machine_id_hash() -> Option<String> {
    raw_os_machine_id()
        .or_else(minted_fallback)
        .map(|raw| hash_raw(&raw))
}

/// The domain-separated hash of a raw machine id (the only form that ever
/// rides the wire or a registry row).
pub fn hash_raw(raw: &str) -> String {
    sha256_hex(format!("{DOMAIN}{}", raw.trim()).as_bytes())
}

/// The OS machine id, raw. Unix: `/etc/machine-id`, then the dbus mirror.
#[cfg(unix)]
fn raw_os_machine_id() -> Option<String> {
    for path in ["/etc/machine-id", "/var/lib/dbus/machine-id"] {
        if let Ok(s) = std::fs::read_to_string(path) {
            let s = s.trim().to_string();
            if !s.is_empty() {
                return Some(s);
            }
        }
    }
    None
}

/// The OS machine id, raw. Windows: the Cryptography `MachineGuid` registry
/// value (stable across reboots, NIC switches, and most upgrades).
#[cfg(windows)]
fn raw_os_machine_id() -> Option<String> {
    use windows_sys::Win32::System::Registry::{
        RegGetValueW, HKEY_LOCAL_MACHINE, RRF_RT_REG_SZ,
    };
    let subkey: Vec<u16> = "SOFTWARE\\Microsoft\\Cryptography\0"
        .encode_utf16()
        .collect();
    let value: Vec<u16> = "MachineGuid\0".encode_utf16().collect();
    let mut buf = [0u16; 128];
    let mut len = (buf.len() * 2) as u32;
    // SAFETY: all pointers reference live, NUL-terminated buffers; len is the
    // byte capacity of `buf` and RegGetValueW writes at most that many bytes.
    let rc = unsafe {
        RegGetValueW(
            HKEY_LOCAL_MACHINE,
            subkey.as_ptr(),
            value.as_ptr(),
            RRF_RT_REG_SZ,
            std::ptr::null_mut(),
            buf.as_mut_ptr().cast(),
            &mut len,
        )
    };
    if rc != 0 {
        return None;
    }
    let chars = (len as usize / 2).saturating_sub(1); // drop the NUL
    let s = String::from_utf16_lossy(&buf[..chars.min(buf.len())])
        .trim()
        .to_string();
    (!s.is_empty()).then_some(s)
}

/// The minted fallback: a persisted random id under the identity dir.
/// Load-or-create; `None` when the dir is unwritable.
fn minted_fallback() -> Option<String> {
    let path = spt_store::perch::identity_dir().join("machine-id");
    if let Ok(s) = std::fs::read_to_string(&path) {
        let s = s.trim().to_string();
        if !s.is_empty() {
            return Some(s);
        }
    }
    // Mint 16 random bytes via the identity primitive's CSPRNG (the same
    // no-extra-rand-dep trick the subnet seed mint uses).
    let minted = {
        let id = spt_proto::identity::Identity::generate();
        // The pubkey is derived from CSPRNG seed material — 32 random-ish
        // hex-stable bytes; we only need uniqueness, not secrecy (the
        // value is hashed before any wire use anyway).
        id.public_key().to_hex()
    };
    if let Some(parent) = path.parent() {
        let _ = std::fs::create_dir_all(parent);
    }
    spt_store::atomic::atomic_write_string(&path, &minted).ok()?;
    Some(minted)
}

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

    // [unit->REQ-SUBNET-7] the hash is domain-separated and deterministic:
    // the same raw id always hashes the same; the raw id and a bare sha256
    // of it never appear in the output.
    #[test]
    fn hash_is_domain_separated_and_stable() {
        let a = hash_raw("abc-123");
        let b = hash_raw("abc-123");
        let c = hash_raw("abc-124");
        assert_eq!(a, b, "deterministic");
        assert_ne!(a, c, "distinct ids hash distinct");
        assert_eq!(a.len(), 64, "sha256 hex");
        assert_ne!(
            a,
            sha256_hex(b"abc-123"),
            "domain separation: never the bare hash"
        );
        // Whitespace-tolerant: /etc/machine-id ends with a newline.
        assert_eq!(hash_raw("abc-123\n"), a);
    }

    // [unit->REQ-SUBNET-7] the OS id resolves on real machines (both rig
    // OSes have one); the full chain yields SOME stable hash either way.
    #[test]
    fn machine_id_hash_resolves_and_is_stable() {
        let first = machine_id_hash();
        assert!(first.is_some(), "OS id or minted fallback must resolve");
        assert_eq!(machine_id_hash(), first, "stable across calls");
    }
}


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.


Edit

    // [unit->REQ-SUBNET-7] the OS id resolves on real machines (both rig
    // OSes have one); the full chain yields SOME stable hash either way.
    #[test]
    fn machine_id_hash_resolves_and_is_stable() {
        let first = machine_id_hash();
        assert!(first.is_some(), "OS id or minted fallback must resolve");
        assert_eq!(machine_id_hash(), first, "stable across calls");
    }

    // [unit->REQ-SUBNET-7] the OS id resolves on real machines (both rig
    // OSes have one); the full chain yields SOME stable hash either way.
    // Homed so a minted-fallback write can never touch a real identity dir.
    #[test]
    fn machine_id_hash_resolves_and_is_stable() {
        crate::test_home::with_home(|_| {
            let first = machine_id_hash();
            assert!(first.is_some(), "OS id or minted fallback must resolve");
            assert_eq!(machine_id_hash(), first, "stable across calls");
        });
    }


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

    // [unit->REQ-SUBNET-7] the OS id resolves on real machines (both rig
    // OSes have one); the full chain yields SOME stable hash either way.
    // Homed so a minted-fallback write can never touch a real identity dir.
    #[test]
    fn machine_id_hash_resolves_and_is_stable() {
        crate::test_home::with_home(|_| {
            let first = machine_id_hash();
            assert!(first.is_some(), "OS id or minted fallback must resolve");
            assert_eq!(machine_id_hash(), first, "stable across calls");
        });
    }

//! Stable per-machine identifier, **hashed** (M8 decisions 13/22,
//! REQ-SUBNET-7) — the re-pair overwrite anchor.
//!
//! Source: the OS machine id — `/etc/machine-id` (systemd; dbus fallback)
//! on unix, `HKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid` on Windows —
//! the only candidate that survives the exact event the overwrite targets
//! (an spt state-dir wipe / key regeneration). Where the OS id is
//! unreadable, a minted UUID persisted under the identity dir is the
//! fallback (weaker: it dies with a state wipe, but better than nothing).
//!
//! The raw id never leaves the machine: every consumer gets the
//! **domain-separated SHA-256** (`sha256("spt-machine-id:" + raw)`) —
//! systemd documents the machine id as confidential-ish, and the hash is
//! exactly as stable. A cloned-VM id collision is acceptable: the overwrite
//! only fires inside a completed pairing ceremony, so the worst case is
//! visible and recoverable by re-pairing (M8 decision 22).
// [impl->REQ-SUBNET-7]

use crate::release::sha256_hex;

/// The domain-separation prefix — never hash the raw id bare (a bare
/// sha256(machine-id) is a rainbow-table anchor shared with every other
/// product that does the same).
const DOMAIN: &str = "spt-machine-id:";

/// This machine's hashed stable identifier. `None` only when BOTH the OS id
/// and the minted fallback are unavailable (an unwritable identity dir) —
/// callers degrade to "no re-pair overwrite", never an error.
pub fn machine_id_hash() -> Option<String> {
    raw_os_machine_id()
        .or_else(minted_fallback)
        .map(|raw| hash_raw(&raw))
}

/// The domain-separated hash of a raw machine id (the only form that ever
/// rides the wire or a registry row).
pub fn hash_raw(raw: &str) -> String {
    sha256_hex(format!("{DOMAIN}{}", raw.trim()).as_bytes())
}

/// The OS machine id, raw. Unix: `/etc/machine-id`, then the dbus mirror.
#[cfg(unix)]
fn raw_os_machine_id() -> Option<String> {
    for path in ["/etc/machine-id", "/var/lib/dbus/machine-id"] {
        if let Ok(s) = std::fs::read_to_string(path) {
            let s = s.trim().to_string();
            if !s.is_empty() {
                return Some(s);
            }
        }
    }
    None
}

/// The OS machine id, raw. Windows: the Cryptography `MachineGuid` registry
/// value (stable across reboots, NIC switches, and most upgrades).
#[cfg(windows)]
fn raw_os_machine_id() -> Option<String> {
    use windows_sys::Win32::System::Registry::{
        RegGetValueW, HKEY_LOCAL_MACHINE, RRF_RT_REG_SZ,
    };
    let subkey: Vec<u16> = "SOFTWARE\\Microsoft\\Cryptography\0"
        .encode_utf16()
        .collect();
    let value: Vec<u16> = "MachineGuid\0".encode_utf16().collect();
    let mut buf = [0u16; 128];
    let mut len = (buf.len() * 2) as u32;
    // SAFETY: all pointers reference live, NUL-terminated buffers; len is the
    // byte capacity of `buf` and RegGetValueW writes at most that many bytes.
    let rc = unsafe {
        RegGetValueW(
            HKEY_LOCAL_MACHINE,
            subkey.as_ptr(),
            value.as_ptr(),
            RRF_RT_REG_SZ,
            std::ptr::null_mut(),
            buf.as_mut_ptr().cast(),
            &mut len,
        )
    };
    if rc != 0 {
        return None;
    }
    let chars = (len as usize / 2).saturating_sub(1); // drop the NUL
    let s = String::from_utf16_lossy(&buf[..chars.min(buf.len())])
        .trim()
        .to_string();
    (!s.is_empty()).then_some(s)
}

/// The minted fallback: a persisted random id under the identity dir.
/// Load-or-create; `None` when the dir is unwritable.
fn minted_fallback() -> Option<String> {
    let path = spt_store::perch::identity_dir().join("machine-id");
    if let Ok(s) = std::fs::read_to_string(&path) {
        let s = s.trim().to_string();
        if !s.is_empty() {
            return Some(s);
        }
    }
    // Mint 16 random bytes via the identity primitive's CSPRNG (the same
    // no-extra-rand-dep trick the subnet seed mint uses).
    let minted = {
        let id = spt_proto::identity::Identity::generate();
        // The pubkey is derived from CSPRNG seed material — 32 random-ish
        // hex-stable bytes; we only need uniqueness, not secrecy (the
        // value is hashed before any wire use anyway).
        id.public_key().to_hex()
    };
    if let Some(parent) = path.parent() {
        let _ = std::fs::create_dir_all(parent);
    }
    spt_store::atomic::atomic_write_string(&path, &minted).ok()?;
    Some(minted)
}

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

    // [unit->REQ-SUBNET-7] the hash is domain-separated and deterministic:
    // the same raw id always hashes the same; the raw id and a bare sha256
    // of it never appear in the output.
    #[test]
    fn hash_is_domain_separated_and_stable() {
        let a = hash_raw("abc-123");
        let b = hash_raw("abc-123");
        let c = hash_raw("abc-124");
        assert_eq!(a, b, "deterministic");
        assert_ne!(a, c, "distinct ids hash distinct");
        assert_eq!(a.len(), 64, "sha256 hex");
        assert_ne!(
            a,
            sha256_hex(b"abc-123"),
            "domain separation: never the bare hash"
        );
        // Whitespace-tolerant: /etc/machine-id ends with a newline.
        assert_eq!(hash_raw("abc-123\n"), a);
    }

    // [unit->REQ-SUBNET-7] the OS id resolves on real machines (both rig
    // OSes have one); the full chain yields SOME stable hash either way.
    #[test]
    fn machine_id_hash_resolves_and_is_stable() {
        let first = machine_id_hash();
        assert!(first.is_some(), "OS id or minted fallback must resolve");
        assert_eq!(machine_id_hash(), first, "stable across calls");
    }
}

+    // Homed so a minted-fallback write can never touch a real identity dir.

-        assert!(first.is_some(), "OS id or minted fallback must resolve");

-        assert_eq!(machine_id_hash(), first, "stable across calls");

+        crate::test_home::with_home(|_| {

+            let first = machine_id_hash();

+            assert!(first.is_some(), "OS id or minted fallback must resolve");

+            assert_eq!(machine_id_hash(), first, "stable across calls");


106:pub mod access;
107:pub mod adapter_update;
108:pub mod applyhost;
109:pub mod attach;
110:pub mod brain;
111:pub mod broker;
112:pub mod codec;
113:pub mod config;
114:pub mod consent;
115:pub mod daemon;
116:pub mod deelevate;
117:pub mod digest;
118:pub mod digesthub;
119:pub mod dispatch;
120:pub mod effect;
121:pub mod endpoint;
122:pub mod frame;
123:pub mod grants;
124:pub mod lifecycle;
125:pub mod linkhost;
126:pub mod msg;
127:pub mod nethost;
128:pub mod notif;
129:pub mod notifsync;
130:pub mod pairhost;
131:pub mod peerloop;
132:pub mod presence;
133:pub mod propagate;
134:pub mod psyrelay;
135:pub mod reconcile;
136:pub mod registryhost;
137:pub mod relay;
138:pub mod relcache;
139:pub mod release;
140:pub mod resthost;
141:pub mod resting;
142:pub mod seedmap;
143:pub mod shellchan;
144:pub mod shelldisc;
145:pub mod shellhost;
146:pub mod shellwake;
147:pub mod sync;
148:pub mod transport;
149:pub mod update;
150:pub mod wan;
151:pub mod xfer;
154:mod test_home;


pub mod linkhost;
pub mod machineid;
pub mod msg;


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

pub mod linkhost;
pub mod machineid;
pub mod msg;

pub mod grants;
pub mod lifecycle;
pub mod linkhost;
+pub mod machineid;
pub mod msg;
pub mod nethost;
pub mod notif;


No matches found


PowerShell

Read spt-daemon Cargo.toml


[package]
name = "spt-daemon"
version.workspace = true
edition = "2021"
publish = false
description = "spt-core broker/brain daemon (M3b): the per-machine supervisor process. A stable broker kernel holds the un-transferable resources (PTY masters from spt-term, harness children, sockets) while a restartable brain holds all logic; a versioned local IPC lets a newer brain talk to an older broker so a self-update swaps the brain with zero endpoint interruption (ADR-0004). Internal supervisor (NOT public SDK, R-ARCH-2) — the `spt` binary is a thin CLI over it. B0 lands the crate + the versioned local-IPC transport."

[dependencies]
# Layer-below set (R-ARCH-1 acyclic): …→spt-live→spt-daemon→spt. The broker
# re-hosts spt-term PTYs + the spt-live lifecycle seams; these edges fix the
# crate's position in the workspace layering. Tasks B1–B8 consume them.
spt-proto = { path = "../spt-proto" }
spt-store = { path = "../spt-store" }
spt-msg = { path = "../spt-msg" }
spt-runtime = { path = "../spt-runtime" }
spt-term = { path = "../spt-term" }
spt-live = { path = "../spt-live" }
# D4 (ADR-0004 §B): the broker owns the live iroh endpoint + QUIC connections so
# they survive a brain restart. spt-net provides the *mechanism* (NetEndpoint);
# the broker hosts the *process* (nethost::NetHost). Default features keep the
# `net` transport on — the daemon is the reference consumer.
spt-net = { path = "../spt-net" }
# The broker is otherwise sync/thread-based; iroh requires tokio, so the broker
# hosts a dedicated runtime on named worker threads (QUIC objects never leave it).
tokio = { version = "1", features = ["rt-multi-thread"] }
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1", features = ["preserve_order"] }
# PTY output is arbitrary bytes; the forward-compat JSON envelope carries it as
# a base64 string in the payload rather than forking the codec for binary.
base64 = "0.22"
# Cross-platform local IPC: named pipe (Windows) / Unix-domain socket (unix)
# behind one API — the OS-split substrate the DaemonTransport trait wraps,
# mirroring how spt-term abstracts ConPTY vs forkpty behind SessionSurface.
interprocess = "2"
# Self-update release signing (C1, REQ-UPD-2): pure-Rust Ed25519 — no C/asm
# build deps, clean cross-compile on win+linux (clean-room posture, ADR-0004 §D).
# `rand_core` lets a release-tooling caller mint a keypair; tests use the
# deterministic `from_bytes` ctor (no RNG).
ed25519-dalek = "2"
# SHA-256 binds the signed release metadata to the actual binary bytes
# (artifact digest) so the signature covers the binary, not just the manifest.
sha2 = "0.10"
# Per-frame link auth on the same-node shell channels (CONTEXT local-link
# authentication): HMAC-SHA256 over the token-derived link key (M5-D3b).
hmac = "0.12"

# De-elevated daemon spawn (KNOWN-HAZARDS 5.7, REQ-HAZARD-ELEVATED-DAEMON-SPAWN):
# the sudo-invoker drop (geteuid/setuid/setgroups/getpwuid_r). Matches the
# workspace's existing libc usage (spt, spt-msg, spt-store); the Windows half
# uses raw advapi32 externs (the daemon.rs detached_no_inherit posture).
[target.'cfg(unix)'.dependencies]
libc = "0.2"

[dev-dependencies]
tempfile = "3"
# Integration tests are separate crates and do not inherit the lib's deps, so the
# B8 digest int-test re-declares the two it touches directly: the manifest seam
# (PtyDigest) and the perch-path/history resolution it asserts persistence against.
spt-runtime = { path = "../spt-runtime" }
spt-store = { path = "../spt-store" }
# The D4a net-broker int-test constructs identities + relay policies directly.
spt-proto = { path = "../spt-proto" }
spt-net = { path = "../spt-net" }
# The D5a wanmsg int-test asserts spool formatting through the spt-msg surface.
spt-msg = { path = "../spt-msg" }


No windows-sys dep — deelevate uses raw externs. Match that posture:


Edit

/// The OS machine id, raw. Windows: the Cryptography `MachineGuid` registry
/// value (stable across reboots, NIC switches, and most upgrades).
#[cfg(windows)]
fn raw_os_machine_id() -> Option<String> {
    use windows_sys::Win32::System::Registry::{
        RegGetValueW, HKEY_LOCAL_MACHINE, RRF_RT_REG_SZ,
    };
    let subkey: Vec<u16> = "SOFTWARE\\Microsoft\\Cryptography\0"
        .encode_utf16()
        .collect();
    let value: Vec<u16> = "MachineGuid\0".encode_utf16().collect();
    let mut buf = [0u16; 128];
    let mut len = (buf.len() * 2) as u32;
    // SAFETY: all pointers reference live, NUL-terminated buffers; len is the
    // byte capacity of `buf` and RegGetValueW writes at most that many bytes.
    let rc = unsafe {
        RegGetValueW(
            HKEY_LOCAL_MACHINE,
            subkey.as_ptr(),
            value.as_ptr(),
            RRF_RT_REG_SZ,
            std::ptr::null_mut(),
            buf.as_mut_ptr().cast(),
            &mut len,
        )
    };
    if rc != 0 {
        return None;
    }
    let chars = (len as usize / 2).saturating_sub(1); // drop the NUL
    let s = String::from_utf16_lossy(&buf[..chars.min(buf.len())])
        .trim()
        .to_string();
    (!s.is_empty()).then_some(s)
}

/// The OS machine id, raw. Windows: the Cryptography `MachineGuid` registry
/// value (stable across reboots, NIC switches, and most upgrades). Raw
/// advapi32 extern — the `deelevate` posture, no windows-sys dependency.
#[cfg(windows)]
fn raw_os_machine_id() -> Option<String> {
    type Hkey = isize;
    /// `HKEY_LOCAL_MACHINE` (sign-extended handle constant).
    const HKLM: Hkey = 0x8000_0002u32 as i32 as isize;
    /// `RRF_RT_REG_SZ` — restrict the read to a string value.
    const RRF_RT_REG_SZ: u32 = 0x0000_0002;
    #[link(name = "advapi32")]
    extern "system" {
        fn RegGetValueW(
            hkey: Hkey,
            lp_sub_key: *const u16,
            lp_value: *const u16,
            dw_flags: u32,
            pdw_type: *mut u32,
            pv_data: *mut core::ffi::c_void,
            pcb_data: *mut u32,
        ) -> i32;
    }
    let subkey: Vec<u16> = "SOFTWARE\\Microsoft\\Cryptography\0"
        .encode_utf16()
        .collect();
    let value: Vec<u16> = "MachineGuid\0".encode_utf16().collect();
    let mut buf = [0u16; 128];
    let mut len = (buf.len() * 2) as u32;
    // SAFETY: all pointers reference live, NUL-terminated buffers; `len` is
    // the byte capacity of `buf` and RegGetValueW writes at most that many.
    let rc = unsafe {
        RegGetValueW(
            HKLM,
            subkey.as_ptr(),
            value.as_ptr(),
            RRF_RT_REG_SZ,
            std::ptr::null_mut(),
            buf.as_mut_ptr().cast(),
            &mut len,
        )
    };
    if rc != 0 {
        return None;
    }
    let chars = (len as usize / 2).saturating_sub(1); // drop the NUL
    let s = String::from_utf16_lossy(&buf[..chars.min(buf.len())])
        .trim()
        .to_string();
    (!s.is_empty()).then_some(s)
}


/// The OS machine id, raw. Windows: the Cryptography `MachineGuid` registry
/// value (stable across reboots, NIC switches, and most upgrades). Raw
/// advapi32 extern — the `deelevate` posture, no windows-sys dependency.
#[cfg(windows)]
fn raw_os_machine_id() -> Option<String> {
    type Hkey = isize;
    /// `HKEY_LOCAL_MACHINE` (sign-extended handle constant).
    const HKLM: Hkey = 0x8000_0002u32 as i32 as isize;
    /// `RRF_RT_REG_SZ` — restrict the read to a string value.
    const RRF_RT_REG_SZ: u32 = 0x0000_0002;
    #[link(name = "advapi32")]
    extern "system" {
        fn RegGetValueW(
            hkey: Hkey,
            lp_sub_key: *const u16,
            lp_value: *const u16,
            dw_flags: u32,
            pdw_type: *mut u32,
            pv_data: *mut core::ffi::c_void,
            pcb_data: *mut u32,
        ) -> i32;
    }
    let subkey: Vec<u16> = "SOFTWARE\\Microsoft\\Cryptography\0"
        .encode_utf16()
        .collect();
    let value: Vec<u16> = "MachineGuid\0".encode_utf16().collect();
    let mut buf = [0u16; 128];
    let mut len = (buf.len() * 2) as u32;
    // SAFETY: all pointers reference live, NUL-terminated buffers; `len` is
    // the byte capacity of `buf` and RegGetValueW writes at most that many.
    let rc = unsafe {
        RegGetValueW(
            HKLM,
            subkey.as_ptr(),
            value.as_ptr(),
            RRF_RT_REG_SZ,
            std::ptr::null_mut(),
            buf.as_mut_ptr().cast(),
            &mut len,
        )
    };
    if rc != 0 {
        return None;
    }
    let chars = (len as usize / 2).saturating_sub(1); // drop the NUL
    let s = String::from_utf16_lossy(&buf[..chars.min(buf.len())])
        .trim()
        .to_string();
    (!s.is_empty()).then_some(s)
}

//! Stable per-machine identifier, **hashed** (M8 decisions 13/22,
//! REQ-SUBNET-7) — the re-pair overwrite anchor.
//!
//! Source: the OS machine id — `/etc/machine-id` (systemd; dbus fallback)
//! on unix, `HKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid` on Windows —
//! the only candidate that survives the exact event the overwrite targets
//! (an spt state-dir wipe / key regeneration). Where the OS id is
//! unreadable, a minted UUID persisted under the identity dir is the
//! fallback (weaker: it dies with a state wipe, but better than nothing).
//!
//! The raw id never leaves the machine: every consumer gets the
//! **domain-separated SHA-256** (`sha256("spt-machine-id:" + raw)`) —
//! systemd documents the machine id as confidential-ish, and the hash is
//! exactly as stable. A cloned-VM id collision is acceptable: the overwrite
//! only fires inside a completed pairing ceremony, so the worst case is
//! visible and recoverable by re-pairing (M8 decision 22).
// [impl->REQ-SUBNET-7]

use crate::release::sha256_hex;

/// The domain-separation prefix — never hash the raw id bare (a bare
/// sha256(machine-id) is a rainbow-table anchor shared with every other
/// product that does the same).
const DOMAIN: &str = "spt-machine-id:";

/// This machine's hashed stable identifier. `None` only when BOTH the OS id
/// and the minted fallback are unavailable (an unwritable identity dir) —
/// callers degrade to "no re-pair overwrite", never an error.
pub fn machine_id_hash() -> Option<String> {
    raw_os_machine_id()
        .or_else(minted_fallback)
        .map(|raw| hash_raw(&raw))
}

/// The domain-separated hash of a raw machine id (the only form that ever
/// rides the wire or a registry row).
pub fn hash_raw(raw: &str) -> String {
    sha256_hex(format!("{DOMAIN}{}", raw.trim()).as_bytes())
}

/// The OS machine id, raw. Unix: `/etc/machine-id`, then the dbus mirror.
#[cfg(unix)]
fn raw_os_machine_id() -> Option<String> {
    for path in ["/etc/machine-id", "/var/lib/dbus/machine-id"] {
        if let Ok(s) = std::fs::read_to_string(path) {
            let s = s.trim().to_string();
            if !s.is_empty() {
                return Some(s);
            }
        }
    }
    None
}

/// The OS machine id, raw. Windows: the Cryptography `MachineGuid` registry
/// value (stable across reboots, NIC switches, and most upgrades).
#[cfg(windows)]
fn raw_os_machine_id() -> Option<String> {
    use windows_sys::Win32::System::Registry::{
        RegGetValueW, HKEY_LOCAL_MACHINE, RRF_RT_REG_SZ,
    };
    let subkey: Vec<u16> = "SOFTWARE\\Microsoft\\Cryptography\0"
        .encode_utf16()
        .collect();
    let value: Vec<u16> = "MachineGuid\0".encode_utf16().collect();
    let mut buf = [0u16; 128];
    let mut len = (buf.len() * 2) as u32;
    // SAFETY: all pointers reference live, NUL-terminated buffers; len is the
    // byte capacity of `buf` and RegGetValueW writes at most that many bytes.
    let rc = unsafe {
        RegGetValueW(
            HKEY_LOCAL_MACHINE,
            subkey.as_ptr(),
            value.as_ptr(),
            RRF_RT_REG_SZ,
            std::ptr::null_mut(),
            buf.as_mut_ptr().cast(),
            &mut len,
        )
    };
    if rc != 0 {
        return None;
    }
    let chars = (len as usize / 2).saturating_sub(1); // drop the NUL
    let s = String::from_utf16_lossy(&buf[..chars.min(buf.len())])
        .trim()
        .to_string();
    (!s.is_empty()).then_some(s)
}

/// The minted fallback: a persisted random id under the identity dir.
/// Load-or-create; `None` when the dir is unwritable.
fn minted_fallback() -> Option<String> {
    let path = spt_store::perch::identity_dir().join("machine-id");
    if let Ok(s) = std::fs::read_to_string(&path) {
        let s = s.trim().to_string();
        if !s.is_empty() {
            return Some(s);
        }
    }
    // Mint 16 random bytes via the identity primitive's CSPRNG (the same
    // no-extra-rand-dep trick the subnet seed mint uses).
    let minted = {
        let id = spt_proto::identity::Identity::generate();
        // The pubkey is derived from CSPRNG seed material — 32 random-ish
        // hex-stable bytes; we only need uniqueness, not secrecy (the
        // value is hashed before any wire use anyway).
        id.public_key().to_hex()
    };
    if let Some(parent) = path.parent() {
        let _ = std::fs::create_dir_all(parent);
    }
    spt_store::atomic::atomic_write_string(&path, &minted).ok()?;
    Some(minted)
}

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

    // [unit->REQ-SUBNET-7] the hash is domain-separated and deterministic:
    // the same raw id always hashes the same; the raw id and a bare sha256
    // of it never appear in the output.
    #[test]
    fn hash_is_domain_separated_and_stable() {
        let a = hash_raw("abc-123");
        let b = hash_raw("abc-123");
        let c = hash_raw("abc-124");
        assert_eq!(a, b, "deterministic");
        assert_ne!(a, c, "distinct ids hash distinct");
        assert_eq!(a.len(), 64, "sha256 hex");
        assert_ne!(
            a,
            sha256_hex(b"abc-123"),
            "domain separation: never the bare hash"
        );
        // Whitespace-tolerant: /etc/machine-id ends with a newline.
        assert_eq!(hash_raw("abc-123\n"), a);
    }

    // [unit->REQ-SUBNET-7] the OS id resolves on real machines (both rig
    // OSes have one); the full chain yields SOME stable hash either way.
    // Homed so a minted-fallback write can never touch a real identity dir.
    #[test]
    fn machine_id_hash_resolves_and_is_stable() {
        crate::test_home::with_home(|_| {
            let first = machine_id_hash();
            assert!(first.is_some(), "OS id or minted fallback must resolve");
            assert_eq!(machine_id_hash(), first, "stable across calls");
        });
    }
}

-/// value (stable across reboots, NIC switches, and most upgrades).

+/// value (stable across reboots, NIC switches, and most upgrades). Raw

+/// advapi32 extern — the `deelevate` posture, no windows-sys dependency.

-    use windows_sys::Win32::System::Registry::{

-        RegGetValueW, HKEY_LOCAL_MACHINE, RRF_RT_REG_SZ,

+    /// `HKEY_LOCAL_MACHINE` (sign-extended handle constant).

+    const HKLM: Hkey = 0x8000_0002u32 as i32 as isize;

+    /// `RRF_RT_REG_SZ` — restrict the read to a string value.

+            pv_data: *mut core::ffi::c_void,

-    // SAFETY: all pointers reference live, NUL-terminated buffers; len is the

-    // byte capacity of `buf` and RegGetValueW writes at most that many bytes.

+    // SAFETY: all pointers reference live, NUL-terminated buffers; `len` is

+    // the byte capacity of `buf` and RegGetValueW writes at most that many.


PowerShell

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

Build after machine-id module


(PowerShell completed with no output)


cargo test -q -p spt-daemon --lib machineid 2>&1 | Select-Object -Last 5

Run machineid tests


running 2 tests
..
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 195 filtered out; finished in 0.00s


Now the wire change. Check wire tests' call shapes first.


crates\spt-daemon\tests\twohost.rs:567:    // no manual run_responder loop; the rung passing proves a stock member
crates\spt-daemon\tests\twohost.rs:781:    // run_initiator as the pairing evidence. A holds NO subnet record yet:
crates\spt-net\src\net\pairing\meet.rs:249:/// for [`run_initiator`](super::wire::run_initiator) — the ceremony itself
crates\spt-net\src\net\pairing\meet.rs:399:        // what run_initiator's transcript + trust pinning consume.
crates\spt-net\src\net\pairing\wire.rs:132:pub async fn run_responder(
crates\spt-net\src\net\pairing\wire.rs:195:/// NOT touch the rate limiter: [`run_responder`] owns the slot and releases it once based
crates\spt-net\src\net\pairing\wire.rs:199:/// The admitted-ceremony parameters [`run_responder`] hands to [`responder_admitted`]
crates\spt-net\src\net\pairing\wire.rs:317:pub async fn run_initiator(
crates\spt-net\src\net\pairing\wire.rs:766:            let outcome = run_responder(&conn, resp_pub, &subnets, &mut rate, &mut trust, NOW)
crates\spt-net\src\net\pairing\wire.rs:779:        let init_outcome = run_initiator(
crates\spt-net\src\net\pairing\wire.rs:841:            let result = run_responder(&conn, resp_pub, &subnets, &mut rate, &mut trust, NOW).await;
crates\spt-net\src\net\pairing\wire.rs:854:        let init_result = run_initiator(
crates\spt-net\src\net\pairing\wire.rs:911:            let result = run_responder(&conn, resp_pub, &subnets, &mut rate, &mut trust, NOW).await;
crates\spt-net\src\net\pairing\wire.rs:922:        let init_result = run_initiator(
crates\spt-net\src\net\pairing\wire.rs:972:            let result = run_responder(&conn, resp_pub, &subnets, &mut rate, &mut trust, NOW).await;
crates\spt-net\src\net\pairing\wire.rs:983:        let init_result = run_initiator(
crates\spt-net\src\net\pairing\wire.rs:1028:            let result = run_responder(&conn, resp_pub, &subnets, &mut rate, &mut trust, NOW).await;
crates\spt-net\src\net\pairing\wire.rs:1040:        let init_result = run_initiator(
crates\spt-net\src\net\pairing\wire.rs:1092:            run_responder(&conn, resp_pub, &subnets, &mut rate, &mut trust, NOW)
crates\spt-net\src\net\pairing\wire.rs:1104:        run_initiator(
crates\spt-net\src\net\pairing\wire.rs:1148:            let outcome = run_responder(&conn, resp_pub, &subnets, &mut rate, &mut trust, NOW)
crates\spt-net\src\net\pairing\wire.rs:1166:        let init_outcome = run_initiator(
crates\spt-daemon\src\pairhost.rs:35:use spt_net::net::pairing::wire::{run_initiator, run_responder, PairWireError};
crates\spt-daemon\src\pairhost.rs:80:        run_responder(
crates\spt-daemon\src\pairhost.rs:265:        run_initiator(
crates\spt-daemon\src\pairhost.rs:300:                run_initiator(


D3 — install & elevation hardening

D5 — docs + CI + acceptance prep


700	                created: "0".into(),
701	                hide_new_endpoints: false,
702	            }],
703	        }
704	    }
705
706	    fn hex(bytes: &[u8]) -> String {
707	        bytes.iter().map(|b| format!("{b:02x}")).collect()
708	    }
709
710	    /// The current code for a seed at `NOW` — what the user "reads off their phone".
711	    fn code_for(seed: [u8; 20]) -> String {
712	        code_at_step(&TotpSeed::from_bytes(seed), time_step(NOW))
713	    }
714
715	    /// Bind a responder + an initiator endpoint on the pairing ALPN over loopback
716	    /// (loopback-scoped sockets: hermetic, and no Windows firewall prompt per
717	    /// freshly-built test binary).
718	    async fn pair_endpoints() -> (NetEndpoint, NetEndpoint, Identity, Identity) {
719	        let resp_id = Identity::generate();
720	        let init_id = Identity::generate();
721	        let responder = NetEndpoint::bind_scoped(
722	            &resp_id,
723	            RelayPolicy::Disabled,
724	            LocalDiscovery::Off,
725	            vec![SPT_PAIR_ALPN.to_vec()],
726	            BindScope::Loopback,
727	        )
728	        .await
729	        .expect("responder bind");
730	        let initiator = NetEndpoint::bind_scoped(
731	            &init_id,
732	            RelayPolicy::Disabled,
733	            LocalDiscovery::Off,
734	            vec![SPT_PAIR_ALPN.to_vec()],
735	            BindScope::Loopback,
736	        )
737	        .await
738	        .expect("initiator bind");
739	        (responder, initiator, resp_id, init_id)
740	    }
741
742	    // [unit->REQ-PAIR-1] HAPPY PATH over the wire: two endpoints, one TOTP code, complete
743	    // the ceremony on the pairing ALPN and each pins the other's pubkey in its trust
744	    // store — "two machines pair via one TOTP code" at the component level (loopback,
745	    // relays disabled). The two-host WAN form is the int stage at D9.
746	    #[tokio::test]
747	    async fn loopback_pairs_and_pins_both_sides() {
748	        let (responder, initiator, resp_id, init_id) = pair_endpoints().await;
749	        let resp_pub = resp_id.public_key();
750	        let init_pub = init_id.public_key();
751	        let seed = [7u8; 20];
752	        let code = code_for(seed);
753	        let resp_addr = responder.addr();
754
755	        // Responder task: accept, run the ceremony, return its trust store + outcome.
756	        let resp_task = tokio::spawn(async move {
757	            let incoming = responder.accept().await.expect("incoming");
758	            let conn = incoming
759	                .accept()
760	                .expect("accept")
761	                .await
762	                .expect("connecting");
763	            let subnets = subnet_store("home", seed, 3);
764	            let mut rate = PairingRateLimiter::new();
765	            let mut trust = TrustStore::default();
766	            let outcome = run_responder(&conn, resp_pub, &subnets, &mut rate, &mut trust, NOW)
767	                .await
768	                .expect("responder pairs");
769	            conn.closed().await;
770	            (trust, outcome)
771	        });
772
773	        let conn = initiator
774	            .connect_with_alpn(resp_addr, SPT_PAIR_ALPN)
775	            .await
776	            .expect("connect");
777	        let mut init_trust = TrustStore::default();
778	        let mut init_subnets = SubnetStore::default();
779	        let init_outcome = run_initiator(
780	            &conn,
781	            init_pub,
782	            "home",
783	            &code,
784	            &mut init_trust,
785	            &mut init_subnets,
786	            NOW,
787	            0, // step_back: the normal attempt
788	        )
789	        .await
790	        .expect("initiator pairs");
791	        conn.close(0u32.into(), b"done");
792
793	        let (resp_trust, resp_outcome) = resp_task.await.expect("responder task");
794
795	        assert_eq!(
796	            init_outcome,
797	            TrustOutcome::Pinned,
798	            "initiator pins responder"
799	        );
800	        assert_eq!(
801	            resp_outcome,
802	            TrustOutcome::Pinned,
803	            "responder pins initiator"
804	        );
805	        // Each side trusts the OTHER's real pubkey within the subnet.
806	        assert!(init_trust.is_trusted("home", &resp_pub.to_hex()));
807	        assert!(resp_trust.is_trusted("home", &init_pub.to_hex()));
808	        assert!(
809	            !init_trust.is_trusted("home", &init_pub.to_hex()),
810	            "self not pinned"
811	        );
812	        // [unit->REQ-PAIR-5] the joiner received the subnet seed (it is now a full member /
813	        // future seed-holder): same name, seed, and the responder's epoch (3).
814	        let joined = init_subnets.find("home").expect("joiner gained the subnet");
815	        assert_eq!(joined.seed_bytes(), Some(seed), "seed transferred intact");
816	        assert_eq!(joined.epoch, 3, "epoch transferred");
817	    }
818
819	    // [unit->REQ-PAIR-1] WRONG CODE: the joiner types a code that doesn't match the
820	    // seed-derived one → key confirmation fails on the wire. Neither side writes trust,
821	    // and the responder charges the failure (so an immediate retry is backed off) — the
822	    // negative path that proves the code actually authenticates the channel.
823	    #[tokio::test]
824	    async fn wrong_code_fails_and_writes_no_trust() {
825	        let (responder, initiator, resp_id, init_id) = pair_endpoints().await;
826	        let resp_pub = resp_id.public_key();
827	        let init_pub = init_id.public_key();
828	        let seed = [9u8; 20];
829	        let resp_addr = responder.addr();

                created: "0".into(),
                hide_new_endpoints: false,
            }],
        }
    }

    fn hex(bytes: &[u8]) -> String {
        bytes.iter().map(|b| format!("{b:02x}")).collect()
    }

    /// The current code for a seed at `NOW` — what the user "reads off their phone".
    fn code_for(seed: [u8; 20]) -> String {
        code_at_step(&TotpSeed::from_bytes(seed), time_step(NOW))
    }

    /// Bind a responder + an initiator endpoint on the pairing ALPN over loopback
    /// (loopback-scoped sockets: hermetic, and no Windows firewall prompt per
    /// freshly-built test binary).
    async fn pair_endpoints() -> (NetEndpoint, NetEndpoint, Identity, Identity) {
        let resp_id = Identity::generate();
        let init_id = Identity::generate();
        let responder = NetEndpoint::bind_scoped(
            &resp_id,
            RelayPolicy::Disabled,
            LocalDiscovery::Off,
            vec![SPT_PAIR_ALPN.to_vec()],
            BindScope::Loopback,
        )
        .await
        .expect("responder bind");
        let initiator = NetEndpoint::bind_scoped(
            &init_id,
            RelayPolicy::Disabled,
            LocalDiscovery::Off,
            vec![SPT_PAIR_ALPN.to_vec()],
            BindScope::Loopback,
        )
        .await
        .expect("initiator bind");
        (responder, initiator, resp_id, init_id)
    }

    // [unit->REQ-PAIR-1] HAPPY PATH over the wire: two endpoints, one TOTP code, complete
    // the ceremony on the pairing ALPN and each pins the other's pubkey in its trust
    // store — "two machines pair via one TOTP code" at the component level (loopback,
    // relays disabled). The two-host WAN form is the int stage at D9.
    #[tokio::test]
    async fn loopback_pairs_and_pins_both_sides() {
        let (responder, initiator, resp_id, init_id) = pair_endpoints().await;
        let resp_pub = resp_id.public_key();
        let init_pub = init_id.public_key();
        let seed = [7u8; 20];
        let code = code_for(seed);
        let resp_addr = responder.addr();

        // Responder task: accept, run the ceremony, return its trust store + outcome.
        let resp_task = tokio::spawn(async move {
            let incoming = responder.accept().await.expect("incoming");
            let conn = incoming
                .accept()
                .expect("accept")
                .await
                .expect("connecting");
            let subnets = subnet_store("home", seed, 3);
            let mut rate = PairingRateLimiter::new();
            let mut trust = TrustStore::default();
            let outcome = run_responder(&conn, resp_pub, &subnets, &mut rate, &mut trust, NOW)
                .await
                .expect("responder pairs");
            conn.closed().await;
            (trust, outcome)
        });

        let conn = initiator
            .connect_with_alpn(resp_addr, SPT_PAIR_ALPN)
            .await
            .expect("connect");
        let mut init_trust = TrustStore::default();
        let mut init_subnets = SubnetStore::default();
        let init_outcome = run_initiator(
            &conn,
            init_pub,
            "home",
            &code,
            &mut init_trust,
            &mut init_subnets,
            NOW,
            0, // step_back: the normal attempt
        )
        .await
        .expect("initiator pairs");
        conn.close(0u32.into(), b"done");

        let (resp_trust, resp_outcome) = resp_task.await.expect("responder task");

        assert_eq!(
            init_outcome,
            TrustOutcome::Pinned,
            "initiator pins responder"
        );
        assert_eq!(
            resp_outcome,
            TrustOutcome::Pinned,
            "responder pins initiator"
        );
        // Each side trusts the OTHER's real pubkey within the subnet.
        assert!(init_trust.is_trusted("home", &resp_pub.to_hex()));
        assert!(resp_trust.is_trusted("home", &init_pub.to_hex()));
        assert!(
            !init_trust.is_trusted("home", &init_pub.to_hex()),
            "self not pinned"
        );
        // [unit->REQ-PAIR-5] the joiner received the subnet seed (it is now a full member /
        // future seed-holder): same name, seed, and the responder's epoch (3).
        let joined = init_subnets.find("home").expect("joiner gained the subnet");
        assert_eq!(joined.seed_bytes(), Some(seed), "seed transferred intact");
        assert_eq!(joined.epoch, 3, "epoch transferred");
    }

    // [unit->REQ-PAIR-1] WRONG CODE: the joiner types a code that doesn't match the
    // seed-derived one → key confirmation fails on the wire. Neither side writes trust,
    // and the responder charges the failure (so an immediate retry is backed off) — the
    // negative path that proves the code actually authenticates the channel.
    #[tokio::test]
    async fn wrong_code_fails_and_writes_no_trust() {
        let (responder, initiator, resp_id, init_id) = pair_endpoints().await;
        let resp_pub = resp_id.public_key();
        let init_pub = init_id.public_key();
        let seed = [9u8; 20];
        let resp_addr = responder.addr();


Wire edits:


Edit

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

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

/// The joiner's self-introduction riding `Hello` (M8 decision 13,
/// REQ-SUBNET-7): its node label + **hashed** machine id — the re-pair
/// overwrite inputs. Claimed pre-authentication but CONSUMED only after the
/// ceremony completes: the SPAKE2 confirm proves the connection peer holds
/// the code, and Hello rode that same iroh-authenticated connection.
/// Optional + additive on the wire: an older joiner sends none, an older
/// responder ignores the trailing Hello bytes (the decoder reads
/// positionally and never rejects a longer frame).
// [impl->REQ-SUBNET-7]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NodeIntro {
    /// The joiner's advertised node label (its OS hostname).
    pub label: String,
    /// The joiner's hashed stable machine id (domain-separated SHA-256 —
    /// never the raw id; `spt-daemon::machineid` is the producer).
    pub machine_id: String,
}

/// What a completed responder ceremony yields (M8 decision 13): the TOFU
/// verdict plus the identity facts the re-pair overwrite consumes — the
/// caller (the daemon's pairing host) decides whether a superseded identity
/// must be evicted; this layer only reports what the ceremony proved.
pub struct ResponderOutcome {
    /// The TOFU verdict for the joiner.
    pub trust: TrustOutcome,
    /// The joiner's iroh-authenticated node pubkey, lowercase hex.
    pub joiner_hex: String,
    /// The subnet paired into.
    pub subnet: String,
    /// The joiner's self-intro, if it sent one.
    pub intro: Option<NodeIntro>,
}


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

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

/// The joiner's self-introduction riding `Hello` (M8 decision 13,
/// REQ-SUBNET-7): its node label + **hashed** machine id — the re-pair
/// overwrite inputs. Claimed pre-authentication but CONSUMED only after the
/// ceremony completes: the SPAKE2 confirm proves the connection peer holds
/// the code, and Hello rode that same iroh-authenticated connection.
/// Optional + additive on the wire: an older joiner sends none, an older
/// responder ignores the trailing Hello bytes (the decoder reads
/// positionally and never rejects a longer frame).
// [impl->REQ-SUBNET-7]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NodeIntro {
    /// The joiner's advertised node label (its OS hostname).
    pub label: String,
    /// The joiner's hashed stable machine id (domain-separated SHA-256 —
    /// never the raw id; `spt-daemon::machineid` is the producer).
    pub machine_id: String,
}

/// What a completed responder ceremony yields (M8 decision 13): the TOFU
/// verdict plus the identity facts the re-pair overwrite consumes — the
/// caller (the daemon's pairing host) decides whether a superseded identity
/// must be evicted; this layer only reports what the ceremony proved.
pub struct ResponderOutcome {
    /// The TOFU verdict for the joiner.
    pub trust: TrustOutcome,
    /// The joiner's iroh-authenticated node pubkey, lowercase hex.
    pub joiner_hex: String,
    /// The subnet paired into.
    pub subnet: String,
    /// The joiner's self-intro, if it sent one.
    pub intro: Option<NodeIntro>,
}

+/// The joiner's self-introduction riding `Hello` (M8 decision 13,

+/// REQ-SUBNET-7): its node label + **hashed** machine id — the re-pair

+/// overwrite inputs. Claimed pre-authentication but CONSUMED only after the

+/// ceremony completes: the SPAKE2 confirm proves the connection peer holds

+/// the code, and Hello rode that same iroh-authenticated connection.

+/// Optional + additive on the wire: an older joiner sends none, an older

+/// responder ignores the trailing Hello bytes (the decoder reads

+/// positionally and never rejects a longer frame).

+    /// The joiner's advertised node label (its OS hostname).

+    /// The joiner's hashed stable machine id (domain-separated SHA-256 —

+    /// never the raw id; `spt-daemon::machineid` is the producer).

+/// What a completed responder ceremony yields (M8 decision 13): the TOFU

+/// verdict plus the identity facts the re-pair overwrite consumes — the

+/// caller (the daemon's pairing host) decides whether a superseded identity

+/// must be evicted; this layer only reports what the ceremony proved.

+    /// The TOFU verdict for the joiner.

+    /// The joiner's iroh-authenticated node pubkey, lowercase hex.

+    /// The joiner's self-intro, if it sent one.

/// Run the **responder** (seed-holder) side of the ceremony over an accepted connection.

/// `local_pub` is this node's own pubkey (the iroh endpoint id); `subnets` supplies the


pub async fn run_responder(
    conn: &Connection,
    local_pub: PublicKey,
    subnets: &SubnetStore,
    rate: &mut PairingRateLimiter,
    trust: &mut TrustStore,
    now: u64,
) -> Result<TrustOutcome, PairWireError> {
    let (mut send, mut recv) = conn
        .accept_bi()
        .await
        .map_err(|e| PairWireError::Io(format!("accept_bi: {e}")))?;
    let initiator_pub = remote_pub(conn)?;

    // F1: Hello — the joiner's subnet name + clock-derived TOTP step.
    let (subnet_name, peer_step) = match read_frame(&mut recv).await? {
        Frame::Hello { subnet_name, step } => (subnet_name, step),
        other => return Err(unexpected("Hello", other)),
    };

pub async fn run_responder(
    conn: &Connection,
    local_pub: PublicKey,
    subnets: &SubnetStore,
    rate: &mut PairingRateLimiter,
    trust: &mut TrustStore,
    now: u64,
) -> Result<ResponderOutcome, PairWireError> {
    let (mut send, mut recv) = conn
        .accept_bi()
        .await
        .map_err(|e| PairWireError::Io(format!("accept_bi: {e}")))?;
    let initiator_pub = remote_pub(conn)?;

    // F1: Hello — the joiner's subnet name + clock-derived TOTP step (+ its
    // optional self-intro, M8 decision 13).
    let (subnet_name, peer_step, intro) = match read_frame(&mut recv).await? {
        Frame::Hello {
            subnet_name,
            step,
            intro,
        } => (subnet_name, step, intro),
        other => return Err(unexpected("Hello", other)),
    };


pub async fn run_responder(
    conn: &Connection,
    local_pub: PublicKey,
    subnets: &SubnetStore,
    rate: &mut PairingRateLimiter,
    trust: &mut TrustStore,
    now: u64,
) -> Result<TrustOutcome, PairWireError> {
    let (mut send, mut recv) = conn
        .accept_bi()
        .await
        .map_err(|e| PairWireError::Io(format!("accept_bi: {e}")))?;
    let initiator_pub = remote_pub(conn)?;

    // F1: Hello — the joiner's subnet name + clock-derived TOTP step.
    let (subnet_name, peer_step) = match read_frame(&mut recv).await? {
        Frame::Hello { subnet_name, step } => (subnet_name, step),
        other => return Err(unexpected("Hello", other)),
    };

pub async fn run_responder(
    conn: &Connection,
    local_pub: PublicKey,
    subnets: &SubnetStore,
    rate: &mut PairingRateLimiter,
    trust: &mut TrustStore,
    now: u64,
) -> Result<ResponderOutcome, PairWireError> {
    let (mut send, mut recv) = conn
        .accept_bi()
        .await
        .map_err(|e| PairWireError::Io(format!("accept_bi: {e}")))?;
    let initiator_pub = remote_pub(conn)?;

    // F1: Hello — the joiner's subnet name + clock-derived TOTP step (+ its
    // optional self-intro, M8 decision 13).
    let (subnet_name, peer_step, intro) = match read_frame(&mut recv).await? {
        Frame::Hello {
            subnet_name,
            step,
            intro,
        } => (subnet_name, step, intro),
        other => return Err(unexpected("Hello", other)),
    };

-) -> Result<TrustOutcome, PairWireError> {

+) -> Result<ResponderOutcome, PairWireError> {

-    // F1: Hello — the joiner's subnet name + clock-derived TOTP step.

-    let (subnet_name, peer_step) = match read_frame(&mut recv).await? {

-        Frame::Hello { subnet_name, step } => (subnet_name, step),

+    // F1: Hello — the joiner's subnet name + clock-derived TOTP step (+ its

+    // optional self-intro, M8 decision 13).

+    let (subnet_name, peer_step, intro) = match read_frame(&mut recv).await? {

+        } => (subnet_name, step, intro),


    let result = responder_admitted(&mut send, &mut recv, &params, trust).await;
    match &result {
        Ok(_) => rate.record_success(&subnet_name),
        Err(_) => rate.record_failure(&subnet_name, now),
    }
    result
}

    let result = responder_admitted(&mut send, &mut recv, &params, trust).await;
    match &result {
        Ok(_) => rate.record_success(&subnet_name),
        Err(_) => rate.record_failure(&subnet_name, now),
    }
    result.map(|trust| ResponderOutcome {
        trust,
        joiner_hex: initiator_pub.to_hex(),
        subnet: subnet_name,
        intro,
    })
}


    let result = responder_admitted(&mut send, &mut recv, &params, trust).await;
    match &result {
        Ok(_) => rate.record_success(&subnet_name),
        Err(_) => rate.record_failure(&subnet_name, now),
    }
    result
}

    let result = responder_admitted(&mut send, &mut recv, &params, trust).await;
    match &result {
        Ok(_) => rate.record_success(&subnet_name),
        Err(_) => rate.record_failure(&subnet_name, now),
    }
    result.map(|trust| ResponderOutcome {
        trust,
        joiner_hex: initiator_pub.to_hex(),
        subnet: subnet_name,
        intro,
    })
}

+    result.map(|trust| ResponderOutcome {

+        joiner_hex: initiator_pub.to_hex(),

/// The responder ceremony *after* admission — the announce → SPAKE → confirm flow. Does


// [impl->REQ-PAIR-1] [impl->REQ-PAIR-5]
#[allow(clippy::too_many_arguments)]
pub async fn run_initiator(
    conn: &Connection,
    local_pub: PublicKey,
    subnet_name: &str,
    code: &str,
    trust: &mut TrustStore,
    subnets: &mut SubnetStore,
    now: u64,
    step_back: u64,
) -> Result<TrustOutcome, PairWireError> {
    let (mut send, mut recv) = conn
        .open_bi()
        .await
        .map_err(|e| PairWireError::Io(format!("open_bi: {e}")))?;
    let responder_pub = remote_pub(conn)?;
    let step = time_step(now).saturating_sub(step_back);

    // F1: Hello — opens the stream (the dialer must write first for accept_bi to fire).
    write_frame(&mut send, &enc_hello(subnet_name, step)).await?;

// [impl->REQ-PAIR-1] [impl->REQ-PAIR-5]
#[allow(clippy::too_many_arguments)]
pub async fn run_initiator(
    conn: &Connection,
    local_pub: PublicKey,
    subnet_name: &str,
    code: &str,
    trust: &mut TrustStore,
    subnets: &mut SubnetStore,
    now: u64,
    step_back: u64,
    intro: Option<&NodeIntro>,
) -> Result<TrustOutcome, PairWireError> {
    let (mut send, mut recv) = conn
        .open_bi()
        .await
        .map_err(|e| PairWireError::Io(format!("open_bi: {e}")))?;
    let responder_pub = remote_pub(conn)?;
    let step = time_step(now).saturating_sub(step_back);

    // F1: Hello — opens the stream (the dialer must write first for accept_bi
    // to fire). Carries the optional self-intro (M8 decision 13).
    write_frame(&mut send, &enc_hello(subnet_name, step, intro)).await?;


// [impl->REQ-PAIR-1] [impl->REQ-PAIR-5]
#[allow(clippy::too_many_arguments)]
pub async fn run_initiator(
    conn: &Connection,
    local_pub: PublicKey,
    subnet_name: &str,
    code: &str,
    trust: &mut TrustStore,
    subnets: &mut SubnetStore,
    now: u64,
    step_back: u64,
) -> Result<TrustOutcome, PairWireError> {
    let (mut send, mut recv) = conn
        .open_bi()
        .await
        .map_err(|e| PairWireError::Io(format!("open_bi: {e}")))?;
    let responder_pub = remote_pub(conn)?;
    let step = time_step(now).saturating_sub(step_back);

    // F1: Hello — opens the stream (the dialer must write first for accept_bi to fire).
    write_frame(&mut send, &enc_hello(subnet_name, step)).await?;

// [impl->REQ-PAIR-1] [impl->REQ-PAIR-5]
#[allow(clippy::too_many_arguments)]
pub async fn run_initiator(
    conn: &Connection,
    local_pub: PublicKey,
    subnet_name: &str,
    code: &str,
    trust: &mut TrustStore,
    subnets: &mut SubnetStore,
    now: u64,
    step_back: u64,
    intro: Option<&NodeIntro>,
) -> Result<TrustOutcome, PairWireError> {
    let (mut send, mut recv) = conn
        .open_bi()
        .await
        .map_err(|e| PairWireError::Io(format!("open_bi: {e}")))?;
    let responder_pub = remote_pub(conn)?;
    let step = time_step(now).saturating_sub(step_back);

    // F1: Hello — opens the stream (the dialer must write first for accept_bi
    // to fire). Carries the optional self-intro (M8 decision 13).
    write_frame(&mut send, &enc_hello(subnet_name, step, intro)).await?;

-    // F1: Hello — opens the stream (the dialer must write first for accept_bi to fire).

-    write_frame(&mut send, &enc_hello(subnet_name, step)).await?;

+    // F1: Hello — opens the stream (the dialer must write first for accept_bi

+    // to fire). Carries the optional self-intro (M8 decision 13).

+    write_frame(&mut send, &enc_hello(subnet_name, step, intro)).await?;

// F2: Announce(epoch) — or an Abort if the responder refused admission / step / name.

let epoch = match read_frame(&mut recv).await? {


Now Frame, codec, cursor:


enum Frame {
    Hello {
        subnet_name: String,
        step: u64,
    },

enum Frame {
    Hello {
        subnet_name: String,
        step: u64,
        /// The joiner's optional self-intro (M8 decision 13) — trailing,
        /// additive fields an older peer never sends and an older decoder
        /// never reads.
        intro: Option<NodeIntro>,
    },


enum Frame {
    Hello {
        subnet_name: String,
        step: u64,
    },

enum Frame {
    Hello {
        subnet_name: String,
        step: u64,
        /// The joiner's optional self-intro (M8 decision 13) — trailing,
        /// additive fields an older peer never sends and an older decoder
        /// never reads.
        intro: Option<NodeIntro>,
    },

+        /// The joiner's optional self-intro (M8 decision 13) — trailing,

+        /// additive fields an older peer never sends and an older decoder


fn enc_hello(subnet_name: &str, step: u64) -> Vec<u8> {
    let mut b = vec![T_HELLO];
    push_field(&mut b, subnet_name.as_bytes());
    b.extend_from_slice(&step.to_be_bytes());
    b
}

fn enc_hello(subnet_name: &str, step: u64, intro: Option<&NodeIntro>) -> Vec<u8> {
    let mut b = vec![T_HELLO];
    push_field(&mut b, subnet_name.as_bytes());
    b.extend_from_slice(&step.to_be_bytes());
    // Trailing additive intro fields (M8 decision 13): both or neither —
    // an older responder's decoder reads positionally and ignores them.
    if let Some(i) = intro {
        push_field(&mut b, i.label.as_bytes());
        push_field(&mut b, i.machine_id.as_bytes());
    }
    b
}


fn enc_hello(subnet_name: &str, step: u64) -> Vec<u8> {
    let mut b = vec![T_HELLO];
    push_field(&mut b, subnet_name.as_bytes());
    b.extend_from_slice(&step.to_be_bytes());
    b
}

fn enc_hello(subnet_name: &str, step: u64, intro: Option<&NodeIntro>) -> Vec<u8> {
    let mut b = vec![T_HELLO];
    push_field(&mut b, subnet_name.as_bytes());
    b.extend_from_slice(&step.to_be_bytes());
    // Trailing additive intro fields (M8 decision 13): both or neither —
    // an older responder's decoder reads positionally and ignores them.
    if let Some(i) = intro {
        push_field(&mut b, i.label.as_bytes());
        push_field(&mut b, i.machine_id.as_bytes());
    }
    b
}

-fn enc_hello(subnet_name: &str, step: u64) -> Vec<u8> {

+fn enc_hello(subnet_name: &str, step: u64, intro: Option<&NodeIntro>) -> Vec<u8> {

+    // Trailing additive intro fields (M8 decision 13): both or neither —

+    // an older responder's decoder reads positionally and ignores them.

+        push_field(&mut b, i.label.as_bytes());

+        push_field(&mut b, i.machine_id.as_bytes());


        T_HELLO => {
            let subnet_name = String::from_utf8(c.field()?.to_vec())
                .map_err(|_| PairWireError::Protocol("subnet name not utf-8".into()))?;
            let step = c.u64()?;
            Frame::Hello { subnet_name, step }
        }

        T_HELLO => {
            let subnet_name = String::from_utf8(c.field()?.to_vec())
                .map_err(|_| PairWireError::Protocol("subnet name not utf-8".into()))?;
            let step = c.u64()?;
            // Optional trailing intro (M8 decision 13): absent on a pre-M8
            // joiner; when present, BOTH fields must decode (a half-written
            // intro is a protocol error, never a silent half-claim).
            let intro = if c.remaining() > 0 {
                let label = String::from_utf8(c.field()?.to_vec())
                    .map_err(|_| PairWireError::Protocol("intro label not utf-8".into()))?;
                let machine_id = String::from_utf8(c.field()?.to_vec())
                    .map_err(|_| PairWireError::Protocol("intro machine id not utf-8".into()))?;
                Some(NodeIntro { label, machine_id })
            } else {
                None
            };
            Frame::Hello {
                subnet_name,
                step,
                intro,
            }
        }


        T_HELLO => {
            let subnet_name = String::from_utf8(c.field()?.to_vec())
                .map_err(|_| PairWireError::Protocol("subnet name not utf-8".into()))?;
            let step = c.u64()?;
            Frame::Hello { subnet_name, step }
        }

        T_HELLO => {
            let subnet_name = String::from_utf8(c.field()?.to_vec())
                .map_err(|_| PairWireError::Protocol("subnet name not utf-8".into()))?;
            let step = c.u64()?;
            // Optional trailing intro (M8 decision 13): absent on a pre-M8
            // joiner; when present, BOTH fields must decode (a half-written
            // intro is a protocol error, never a silent half-claim).
            let intro = if c.remaining() > 0 {
                let label = String::from_utf8(c.field()?.to_vec())
                    .map_err(|_| PairWireError::Protocol("intro label not utf-8".into()))?;
                let machine_id = String::from_utf8(c.field()?.to_vec())
                    .map_err(|_| PairWireError::Protocol("intro machine id not utf-8".into()))?;
                Some(NodeIntro { label, machine_id })
            } else {
                None
            };
            Frame::Hello {
                subnet_name,
                step,
                intro,
            }
        }

-            Frame::Hello { subnet_name, step }

+            // Optional trailing intro (M8 decision 13): absent on a pre-M8

+            // joiner; when present, BOTH fields must decode (a half-written

+            // intro is a protocol error, never a silent half-claim).

+            let intro = if c.remaining() > 0 {

+                let label = String::from_utf8(c.field()?.to_vec())

+                    .map_err(|_| PairWireError::Protocol("intro label not utf-8".into()))?;

+                let machine_id = String::from_utf8(c.field()?.to_vec())

+                    .map_err(|_| PairWireError::Protocol("intro machine id not utf-8".into()))?;

+                Some(NodeIntro { label, machine_id })

T_ANNOUNCE => Frame::Announce { epoch: c.u64()? },


    fn tag(&mut self) -> Result<[u8; TAG_LEN], PairWireError> {
        Ok(self.take(TAG_LEN)?.try_into().expect("took TAG_LEN"))
    }
}

    fn tag(&mut self) -> Result<[u8; TAG_LEN], PairWireError> {
        Ok(self.take(TAG_LEN)?.try_into().expect("took TAG_LEN"))
    }

    /// Unread bytes left in the frame (the optional-trailing-fields probe).
    fn remaining(&self) -> usize {
        self.buf.len().saturating_sub(self.pos)
    }
}


    fn tag(&mut self) -> Result<[u8; TAG_LEN], PairWireError> {
        Ok(self.take(TAG_LEN)?.try_into().expect("took TAG_LEN"))
    }
}

    fn tag(&mut self) -> Result<[u8; TAG_LEN], PairWireError> {
        Ok(self.take(TAG_LEN)?.try_into().expect("took TAG_LEN"))
    }

    /// Unread bytes left in the frame (the optional-trailing-fields probe).
    fn remaining(&self) -> usize {
        self.buf.len().saturating_sub(self.pos)
    }
}

+    /// Unread bytes left in the frame (the optional-trailing-fields probe).

+        self.buf.len().saturating_sub(self.pos)


Fix the 7 test initiator call sites + 2 happy-path outcome assertions:


PowerShell

$f = 'crates\spt-net\src\net\pairing\wire.rs'
$text = Get-Content $f -Raw
# Add the intro arg after every test call's step_back literal (0 or 1) in run_initiator invocations.
$new = [regex]::Replace($text, '(?m)^(\s*)(0|1), // step_back(.*)$', "`$1`$2, // step_back`$3`r`n`$1None, // intro: pre-M8 joiner shape")
Set-Content -Path $f -Value $new -NoNewline
[regex]::Matches($new, 'None, // intro').Count

Append intro arg to test run_initiator calls


1	//! D2-wire: the pairing ALPN ceremony driver (ADR-0005, REQ-PAIR-1).
2	//!
3	//! This is the transport glue that runs the TOTP-SPAKE2 ceremony (the crypto core of
4	//! [`spake`](super::spake) + [`transcript`](super::transcript)) over a real iroh bidi
5	//! stream on a dedicated pre-trust **pairing ALPN** ([`SPT_PAIR_ALPN`]), gated by the
6	//! subnet-global [rate limiter](super::ratelimit), and writes the confirmed peer into
7	//! each side's [`TrustStore`]. It turns "two machines pair via one TOTP code" from a
8	//! same-process unit test into an over-the-wire exchange.
9	//!
10	//! ## Why a responder `Announce{epoch}` precedes the SPAKE messages
11	//!
12	//! [`Initiator::start`](super::spake::Initiator::start) bakes the **full transcript**
13	//! into `msg_a`, and the transcript binds the seed-rotation [`epoch`] — which is
14	//! *responder-authoritative* (only seed-holders track it; the joiner has just the
15	//! 6-digit code + subnet name, per [`transcript`](super::transcript) docs). So the
16	//! joiner cannot construct `msg_a` until the responder tells it the current epoch. The
17	//! wire therefore opens with the joiner's [`Hello`](Frame::Hello) (subnet name + its
18	//! clock-derived TOTP step), the responder's [`Announce`](Frame::Announce) (the epoch,
19	//! *after* it has admitted the ceremony and accepted the ±1 step), and only then the
20	//! four SPAKE messages. The TOTP **step** is dialer-announced and clock-derived
21	//! (`totp.rs`), so it needs no announce — only the seed epoch does.
22	//!
23	//! ## Authenticated identities
24	//!
25	//! Both node pubkeys bound into the transcript come from the **iroh-authenticated**
26	//! [`Connection::remote_id`] (the QUIC handshake already proves the peer holds the
27	//! Ed25519 key that *is* its node id) plus the local node's own pubkey — never the
28	//! bytes a peer claims in a frame. A MITM substituting its key is rejected by the
29	//! transcript binding (`spake.rs` `substituted_pubkey_fails`).
30	//!
31	//! ## Scope (component-level; WAN is D9)
32	//!
33	//! Like [`endpoint`](super::super::endpoint) tests, this dials by `EndpointAddr` over
34	//! loopback with relays disabled. The relay **rendezvous** (`H(subnet-name ‖ step)`,
35	//! `CONTEXT.md`) that lets two nodes that don't know each other's id *meet* is deferred
36	//! to D9; here the joiner already holds the responder's dialable address. Seed transfer
37	//! to the joiner (so it becomes a future seed-holder) is deferred to D2f — this driver
38	//! delivers the ceremony + mutual trust write only.
39	//
40	// [impl->REQ-PAIR-1]
41
42	use iroh::endpoint::{Connection, RecvStream, SendStream};
43	use spt_proto::identity::PublicKey;
44	use spt_store::trust::{TrustOutcome, TrustStore};
45
46	use spt_store::subnet::{SubnetError, SubnetStore, TOTP_SEED_LEN};
47
48	use super::ratelimit::{PairingRateLimiter, RateLimited};
49	use super::spake::{Initiator, PairError, Responder};
50	use super::totp::{accepts_step, time_step, TotpSeed, DIGITS};
51	use super::transcript::{PairingTranscript, TAG_LEN};
52
53	/// The pre-trust pairing ALPN. Distinct from the trusted
54	/// [`SPT_NET_ALPN`](super::super::endpoint::SPT_NET_ALPN) so the relay can permit
55	/// pre-trust contact (a seed-holder must be reachable by an untrusted new node) without
56	/// exposing the trusted message surface (ADR-0005; `pairing.rs`). Versioned for a clean
57	/// future wire break.
58	pub const SPT_PAIR_ALPN: &[u8] = b"spt-core/pair/0";
59
60	/// Hard cap on a single wire frame. The ceremony's largest field is a SPAKE2 message
61	/// (~tens of bytes) or a subnet name; 4 KiB is generous slack while bounding a hostile
62	/// length prefix so a pre-trust peer cannot force a huge allocation.
63	const MAX_FRAME: usize = 4096;
64
65	// Frame tag bytes.
66	const T_ABORT: u8 = 0;
67	const T_HELLO: u8 = 1;
68	const T_ANNOUNCE: u8 = 2;
69	const T_SPAKE1: u8 = 3;
70	const T_SPAKE2: u8 = 4;
71	const T_CONFIRM: u8 = 5;
72	const T_DONE: u8 = 6;
73	const T_SEED: u8 = 7;
74
75	// Abort reason bytes.
76	const A_BUSY: u8 = 1;
77	const A_BACKOFF: u8 = 2;
78	const A_BAD_STEP: u8 = 3;
79	const A_UNKNOWN_SUBNET: u8 = 4;
80	const A_BAD_MSG: u8 = 5;
81	const A_CONFIRM_FAIL: u8 = 6;
82
83	/// A failure of the pairing wire ceremony. Carries no secret material.
84	#[derive(Debug)]
85	pub enum PairWireError {
86	    /// Stream read/write/framing failure (peer vanished, malformed length, EOF).
87	    Io(String),
88	    /// A frame was the wrong type or malformed for the current ceremony step.
89	    Protocol(String),
90	    /// The responder refused to admit the ceremony (rate-limit): another is in flight
91	    /// ([`Busy`](RateLimited::Busy)) or the backoff window is open
92	    /// ([`Backoff`](RateLimited::Backoff)).
93	    Refused(RateLimited),
94	    /// The dialer's announced TOTP step was outside the responder's ±1 window.
95	    StepRejected,
96	    /// The responder holds no subnet by that name.
97	    UnknownSubnet(String),
98	    /// The SPAKE2 / key-confirmation step failed: wrong code, MITM, wrong subnet, stale
99	    /// step — the one signal a bad pairing gives ([`spake`](super::spake)).
100	    Pairing(PairError),
101	}
102
103	impl std::fmt::Display for PairWireError {
104	    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
105	        match self {
106	            PairWireError::Io(e) => write!(f, "pairing wire io: {e}"),
107	            PairWireError::Protocol(e) => write!(f, "pairing wire protocol error: {e}"),
108	            PairWireError::Refused(r) => write!(f, "pairing refused: {r}"),
109	            PairWireError::StepRejected => {
110	                write!(f, "pairing rejected: TOTP step outside the ±1 window")
111	            }
112	            PairWireError::UnknownSubnet(n) => write!(f, "pairing rejected: unknown subnet {n}"),
113	            PairWireError::Pairing(e) => write!(f, "{e}"),
114	        }
115	    }
116	}
117
118	impl std::error::Error for PairWireError {}
119
120	/// The joiner's self-introduction riding `Hello` (M8 decision 13,
121	/// REQ-SUBNET-7): its node label + **hashed** machine id — the re-pair
122	/// overwrite inputs. Claimed pre-authentication but CONSUMED only after the
123	/// ceremony completes: the SPAKE2 confirm proves the connection peer holds
124	/// the code, and Hello rode that same iroh-authenticated connection.
125	/// Optional + additive on the wire: an older joiner sends none, an older
126	/// responder ignores the trailing Hello bytes (the decoder reads
127	/// positionally and never rejects a longer frame).
128	// [impl->REQ-SUBNET-7]
129	#[derive(Debug, Clone, PartialEq, Eq)]
130	pub struct NodeIntro {
131	    /// The joiner's advertised node label (its OS hostname).
132	    pub label: String,
133	    /// The joiner's hashed stable machine id (domain-separated SHA-256 —
134	    /// never the raw id; `spt-daemon::machineid` is the producer).
135	    pub machine_id: String,
136	}
137
138	/// What a completed responder ceremony yields (M8 decision 13): the TOFU
139	/// verdict plus the identity facts the re-pair overwrite consumes — the
140	/// caller (the daemon's pairing host) decides whether a superseded identity
141	/// must be evicted; this layer only reports what the ceremony proved.
142	pub struct ResponderOutcome {
143	    /// The TOFU verdict for the joiner.
144	    pub trust: TrustOutcome,
145	    /// The joiner's iroh-authenticated node pubkey, lowercase hex.
146	    pub joiner_hex: String,
147	    /// The subnet paired into.
148	    pub subnet: String,
149	    /// The joiner's self-intro, if it sent one.
150	    pub intro: Option<NodeIntro>,
151	}
152
153	/// Run the **responder** (seed-holder) side of the ceremony over an accepted connection.
154	///
155	/// `local_pub` is this node's own pubkey (the iroh endpoint id); `subnets` supplies the
156	/// seed + epoch for the named subnet; `rate` is the subnet-global admission gate; `trust`
157	/// receives the peer on success. `now` (unix seconds) is injected for deterministic
158	/// tests, mirroring [`ratelimit`](super::ratelimit) / [`totp`](super::totp).
159	///

... [1153 lines truncated] ...


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

Build spt-net


(PowerShell completed with no output)


cargo test -q -p spt-net pairing::wire 2>&1 | Select-Object -First 40

Run wire tests


error[E0369]: binary operation `==` cannot be applied to type `ResponderOutcome`
   --> crates\spt-net\src\net\pairing\wire.rs:877:9
    |
877 | /         assert_eq!(
878 | |             resp_outcome,
879 | |             TrustOutcome::Pinned,
880 | |             "responder pins initiator"
881 | |         );
    | |         ^
    | |         |
    | |_________ResponderOutcome
    |           spt_store::trust::TrustOutcome
    |
note: an implementation of `PartialEq<spt_store::trust::TrustOutcome>` might be missing for `ResponderOutcome`
   --> crates\spt-net\src\net\pairing\wire.rs:142:1
    |
142 | pub struct ResponderOutcome {
    | ^^^^^^^^^^^^^^^^^^^^^^^^^^^ must implement `PartialEq<spt_store::trust::TrustOutcome>`
    = note: this error originates in the macro `assert_eq` (in Nightly builds, run with -Z macro-backtrace for more info)

error[E0277]: `ResponderOutcome` doesn't implement `Debug`
   --> crates\spt-net\src\net\pairing\wire.rs:877:9
    |
877 | /         assert_eq!(
878 | |             resp_outcome,
879 | |             TrustOutcome::Pinned,
880 | |             "responder pins initiator"
881 | |         );
    | |_________^ the trait `Debug` is not implemented for `ResponderOutcome`
    |
    = note: add `#[derive(Debug)]` to `ResponderOutcome` or manually `impl Debug for ResponderOutcome`
    = note: this error originates in the macro `assert_eq` (in Nightly builds, run with -Z macro-backtrace for more info)
help: consider annotating `ResponderOutcome` with `#[derive(Debug)]`
    |
142 + #[derive(Debug)]
143 | pub struct ResponderOutcome {
    |

error[E0369]: binary operation `==` cannot be applied to type `ResponderOutcome`
    --> crates\spt-net\src\net\pairing\wire.rs:1269:9


Edit

/// caller (the daemon's pairing host) decides whether a superseded identity
/// must be evicted; this layer only reports what the ceremony proved.
pub struct ResponderOutcome {

/// caller (the daemon's pairing host) decides whether a superseded identity
/// must be evicted; this layer only reports what the ceremony proved.
#[derive(Debug)]
pub struct ResponderOutcome {


/// caller (the daemon's pairing host) decides whether a superseded identity
/// must be evicted; this layer only reports what the ceremony proved.
pub struct ResponderOutcome {

/// caller (the daemon's pairing host) decides whether a superseded identity
/// must be evicted; this layer only reports what the ceremony proved.
#[derive(Debug)]
pub struct ResponderOutcome {

/// verdict plus the identity facts the re-pair overwrite consumes — the