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

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

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

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

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

+        // [unit->REQ-SUBNET-6] leave/prune: positional target, no flags.

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

+            Some(SubnetCmd::Leave { name }) if name == "home"

+            subnet_cmd(&["spt", "subnet", "prune", "09ef831e"]),

+            Some(SubnetCmd::Prune { node }) if node == "09ef831e"

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

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

+    // [unit->REQ-SUBNET-6] leave/prune are elevation-gated with the join

+    // gate's fail-safe posture: NotElevated and Unknown both refuse.

+    fn trust_mutation_gate_refuses_without_confirmed_elevation() {

+        assert!(trust_mutation_refusal(Elevation::Elevated, "leaving").is_none());

+        assert!(trust_mutation_refusal(Elevation::NotElevated, "leaving").is_some());

+        assert!(trust_mutation_refusal(Elevation::Unknown, "leaving").is_some());

+    // [unit->REQ-SUBNET-6] prune target resolution: full hex, unambiguous

+    // prefix, and label all resolve; a shared prefix yields BOTH candidates

+    // (the caller refuses); unknown queries match nothing.

+    fn prune_candidates_resolve_never_guess() {

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

+        trust.record("bignet", "09ef831e00112233", None, None); // idempotent

+        trust.record("worknet", "09ef831e00112233", None, None); // same id, 2nd subnet

+        trust.record("bignet", "09aa000011223344", Some("GRAVITY"), None);

+        // Full hex + unambiguous prefix + label each resolve to one identity.

+            prune_candidates(&trust, "09ef831e00112233"),

+            prune_candidates(&trust, "09ef"),

+            "cross-subnet rows of ONE identity are one candidate"

+            prune_candidates(&trust, "gravity"),

+            "label resolves case-insensitively"

+        // A shared prefix surfaces both — the command refuses, never guesses.

+        assert_eq!(prune_candidates(&trust, "09").len(), 2);

+        // Unknown query matches nothing.

+        assert!(prune_candidates(&trust, "ff00").is_empty());

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

// posture as the seed-reveal paths: only CONFIRMED elevation proceeds;

// NotElevated and the undeterminable Unknown both refuse (before any


PowerShell

cargo test -q -p spt --bin spt cli::tests 2>&1 | Select-Object -Last 8

Build + run cli tests


running 46 tests
..............................................
test result: ok. 46 passed; 0 failed; 0 ignored; 0 measured; 53 filtered out; finished in 27.66s


Re-pair overwrite next — the big leg. Survey pairing wire.


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	/// Run the **responder** (seed-holder) side of the ceremony over an accepted connection.
121	///
122	/// `local_pub` is this node's own pubkey (the iroh endpoint id); `subnets` supplies the
123	/// seed + epoch for the named subnet; `rate` is the subnet-global admission gate; `trust`
124	/// receives the peer on success. `now` (unix seconds) is injected for deterministic
125	/// tests, mirroring [`ratelimit`](super::ratelimit) / [`totp`](super::totp).
126	///
127	/// On `Ok` the peer was confirmed and pinned (the returned [`TrustOutcome`]); the rate
128	/// limiter slot is released as a success. Every error path releases the slot too — as a
129	/// failure when a ceremony was admitted (charging backoff), or not at all when admission
130	/// itself was refused.

//! D2-wire: the pairing ALPN ceremony driver (ADR-0005, REQ-PAIR-1).
//!
//! This is the transport glue that runs the TOTP-SPAKE2 ceremony (the crypto core of
//! [`spake`](super::spake) + [`transcript`](super::transcript)) over a real iroh bidi
//! stream on a dedicated pre-trust **pairing ALPN** ([`SPT_PAIR_ALPN`]), gated by the
//! subnet-global [rate limiter](super::ratelimit), and writes the confirmed peer into
//! each side's [`TrustStore`]. It turns "two machines pair via one TOTP code" from a
//! same-process unit test into an over-the-wire exchange.
//!
//! ## Why a responder `Announce{epoch}` precedes the SPAKE messages
//!
//! [`Initiator::start`](super::spake::Initiator::start) bakes the **full transcript**
//! into `msg_a`, and the transcript binds the seed-rotation [`epoch`] — which is
//! *responder-authoritative* (only seed-holders track it; the joiner has just the
//! 6-digit code + subnet name, per [`transcript`](super::transcript) docs). So the
//! joiner cannot construct `msg_a` until the responder tells it the current epoch. The
//! wire therefore opens with the joiner's [`Hello`](Frame::Hello) (subnet name + its
//! clock-derived TOTP step), the responder's [`Announce`](Frame::Announce) (the epoch,
//! *after* it has admitted the ceremony and accepted the ±1 step), and only then the
//! four SPAKE messages. The TOTP **step** is dialer-announced and clock-derived
//! (`totp.rs`), so it needs no announce — only the seed epoch does.
//!
//! ## Authenticated identities
//!
//! Both node pubkeys bound into the transcript come from the **iroh-authenticated**
//! [`Connection::remote_id`] (the QUIC handshake already proves the peer holds the
//! Ed25519 key that *is* its node id) plus the local node's own pubkey — never the
//! bytes a peer claims in a frame. A MITM substituting its key is rejected by the
//! transcript binding (`spake.rs` `substituted_pubkey_fails`).
//!
//! ## Scope (component-level; WAN is D9)
//!
//! Like [`endpoint`](super::super::endpoint) tests, this dials by `EndpointAddr` over
//! loopback with relays disabled. The relay **rendezvous** (`H(subnet-name ‖ step)`,
//! `CONTEXT.md`) that lets two nodes that don't know each other's id *meet* is deferred
//! to D9; here the joiner already holds the responder's dialable address. Seed transfer
//! to the joiner (so it becomes a future seed-holder) is deferred to D2f — this driver
//! delivers the ceremony + mutual trust write only.
//
// [impl->REQ-PAIR-1]

use iroh::endpoint::{Connection, RecvStream, SendStream};
use spt_proto::identity::PublicKey;
use spt_store::trust::{TrustOutcome, TrustStore};

use spt_store::subnet::{SubnetError, SubnetStore, TOTP_SEED_LEN};

use super::ratelimit::{PairingRateLimiter, RateLimited};
use super::spake::{Initiator, PairError, Responder};
use super::totp::{accepts_step, time_step, TotpSeed, DIGITS};
use super::transcript::{PairingTranscript, TAG_LEN};

/// The pre-trust pairing ALPN. Distinct from the trusted
/// [`SPT_NET_ALPN`](super::super::endpoint::SPT_NET_ALPN) so the relay can permit
/// pre-trust contact (a seed-holder must be reachable by an untrusted new node) without
/// exposing the trusted message surface (ADR-0005; `pairing.rs`). Versioned for a clean
/// future wire break.
pub const SPT_PAIR_ALPN: &[u8] = b"spt-core/pair/0";

/// Hard cap on a single wire frame. The ceremony's largest field is a SPAKE2 message
/// (~tens of bytes) or a subnet name; 4 KiB is generous slack while bounding a hostile
/// length prefix so a pre-trust peer cannot force a huge allocation.
const MAX_FRAME: usize = 4096;

// Frame tag bytes.
const T_ABORT: u8 = 0;
const T_HELLO: u8 = 1;
const T_ANNOUNCE: u8 = 2;
const T_SPAKE1: u8 = 3;
const T_SPAKE2: u8 = 4;
const T_CONFIRM: u8 = 5;
const T_DONE: u8 = 6;
const T_SEED: u8 = 7;

// Abort reason bytes.
const A_BUSY: u8 = 1;
const A_BACKOFF: u8 = 2;
const A_BAD_STEP: u8 = 3;
const A_UNKNOWN_SUBNET: u8 = 4;
const A_BAD_MSG: u8 = 5;
const A_CONFIRM_FAIL: u8 = 6;

/// A failure of the pairing wire ceremony. Carries no secret material.
#[derive(Debug)]
pub enum PairWireError {
    /// Stream read/write/framing failure (peer vanished, malformed length, EOF).
    Io(String),
    /// A frame was the wrong type or malformed for the current ceremony step.
    Protocol(String),
    /// The responder refused to admit the ceremony (rate-limit): another is in flight
    /// ([`Busy`](RateLimited::Busy)) or the backoff window is open
    /// ([`Backoff`](RateLimited::Backoff)).
    Refused(RateLimited),
    /// The dialer's announced TOTP step was outside the responder's ±1 window.
    StepRejected,
    /// The responder holds no subnet by that name.
    UnknownSubnet(String),
    /// The SPAKE2 / key-confirmation step failed: wrong code, MITM, wrong subnet, stale
    /// step — the one signal a bad pairing gives ([`spake`](super::spake)).
    Pairing(PairError),
}

impl std::fmt::Display for PairWireError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            PairWireError::Io(e) => write!(f, "pairing wire io: {e}"),
            PairWireError::Protocol(e) => write!(f, "pairing wire protocol error: {e}"),
            PairWireError::Refused(r) => write!(f, "pairing refused: {r}"),
            PairWireError::StepRejected => {
                write!(f, "pairing rejected: TOTP step outside the ±1 window")
            }
            PairWireError::UnknownSubnet(n) => write!(f, "pairing rejected: unknown subnet {n}"),
            PairWireError::Pairing(e) => write!(f, "{e}"),
        }
    }
}

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

/// 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
/// seed + epoch for the named subnet; `rate` is the subnet-global admission gate; `trust`
/// receives the peer on success. `now` (unix seconds) is injected for deterministic
/// tests, mirroring [`ratelimit`](super::ratelimit) / [`totp`](super::totp).
///
/// On `Ok` the peer was confirmed and pinned (the returned [`TrustOutcome`]); the rate
/// limiter slot is released as a success. Every error path releases the slot too — as a
/// failure when a ceremony was admitted (charging backoff), or not at all when admission
/// itself was refused.


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

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

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

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

D1 — endpoint + daemon nouns (breaking CLI reorg)

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

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

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

D3 — install & elevation hardening

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

D4 — convergence polish + pump resilience

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

D5 — docs + CI + acceptance prep

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


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


Update CLI flags for endpoint and subnet commands


130	/// itself was refused.
131	// [impl->REQ-PAIR-1]
132	pub async fn run_responder(
133	    conn: &Connection,
134	    local_pub: PublicKey,
135	    subnets: &SubnetStore,
136	    rate: &mut PairingRateLimiter,
137	    trust: &mut TrustStore,
138	    now: u64,
139	) -> Result<TrustOutcome, PairWireError> {
140	    let (mut send, mut recv) = conn
141	        .accept_bi()
142	        .await
143	        .map_err(|e| PairWireError::Io(format!("accept_bi: {e}")))?;
144	    let initiator_pub = remote_pub(conn)?;
145
146	    // F1: Hello — the joiner's subnet name + clock-derived TOTP step.
147	    let (subnet_name, peer_step) = match read_frame(&mut recv).await? {
148	        Frame::Hello { subnet_name, step } => (subnet_name, step),
149	        other => return Err(unexpected("Hello", other)),
150	    };
151
152	    // Subnet lookup BEFORE admission so an unknown name never consumes a ceremony slot.
153	    let record = match subnets.find(&subnet_name) {
154	        Some(r) => r,
155	        None => {
156	            abort(&mut send, A_UNKNOWN_SUBNET, 0).await?;
157	            return Err(PairWireError::UnknownSubnet(subnet_name));
158	        }
159	    };
160
161	    // Admission: one ceremony per subnet + exponential backoff (#11). On refusal the
162	    // slot is NOT taken, so there is nothing to release.
163	    if let Err(rl) = rate.begin(&subnet_name, now) {
164	        let (reason, retry) = match &rl {
165	            RateLimited::Busy => (A_BUSY, 0),
166	            RateLimited::Backoff { retry_after_secs } => (A_BACKOFF, *retry_after_secs),
167	        };
168	        abort(&mut send, reason, retry).await?;
169	        return Err(PairWireError::Refused(rl));
170	    }
171	    // From here the slot is HELD. Release it exactly once below, charging a failure on
172	    // ANY error path — a wrong code, a peer that vanished, a malformed frame, or an
173	    // abandoned ceremony all count against the subnet's backoff (the #11 guarantee only
174	    // holds if every admitted-but-unsuccessful ceremony is charged, not just clean
175	    // confirmation mismatches). Decoding seed + epoch here drops the `subnets` borrow
176	    // before the (mutating) release.
177	    let params = Admitted {
178	        subnet_name: &subnet_name,
179	        peer_step,
180	        epoch: record.epoch,
181	        seed: record.seed_bytes(),
182	        initiator_pub,
183	        local_pub,
184	        now,
185	    };
186	    let result = responder_admitted(&mut send, &mut recv, &params, trust).await;
187	    match &result {
188	        Ok(_) => rate.record_success(&subnet_name),
189	        Err(_) => rate.record_failure(&subnet_name, now),
190	    }
191	    result
192	}
193
194	/// The responder ceremony *after* admission — the announce → SPAKE → confirm flow. Does
195	/// NOT touch the rate limiter: [`run_responder`] owns the slot and releases it once based
196	/// on this result, so every early return here (including a propagated I/O error) is
197	/// charged uniformly. Best-effort `Abort`/`Done` frames are sent so the joiner fails fast
198	/// instead of waiting out the ceremony timeout.
199	/// The admitted-ceremony parameters [`run_responder`] hands to [`responder_admitted`]
200	/// once a subnet seed is found and the rate-limit slot is held. Bundled so the inner
201	/// driver stays a tidy four-argument call (the streams, this, and the trust store).
202	struct Admitted<'a> {
203	    subnet_name: &'a str,
204	    /// The dialer-announced TOTP step (already known to exist; ±1-checked inside).
205	    peer_step: u64,
206	    /// The responder-authoritative seed epoch bound into the transcript.
207	    epoch: u64,
208	    /// The subnet seed bytes — `None` if the stored hex was malformed (an internal fault).
209	    seed: Option<[u8; 20]>,
210	    /// The joiner's iroh-authenticated node pubkey (the trust write target).
211	    initiator_pub: PublicKey,
212	    /// This seed-holder's own node pubkey (the transcript's responder side).
213	    local_pub: PublicKey,
214	    /// This node's clock, for the ±1 step window. Injected for deterministic tests.
215	    now: u64,
216	}
217
218	async fn responder_admitted(
219	    send: &mut SendStream,
220	    recv: &mut RecvStream,
221	    p: &Admitted<'_>,
222	    trust: &mut TrustStore,
223	) -> Result<TrustOutcome, PairWireError> {
224	    // ±1 clock-skew window on the dialer-announced step.
225	    if !accepts_step(time_step(p.now), p.peer_step) {
226	        abort(send, A_BAD_STEP, 0).await?;
227	        return Err(PairWireError::StepRejected);
228	    }
229
230	    // The PAKE password: the code at the dialer's exact step (both sides bind that step).
231	    let code = match p.seed {
232	        Some(bytes) => code_at_step(&TotpSeed::from_bytes(bytes), p.peer_step),
233	        None => {
234	            abort(send, A_BAD_MSG, 0).await?;
235	            return Err(PairWireError::Protocol("stored seed is malformed".into()));
236	        }
237	    };
238
239	    // F2: Announce the epoch — the joiner needs it to build the transcript for msg_a.
240	    write_frame(send, &enc_announce(p.epoch)).await?;
241
242	    // F3: Spake1 — the joiner's msg_a (built against the announced epoch).
243	    let msg_a = match read_frame(recv).await? {
244	        Frame::Spake1 { msg_a } => msg_a,
245	        Frame::Abort { .. } => return Err(PairWireError::Pairing(PairError::Confirm)),
246	        other => return Err(unexpected("Spake1", other)),
247	    };
248
249	    let transcript = PairingTranscript {
250	        subnet_name: p.subnet_name.to_string(),
251	        epoch: p.epoch,
252	        step: p.peer_step,
253	        initiator_pub: p.initiator_pub,
254	        responder_pub: p.local_pub,
255	    };
256
257	    let responder = match Responder::respond(transcript, &code, &msg_a) {
258	        Ok((r, msg_b, responder_tag)) => {
259	            // F4: Spake2 — msg_b + the responder's confirmation tag.
260	            write_frame(send, &enc_spake2(&msg_b, &responder_tag)).await?;
261	            r
262	        }
263	        Err(e) => {
264	            abort(send, A_BAD_MSG, 0).await?;
265	            return Err(PairWireError::Pairing(e));
266	        }
267	    };
268
269	    // F5: Confirm — the joiner's tag (or an Abort if its finish() already failed).
270	    let initiator_tag = match read_frame(recv).await? {
271	        Frame::Confirm { tag } => tag,
272	        Frame::Abort { .. } => return Err(PairWireError::Pairing(PairError::Confirm)),
273	        other => return Err(unexpected("Confirm", other)),
274	    };
275
276	    match responder.confirm(&initiator_tag) {
277	        Ok(_paired) => {
278	            // Confirmed live: pin the peer (TOFU). The peer id is the iroh-authenticated
279	            // remote id; label/user None (v1).
280	            let outcome = trust.record(p.subnet_name, &p.initiator_pub.to_hex(), None, None);
281	            write_frame(send, &enc_done(true)).await?;
282	            // F7: transfer the subnet seed so the new node becomes a full member /
283	            // future seed-holder ("every trusted node holds the seed", CONTEXT.md). Sent
284	            // ONLY on confirm-success, over the iroh-authenticated TLS stream to the
285	            // now-trust-pinned peer. `seed` is Some here (a None seed aborted upstream).
286	            let seed = p.seed.expect("seed present on the confirm-success path");
287	            write_frame(send, &enc_seed(&seed, p.epoch)).await?;
288	            let _ = send.finish();
289	            Ok(outcome)
290	        }
291	        Err(e) => {
292	            write_frame(send, &enc_done(false)).await?;
293	            let _ = send.finish();
294	            Err(PairWireError::Pairing(e))
295	        }
296	    }
297	}
298
299	/// Run the **initiator** (joining node) side of the ceremony over a dialed connection.
300	///
301	/// `local_pub` is this node's own pubkey; `subnet_name` + `code` are the two user-entered
302	/// inputs (the subnet label and the current 6-digit TOTP code); `now` is the local clock.
303	/// On success the responder is pinned in `trust` AND the transferred subnet seed is written
304	/// into `subnets` (the joiner becomes a full member / future seed-holder — D2f, REQ-PAIR-5);
305	/// the returned [`TrustOutcome`] is the TOFU decision.
306	///
307	/// `step_back` shifts the ANNOUNCED time-step below the local clock's (0 =
308	/// the normal attempt). The joiner cannot validate the typed code locally
309	/// (it holds no seed yet), so a code read just before a rotation and entered
310	/// just after binds to the wrong step and fails as "wrong code" — the ±1
311	/// responder window ([`accepts_step`](super::totp)) tolerates an announced
312	/// `step-1`, letting the caller retry the SAME typed code against the
313	/// previous step before re-prompting the user (the late-code retry, field
314	/// report 2026-06-06).
315	// [impl->REQ-PAIR-1] [impl->REQ-PAIR-5]
316	#[allow(clippy::too_many_arguments)]
317	pub async fn run_initiator(
318	    conn: &Connection,
319	    local_pub: PublicKey,
320	    subnet_name: &str,
321	    code: &str,
322	    trust: &mut TrustStore,
323	    subnets: &mut SubnetStore,
324	    now: u64,
325	    step_back: u64,
326	) -> Result<TrustOutcome, PairWireError> {
327	    let (mut send, mut recv) = conn
328	        .open_bi()
329	        .await
330	        .map_err(|e| PairWireError::Io(format!("open_bi: {e}")))?;
331	    let responder_pub = remote_pub(conn)?;
332	    let step = time_step(now).saturating_sub(step_back);
333
334	    // F1: Hello — opens the stream (the dialer must write first for accept_bi to fire).
335	    write_frame(&mut send, &enc_hello(subnet_name, step)).await?;
336
337	    // F2: Announce(epoch) — or an Abort if the responder refused admission / step / name.
338	    let epoch = match read_frame(&mut recv).await? {
339	        Frame::Announce { epoch } => epoch,
340	        Frame::Abort {
341	            reason,
342	            retry_after,
343	        } => return Err(map_abort(reason, retry_after, subnet_name)),
344	        other => return Err(unexpected("Announce", other)),
345	    };
346
347	    // Now the joiner can bind the full transcript and run SPAKE2 side A.
348	    let transcript = PairingTranscript {
349	        subnet_name: subnet_name.to_string(),

/// itself was refused.
// [impl->REQ-PAIR-1]
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)),
    };

    // Subnet lookup BEFORE admission so an unknown name never consumes a ceremony slot.
    let record = match subnets.find(&subnet_name) {
        Some(r) => r,
        None => {
            abort(&mut send, A_UNKNOWN_SUBNET, 0).await?;
            return Err(PairWireError::UnknownSubnet(subnet_name));
        }
    };

    // Admission: one ceremony per subnet + exponential backoff (#11). On refusal the
    // slot is NOT taken, so there is nothing to release.
    if let Err(rl) = rate.begin(&subnet_name, now) {
        let (reason, retry) = match &rl {
            RateLimited::Busy => (A_BUSY, 0),
            RateLimited::Backoff { retry_after_secs } => (A_BACKOFF, *retry_after_secs),
        };
        abort(&mut send, reason, retry).await?;
        return Err(PairWireError::Refused(rl));
    }
    // From here the slot is HELD. Release it exactly once below, charging a failure on
    // ANY error path — a wrong code, a peer that vanished, a malformed frame, or an
    // abandoned ceremony all count against the subnet's backoff (the #11 guarantee only
    // holds if every admitted-but-unsuccessful ceremony is charged, not just clean
    // confirmation mismatches). Decoding seed + epoch here drops the `subnets` borrow
    // before the (mutating) release.
    let params = Admitted {
        subnet_name: &subnet_name,
        peer_step,
        epoch: record.epoch,
        seed: record.seed_bytes(),
        initiator_pub,
        local_pub,
        now,
    };
    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
}

/// The responder ceremony *after* admission — the announce → SPAKE → confirm flow. Does
/// NOT touch the rate limiter: [`run_responder`] owns the slot and releases it once based
/// on this result, so every early return here (including a propagated I/O error) is
/// charged uniformly. Best-effort `Abort`/`Done` frames are sent so the joiner fails fast
/// instead of waiting out the ceremony timeout.
/// The admitted-ceremony parameters [`run_responder`] hands to [`responder_admitted`]
/// once a subnet seed is found and the rate-limit slot is held. Bundled so the inner
/// driver stays a tidy four-argument call (the streams, this, and the trust store).
struct Admitted<'a> {
    subnet_name: &'a str,
    /// The dialer-announced TOTP step (already known to exist; ±1-checked inside).
    peer_step: u64,
    /// The responder-authoritative seed epoch bound into the transcript.
    epoch: u64,
    /// The subnet seed bytes — `None` if the stored hex was malformed (an internal fault).
    seed: Option<[u8; 20]>,
    /// The joiner's iroh-authenticated node pubkey (the trust write target).
    initiator_pub: PublicKey,
    /// This seed-holder's own node pubkey (the transcript's responder side).
    local_pub: PublicKey,
    /// This node's clock, for the ±1 step window. Injected for deterministic tests.
    now: u64,
}

async fn responder_admitted(
    send: &mut SendStream,
    recv: &mut RecvStream,
    p: &Admitted<'_>,
    trust: &mut TrustStore,
) -> Result<TrustOutcome, PairWireError> {
    // ±1 clock-skew window on the dialer-announced step.
    if !accepts_step(time_step(p.now), p.peer_step) {
        abort(send, A_BAD_STEP, 0).await?;
        return Err(PairWireError::StepRejected);
    }

    // The PAKE password: the code at the dialer's exact step (both sides bind that step).
    let code = match p.seed {
        Some(bytes) => code_at_step(&TotpSeed::from_bytes(bytes), p.peer_step),
        None => {
            abort(send, A_BAD_MSG, 0).await?;
            return Err(PairWireError::Protocol("stored seed is malformed".into()));
        }
    };

    // F2: Announce the epoch — the joiner needs it to build the transcript for msg_a.
    write_frame(send, &enc_announce(p.epoch)).await?;

    // F3: Spake1 — the joiner's msg_a (built against the announced epoch).
    let msg_a = match read_frame(recv).await? {
        Frame::Spake1 { msg_a } => msg_a,
        Frame::Abort { .. } => return Err(PairWireError::Pairing(PairError::Confirm)),
        other => return Err(unexpected("Spake1", other)),
    };

    let transcript = PairingTranscript {
        subnet_name: p.subnet_name.to_string(),
        epoch: p.epoch,
        step: p.peer_step,
        initiator_pub: p.initiator_pub,
        responder_pub: p.local_pub,
    };

    let responder = match Responder::respond(transcript, &code, &msg_a) {
        Ok((r, msg_b, responder_tag)) => {
            // F4: Spake2 — msg_b + the responder's confirmation tag.
            write_frame(send, &enc_spake2(&msg_b, &responder_tag)).await?;
            r
        }
        Err(e) => {
            abort(send, A_BAD_MSG, 0).await?;
            return Err(PairWireError::Pairing(e));
        }
    };

    // F5: Confirm — the joiner's tag (or an Abort if its finish() already failed).
    let initiator_tag = match read_frame(recv).await? {
        Frame::Confirm { tag } => tag,
        Frame::Abort { .. } => return Err(PairWireError::Pairing(PairError::Confirm)),
        other => return Err(unexpected("Confirm", other)),
    };

    match responder.confirm(&initiator_tag) {
        Ok(_paired) => {
            // Confirmed live: pin the peer (TOFU). The peer id is the iroh-authenticated
            // remote id; label/user None (v1).
            let outcome = trust.record(p.subnet_name, &p.initiator_pub.to_hex(), None, None);
            write_frame(send, &enc_done(true)).await?;
            // F7: transfer the subnet seed so the new node becomes a full member /
            // future seed-holder ("every trusted node holds the seed", CONTEXT.md). Sent
            // ONLY on confirm-success, over the iroh-authenticated TLS stream to the
            // now-trust-pinned peer. `seed` is Some here (a None seed aborted upstream).
            let seed = p.seed.expect("seed present on the confirm-success path");
            write_frame(send, &enc_seed(&seed, p.epoch)).await?;
            let _ = send.finish();
            Ok(outcome)
        }
        Err(e) => {
            write_frame(send, &enc_done(false)).await?;
            let _ = send.finish();
            Err(PairWireError::Pairing(e))
        }
    }
}

/// Run the **initiator** (joining node) side of the ceremony over a dialed connection.
///
/// `local_pub` is this node's own pubkey; `subnet_name` + `code` are the two user-entered
/// inputs (the subnet label and the current 6-digit TOTP code); `now` is the local clock.
/// On success the responder is pinned in `trust` AND the transferred subnet seed is written
/// into `subnets` (the joiner becomes a full member / future seed-holder — D2f, REQ-PAIR-5);
/// the returned [`TrustOutcome`] is the TOFU decision.
///
/// `step_back` shifts the ANNOUNCED time-step below the local clock's (0 =
/// the normal attempt). The joiner cannot validate the typed code locally
/// (it holds no seed yet), so a code read just before a rotation and entered
/// just after binds to the wrong step and fails as "wrong code" — the ±1
/// responder window ([`accepts_step`](super::totp)) tolerates an announced
/// `step-1`, letting the caller retry the SAME typed code against the
/// previous step before re-prompting the user (the late-code retry, field
/// report 2026-06-06).
// [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?;

    // F2: Announce(epoch) — or an Abort if the responder refused admission / step / name.
    let epoch = match read_frame(&mut recv).await? {
        Frame::Announce { epoch } => epoch,
        Frame::Abort {
            reason,
            retry_after,
        } => return Err(map_abort(reason, retry_after, subnet_name)),
        other => return Err(unexpected("Announce", other)),
    };

    // Now the joiner can bind the full transcript and run SPAKE2 side A.
    let transcript = PairingTranscript {
        subnet_name: subnet_name.to_string(),


350	        epoch,
351	        step,
352	        initiator_pub: local_pub,
353	        responder_pub,
354	    };
355	    let (initiator, msg_a) = Initiator::start(transcript, code);
356
357	    // F3: Spake1 — msg_a.
358	    write_frame(&mut send, &enc_spake1(&msg_a)).await?;
359
360	    // F4: Spake2 — msg_b + the responder's confirmation tag.
361	    let (msg_b, responder_tag) = match read_frame(&mut recv).await? {
362	        Frame::Spake2 { msg_b, tag } => (msg_b, tag),
363	        Frame::Abort {
364	            reason,
365	            retry_after,
366	        } => return Err(map_abort(reason, retry_after, subnet_name)),
367	        other => return Err(unexpected("Spake2", other)),
368	    };
369
370	    // Finish the PAKE and verify the responder's tag. On failure, signal the responder
371	    // with an Abort so it doesn't wait out the ceremony timeout, then surface the error.
372	    let (paired, initiator_tag) = match initiator.finish(&msg_b, &responder_tag) {
373	        Ok(v) => v,
374	        Err(e) => {
375	            abort(&mut send, A_CONFIRM_FAIL, 0).await?;
376	            let _ = send.finish();
377	            return Err(PairWireError::Pairing(e));
378	        }
379	    };
380
381	    // F5: Confirm — the joiner's own tag (proves to the responder it holds the same key).
382	    write_frame(&mut send, &enc_confirm(&initiator_tag)).await?;
383	    let _ = send.finish();
384
385	    // F6: Done — the responder's verdict on our tag.
386	    match read_frame(&mut recv).await? {
387	        Frame::Done { ok: true } => {
388	            // Pin the responder (its pubkey was proven live by the confirmation round).
389	            let outcome = trust.record(subnet_name, &paired.peer_pub.to_hex(), None, None);
390	            // F7: receive the subnet seed and become a full member / future seed-holder.
391	            // Read it on the same confirmed stream; a missing/short Seed frame is a
392	            // protocol error (the responder always sends it on success).
393	            match read_frame(&mut recv).await? {
394	                Frame::Seed { seed, epoch } => {
395	                    // `add_joined` errors `Exists` for an idempotent re-pair (we already
396	                    // hold this subnet) — that is success, not failure: keep our record.
397	                    match subnets.add_joined(subnet_name, seed, epoch) {
398	                        Ok(_) | Err(SubnetError::Exists(_)) => {}
399	                        Err(e) => return Err(PairWireError::Protocol(format!("seed store: {e}"))),
400	                    }
401	                }
402	                other => return Err(unexpected("Seed", other)),
403	            }
404	            Ok(outcome)
405	        }
406	        Frame::Done { ok: false } => Err(PairWireError::Pairing(PairError::Confirm)),
407	        Frame::Abort {
408	            reason,
409	            retry_after,
410	        } => Err(map_abort(reason, retry_after, subnet_name)),
411	        other => Err(unexpected("Done", other)),
412	    }
413	}
414
415	/// The pubkey of the connection's remote peer, from the iroh-authenticated endpoint id.
416	fn remote_pub(conn: &Connection) -> Result<PublicKey, PairWireError> {
417	    PublicKey::from_bytes(conn.remote_id().as_bytes())
418	        .map_err(|e| PairWireError::Protocol(format!("invalid remote id: {e}")))
419	}
420
421	/// The TOTP code string at an exact step (zero-padded to [`DIGITS`]) — the PAKE password.
422	fn code_at_step(seed: &TotpSeed, step: u64) -> String {
423	    let n = seed.code_at_step(step, DIGITS);
424	    format!("{n:0width$}", width = DIGITS as usize)
425	}
426
427	/// Map a responder Abort reason to the initiator-side error.
428	fn map_abort(reason: u8, retry_after: u64, subnet_name: &str) -> PairWireError {
429	    match reason {
430	        A_BUSY => PairWireError::Refused(RateLimited::Busy),
431	        A_BACKOFF => PairWireError::Refused(RateLimited::Backoff {
432	            retry_after_secs: retry_after,
433	        }),
434	        A_BAD_STEP => PairWireError::StepRejected,
435	        A_UNKNOWN_SUBNET => PairWireError::UnknownSubnet(subnet_name.to_string()),
436	        A_BAD_MSG | A_CONFIRM_FAIL => PairWireError::Pairing(PairError::Confirm),
437	        r => PairWireError::Protocol(format!("unknown abort reason {r}")),
438	    }
439	}
440
441	fn unexpected(want: &str, got: Frame) -> PairWireError {
442	    PairWireError::Protocol(format!("expected {want}, got {}", got.kind()))
443	}
444
445	// ── Frame model + codec ──────────────────────────────────────────────────────────
446
447	/// A decoded ceremony frame. The wire form is a u32-BE total length followed by a tag
448	/// byte and the tag's fields; variable fields are themselves u32-BE length-prefixed
449	/// (the same convention as [`transcript`](super::transcript)).
450	enum Frame {
451	    Hello {
452	        subnet_name: String,
453	        step: u64,
454	    },
455	    Announce {
456	        epoch: u64,
457	    },
458	    Spake1 {
459	        msg_a: Vec<u8>,
460	    },
461	    Spake2 {
462	        msg_b: Vec<u8>,
463	        tag: [u8; TAG_LEN],
464	    },
465	    Confirm {
466	        tag: [u8; TAG_LEN],
467	    },
468	    Done {
469	        ok: bool,
470	    },
471	    Seed {
472	        seed: [u8; TOTP_SEED_LEN],
473	        epoch: u64,
474	    },
475	    Abort {
476	        reason: u8,
477	        retry_after: u64,
478	    },
479	}
480
481	impl Frame {
482	    fn kind(&self) -> &'static str {
483	        match self {
484	            Frame::Hello { .. } => "Hello",
485	            Frame::Announce { .. } => "Announce",
486	            Frame::Spake1 { .. } => "Spake1",
487	            Frame::Spake2 { .. } => "Spake2",
488	            Frame::Confirm { .. } => "Confirm",
489	            Frame::Done { .. } => "Done",
490	            Frame::Seed { .. } => "Seed",
491	            Frame::Abort { .. } => "Abort",
492	        }
493	    }
494	}
495
496	fn enc_hello(subnet_name: &str, step: u64) -> Vec<u8> {
497	    let mut b = vec![T_HELLO];
498	    push_field(&mut b, subnet_name.as_bytes());
499	    b.extend_from_slice(&step.to_be_bytes());
500	    b
501	}
502
503	fn enc_announce(epoch: u64) -> Vec<u8> {
504	    let mut b = vec![T_ANNOUNCE];
505	    b.extend_from_slice(&epoch.to_be_bytes());
506	    b
507	}
508
509	fn enc_spake1(msg_a: &[u8]) -> Vec<u8> {
510	    let mut b = vec![T_SPAKE1];
511	    push_field(&mut b, msg_a);
512	    b
513	}
514
515	fn enc_spake2(msg_b: &[u8], tag: &[u8; TAG_LEN]) -> Vec<u8> {
516	    let mut b = vec![T_SPAKE2];
517	    push_field(&mut b, msg_b);
518	    b.extend_from_slice(tag);
519	    b
520	}
521
522	fn enc_confirm(tag: &[u8; TAG_LEN]) -> Vec<u8> {
523	    let mut b = vec![T_CONFIRM];
524	    b.extend_from_slice(tag);
525	    b
526	}
527
528	fn enc_done(ok: bool) -> Vec<u8> {
529	    vec![T_DONE, ok as u8]
530	}
531
532	fn enc_seed(seed: &[u8; TOTP_SEED_LEN], epoch: u64) -> Vec<u8> {
533	    let mut b = vec![T_SEED];
534	    b.extend_from_slice(seed);
535	    b.extend_from_slice(&epoch.to_be_bytes());
536	    b
537	}
538
539	fn enc_abort(reason: u8, retry_after: u64) -> Vec<u8> {
540	    let mut b = vec![T_ABORT, reason];
541	    b.extend_from_slice(&retry_after.to_be_bytes());
542	    b
543	}
544
545	/// Append a u32-BE length-prefixed field.
546	fn push_field(buf: &mut Vec<u8>, field: &[u8]) {
547	    buf.extend_from_slice(&(field.len() as u32).to_be_bytes());
548	    buf.extend_from_slice(field);
549	}
550
551	/// Write one length-framed message: u32-BE total length, then the body.
552	async fn write_frame(send: &mut SendStream, body: &[u8]) -> Result<(), PairWireError> {
553	    let len = body.len() as u32;
554	    send.write_all(&len.to_be_bytes())
555	        .await
556	        .map_err(|e| PairWireError::Io(format!("write len: {e}")))?;
557	    send.write_all(body)
558	        .await
559	        .map_err(|e| PairWireError::Io(format!("write body: {e}")))
560	}
561
562	/// Send an Abort frame (best-effort signal to the peer before returning an error).
563	async fn abort(send: &mut SendStream, reason: u8, retry_after: u64) -> Result<(), PairWireError> {
564	    write_frame(send, &enc_abort(reason, retry_after)).await?;
565	    let _ = send.finish();
566	    Ok(())
567	}
568
569	/// Read and decode one length-framed message, bounding the length at [`MAX_FRAME`].
570	async fn read_frame(recv: &mut RecvStream) -> Result<Frame, PairWireError> {
571	    let mut len_buf = [0u8; 4];
572	    recv.read_exact(&mut len_buf)
573	        .await
574	        .map_err(|e| PairWireError::Io(format!("read len: {e}")))?;
575	    let len = u32::from_be_bytes(len_buf) as usize;
576	    if len == 0 || len > MAX_FRAME {
577	        return Err(PairWireError::Protocol(format!(
578	            "frame length {len} out of range"
579	        )));

        epoch,
        step,
        initiator_pub: local_pub,
        responder_pub,
    };
    let (initiator, msg_a) = Initiator::start(transcript, code);

    // F3: Spake1 — msg_a.
    write_frame(&mut send, &enc_spake1(&msg_a)).await?;

    // F4: Spake2 — msg_b + the responder's confirmation tag.
    let (msg_b, responder_tag) = match read_frame(&mut recv).await? {
        Frame::Spake2 { msg_b, tag } => (msg_b, tag),
        Frame::Abort {
            reason,
            retry_after,
        } => return Err(map_abort(reason, retry_after, subnet_name)),
        other => return Err(unexpected("Spake2", other)),
    };

    // Finish the PAKE and verify the responder's tag. On failure, signal the responder
    // with an Abort so it doesn't wait out the ceremony timeout, then surface the error.
    let (paired, initiator_tag) = match initiator.finish(&msg_b, &responder_tag) {
        Ok(v) => v,
        Err(e) => {
            abort(&mut send, A_CONFIRM_FAIL, 0).await?;
            let _ = send.finish();
            return Err(PairWireError::Pairing(e));
        }
    };

    // F5: Confirm — the joiner's own tag (proves to the responder it holds the same key).
    write_frame(&mut send, &enc_confirm(&initiator_tag)).await?;
    let _ = send.finish();

    // F6: Done — the responder's verdict on our tag.
    match read_frame(&mut recv).await? {
        Frame::Done { ok: true } => {
            // Pin the responder (its pubkey was proven live by the confirmation round).
            let outcome = trust.record(subnet_name, &paired.peer_pub.to_hex(), None, None);
            // F7: receive the subnet seed and become a full member / future seed-holder.
            // Read it on the same confirmed stream; a missing/short Seed frame is a
            // protocol error (the responder always sends it on success).
            match read_frame(&mut recv).await? {
                Frame::Seed { seed, epoch } => {
                    // `add_joined` errors `Exists` for an idempotent re-pair (we already
                    // hold this subnet) — that is success, not failure: keep our record.
                    match subnets.add_joined(subnet_name, seed, epoch) {
                        Ok(_) | Err(SubnetError::Exists(_)) => {}
                        Err(e) => return Err(PairWireError::Protocol(format!("seed store: {e}"))),
                    }
                }
                other => return Err(unexpected("Seed", other)),
            }
            Ok(outcome)
        }
        Frame::Done { ok: false } => Err(PairWireError::Pairing(PairError::Confirm)),
        Frame::Abort {
            reason,
            retry_after,
        } => Err(map_abort(reason, retry_after, subnet_name)),
        other => Err(unexpected("Done", other)),
    }
}

/// The pubkey of the connection's remote peer, from the iroh-authenticated endpoint id.
fn remote_pub(conn: &Connection) -> Result<PublicKey, PairWireError> {
    PublicKey::from_bytes(conn.remote_id().as_bytes())
        .map_err(|e| PairWireError::Protocol(format!("invalid remote id: {e}")))
}

/// The TOTP code string at an exact step (zero-padded to [`DIGITS`]) — the PAKE password.
fn code_at_step(seed: &TotpSeed, step: u64) -> String {
    let n = seed.code_at_step(step, DIGITS);
    format!("{n:0width$}", width = DIGITS as usize)
}

/// Map a responder Abort reason to the initiator-side error.
fn map_abort(reason: u8, retry_after: u64, subnet_name: &str) -> PairWireError {
    match reason {
        A_BUSY => PairWireError::Refused(RateLimited::Busy),
        A_BACKOFF => PairWireError::Refused(RateLimited::Backoff {
            retry_after_secs: retry_after,
        }),
        A_BAD_STEP => PairWireError::StepRejected,
        A_UNKNOWN_SUBNET => PairWireError::UnknownSubnet(subnet_name.to_string()),
        A_BAD_MSG | A_CONFIRM_FAIL => PairWireError::Pairing(PairError::Confirm),
        r => PairWireError::Protocol(format!("unknown abort reason {r}")),
    }
}

fn unexpected(want: &str, got: Frame) -> PairWireError {
    PairWireError::Protocol(format!("expected {want}, got {}", got.kind()))
}

// ── Frame model + codec ──────────────────────────────────────────────────────────

/// A decoded ceremony frame. The wire form is a u32-BE total length followed by a tag
/// byte and the tag's fields; variable fields are themselves u32-BE length-prefixed
/// (the same convention as [`transcript`](super::transcript)).
enum Frame {
    Hello {
        subnet_name: String,
        step: u64,
    },
    Announce {
        epoch: u64,
    },
    Spake1 {
        msg_a: Vec<u8>,
    },
    Spake2 {
        msg_b: Vec<u8>,
        tag: [u8; TAG_LEN],
    },
    Confirm {
        tag: [u8; TAG_LEN],
    },
    Done {
        ok: bool,
    },
    Seed {
        seed: [u8; TOTP_SEED_LEN],
        epoch: u64,
    },
    Abort {
        reason: u8,
        retry_after: u64,
    },
}

impl Frame {
    fn kind(&self) -> &'static str {
        match self {
            Frame::Hello { .. } => "Hello",
            Frame::Announce { .. } => "Announce",
            Frame::Spake1 { .. } => "Spake1",
            Frame::Spake2 { .. } => "Spake2",
            Frame::Confirm { .. } => "Confirm",
            Frame::Done { .. } => "Done",
            Frame::Seed { .. } => "Seed",
            Frame::Abort { .. } => "Abort",
        }
    }
}

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_announce(epoch: u64) -> Vec<u8> {
    let mut b = vec![T_ANNOUNCE];
    b.extend_from_slice(&epoch.to_be_bytes());
    b
}

fn enc_spake1(msg_a: &[u8]) -> Vec<u8> {
    let mut b = vec![T_SPAKE1];
    push_field(&mut b, msg_a);
    b
}

fn enc_spake2(msg_b: &[u8], tag: &[u8; TAG_LEN]) -> Vec<u8> {
    let mut b = vec![T_SPAKE2];
    push_field(&mut b, msg_b);
    b.extend_from_slice(tag);
    b
}

fn enc_confirm(tag: &[u8; TAG_LEN]) -> Vec<u8> {
    let mut b = vec![T_CONFIRM];
    b.extend_from_slice(tag);
    b
}

fn enc_done(ok: bool) -> Vec<u8> {
    vec![T_DONE, ok as u8]
}

fn enc_seed(seed: &[u8; TOTP_SEED_LEN], epoch: u64) -> Vec<u8> {
    let mut b = vec![T_SEED];
    b.extend_from_slice(seed);
    b.extend_from_slice(&epoch.to_be_bytes());
    b
}

fn enc_abort(reason: u8, retry_after: u64) -> Vec<u8> {
    let mut b = vec![T_ABORT, reason];
    b.extend_from_slice(&retry_after.to_be_bytes());
    b
}

/// Append a u32-BE length-prefixed field.
fn push_field(buf: &mut Vec<u8>, field: &[u8]) {
    buf.extend_from_slice(&(field.len() as u32).to_be_bytes());
    buf.extend_from_slice(field);
}

/// Write one length-framed message: u32-BE total length, then the body.
async fn write_frame(send: &mut SendStream, body: &[u8]) -> Result<(), PairWireError> {
    let len = body.len() as u32;
    send.write_all(&len.to_be_bytes())
        .await
        .map_err(|e| PairWireError::Io(format!("write len: {e}")))?;
    send.write_all(body)
        .await
        .map_err(|e| PairWireError::Io(format!("write body: {e}")))
}

/// Send an Abort frame (best-effort signal to the peer before returning an error).
async fn abort(send: &mut SendStream, reason: u8, retry_after: u64) -> Result<(), PairWireError> {
    write_frame(send, &enc_abort(reason, retry_after)).await?;
    let _ = send.finish();
    Ok(())
}

/// Read and decode one length-framed message, bounding the length at [`MAX_FRAME`].
async fn read_frame(recv: &mut RecvStream) -> Result<Frame, PairWireError> {
    let mut len_buf = [0u8; 4];
    recv.read_exact(&mut len_buf)
        .await
        .map_err(|e| PairWireError::Io(format!("read len: {e}")))?;
    let len = u32::from_be_bytes(len_buf) as usize;
    if len == 0 || len > MAX_FRAME {
        return Err(PairWireError::Protocol(format!(
            "frame length {len} out of range"
        )));


Codec + flow clear. Check Hello decoder strictness + respond call site.


580	    }
581	    let mut body = vec![0u8; len];
582	    recv.read_exact(&mut body)
583	        .await
584	        .map_err(|e| PairWireError::Io(format!("read body: {e}")))?;
585	    decode_frame(&body)
586	}
587
588	fn decode_frame(body: &[u8]) -> Result<Frame, PairWireError> {
589	    let mut c = Cursor::new(body);
590	    let tag = c.u8()?;
591	    let frame = match tag {
592	        T_HELLO => {
593	            let subnet_name = String::from_utf8(c.field()?.to_vec())
594	                .map_err(|_| PairWireError::Protocol("subnet name not utf-8".into()))?;
595	            let step = c.u64()?;
596	            Frame::Hello { subnet_name, step }
597	        }
598	        T_ANNOUNCE => Frame::Announce { epoch: c.u64()? },
599	        T_SPAKE1 => Frame::Spake1 {
600	            msg_a: c.field()?.to_vec(),
601	        },
602	        T_SPAKE2 => {
603	            let msg_b = c.field()?.to_vec();
604	            let tag = c.tag()?;
605	            Frame::Spake2 { msg_b, tag }
606	        }
607	        T_CONFIRM => Frame::Confirm { tag: c.tag()? },
608	        T_DONE => Frame::Done { ok: c.u8()? != 0 },
609	        T_SEED => {
610	            let seed: [u8; TOTP_SEED_LEN] = c
611	                .take(TOTP_SEED_LEN)?
612	                .try_into()
613	                .expect("took TOTP_SEED_LEN");
614	            let epoch = c.u64()?;
615	            Frame::Seed { seed, epoch }
616	        }
617	        T_ABORT => {
618	            let reason = c.u8()?;
619	            let retry_after = c.u64()?;
620	            Frame::Abort {
621	                reason,
622	                retry_after,
623	            }
624	        }
625	        other => {
626	            return Err(PairWireError::Protocol(format!(
627	                "unknown frame tag {other}"
628	            )))
629	        }
630	    };
631	    Ok(frame)
632	}
633
634	/// A minimal big-endian field reader over a frame body — every accessor bounds-checks so
635	/// a truncated or oversized field is a [`Protocol`](PairWireError::Protocol) error, never
636	/// a panic (the input is pre-trust attacker-reachable).
637	struct Cursor<'a> {
638	    buf: &'a [u8],
639	    pos: usize,
640	}
641
642	impl<'a> Cursor<'a> {
643	    fn new(buf: &'a [u8]) -> Cursor<'a> {
644	        Cursor { buf, pos: 0 }
645	    }
646
647	    fn take(&mut self, n: usize) -> Result<&'a [u8], PairWireError> {
648	        let end = self
649	            .pos
650	            .checked_add(n)
651	            .filter(|e| *e <= self.buf.len())
652	            .ok_or_else(|| PairWireError::Protocol("frame truncated".into()))?;
653	        let slice = &self.buf[self.pos..end];
654	        self.pos = end;
655	        Ok(slice)
656	    }
657
658	    fn u8(&mut self) -> Result<u8, PairWireError> {
659	        Ok(self.take(1)?[0])
660	    }
661
662	    fn u64(&mut self) -> Result<u64, PairWireError> {
663	        let b = self.take(8)?;
664	        Ok(u64::from_be_bytes(b.try_into().expect("took 8")))
665	    }
666
667	    /// A u32-BE length-prefixed field, bounded by [`MAX_FRAME`].
668	    fn field(&mut self) -> Result<&'a [u8], PairWireError> {
669	        let len = u32::from_be_bytes(self.take(4)?.try_into().expect("took 4")) as usize;
670	        if len > MAX_FRAME {
671	            return Err(PairWireError::Protocol("field length out of range".into()));
672	        }
673	        self.take(len)
674	    }
675
676	    fn tag(&mut self) -> Result<[u8; TAG_LEN], PairWireError> {
677	        Ok(self.take(TAG_LEN)?.try_into().expect("took TAG_LEN"))
678	    }
679	}
680
681	#[cfg(test)]
682	mod tests {
683	    use super::*;
684	    use crate::net::endpoint::{BindScope, LocalDiscovery, NetEndpoint, RelayPolicy};
685	    use spt_proto::identity::Identity;
686	    use spt_store::subnet::{SubnetRecord, SubnetStore};
687	    use spt_store::trust::TrustStore;
688
689	    /// Reference time: a fixed unix second so both sides share a TOTP step deterministically.
690	    const NOW: u64 = 1_711_111_110;
691
692	    /// A responder-side subnet store holding one subnet with a known seed at `epoch`.
693	    fn subnet_store(name: &str, seed: [u8; 20], epoch: u64) -> SubnetStore {
694	        SubnetStore {
695	            subnets: vec![SubnetRecord {
696	                name: name.to_string(),
697	                seed_hex: hex(&seed),
698	                epoch,
699	                user: None,

    }
    let mut body = vec![0u8; len];
    recv.read_exact(&mut body)
        .await
        .map_err(|e| PairWireError::Io(format!("read body: {e}")))?;
    decode_frame(&body)
}

fn decode_frame(body: &[u8]) -> Result<Frame, PairWireError> {
    let mut c = Cursor::new(body);
    let tag = c.u8()?;
    let frame = match tag {
        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_ANNOUNCE => Frame::Announce { epoch: c.u64()? },
        T_SPAKE1 => Frame::Spake1 {
            msg_a: c.field()?.to_vec(),
        },
        T_SPAKE2 => {
            let msg_b = c.field()?.to_vec();
            let tag = c.tag()?;
            Frame::Spake2 { msg_b, tag }
        }
        T_CONFIRM => Frame::Confirm { tag: c.tag()? },
        T_DONE => Frame::Done { ok: c.u8()? != 0 },
        T_SEED => {
            let seed: [u8; TOTP_SEED_LEN] = c
                .take(TOTP_SEED_LEN)?
                .try_into()
                .expect("took TOTP_SEED_LEN");
            let epoch = c.u64()?;
            Frame::Seed { seed, epoch }
        }
        T_ABORT => {
            let reason = c.u8()?;
            let retry_after = c.u64()?;
            Frame::Abort {
                reason,
                retry_after,
            }
        }
        other => {
            return Err(PairWireError::Protocol(format!(
                "unknown frame tag {other}"
            )))
        }
    };
    Ok(frame)
}

/// A minimal big-endian field reader over a frame body — every accessor bounds-checks so
/// a truncated or oversized field is a [`Protocol`](PairWireError::Protocol) error, never
/// a panic (the input is pre-trust attacker-reachable).
struct Cursor<'a> {
    buf: &'a [u8],
    pos: usize,
}

impl<'a> Cursor<'a> {
    fn new(buf: &'a [u8]) -> Cursor<'a> {
        Cursor { buf, pos: 0 }
    }

    fn take(&mut self, n: usize) -> Result<&'a [u8], PairWireError> {
        let end = self
            .pos
            .checked_add(n)
            .filter(|e| *e <= self.buf.len())
            .ok_or_else(|| PairWireError::Protocol("frame truncated".into()))?;
        let slice = &self.buf[self.pos..end];
        self.pos = end;
        Ok(slice)
    }

    fn u8(&mut self) -> Result<u8, PairWireError> {
        Ok(self.take(1)?[0])
    }

    fn u64(&mut self) -> Result<u64, PairWireError> {
        let b = self.take(8)?;
        Ok(u64::from_be_bytes(b.try_into().expect("took 8")))
    }

    /// A u32-BE length-prefixed field, bounded by [`MAX_FRAME`].
    fn field(&mut self) -> Result<&'a [u8], PairWireError> {
        let len = u32::from_be_bytes(self.take(4)?.try_into().expect("took 4")) as usize;
        if len > MAX_FRAME {
            return Err(PairWireError::Protocol("field length out of range".into()));
        }
        self.take(len)
    }

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

#[cfg(test)]
mod tests {
    use super::*;
    use crate::net::endpoint::{BindScope, LocalDiscovery, NetEndpoint, RelayPolicy};
    use spt_proto::identity::Identity;
    use spt_store::subnet::{SubnetRecord, SubnetStore};
    use spt_store::trust::TrustStore;

    /// Reference time: a fixed unix second so both sides share a TOTP step deterministically.
    const NOW: u64 = 1_711_111_110;

    /// A responder-side subnet store holding one subnet with a known seed at `epoch`.
    fn subnet_store(name: &str, seed: [u8; 20], epoch: u64) -> SubnetStore {
        SubnetStore {
            subnets: vec![SubnetRecord {
                name: name.to_string(),
                seed_hex: hex(&seed),
                epoch,
                user: None,


Decoder ignores trailing bytes — Hello extension backward-tolerant. Find `respond` call site.


crates\spt-daemon\src\nethost.rs:410:    /// ([`crate::pairhost::respond`]), never registered in the stream table.
crates\spt-daemon\src\nethost.rs:459:                        crate::pairhost::respond(conn, local_pub, rate).await;
crates\spt-daemon\src\nethost.rs:469:        crate::pairhost::spawn_meet_rotation(
crates\spt-daemon\src\nethost.rs:503:            crate::pairhost::join_ceremony(&endpoint, subnet, code, responder_addr).await
crates\spt-daemon\src\pairhost.rs:15://! - **Meet rotation** ([`spawn_meet_rotation`]): for each member subnet, a
crates\spt-daemon\src\pairhost.rs:19://! - **Joiner leg** ([`join_ceremony`]): dial via rendezvous, run the
crates\spt-daemon\src\pairhost.rs:127:pub fn spawn_meet_rotation(
crates\spt-daemon\src\pairhost.rs:254:pub async fn join_ceremony(


395	    endpoint: Arc<NetEndpoint>,
396	    shared: Arc<NetShared>,
397	    /// `op_id → conn_id` for exactly-once dial replies: a deduped replay of a
398	    /// journaled dial is answered with the connection the first apply opened.
399	    dial_ops: Mutex<HashMap<u64, u64>>,
400	    /// `op_id → stream_id` for exactly-once stream-open replies (D4b), same
401	    /// discipline as `dial_ops`. Shares the one net op-id namespace.
402	    stream_ops: Mutex<HashMap<u64, u64>>,
403	}
404
405	impl NetHost {
406	    /// Bind the broker's endpoint on a fresh dedicated runtime and start the
407	    /// accept loop. The endpoint speaks TWO ALPNs (M7 D3, REQ-SUBNET-2): the
408	    /// trusted [`SPT_NET_ALPN`] (connections join the table as before) and
409	    /// the pre-trust pairing ALPN — routed to the always-on responder
410	    /// ([`crate::pairhost::respond`]), never registered in the stream table.
411	    /// One endpoint for both is deliberate: the pairing transcript binds and
412	    /// pins the REAL node pubkey, and the relay can only route one endpoint
413	    /// per key — the ALPN is the pre-trust/trusted boundary (ADR-0005).
414	    /// The meet-rotation listener loop rides the same runtime.
415	    // [impl->REQ-DAEMON-2]
416	    // [impl->REQ-SUBNET-2]
417	    pub fn start(cfg: NetConfig) -> io::Result<NetHost> {
418	        let runtime = tokio::runtime::Builder::new_multi_thread()
419	            .worker_threads(2)
420	            .thread_name("spt-broker-net")
421	            .enable_all()
422	            .build()?;
423	        let endpoint = runtime
424	            .block_on(NetEndpoint::bind_scoped(
425	                &cfg.identity,
426	                cfg.relay.clone(),
427	                cfg.discovery,
428	                vec![
429	                    SPT_NET_ALPN.to_vec(),
430	                    spt_net::net::pairing::wire::SPT_PAIR_ALPN.to_vec(),
431	                ],
432	                cfg.scope,
433	            ))
434	            .map_err(|e| io::Error::other(e.to_string()))?;
435	        let endpoint = Arc::new(endpoint);
436	        let shared = Arc::new(NetShared::new());
437	        let local_pub = cfg.identity.public_key();
438	        let pair_rate = Arc::new(tokio::sync::Mutex::new(
439	            spt_net::net::pairing::ratelimit::PairingRateLimiter::new(),
440	        ));
441
442	        // Accept loop: lives for the endpoint's lifetime on the host's runtime.
443	        // Each handshake completes on its own task so a slow peer can't stall
444	        // the loop; a failed handshake is simply not registered. The pairing
445	        // ALPN routes to the responder; everything else is the trusted table.
446	        let ep = Arc::clone(&endpoint);
447	        let sh = Arc::clone(&shared);
448	        let rate = Arc::clone(&pair_rate);
449	        runtime.spawn(async move {
450	            while let Some(incoming) = ep.accept().await {
451	                let sh = Arc::clone(&sh);
452	                let rate = Arc::clone(&rate);
453	                tokio::spawn(async move {
454	                    let Ok(connecting) = incoming.accept() else {
455	                        return;
456	                    };
457	                    let Ok(conn) = connecting.await else { return };
458	                    if conn.alpn() == spt_net::net::pairing::wire::SPT_PAIR_ALPN {
459	                        crate::pairhost::respond(conn, local_pub, rate).await;
460	                    } else {
461	                        register_conn(&sh, conn);
462	                    }
463	                });
464	            }
465	        });
466
467	        // The meet-rotation loop (M7 D3): how a joiner that knows only
468	        // (subnet name, code) finds this responder. Idles with no subnets.
469	        crate::pairhost::spawn_meet_rotation(
470	            runtime.handle(),
471	            Arc::clone(&endpoint),
472	            cfg.relay,
473	            cfg.discovery,
474	            cfg.scope,
475	        );
476
477	        // The limiter Arc lives on in the accept loop; the host keeps no
478	        // handle (per-ceremony state is the responder's, not the table's).
479	        drop(pair_rate);
480
481	        Ok(NetHost {
482	            runtime,
483	            endpoint,
484	            shared,
485	            dial_ops: Mutex::new(HashMap::new()),
486	            stream_ops: Mutex::new(HashMap::new()),
487	        })
488	    }
489
490	    /// Run the joiner leg of the pairing ceremony (M7 D3, REQ-SUBNET-2) on
491	    /// the host's runtime — the broker IPC `pair-join` arm. The ceremony
492	    /// must run HERE because it binds and pins this endpoint's real node
493	    /// pubkey. Returns the rendered trust outcome, or the classified failure.
494	    // [impl->REQ-SUBNET-2]
495	    pub fn pair_join(
496	        &self,
497	        subnet: &str,
498	        code: &str,
499	        responder_addr: Option<EndpointAddr>,
500	    ) -> Result<String, crate::pairhost::JoinFail> {
501	        let endpoint = Arc::clone(&self.endpoint);
502	        self.runtime.handle().block_on(async move {
503	            crate::pairhost::join_ceremony(&endpoint, subnet, code, responder_addr).await
504	        })
505	    }
506
507	    /// The node's stable name (lowercase-hex pubkey == iroh EndpointId).
508	    pub fn node_id_hex(&self) -> String {
509	        self.endpoint.node_id_hex()
510	    }
511
512	    /// The endpoint's dialable address (id + currently-known direct/relay paths).
513	    pub fn addr(&self) -> EndpointAddr {
514	        self.endpoint.addr()
515	    }
516
517	    /// [`Self::addr`] as the opaque JSON the IPC status reply carries.
518	    pub fn addr_json(&self) -> serde_json::Value {
519	        serde_json::to_value(self.endpoint.addr()).unwrap_or(serde_json::Value::Null)

    endpoint: Arc<NetEndpoint>,
    shared: Arc<NetShared>,
    /// `op_id → conn_id` for exactly-once dial replies: a deduped replay of a
    /// journaled dial is answered with the connection the first apply opened.
    dial_ops: Mutex<HashMap<u64, u64>>,
    /// `op_id → stream_id` for exactly-once stream-open replies (D4b), same
    /// discipline as `dial_ops`. Shares the one net op-id namespace.
    stream_ops: Mutex<HashMap<u64, u64>>,
}

impl NetHost {
    /// Bind the broker's endpoint on a fresh dedicated runtime and start the
    /// accept loop. The endpoint speaks TWO ALPNs (M7 D3, REQ-SUBNET-2): the
    /// trusted [`SPT_NET_ALPN`] (connections join the table as before) and
    /// the pre-trust pairing ALPN — routed to the always-on responder
    /// ([`crate::pairhost::respond`]), never registered in the stream table.
    /// One endpoint for both is deliberate: the pairing transcript binds and
    /// pins the REAL node pubkey, and the relay can only route one endpoint
    /// per key — the ALPN is the pre-trust/trusted boundary (ADR-0005).
    /// The meet-rotation listener loop rides the same runtime.
    // [impl->REQ-DAEMON-2]
    // [impl->REQ-SUBNET-2]
    pub fn start(cfg: NetConfig) -> io::Result<NetHost> {
        let runtime = tokio::runtime::Builder::new_multi_thread()
            .worker_threads(2)
            .thread_name("spt-broker-net")
            .enable_all()
            .build()?;
        let endpoint = runtime
            .block_on(NetEndpoint::bind_scoped(
                &cfg.identity,
                cfg.relay.clone(),
                cfg.discovery,
                vec![
                    SPT_NET_ALPN.to_vec(),
                    spt_net::net::pairing::wire::SPT_PAIR_ALPN.to_vec(),
                ],
                cfg.scope,
            ))
            .map_err(|e| io::Error::other(e.to_string()))?;
        let endpoint = Arc::new(endpoint);
        let shared = Arc::new(NetShared::new());
        let local_pub = cfg.identity.public_key();
        let pair_rate = Arc::new(tokio::sync::Mutex::new(
            spt_net::net::pairing::ratelimit::PairingRateLimiter::new(),
        ));

        // Accept loop: lives for the endpoint's lifetime on the host's runtime.
        // Each handshake completes on its own task so a slow peer can't stall
        // the loop; a failed handshake is simply not registered. The pairing
        // ALPN routes to the responder; everything else is the trusted table.
        let ep = Arc::clone(&endpoint);
        let sh = Arc::clone(&shared);
        let rate = Arc::clone(&pair_rate);
        runtime.spawn(async move {
            while let Some(incoming) = ep.accept().await {
                let sh = Arc::clone(&sh);
                let rate = Arc::clone(&rate);
                tokio::spawn(async move {
                    let Ok(connecting) = incoming.accept() else {
                        return;
                    };
                    let Ok(conn) = connecting.await else { return };
                    if conn.alpn() == spt_net::net::pairing::wire::SPT_PAIR_ALPN {
                        crate::pairhost::respond(conn, local_pub, rate).await;
                    } else {
                        register_conn(&sh, conn);
                    }
                });
            }
        });

        // The meet-rotation loop (M7 D3): how a joiner that knows only
        // (subnet name, code) finds this responder. Idles with no subnets.
        crate::pairhost::spawn_meet_rotation(
            runtime.handle(),
            Arc::clone(&endpoint),
            cfg.relay,
            cfg.discovery,
            cfg.scope,
        );

        // The limiter Arc lives on in the accept loop; the host keeps no
        // handle (per-ceremony state is the responder's, not the table's).
        drop(pair_rate);

        Ok(NetHost {
            runtime,
            endpoint,
            shared,
            dial_ops: Mutex::new(HashMap::new()),
            stream_ops: Mutex::new(HashMap::new()),
        })
    }

    /// Run the joiner leg of the pairing ceremony (M7 D3, REQ-SUBNET-2) on
    /// the host's runtime — the broker IPC `pair-join` arm. The ceremony
    /// must run HERE because it binds and pins this endpoint's real node
    /// pubkey. Returns the rendered trust outcome, or the classified failure.
    // [impl->REQ-SUBNET-2]
    pub fn pair_join(
        &self,
        subnet: &str,
        code: &str,
        responder_addr: Option<EndpointAddr>,
    ) -> Result<String, crate::pairhost::JoinFail> {
        let endpoint = Arc::clone(&self.endpoint);
        self.runtime.handle().block_on(async move {
            crate::pairhost::join_ceremony(&endpoint, subnet, code, responder_addr).await
        })
    }

    /// The node's stable name (lowercase-hex pubkey == iroh EndpointId).
    pub fn node_id_hex(&self) -> String {
        self.endpoint.node_id_hex()
    }

    /// The endpoint's dialable address (id + currently-known direct/relay paths).
    pub fn addr(&self) -> EndpointAddr {
        self.endpoint.addr()
    }

    /// [`Self::addr`] as the opaque JSON the IPC status reply carries.
    pub fn addr_json(&self) -> serde_json::Value {
        serde_json::to_value(self.endpoint.addr()).unwrap_or(serde_json::Value::Null)


crates\spt-daemon\tests\dispatch.rs:593:            node_label: None,
crates\spt\src\cli.rs:6016:                node_label: None,
crates\spt\src\cli.rs:6337:                    node_label: None,
crates\spt\src\cli.rs:6409:            node_label: label.map(str::to_string),
crates\spt\src\wansend.rs:593:                node_label: None,
crates\spt\src\wansend.rs:903:                    node_label: label.map(str::to_string),
crates\spt-daemon\tests\peerloop.rs:274:                    node_label: None,
crates\spt-daemon\tests\sync.rs:423:            node_label: None,
crates\spt-daemon\tests\sync.rs:432:            node_label: None,
crates\spt-daemon\tests\replicate.rs:121:        node_label: None,
crates\spt-daemon\tests\replicate.rs:152:            node_label: None,
crates\spt-net\src\net\registry.rs:667:            node_label: None,
crates\spt-net\src\net\registry.rs:821:            node_label: None,
crates\spt-net\src\net\registry.rs:867:            node_label: label.map(str::to_string),
crates\spt-net\src\net\registry.rs:904:            node_label: Some(label.to_string()),
crates\spt-net\src\net\registry.rs:974:            node_label: None,
crates\spt-net\src\net\registry.rs:1015:                node_label: None,
crates\spt-net\src\net\replicate.rs:93:                node_label: None,
crates\spt-daemon\src\notif.rs:622:                    node_label: None,
crates\spt-daemon\src\presence.rs:225:            node_label: None,
crates\spt-daemon\src\reconcile.rs:57:            node_label: None,
crates\spt-daemon\src\registryhost.rs:130:            node_label: None,
crates\spt-daemon\src\registryhost.rs:507:                node_label: None,
crates\spt-daemon\src\registryhost.rs:687:                    node_label: None,
crates\spt-net\src\net\wanmsg.rs:113:                node_label: None,
crates\spt-daemon\src\shelldisc.rs:329:            node_label: None,


26 literal sites — bulk-insert `machine_id: None` after each, then hand-fix the stamping sites. First the field decl:


40	//! one subnet, visible in another resolves cleanly in the visible one.
41	// [impl->REQ-INST-7]
42
43	use std::collections::BTreeMap;
44
45	use serde::{Deserialize, Serialize};
46	use spt_proto::addr::Address;
47	use spt_store::visibility::VisibilityStore;
48
49	/// An endpoint instance's liveness within a subnet. Ordering carries no precedence —
50	/// precedence is the epoch's job; status is just the advertised state.
51	#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
52	pub enum Status {
53	    /// Live and currently reachable.
54	    Active,
55	    /// Known but idle (e.g. a paused live agent) — addressable, not currently serving.
56	    Dormant,
57	    /// Resting **cold** (D9-2, REQ-INST-3): the harness session is closed,
58	    /// resumed-on-wake. Still addressable (`spt wake ling@desktop` must route) —
59	    /// only `Offline` is unroutable. Additive variant on a pre-release fleet;
60	    /// the NDJSON decode posture (unknown-kind skip) is the registry record's
61	    /// forward-compat story.
62	    // [impl->REQ-INST-3]
63	    Suspended,
64	    /// Believed down. Retained (not dropped) so a newer-epoch revival or a stale-Active
65	    /// suppression can be reasoned about; the resolver skips it.
66	    Offline,
67	}
68
69	/// One endpoint as seen on one node: the node it runs on, its advertised [`Status`], and
70	/// the author node's monotonic [`epoch`](Self::epoch) (the merge-precedence key).
71	#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
72	pub struct Instance {
73	    /// The node this instance runs on (node-qualifier in `id@node` addressing).
74	    pub node: String,
75	    /// Advertised liveness.
76	    pub status: Status,
77	    /// The author node's per-node monotonic epoch at the time of this update. The sole
78	    /// precedence key in [`merge_instance`] — strictly-greater wins.
79	    pub epoch: u64,
80	    /// The resource-advertisement blurb (D9-4, REQ-INST-14 — CONTEXT §resource
81	    /// advertisement): a free-text "yellow-pages" line describing what this
82	    /// endpoint can serve. **Not a separate registry** — a field on the
83	    /// instance row, riding the existing epoch lease + `RegistryUpdate`
84	    /// replication (a blurb update is ordered like any instance update);
85	    /// the "subnet resource registry" is a [projection](resource_projection)
86	    /// of these rows. Additive serde-default: absent = no blurb, and a
87	    /// pre-D9-4 record parses clean.
88	    #[serde(default, skip_serializing_if = "Option::is_none")]
89	    pub resources: Option<String>,
90	    /// The presence datum (M5-D6a, REQ-PRES-1 — CONTEXT §presence): this
91	    /// instance's `last_active_ms` recency stamp as of the authoring node's
92	    /// advertisement scan. **Not a separate gossip channel** — a field on the
93	    /// instance row riding the existing epoch lease + `RegistryUpdate`
94	    /// replication, exactly like [`resources`](Self::resources); subnet-wide
95	    /// `(last_active_node, last_active_endpoint, ts)` is a projection of
96	    /// these rows. Wall-clock ms, cross-compared ONLY as a routing heuristic
97	    /// (most-recently-active resolution) — the epoch stays the sole merge-
98	    /// precedence key; clock skew can cost a suboptimal surface target, never
99	    /// a wrong merge. Additive serde-default: a pre-D6 row parses clean.
100	    // [impl->REQ-PRES-1]
101	    #[serde(default, skip_serializing_if = "Option::is_none")]
102	    pub last_active_ms: Option<u64>,
103	    /// The authoring node's subnet-broadcast shell adapters (M5-D8c — CONTEXT
104	    /// §Shell model "Discovery scope": shell adapters on *other* subnet nodes
105	    /// with `broadcast = subnet` surface to agents). **Not a shell-instance
106	    /// registry** (shell perches stay private to the owner link, CONTEXT
107	    /// "Not in the subnet registry") — the names of `kind="shell"` adapters
108	    /// REGISTERED on the authoring node that chose subnet-wide broadcast,
109	    /// riding the instance row like [`resources`](Self::resources) does
110	    /// (same epoch lease, same visibility gate, no new channel; node-level
111	    /// data carried per-row because the rows are the distribution).
112	    /// Discovery only: spawning one rides the deferred instantiate-anywhere
113	    /// gate. Additive serde-default: a pre-D8 row parses clean.
114	    // [impl->REQ-EP-5]
115	    #[serde(default, skip_serializing_if = "Vec::is_empty")]
116	    pub shell_adapters: Vec<String>,
117	    /// The authoring node's human-readable label (M7 D2, REQ-SUBNET-3 —
118	    /// CONTEXT §node label): defaults to the node's OS hostname, re-checked
119	    /// at daemon startup (a hostname change updates the advertised label).
120	    /// Display + addressing sugar only — the pubkey stays the identity; views
121	    /// render `LABEL (keyprefix…)`. **Not a separate channel** — node-level
122	    /// data riding the instance row like [`resources`](Self::resources) does
123	    /// (the rows are the distribution; same epoch lease, same visibility
124	    /// gate). Additive serde-default: a pre-M7 row parses clean.
125	    // [impl->REQ-SUBNET-3]
126	    #[serde(default, skip_serializing_if = "Option::is_none")]
127	    pub node_label: Option<String>,
128	}
129
130	/// The per-subnet registry: `endpoint_id → [instance...]`. `BTreeMap`/`Vec` for stable,
131	/// deterministic serde ordering (no hash-seed nondeterminism across nodes).
132	#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
133	pub struct SubnetRegistry {
134	    #[serde(default)]

//! one subnet, visible in another resolves cleanly in the visible one.
// [impl->REQ-INST-7]

use std::collections::BTreeMap;

use serde::{Deserialize, Serialize};
use spt_proto::addr::Address;
use spt_store::visibility::VisibilityStore;

/// An endpoint instance's liveness within a subnet. Ordering carries no precedence —
/// precedence is the epoch's job; status is just the advertised state.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Status {
    /// Live and currently reachable.
    Active,
    /// Known but idle (e.g. a paused live agent) — addressable, not currently serving.
    Dormant,
    /// Resting **cold** (D9-2, REQ-INST-3): the harness session is closed,
    /// resumed-on-wake. Still addressable (`spt wake ling@desktop` must route) —
    /// only `Offline` is unroutable. Additive variant on a pre-release fleet;
    /// the NDJSON decode posture (unknown-kind skip) is the registry record's
    /// forward-compat story.
    // [impl->REQ-INST-3]
    Suspended,
    /// Believed down. Retained (not dropped) so a newer-epoch revival or a stale-Active
    /// suppression can be reasoned about; the resolver skips it.
    Offline,
}

/// One endpoint as seen on one node: the node it runs on, its advertised [`Status`], and
/// the author node's monotonic [`epoch`](Self::epoch) (the merge-precedence key).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Instance {
    /// The node this instance runs on (node-qualifier in `id@node` addressing).
    pub node: String,
    /// Advertised liveness.
    pub status: Status,
    /// The author node's per-node monotonic epoch at the time of this update. The sole
    /// precedence key in [`merge_instance`] — strictly-greater wins.
    pub epoch: u64,
    /// The resource-advertisement blurb (D9-4, REQ-INST-14 — CONTEXT §resource
    /// advertisement): a free-text "yellow-pages" line describing what this
    /// endpoint can serve. **Not a separate registry** — a field on the
    /// instance row, riding the existing epoch lease + `RegistryUpdate`
    /// replication (a blurb update is ordered like any instance update);
    /// the "subnet resource registry" is a [projection](resource_projection)
    /// of these rows. Additive serde-default: absent = no blurb, and a
    /// pre-D9-4 record parses clean.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub resources: Option<String>,
    /// The presence datum (M5-D6a, REQ-PRES-1 — CONTEXT §presence): this
    /// instance's `last_active_ms` recency stamp as of the authoring node's
    /// advertisement scan. **Not a separate gossip channel** — a field on the
    /// instance row riding the existing epoch lease + `RegistryUpdate`
    /// replication, exactly like [`resources`](Self::resources); subnet-wide
    /// `(last_active_node, last_active_endpoint, ts)` is a projection of
    /// these rows. Wall-clock ms, cross-compared ONLY as a routing heuristic
    /// (most-recently-active resolution) — the epoch stays the sole merge-
    /// precedence key; clock skew can cost a suboptimal surface target, never
    /// a wrong merge. Additive serde-default: a pre-D6 row parses clean.
    // [impl->REQ-PRES-1]
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub last_active_ms: Option<u64>,
    /// The authoring node's subnet-broadcast shell adapters (M5-D8c — CONTEXT
    /// §Shell model "Discovery scope": shell adapters on *other* subnet nodes
    /// with `broadcast = subnet` surface to agents). **Not a shell-instance
    /// registry** (shell perches stay private to the owner link, CONTEXT
    /// "Not in the subnet registry") — the names of `kind="shell"` adapters
    /// REGISTERED on the authoring node that chose subnet-wide broadcast,
    /// riding the instance row like [`resources`](Self::resources) does
    /// (same epoch lease, same visibility gate, no new channel; node-level
    /// data carried per-row because the rows are the distribution).
    /// Discovery only: spawning one rides the deferred instantiate-anywhere
    /// gate. Additive serde-default: a pre-D8 row parses clean.
    // [impl->REQ-EP-5]
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub shell_adapters: Vec<String>,
    /// The authoring node's human-readable label (M7 D2, REQ-SUBNET-3 —
    /// CONTEXT §node label): defaults to the node's OS hostname, re-checked
    /// at daemon startup (a hostname change updates the advertised label).
    /// Display + addressing sugar only — the pubkey stays the identity; views
    /// render `LABEL (keyprefix…)`. **Not a separate channel** — node-level
    /// data riding the instance row like [`resources`](Self::resources) does
    /// (the rows are the distribution; same epoch lease, same visibility
    /// gate). Additive serde-default: a pre-M7 row parses clean.
    // [impl->REQ-SUBNET-3]
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub node_label: Option<String>,
}

/// The per-subnet registry: `endpoint_id → [instance...]`. `BTreeMap`/`Vec` for stable,
/// deterministic serde ordering (no hash-seed nondeterminism across nodes).
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct SubnetRegistry {
    #[serde(default)]


D3 — install & elevation hardening

D5 — docs + CI + acceptance prep


Edit

    // [impl->REQ-SUBNET-3]
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub node_label: Option<String>,
}

    // [impl->REQ-SUBNET-3]
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub node_label: Option<String>,
    /// The authoring node's stable machine identifier, **hashed** (M8
    /// decision 13/22, REQ-SUBNET-7): domain-separated SHA-256 of the OS
    /// machine id (`/etc/machine-id` / Windows `MachineGuid`; spt-minted
    /// persisted UUID where unreadable) — never the raw id on the wire.
    /// The re-pair overwrite anchor: a completed pairing ceremony presenting
    /// the same node label AND the same machine id as an existing trusted
    /// row marks that row's identity as superseded (the machine re-paired
    /// under a new key). Node-level data riding the instance row like
    /// [`node_label`](Self::node_label) (the rows are the distribution;
    /// same epoch lease, same visibility gate). Additive serde-default: a
    /// pre-M8 row parses clean.
    // [impl->REQ-SUBNET-7]
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub machine_id: Option<String>,
}


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

    // [impl->REQ-SUBNET-3]
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub node_label: Option<String>,
}

    // [impl->REQ-SUBNET-3]
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub node_label: Option<String>,
    /// The authoring node's stable machine identifier, **hashed** (M8
    /// decision 13/22, REQ-SUBNET-7): domain-separated SHA-256 of the OS
    /// machine id (`/etc/machine-id` / Windows `MachineGuid`; spt-minted
    /// persisted UUID where unreadable) — never the raw id on the wire.
    /// The re-pair overwrite anchor: a completed pairing ceremony presenting
    /// the same node label AND the same machine id as an existing trusted
    /// row marks that row's identity as superseded (the machine re-paired
    /// under a new key). Node-level data riding the instance row like
    /// [`node_label`](Self::node_label) (the rows are the distribution;
    /// same epoch lease, same visibility gate). Additive serde-default: a
    /// pre-M8 row parses clean.
    // [impl->REQ-SUBNET-7]
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub machine_id: Option<String>,
}

+    /// The authoring node's stable machine identifier, **hashed** (M8

+    /// decision 13/22, REQ-SUBNET-7): domain-separated SHA-256 of the OS

+    /// machine id (`/etc/machine-id` / Windows `MachineGuid`; spt-minted

+    /// persisted UUID where unreadable) — never the raw id on the wire.

+    /// The re-pair overwrite anchor: a completed pairing ceremony presenting

+    /// the same node label AND the same machine id as an existing trusted

+    /// row marks that row's identity as superseded (the machine re-paired

+    /// under a new key). Node-level data riding the instance row like

+    /// [`node_label`](Self::node_label) (the rows are the distribution;

+    /// same epoch lease, same visibility gate). Additive serde-default: a

+    #[serde(default, skip_serializing_if = "Option::is_none")]

/// The per-subnet registry: `endpoint_id → [instance...]`. `BTreeMap`/`Vec` for stable,


PowerShell

$files = @(
  'crates\spt-daemon\tests\dispatch.rs','crates\spt\src\cli.rs','crates\spt\src\wansend.rs',
  'crates\spt-daemon\tests\peerloop.rs','crates\spt-daemon\tests\sync.rs','crates\spt-daemon\tests\replicate.rs',
  'crates\spt-net\src\net\registry.rs','crates\spt-net\src\net\replicate.rs','crates\spt-daemon\src\notif.rs',
  'crates\spt-daemon\src\presence.rs','crates\spt-daemon\src\reconcile.rs','crates\spt-daemon\src\registryhost.rs',
  'crates\spt-net\src\net\wanmsg.rs','crates\spt-daemon\src\shelldisc.rs'
)
foreach ($f in $files) {
  $text = Get-Content $f -Raw
  $new = [regex]::Replace($text, '(?m)^([ \t]*)node_label: (.+),\r?$', "`$1node_label: `$2,`r`n`$1machine_id: None,")
  if ($new -ne $text) { Set-Content -Path $f -Value $new -NoNewline; Write-Output "patched $f" }
}

Insert machine_id: None after every Instance node_label literal


patched crates\spt-daemon\tests\dispatch.rs
patched crates\spt\src\cli.rs
patched crates\spt\src\wansend.rs
patched crates\spt-daemon\tests\peerloop.rs
patched crates\spt-daemon\tests\sync.rs
patched crates\spt-daemon\tests\replicate.rs
patched crates\spt-net\src\net\registry.rs
patched crates\spt-net\src\net\replicate.rs
patched crates\spt-daemon\src\notif.rs
patched crates\spt-daemon\src\presence.rs
patched crates\spt-daemon\src\reconcile.rs
patched crates\spt-daemon\src\registryhost.rs
patched crates\spt-net\src\net\wanmsg.rs
patched crates\spt-daemon\src\shelldisc.rs


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

Build after Instance field addition


Error: Exit code 101
error[E0573]: expected type, found variant `None`
  --> crates\spt-daemon\src\registryhost.rs:97:17
   |
97 |     machine_id: None,
   |                 ^^^^ not a type
   |
help: consider importing this enum instead
   |
29 + use interprocess::os::windows::named_pipe::pipe_mode::None;
   |

For more information about this error, try `rustc --explain E0573`.
error: could not compile `spt-daemon` (lib) due to 1 previous error