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.
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]>,

    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.
// [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]>,


crates\spt-net\src\net\endpoint.rs:134:    /// only to the pairing ALPN (`super::pairing::wire::SPT_PAIR_ALPN`) — the pre-trust
crates\spt-net\src\net\endpoint.rs:253:    /// Dial a peer over an explicit ALPN (the pairing driver dials `SPT_PAIR_ALPN`).
crates\spt-net\src\net\pairing\wire.rs:5://! stream on a dedicated pre-trust **pairing ALPN** ([`SPT_PAIR_ALPN`]), gated by the
crates\spt-net\src\net\pairing\wire.rs:58:pub const SPT_PAIR_ALPN: &[u8] = b"spt-core/pair/0";
crates\spt-net\src\net\pairing\wire.rs:132:pub async fn run_responder(
crates\spt-net\src\net\pairing\wire.rs:195:/// NOT touch the rate limiter: [`run_responder`] owns the slot and releases it once based
crates\spt-net\src\net\pairing\wire.rs:199:/// The admitted-ceremony parameters [`run_responder`] hands to [`responder_admitted`]
crates\spt-net\src\net\pairing\wire.rs:307:pub async fn run_initiator(
crates\spt-net\src\net\pairing\wire.rs:679:            vec![SPT_PAIR_ALPN.to_vec()],
crates\spt-net\src\net\pairing\wire.rs:688:            vec![SPT_PAIR_ALPN.to_vec()],
crates\spt-net\src\net\pairing\wire.rs:716:            let outcome = run_responder(&conn, resp_pub, &subnets, &mut rate, &mut trust, NOW)
crates\spt-net\src\net\pairing\wire.rs:724:            .connect_with_alpn(resp_addr, SPT_PAIR_ALPN)
crates\spt-net\src\net\pairing\wire.rs:729:        let init_outcome = run_initiator(
crates\spt-net\src\net\pairing\wire.rs:775:            let result = run_responder(&conn, resp_pub, &subnets, &mut rate, &mut trust, NOW).await;
crates\spt-net\src\net\pairing\wire.rs:783:            .connect_with_alpn(resp_addr, SPT_PAIR_ALPN)
crates\spt-net\src\net\pairing\wire.rs:788:        let init_result = run_initiator(
crates\spt-net\src\net\pairing\wire.rs:834:            let result = run_responder(&conn, resp_pub, &subnets, &mut rate, &mut trust, NOW).await;
crates\spt-net\src\net\pairing\wire.rs:840:            .connect_with_alpn(resp_addr, SPT_PAIR_ALPN)
crates\spt-net\src\net\pairing\wire.rs:845:        let init_result = run_initiator(
crates\spt-net\src\net\pairing\wire.rs:887:            let result = run_responder(&conn, resp_pub, &subnets, &mut rate, &mut trust, NOW).await;
crates\spt-net\src\net\pairing\wire.rs:893:            .connect_with_alpn(resp_addr, SPT_PAIR_ALPN)
crates\spt-net\src\net\pairing\wire.rs:898:        let init_result = run_initiator(
crates\spt-net\src\net\pairing\wire.rs:938:            let result = run_responder(&conn, resp_pub, &subnets, &mut rate, &mut trust, NOW).await;
crates\spt-net\src\net\pairing\wire.rs:944:            .connect_with_alpn(resp_addr, SPT_PAIR_ALPN)
crates\spt-net\src\net\pairing\wire.rs:950:        let init_result = run_initiator(
crates\spt-net\src\net\pairing\wire.rs:995:            run_responder(&conn, resp_pub, &subnets, &mut rate, &mut trust, NOW)
crates\spt-net\src\net\pairing\wire.rs:1002:            .connect_with_alpn(resp_addr, SPT_PAIR_ALPN)
crates\spt-net\src\net\pairing\wire.rs:1007:        run_initiator(
crates\spt-net\src\net\pairing\wire.rs:1044:            let outcome = run_responder(&conn, resp_pub, &subnets, &mut rate, &mut trust, NOW)
crates\spt-net\src\net\pairing\wire.rs:1059:            .connect_with_alpn(resp_addr, SPT_PAIR_ALPN)
crates\spt-net\src\net\pairing\wire.rs:1062:        let init_outcome = run_initiator(


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	// [impl->REQ-PAIR-1] [impl->REQ-PAIR-5]
307	pub async fn run_initiator(
308	    conn: &Connection,
309	    local_pub: PublicKey,
310	    subnet_name: &str,
311	    code: &str,
312	    trust: &mut TrustStore,
313	    subnets: &mut SubnetStore,
314	    now: u64,
315	) -> Result<TrustOutcome, PairWireError> {
316	    let (mut send, mut recv) = conn
317	        .open_bi()
318	        .await
319	        .map_err(|e| PairWireError::Io(format!("open_bi: {e}")))?;
320	    let responder_pub = remote_pub(conn)?;
321	    let step = time_step(now);
322
323	    // F1: Hello — opens the stream (the dialer must write first for accept_bi to fire).
324	    write_frame(&mut send, &enc_hello(subnet_name, step)).await?;
325
326	    // F2: Announce(epoch) — or an Abort if the responder refused admission / step / name.
327	    let epoch = match read_frame(&mut recv).await? {
328	        Frame::Announce { epoch } => epoch,
329	        Frame::Abort { reason, retry_after } => return Err(map_abort(reason, retry_after, subnet_name)),
330	        other => return Err(unexpected("Announce", other)),
331	    };
332
333	    // Now the joiner can bind the full transcript and run SPAKE2 side A.
334	    let transcript = PairingTranscript {
335	        subnet_name: subnet_name.to_string(),
336	        epoch,
337	        step,
338	        initiator_pub: local_pub,
339	        responder_pub,
340	    };
341	    let (initiator, msg_a) = Initiator::start(transcript, code);
342
343	    // F3: Spake1 — msg_a.
344	    write_frame(&mut send, &enc_spake1(&msg_a)).await?;
345
346	    // F4: Spake2 — msg_b + the responder's confirmation tag.
347	    let (msg_b, responder_tag) = match read_frame(&mut recv).await? {
348	        Frame::Spake2 { msg_b, tag } => (msg_b, tag),
349	        Frame::Abort { reason, retry_after } => return Err(map_abort(reason, retry_after, subnet_name)),
350	        other => return Err(unexpected("Spake2", other)),
351	    };
352
353	    // Finish the PAKE and verify the responder's tag. On failure, signal the responder
354	    // with an Abort so it doesn't wait out the ceremony timeout, then surface the error.
355	    let (paired, initiator_tag) = match initiator.finish(&msg_b, &responder_tag) {
356	        Ok(v) => v,
357	        Err(e) => {
358	            abort(&mut send, A_CONFIRM_FAIL, 0).await?;
359	            let _ = send.finish();
360	            return Err(PairWireError::Pairing(e));
361	        }
362	    };
363
364	    // F5: Confirm — the joiner's own tag (proves to the responder it holds the same key).
365	    write_frame(&mut send, &enc_confirm(&initiator_tag)).await?;
366	    let _ = send.finish();
367
368	    // F6: Done — the responder's verdict on our tag.
369	    match read_frame(&mut recv).await? {
370	        Frame::Done { ok: true } => {
371	            // Pin the responder (its pubkey was proven live by the confirmation round).
372	            let outcome = trust.record(subnet_name, &paired.peer_pub.to_hex(), None, None);
373	            // F7: receive the subnet seed and become a full member / future seed-holder.
374	            // Read it on the same confirmed stream; a missing/short Seed frame is a
375	            // protocol error (the responder always sends it on success).
376	            match read_frame(&mut recv).await? {
377	                Frame::Seed { seed, epoch } => {
378	                    // `add_joined` errors `Exists` for an idempotent re-pair (we already
379	                    // hold this subnet) — that is success, not failure: keep our record.
380	                    match subnets.add_joined(subnet_name, seed, epoch) {
381	                        Ok(_) | Err(SubnetError::Exists(_)) => {}
382	                        Err(e) => return Err(PairWireError::Protocol(format!("seed store: {e}"))),
383	                    }
384	                }
385	                other => return Err(unexpected("Seed", other)),
386	            }
387	            Ok(outcome)
388	        }
389	        Frame::Done { ok: false } => Err(PairWireError::Pairing(PairError::Confirm)),
390	        Frame::Abort { reason, retry_after } => Err(map_abort(reason, retry_after, subnet_name)),
391	        other => Err(unexpected("Done", other)),
392	    }
393	}
394
395	/// The pubkey of the connection's remote peer, from the iroh-authenticated endpoint id.
396	fn remote_pub(conn: &Connection) -> Result<PublicKey, PairWireError> {
397	    PublicKey::from_bytes(conn.remote_id().as_bytes())
398	        .map_err(|e| PairWireError::Protocol(format!("invalid remote id: {e}")))
399	}
400
401	/// The TOTP code string at an exact step (zero-padded to [`DIGITS`]) — the PAKE password.
402	fn code_at_step(seed: &TotpSeed, step: u64) -> String {
403	    let n = seed.code_at_step(step, DIGITS);
404	    format!("{n:0width$}", width = DIGITS as usize)
405	}
406
407	/// Map a responder Abort reason to the initiator-side error.
408	fn map_abort(reason: u8, retry_after: u64, subnet_name: &str) -> PairWireError {
409	    match reason {

            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.
// [impl->REQ-PAIR-1] [impl->REQ-PAIR-5]
pub async fn run_initiator(
    conn: &Connection,
    local_pub: PublicKey,
    subnet_name: &str,
    code: &str,
    trust: &mut TrustStore,
    subnets: &mut SubnetStore,
    now: 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);

    // 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(),
        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 {


Pairing drivable at spt-net level. Need pub TOTP helpers for test-side code derivation from shared seed env.


38:pub const PERIOD_SECS: u64 = 30;
42:pub const DIGITS: u32 = 6;
46:pub const SEED_LEN: usize = 20;
55:pub struct TotpSeed([u8; SEED_LEN]);
61:    pub fn generate() -> TotpSeed {
68:    pub fn from_bytes(bytes: [u8; SEED_LEN]) -> TotpSeed {
73:    pub fn as_bytes(&self) -> &[u8; SEED_LEN] {
79:    pub fn to_base32(&self) -> String {
86:    pub fn from_base32(s: &str) -> Option<TotpSeed> {
103:    pub fn code_at_step(&self, step: u64, digits: u32) -> u32 {
121:    pub fn code_at(&self, unix_secs: u64) -> String {
129:    pub fn otpauth_uri(&self, issuer: &str, subnet_name: &str) -> String {
151:pub fn time_step(unix_secs: u64) -> u64 {
157:pub fn window_steps(step: u64) -> [u64; 3] {
164:pub fn accepts_step(local_step: u64, peer_step: u64) -> bool {


Grep

pub fn (create_subnet|add_joined|find|seed_bytes)|pub fn time_step|pub const DIGITS|fn time_step


crates\spt-net\src\net\pairing\totp.rs:42:pub const DIGITS: u32 = 6;
crates\spt-net\src\net\pairing\totp.rs:151:pub fn time_step(unix_secs: u64) -> u64 {
crates\spt-net\src\net\pairing\totp.rs:234:    fn time_step_is_floor_unix_over_period() {
crates\spt-store\src\subnet.rs:95:    pub fn seed_bytes(&self) -> Option<[u8; TOTP_SEED_LEN]> {
crates\spt-store\src\subnet.rs:143:    pub fn find(&self, name: &str) -> Option<&SubnetRecord> {
crates\spt-store\src\subnet.rs:155:    pub fn create_subnet(&mut self, name: &str) -> Result<&SubnetRecord, SubnetError> {
crates\spt-store\src\subnet.rs:174:    pub fn add_joined(


Write SPT_TWO_HOST=1 gated int tests (D9b ladder)

tests gated on SPT_TWO_HOST=1 + role/peer-addr env, silent skip otherwise. Ladder: pair → register → cross-node message → remote-drive → Psyche sync → update self-heal + notif cross-node leg (insert A→fires B; dismiss B→replicates A). Int tags [int->REQ-NET-1], [int->REQ-INST-5], [int->REQ-UPD-1], [int->REQ-NOTIF-1] + pairing/registry as touched. Stages NOT activated until rig run green.

Write docs/TWO-HOST-RUNBOOK.md

Exact rig procedure HFENDULEAM ↔ gravity-linux: env setup, run order, what green looks like. Evidence appendix filled after real run. Consider [twohost] commit-message CI gate for gravity leg (no SSH from HFENDULEAM).

Run two-host proof on rig, capture evidence

Execute runbook on HFENDULEAM + gravity (likely via [twohost] CI gate for gravity leg). Capture run output into runbook appendix. Then activate int stages on touched reqs in traceable-reqs.toml. Flake-ledger discipline for any rig flake.

Retire gh-repo interim sync (CONTEXT.md note)

After proof green: note in CONTEXT.md — sister gh-repo-sync skill stands down; psyche-sync-setup remains for hub-mode seam but no longer THE sync path.


58-impl Identity {
59-    /// Generate a fresh random identity from the OS CSPRNG.
60-    // [impl->REQ-NODE-IDENTITY]
61:    pub fn generate() -> Identity {
62-        Identity {
63-            signing: SigningKey::generate(&mut OsRng),
64-        }
--
66-
67-    /// Reconstruct an identity from its 32-byte secret seed (deterministic).
68-    // [impl->REQ-NODE-IDENTITY]
69:    pub fn from_seed(seed: &[u8; SEED_LEN]) -> Identity {
70-        Identity {
71-            signing: SigningKey::from_bytes(seed),
72-        }
73-    }
74-
75-    /// The 32-byte secret seed. Treat as sensitive — for persistence only.
76:    pub fn seed(&self) -> [u8; SEED_LEN] {
77-        self.signing.to_bytes()
78-    }
79-
--
110-
111-    /// Reconstruct from 32 raw bytes. Errors if the bytes are not a valid
112-    /// curve point.
113:    pub fn from_bytes(bytes: &[u8; PUBLIC_KEY_LEN]) -> Result<PublicKey, IdentityError> {
114-        VerifyingKey::from_bytes(bytes)
115-            .map(PublicKey)
116-            .map_err(|_| IdentityError::BadPublicKey)


1	[package]
2	name = "spt-daemon"
3	version = "0.0.0"
4	edition = "2021"
5	publish = false
6	description = "spt-core broker/brain daemon (M3b): the per-machine supervisor process. A stable broker kernel holds the un-transferable resources (PTY masters from spt-term, harness children, sockets) while a restartable brain holds all logic; a versioned local IPC lets a newer brain talk to an older broker so a self-update swaps the brain with zero endpoint interruption (ADR-0004). Internal supervisor (NOT public SDK, R-ARCH-2) — the `spt` binary is a thin CLI over it. B0 lands the crate + the versioned local-IPC transport."
7
8	[dependencies]
9	# Layer-below set (R-ARCH-1 acyclic): …→spt-live→spt-daemon→spt. The broker
10	# re-hosts spt-term PTYs + the spt-live lifecycle seams; these edges fix the
11	# crate's position in the workspace layering. Tasks B1–B8 consume them.
12	spt-proto = { path = "../spt-proto" }
13	spt-store = { path = "../spt-store" }
14	spt-msg = { path = "../spt-msg" }
15	spt-runtime = { path = "../spt-runtime" }
16	spt-term = { path = "../spt-term" }
17	spt-live = { path = "../spt-live" }
18	# D4 (ADR-0004 §B): the broker owns the live iroh endpoint + QUIC connections so
19	# they survive a brain restart. spt-net provides the *mechanism* (NetEndpoint);
20	# the broker hosts the *process* (nethost::NetHost). Default features keep the
21	# `net` transport on — the daemon is the reference consumer.
22	spt-net = { path = "../spt-net" }
23	# The broker is otherwise sync/thread-based; iroh requires tokio, so the broker
24	# hosts a dedicated runtime on named worker threads (QUIC objects never leave it).
25	tokio = { version = "1", features = ["rt-multi-thread"] }
26	serde = { version = "1", features = ["derive"] }
27	serde_json = { version = "1", features = ["preserve_order"] }
28	# PTY output is arbitrary bytes; the forward-compat JSON envelope carries it as
29	# a base64 string in the payload rather than forking the codec for binary.
30	base64 = "0.22"
31	# Cross-platform local IPC: named pipe (Windows) / Unix-domain socket (unix)
32	# behind one API — the OS-split substrate the DaemonTransport trait wraps,
33	# mirroring how spt-term abstracts ConPTY vs forkpty behind SessionSurface.
34	interprocess = "2"
35	# Self-update release signing (C1, REQ-UPD-2): pure-Rust Ed25519 — no C/asm
36	# build deps, clean cross-compile on win+linux (clean-room posture, ADR-0004 §D).
37	# `rand_core` lets a release-tooling caller mint a keypair; tests use the
38	# deterministic `from_bytes` ctor (no RNG).
39	ed25519-dalek = "2"
40	# SHA-256 binds the signed release metadata to the actual binary bytes
41	# (artifact digest) so the signature covers the binary, not just the manifest.
42	sha2 = "0.10"
43
44	[dev-dependencies]
45	tempfile = "3"
46	# Integration tests are separate crates and do not inherit the lib's deps, so the
47	# B8 digest int-test re-declares the two it touches directly: the manifest seam
48	# (PtyDigest) and the perch-path/history resolution it asserts persistence against.
49	spt-runtime = { path = "../spt-runtime" }
50	spt-store = { path = "../spt-store" }
51	# The D4a net-broker int-test constructs identities + relay policies directly.
52	spt-proto = { path = "../spt-proto" }
53	spt-net = { path = "../spt-net" }
54	# The D5a wanmsg int-test asserts spool formatting through the spt-msg surface.
55	spt-msg = { path = "../spt-msg" }
56

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

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

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


tokio/sha2 need dev-dep entries (already in tree). Now pump config surface.


1	//! Peer-pump E2E (D9-1-2): the subnet self-drives. One in-process "node A"
2	//! runs the production pump loop against "node B" running the production
3	//! dispatcher — and registry rows, notif rows, and a mind A never held all
4	//! converge with NO test code driving a single protocol exchange.
5	//!
6	//! Topology: B owns the process's canonical SPT_HOME (its dispatcher's gates
7	//! load from it — the production read path); A's pump-side stores live under
8	//! explicit test roots ([`PumpPaths`]). The resolver is injected (hermetic
9	//! loopback endpoints run no discovery); production resolves by id.
10
11	use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
12	use std::sync::{Arc, Mutex, OnceLock};
13	use std::thread;
14	use std::time::Duration;
15
16	use spt_daemon::brain::Brain;
17	use spt_daemon::broker::Broker;
18	use spt_daemon::dispatch::{run_dispatch_loop, DispatchPaths};
19	use spt_daemon::nethost::{NetConfig, NetHost};
20	use spt_daemon::peerloop::{
21	    run_peer_pump, PeerResolver, PumpCadence, PumpConfig, PumpHooks, PumpPaths,
22	};
23	use spt_daemon::registryhost::{RegistryGatePolicy, RegistryHost};
24	use spt_daemon::relcache::ReleaseCache;
25	use spt_daemon::release::{sha256_hex, ReleaseMetadata, SignedRelease};
26	use spt_daemon::update::BROKER_RESOURCE_ABI;
27	use spt_net::net::endpoint::{BindScope, LocalDiscovery, RelayPolicy};
28	use spt_net::net::registry::{Instance, Status};
29	use spt_net::net::replicate::RegistryUpdate;
30	use spt_proto::identity::Identity;
31	use spt_store::contextstore::ContextStore;
32	use spt_store::epoch::EpochSource;
33	use spt_store::info::{self, InfoJson};
34	use spt_store::notif::{NotifRow, NotifStore};
35	use spt_store::perch::{self, ParentHint};
36	use spt_store::subnet::SubnetStore;
37	use spt_store::trust::TrustStore;
38	use spt_store::visibility::VisibilityStore;
39	use tempfile::TempDir;
40
41	static HOME: OnceLock<TempDir> = OnceLock::new();
42	static LOCK: Mutex<()> = Mutex::new(());
43	fn init_home() {
44	    HOME.get_or_init(|| {
45	        let dir = TempDir::new().expect("temp dir");
46	        std::env::set_var("SPT_HOME", dir.path());
47	        dir
48	    });
49	}
50
51	static SEQ: AtomicU32 = AtomicU32::new(0);
52	fn unique_name() -> String {
53	    let n = SEQ.fetch_add(1, Ordering::Relaxed);
54	    format!("spt-daemon-d912-{}-{}.sock", std::process::id(), n)
55	}
56
57	fn hermetic() -> NetConfig {
58	    NetConfig {
59	        identity: Identity::generate(),
60	        relay: RelayPolicy::Disabled,
61	        discovery: LocalDiscovery::Off,
62	        scope: BindScope::Loopback,
63	    }
64	}
65
66	fn net_broker(name: &str, dir: &std::path::Path) -> Arc<Broker> {
67	    let host = NetHost::start(hermetic()).expect("net host start");
68	    let broker =
69	        Broker::bind_in_with_net(name, dir.join("effects.log"), Some(host)).expect("bind broker");
70	    let serve = Arc::clone(&broker);
71	    thread::spawn(move || {
72	        let _ = serve.serve();
73	    });
74	    broker
75	}
76
77	fn connect_retry(name: &str) -> Brain {
78	    for _ in 0..300 {
79	        if let Ok(b) = Brain::cold_start(name, 1) {
80	            return b;
81	        }
82	        thread::sleep(Duration::from_millis(10));
83	    }
84	    panic!("brain could not connect");
85	}
86
87	fn stamped(body: &str) -> String {
88	    format!("<!-- spt:source=llm spt:routed_at_ms=1000 spt:node=t spt:vector=t:1 -->\n{body}")
89	}
90
91	/// Poll `probe` until it returns true, panicking with `what` on timeout.
92	fn converge(what: &str, mut probe: impl FnMut() -> bool) {
93	    for _ in 0..600 {
94	        if probe() {
95	            return;
96	        }
97	        thread::sleep(Duration::from_millis(25));
98	    }
99	    panic!("never converged: {what}");
100	}
101
102	// [int->REQ-INST-5] [int->REQ-INST-7] [int->REQ-NOTIF-1] the full self-drive:
103	// A's pump pushes its registry advertisement and notif spool to B and pulls
104	// from B a mind A NEVER HELD (registry-derived bootstrap ref) — all three
105	// converge undriven, A's pump against B's dispatcher, both production loops.
106	#[test]
107	fn pump_and_dispatch_self_drive_the_subnet() {
108	    init_home();
109	    let _g = LOCK.lock().unwrap_or_else(|p| p.into_inner());
110	    let dir = TempDir::new().unwrap();
111	    let subnet = "d912";
112	    let (a_name, b_name) = (unique_name(), unique_name());
113	    let _a_broker = net_broker(&a_name, dir.path());
114	    let _b_broker = net_broker(&b_name, dir.path());
115
116	    let mut a_probe = connect_retry(&a_name);
117	    let mut b_probe = connect_retry(&b_name);
118	    let a_hex = a_probe.net_status().expect("status").node_id_hex.expect("net");
119	    let b_hex = b_probe.net_status().expect("status").node_id_hex.expect("net");
120	    let b_addr = b_probe.net_status().expect("status").addr;
121
122	    // ── B (canonical home): gates + the mind A will bootstrap-pull. ──────
123	    let mut subnets = SubnetStore::load();
124	    subnets.create_subnet(subnet).expect("create subnet");
125	    subnets.save().expect("save subnets");
126	    let mut trust = TrustStore::load();
127	    trust.record(subnet, &a_hex, None, None);
128	    trust.save().expect("save trust");
129	    let mut vis = VisibilityStore::load();
130	    vis.set_sync_subnets("ling-pumped", vec![subnet.to_string()]);
131	    vis.save().expect("save visibility");
132
133	    let cs_b = ContextStore::open_or_init().expect("B context store");
134	    let p = cs_b.live_context_path("ling-pumped").unwrap();
135	    std::fs::write(&p, stamped("a mind worth following")).unwrap();
136	    cs_b.commit_live("ling-pumped", "seed").unwrap();
137
138	    // B stages a v6 release (the canonical releases dir B's dispatcher serves
139	    // from); A's keys file will trust the signer.
140	    use ed25519_dalek::{Signer, SigningKey};
141	    let sk = SigningKey::from_bytes(&[5u8; 32]);
142	    let artifact = b"pump-propagated binary".to_vec();
143	    let meta = ReleaseMetadata {
144	        version: 6,
145	        channel: "stable".to_string(),
146	        expires_at_ms: u64::MAX,
147	        key_id: "k".to_string(),
148	        artifact_sha256: sha256_hex(&artifact),
149	        brain_ipc_version: spt_daemon::IPC_PROTOCOL_VERSION,
150	        broker_resource_abi: BROKER_RESOURCE_ABI,
151	    };
152	    let metadata_json = serde_json::to_string(&meta).unwrap();
153	    let sig = sk.sign(metadata_json.as_bytes());
154	    let signed = SignedRelease {
155	        metadata_json,
156	        signature_hex: sig.to_bytes().iter().map(|b| format!("{b:02x}")).collect(),
157	    };
158	    ReleaseCache::open(&perch::spt_home().join("releases"))
159	        .stage(&signed, &artifact)
160	        .expect("stage at B");
161
162	    let b_registry = Arc::new(RegistryHost::new_at(
163	        &b_hex,
164	        EpochSource::load_from(&dir.path().join("b-epoch.json")),
165	        dir.path().join("b-registry"),
166	    ));
167	    let stop = Arc::new(AtomicBool::new(false));
168	    {
169	        let name = b_name.clone();
170	        let reg = Arc::clone(&b_registry);
171	        let flag = Arc::clone(&stop);
172	        let paths = DispatchPaths {
173	            owlery: perch::owlery_dir(),
174	            releases: perch::spt_home().join("releases"),
175	            scratch: dir.path().join("b-scratch"),
176	        };
177	        thread::spawn(move || {
178	            let _ = run_dispatch_loop(&name, &paths, reg, &flag, Duration::from_millis(25));
179	        });
180	    }
181
182	    // ── A (explicit roots): a perch to advertise, a notif row to push, an
183	    //    empty context store that will receive the bootstrap pull. ────────
184	    let a_root = dir.path().join("a");
185	    let a_paths = PumpPaths {
186	        owlery: a_root.join("owlery"),
187	        ctx_root: a_root.join("context"),
188	        notif_db: a_root.join("notifs.db"),
189	        scratch: a_root.join("scratch"),
190	        subnets: a_root.join("subnet.json"),
191	        trust: a_root.join("trust.json"),
192	        vis: a_root.join("visibility.json"),
193	        ops: a_root.join("pump-ops.json"),
194	        releases: a_root.join("releases"),
195	        release_keys: a_root.join("release-keys.json"),
196	        pull_marker: a_root.join("sync-pull-now"),
197	    };
198	    // A trusts B's release signer — the update leg's production gate.
199	    std::fs::create_dir_all(&a_root).unwrap();
200	    let keys_json = serde_json::json!({
201	        "keys": { "k": sk.verifying_key().as_bytes().iter()
202	            .map(|b| format!("{b:02x}")).collect::<String>() },
203	        "channel": "stable",
204	    });
205	    std::fs::write(&a_paths.release_keys, serde_json::to_string(&keys_json).unwrap()).unwrap();
206	    let perch_path = perch::resolve_perch_path_in(&a_paths.owlery, "pump-ag", ParentHint::Infer);
207	    std::fs::create_dir_all(&perch_path).unwrap();
208	    let rec = InfoJson::new("pump-ag", "now", std::process::id(), "s", "ready_agent");
209	    info::write_info(&perch_path, &rec).unwrap();
210
211	    let mut a_subnets = SubnetStore::default();
212	    a_subnets.create_subnet(subnet).expect("A subnet");
213	    a_subnets.save_to(&a_paths.subnets).expect("save A subnets");
214	    let mut a_trust = TrustStore::default();
215	    a_trust.record(subnet, &b_hex, None, None);
216	    a_trust.save_to(&a_paths.trust).expect("save A trust");
217	    let mut a_vis = VisibilityStore::default();
218	    a_vis.set_sync_subnets("ling-pumped", vec![subnet.to_string()]);
219	    a_vis.save_to(&a_paths.vis).expect("save A visibility");
220
221	    let a_store = NotifStore::open_at(&a_paths.notif_db).expect("A notif store");
222	    a_store
223	        .merge_row(&NotifRow {
224	            notif_id: format!("{a_hex}:1"),
225	            subnet: subnet.to_string(),
226	            kind: "agent".to_string(),
227	            from_id: format!("pump-ag@{a_hex}"),
228	            body: "pumped across".to_string(),
229	            created_ms: 1_000,
230	            dismissed: false,
231	            seen: Default::default(),
232	            last_surfaced_ms: 0,
233	        })
234	        .expect("seed A notif row");
235
236	    let a_registry = Arc::new(RegistryHost::new_at(
237	        &a_hex,
238	        EpochSource::load_from(&a_root.join("epoch.json")),
239	        a_root.join("registry"),
240	    ));
241	    // A learned of B's endpoint via an earlier inbound feed (seeded directly
242	    // here — B runs no pump in this test): the bootstrap-ref source.
243	    {
244	        let mut t = TrustStore::default();
245	        t.record(subnet, &b_hex, None, None);
246	        let pol = RegistryGatePolicy { subnets: vec![subnet.to_string()], trust: t };
247	        a_registry.apply_feed(
248	            &b_hex,
249	            &[RegistryUpdate {
250	                subnet: subnet.to_string(),
251	                endpoint_id: "ling-pumped".to_string(),
252	                instance: Instance { node: b_hex.clone(), status: Status::Active, epoch: 1, resources: None },
253	            }],
254	            &pol,
255	        );
256	    }
257
258	    // ── The pump: production loop, injected resolver, fast cadences. ─────
259	    let resolver: PeerResolver = {
260	        let b_hex = b_hex.clone();
261	        let b_addr = b_addr.clone();
262	        Box::new(move |hex| if hex == b_hex { Some(b_addr.clone()) } else { None })
263	    };
264	    {
265	        let name = a_name.clone();
266	        let reg = Arc::clone(&a_registry);
267	        let flag = Arc::clone(&stop);
268	        let paths = PumpPaths {
269	            owlery: a_paths.owlery.clone(),
270	            ctx_root: a_paths.ctx_root.clone(),
271	            notif_db: a_paths.notif_db.clone(),
272	            scratch: a_paths.scratch.clone(),
273	            subnets: a_paths.subnets.clone(),
274	            trust: a_paths.trust.clone(),
275	            vis: a_paths.vis.clone(),
276	            ops: a_paths.ops.clone(),
277	            releases: a_paths.releases.clone(),
278	            release_keys: a_paths.release_keys.clone(),
279	            pull_marker: a_paths.pull_marker.clone(),
280	        };
281	        thread::spawn(move || {
282	            let config = PumpConfig {
283	                cadence: PumpCadence {
284	                    registry: Duration::from_millis(100),
285	                    notif: Duration::from_millis(100),
286	                    sync: Duration::from_millis(100),
287	                    update: Duration::from_millis(100),
288	                },
289	                // Gated default (REQ-UPD-4) — the consent notif is the assert.
290	                full_auto_update: false,
291	            };
292	            let _ = run_peer_pump(
293	                &name,
294	                reg,
295	                &paths,
296	                resolver,
297	                config,
298	                PumpHooks::default(),
299	                &flag,
300	            );
301	        });
302	    }
303
304	    // 1. Registry converged at B: A's perch row arrived through the
305	    //    dispatcher's gated registry arm.
306	    converge("B registry holds pump-ag@A", || {
307	        b_registry.rows(subnet, "pump-ag").iter().any(|i| i.node == a_hex)
308	    });
309
310	    // 2. Notif converged at B's canonical store (the semilattice join).
311	    let store_b = NotifStore::open().expect("B notif store");
312	    converge("B notif store holds A's row", || {
313	        store_b
314	            .list(subnet)
315	            .map(|rows| rows.iter().any(|r| r.body == "pumped across"))
316	            .unwrap_or(false)
317	    });
318
319	    // 3. The bootstrap pull: A now holds a mind it NEVER had locally — the
320	    //    want-ref came from A's registry, the serve gate ran at B.
321	    let cs_a = ContextStore::open_or_init_in(&a_paths.ctx_root).expect("A context store");
322	    converge("A pulled the registry-derived mind", || {
323	        cs_a.branch_store().tip("a-ling-pumped").ok().flatten().is_some()
324	    });
325
326	    // 4. The update leg: A pulled, gated, and staged B's v6 under the
327	    //    production keys file — and surfaced it as a CONSENT notif (REQ-UPD-4
328	    //    gated default), never a silent auto-apply.
329	    let cache_a = ReleaseCache::open(&a_paths.releases);
330	    converge("A staged the propagated release", || {
331	        cache_a.load_artifact().map(|b| b == artifact).unwrap_or(false)
332	    });
333	    converge("A holds the consent notif", || {
334	        a_store
335	            .list(subnet)
336	            .map(|rows| rows.iter().any(|r| r.kind == "consent"))
337	            .unwrap_or(false)
338	    });
339
340	    stop.store(true, Ordering::Relaxed);
341	}
342

//! Peer-pump E2E (D9-1-2): the subnet self-drives. One in-process "node A"
//! runs the production pump loop against "node B" running the production
//! dispatcher — and registry rows, notif rows, and a mind A never held all
//! converge with NO test code driving a single protocol exchange.
//!
//! Topology: B owns the process's canonical SPT_HOME (its dispatcher's gates
//! load from it — the production read path); A's pump-side stores live under
//! explicit test roots ([`PumpPaths`]). The resolver is injected (hermetic
//! loopback endpoints run no discovery); production resolves by id.

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

use spt_daemon::brain::Brain;
use spt_daemon::broker::Broker;
use spt_daemon::dispatch::{run_dispatch_loop, DispatchPaths};
use spt_daemon::nethost::{NetConfig, NetHost};
use spt_daemon::peerloop::{
    run_peer_pump, PeerResolver, PumpCadence, PumpConfig, PumpHooks, PumpPaths,
};
use spt_daemon::registryhost::{RegistryGatePolicy, RegistryHost};
use spt_daemon::relcache::ReleaseCache;
use spt_daemon::release::{sha256_hex, ReleaseMetadata, SignedRelease};
use spt_daemon::update::BROKER_RESOURCE_ABI;
use spt_net::net::endpoint::{BindScope, LocalDiscovery, RelayPolicy};
use spt_net::net::registry::{Instance, Status};
use spt_net::net::replicate::RegistryUpdate;
use spt_proto::identity::Identity;
use spt_store::contextstore::ContextStore;
use spt_store::epoch::EpochSource;
use spt_store::info::{self, InfoJson};
use spt_store::notif::{NotifRow, NotifStore};
use spt_store::perch::{self, ParentHint};
use spt_store::subnet::SubnetStore;
use spt_store::trust::TrustStore;
use spt_store::visibility::VisibilityStore;
use tempfile::TempDir;

static HOME: OnceLock<TempDir> = OnceLock::new();
static LOCK: Mutex<()> = Mutex::new(());
fn init_home() {
    HOME.get_or_init(|| {
        let dir = TempDir::new().expect("temp dir");
        std::env::set_var("SPT_HOME", dir.path());
        dir
    });
}

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

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

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

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

fn stamped(body: &str) -> String {
    format!("<!-- spt:source=llm spt:routed_at_ms=1000 spt:node=t spt:vector=t:1 -->\n{body}")
}

/// Poll `probe` until it returns true, panicking with `what` on timeout.
fn converge(what: &str, mut probe: impl FnMut() -> bool) {
    for _ in 0..600 {
        if probe() {
            return;
        }
        thread::sleep(Duration::from_millis(25));
    }
    panic!("never converged: {what}");
}

// [int->REQ-INST-5] [int->REQ-INST-7] [int->REQ-NOTIF-1] the full self-drive:
// A's pump pushes its registry advertisement and notif spool to B and pulls
// from B a mind A NEVER HELD (registry-derived bootstrap ref) — all three
// converge undriven, A's pump against B's dispatcher, both production loops.
#[test]
fn pump_and_dispatch_self_drive_the_subnet() {
    init_home();
    let _g = LOCK.lock().unwrap_or_else(|p| p.into_inner());
    let dir = TempDir::new().unwrap();
    let subnet = "d912";
    let (a_name, b_name) = (unique_name(), unique_name());
    let _a_broker = net_broker(&a_name, dir.path());
    let _b_broker = net_broker(&b_name, dir.path());

    let mut a_probe = connect_retry(&a_name);
    let mut b_probe = connect_retry(&b_name);
    let a_hex = a_probe.net_status().expect("status").node_id_hex.expect("net");
    let b_hex = b_probe.net_status().expect("status").node_id_hex.expect("net");
    let b_addr = b_probe.net_status().expect("status").addr;

    // ── B (canonical home): gates + the mind A will bootstrap-pull. ──────
    let mut subnets = SubnetStore::load();
    subnets.create_subnet(subnet).expect("create subnet");
    subnets.save().expect("save subnets");
    let mut trust = TrustStore::load();
    trust.record(subnet, &a_hex, None, None);
    trust.save().expect("save trust");
    let mut vis = VisibilityStore::load();
    vis.set_sync_subnets("ling-pumped", vec![subnet.to_string()]);
    vis.save().expect("save visibility");

    let cs_b = ContextStore::open_or_init().expect("B context store");
    let p = cs_b.live_context_path("ling-pumped").unwrap();
    std::fs::write(&p, stamped("a mind worth following")).unwrap();
    cs_b.commit_live("ling-pumped", "seed").unwrap();

    // B stages a v6 release (the canonical releases dir B's dispatcher serves
    // from); A's keys file will trust the signer.
    use ed25519_dalek::{Signer, SigningKey};
    let sk = SigningKey::from_bytes(&[5u8; 32]);
    let artifact = b"pump-propagated binary".to_vec();
    let meta = ReleaseMetadata {
        version: 6,
        channel: "stable".to_string(),
        expires_at_ms: u64::MAX,
        key_id: "k".to_string(),
        artifact_sha256: sha256_hex(&artifact),
        brain_ipc_version: spt_daemon::IPC_PROTOCOL_VERSION,
        broker_resource_abi: BROKER_RESOURCE_ABI,
    };
    let metadata_json = serde_json::to_string(&meta).unwrap();
    let sig = sk.sign(metadata_json.as_bytes());
    let signed = SignedRelease {
        metadata_json,
        signature_hex: sig.to_bytes().iter().map(|b| format!("{b:02x}")).collect(),
    };
    ReleaseCache::open(&perch::spt_home().join("releases"))
        .stage(&signed, &artifact)
        .expect("stage at B");

    let b_registry = Arc::new(RegistryHost::new_at(
        &b_hex,
        EpochSource::load_from(&dir.path().join("b-epoch.json")),
        dir.path().join("b-registry"),
    ));
    let stop = Arc::new(AtomicBool::new(false));
    {
        let name = b_name.clone();
        let reg = Arc::clone(&b_registry);
        let flag = Arc::clone(&stop);
        let paths = DispatchPaths {
            owlery: perch::owlery_dir(),
            releases: perch::spt_home().join("releases"),
            scratch: dir.path().join("b-scratch"),
        };
        thread::spawn(move || {
            let _ = run_dispatch_loop(&name, &paths, reg, &flag, Duration::from_millis(25));
        });
    }

    // ── A (explicit roots): a perch to advertise, a notif row to push, an
    //    empty context store that will receive the bootstrap pull. ────────
    let a_root = dir.path().join("a");
    let a_paths = PumpPaths {
        owlery: a_root.join("owlery"),
        ctx_root: a_root.join("context"),
        notif_db: a_root.join("notifs.db"),
        scratch: a_root.join("scratch"),
        subnets: a_root.join("subnet.json"),
        trust: a_root.join("trust.json"),
        vis: a_root.join("visibility.json"),
        ops: a_root.join("pump-ops.json"),
        releases: a_root.join("releases"),
        release_keys: a_root.join("release-keys.json"),
        pull_marker: a_root.join("sync-pull-now"),
    };
    // A trusts B's release signer — the update leg's production gate.
    std::fs::create_dir_all(&a_root).unwrap();
    let keys_json = serde_json::json!({
        "keys": { "k": sk.verifying_key().as_bytes().iter()
            .map(|b| format!("{b:02x}")).collect::<String>() },
        "channel": "stable",
    });
    std::fs::write(&a_paths.release_keys, serde_json::to_string(&keys_json).unwrap()).unwrap();
    let perch_path = perch::resolve_perch_path_in(&a_paths.owlery, "pump-ag", ParentHint::Infer);
    std::fs::create_dir_all(&perch_path).unwrap();
    let rec = InfoJson::new("pump-ag", "now", std::process::id(), "s", "ready_agent");
    info::write_info(&perch_path, &rec).unwrap();

    let mut a_subnets = SubnetStore::default();
    a_subnets.create_subnet(subnet).expect("A subnet");
    a_subnets.save_to(&a_paths.subnets).expect("save A subnets");
    let mut a_trust = TrustStore::default();
    a_trust.record(subnet, &b_hex, None, None);
    a_trust.save_to(&a_paths.trust).expect("save A trust");
    let mut a_vis = VisibilityStore::default();
    a_vis.set_sync_subnets("ling-pumped", vec![subnet.to_string()]);
    a_vis.save_to(&a_paths.vis).expect("save A visibility");

    let a_store = NotifStore::open_at(&a_paths.notif_db).expect("A notif store");
    a_store
        .merge_row(&NotifRow {
            notif_id: format!("{a_hex}:1"),
            subnet: subnet.to_string(),
            kind: "agent".to_string(),
            from_id: format!("pump-ag@{a_hex}"),
            body: "pumped across".to_string(),
            created_ms: 1_000,
            dismissed: false,
            seen: Default::default(),
            last_surfaced_ms: 0,
        })
        .expect("seed A notif row");

    let a_registry = Arc::new(RegistryHost::new_at(
        &a_hex,
        EpochSource::load_from(&a_root.join("epoch.json")),
        a_root.join("registry"),
    ));
    // A learned of B's endpoint via an earlier inbound feed (seeded directly
    // here — B runs no pump in this test): the bootstrap-ref source.
    {
        let mut t = TrustStore::default();
        t.record(subnet, &b_hex, None, None);
        let pol = RegistryGatePolicy { subnets: vec![subnet.to_string()], trust: t };
        a_registry.apply_feed(
            &b_hex,
            &[RegistryUpdate {
                subnet: subnet.to_string(),
                endpoint_id: "ling-pumped".to_string(),
                instance: Instance { node: b_hex.clone(), status: Status::Active, epoch: 1, resources: None },
            }],
            &pol,
        );
    }

    // ── The pump: production loop, injected resolver, fast cadences. ─────
    let resolver: PeerResolver = {
        let b_hex = b_hex.clone();
        let b_addr = b_addr.clone();
        Box::new(move |hex| if hex == b_hex { Some(b_addr.clone()) } else { None })
    };
    {
        let name = a_name.clone();
        let reg = Arc::clone(&a_registry);
        let flag = Arc::clone(&stop);
        let paths = PumpPaths {
            owlery: a_paths.owlery.clone(),
            ctx_root: a_paths.ctx_root.clone(),
            notif_db: a_paths.notif_db.clone(),
            scratch: a_paths.scratch.clone(),
            subnets: a_paths.subnets.clone(),
            trust: a_paths.trust.clone(),
            vis: a_paths.vis.clone(),
            ops: a_paths.ops.clone(),
            releases: a_paths.releases.clone(),
            release_keys: a_paths.release_keys.clone(),
            pull_marker: a_paths.pull_marker.clone(),
        };
        thread::spawn(move || {
            let config = PumpConfig {
                cadence: PumpCadence {
                    registry: Duration::from_millis(100),
                    notif: Duration::from_millis(100),
                    sync: Duration::from_millis(100),
                    update: Duration::from_millis(100),
                },
                // Gated default (REQ-UPD-4) — the consent notif is the assert.
                full_auto_update: false,
            };
            let _ = run_peer_pump(
                &name,
                reg,
                &paths,
                resolver,
                config,
                PumpHooks::default(),
                &flag,
            );
        });
    }

    // 1. Registry converged at B: A's perch row arrived through the
    //    dispatcher's gated registry arm.
    converge("B registry holds pump-ag@A", || {
        b_registry.rows(subnet, "pump-ag").iter().any(|i| i.node == a_hex)
    });

    // 2. Notif converged at B's canonical store (the semilattice join).
    let store_b = NotifStore::open().expect("B notif store");
    converge("B notif store holds A's row", || {
        store_b
            .list(subnet)
            .map(|rows| rows.iter().any(|r| r.body == "pumped across"))
            .unwrap_or(false)
    });

    // 3. The bootstrap pull: A now holds a mind it NEVER had locally — the
    //    want-ref came from A's registry, the serve gate ran at B.
    let cs_a = ContextStore::open_or_init_in(&a_paths.ctx_root).expect("A context store");
    converge("A pulled the registry-derived mind", || {
        cs_a.branch_store().tip("a-ling-pumped").ok().flatten().is_some()
    });

    // 4. The update leg: A pulled, gated, and staged B's v6 under the
    //    production keys file — and surfaced it as a CONSENT notif (REQ-UPD-4
    //    gated default), never a silent auto-apply.
    let cache_a = ReleaseCache::open(&a_paths.releases);
    converge("A staged the propagated release", || {
        cache_a.load_artifact().map(|b| b == artifact).unwrap_or(false)
    });
    converge("A holds the consent notif", || {
        a_store
            .list(subnet)
            .map(|rows| rows.iter().any(|r| r.kind == "consent"))
            .unwrap_or(false)
    });

    stop.store(true, Ordering::Relaxed);
}


Bash

grep -rn "impl EndpointAddr" ~/.cargo/registry/src/*/iroh-base-0.98*/src/*.rs 2>/dev/null | head -5; ls ~/.cargo/registry/src/*/ 2>/dev/null | grep -i "iroh" | head

Locate vendored iroh source


/c/Users/decid/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/iroh-base-0.98.0/src/endpoint_addr.rs:91:impl EndpointAddr {
iroh-0.98.2
iroh-base-0.98.0
iroh-dns-0.98.0
iroh-metrics-0.38.3
iroh-metrics-derive-0.4.1
iroh-relay-0.98.0


Read EndpointAddr API


//! Addressing for iroh endpoints.
//!
//! This module contains some common addressing types for iroh.  An endpoint is uniquely
//! identified by the [`EndpointId`] but that does not make it addressable on the network layer.
//! For this the addition of a [`RelayUrl`] and/or direct addresses are required.
//!
//! The primary way of addressing an endpoint is by using the [`EndpointAddr`].

use std::{collections::BTreeSet, fmt, net::SocketAddr};

use data_encoding::HEXLOWER;
use n0_error::stack_error;
use serde::{Deserialize, Serialize};

use crate::{EndpointId, PublicKey, RelayUrl};

/// Network-level addressing information for an iroh endpoint.
///
/// This combines an endpoint's identifier with network-level addressing information of how to
/// contact the endpoint.
///
/// To establish a network connection to an endpoint both the [`EndpointId`] and one or more network
/// paths are needed.  The network paths can come from various sources, current sources can come from
///
/// - An [Address Lookup] service which can provide routing information for a given [`EndpointId`].
///
/// - A [`RelayUrl`] of the endpoint's [home relay], this allows establishing the connection via
///   the Relay server and is very reliable.
///
/// - One or more *IP based addresses* on which the endpoint might be reachable.  Depending on the
///   network location of both endpoints it might not be possible to establish a direct
///   connection without the help of a [Relay server].
///
/// This structure will always contain the required [`EndpointId`] and will contain an optional
/// number of other addressing information.  It is a generic addressing type used whenever a connection
/// to other endpoints needs to be established.
///
/// [Address Lookup]: https://docs.rs/iroh/*/iroh/index.html#address-lookup
/// [home relay]: https://docs.rs/iroh/*/iroh/relay/index.html
/// [Relay server]: https://docs.rs/iroh/*/iroh/index.html#relay-servers
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct EndpointAddr {
    /// The endpoint's identifier.
    pub id: EndpointId,
    /// The endpoint's addresses
    pub addrs: BTreeSet<TransportAddr>,
}

/// Available address types.
#[derive(
    derive_more::Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash,
)]
#[non_exhaustive]
pub enum TransportAddr {
    /// Relays
    #[debug("Relay({_0})")]
    Relay(RelayUrl),
    /// IP based addresses
    Ip(SocketAddr),
    /// Custom transport address
    Custom(CustomAddr),
}

impl TransportAddr {
    /// Whether this is a transport address via a relay server.
    pub fn is_relay(&self) -> bool {
        matches!(self, Self::Relay(_))
    }

    /// Whether this is an IP transport address.
    pub fn is_ip(&self) -> bool {
        matches!(self, Self::Ip(_))
    }

    /// Whether this is a custom transport address.
    pub fn is_custom(&self) -> bool {
        matches!(self, Self::Custom(_))
    }
}

impl fmt::Display for TransportAddr {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Relay(url) => write!(f, "relay:{url}"),
            Self::Ip(addr) => write!(f, "ip:{addr}"),
            Self::Custom(addr) => write!(f, "custom:{addr}"),
        }
    }
}

impl EndpointAddr {
    /// Creates a new [`EndpointAddr`] with no network level addresses.
    ///
    /// This still is usable with e.g. an address lookup service to establish a connection,
    /// depending on the situation.
    pub fn new(id: PublicKey) -> Self {
        EndpointAddr {
            id,
            addrs: Default::default(),
        }
    }

    /// Creates a new [`EndpointAddr`] from its parts.
    pub fn from_parts(id: PublicKey, addrs: impl IntoIterator<Item = TransportAddr>) -> Self {
        Self {
            id,
            addrs: addrs.into_iter().collect(),
        }
    }

    /// Adds a [`RelayUrl`] address.
    pub fn with_relay_url(mut self, relay_url: RelayUrl) -> Self {
        self.addrs.insert(TransportAddr::Relay(relay_url));
        self
    }

    /// Adds an IP based address.
    pub fn with_ip_addr(mut self, addr: SocketAddr) -> Self {
        self.addrs.insert(TransportAddr::Ip(addr));
        self
    }

    /// Adds a list of addresses.
    pub fn with_addrs(mut self, addrs: impl IntoIterator<Item = TransportAddr>) -> Self {
        for addr in addrs.into_iter() {
            self.addrs.insert(addr);
        }
        self
    }

    /// Returns true, if only a [`EndpointId`] is present.
    pub fn is_empty(&self) -> bool {
        self.addrs.is_empty()
    }

    /// Returns a list of IP addresses of this peer.
    pub fn ip_addrs(&self) -> impl Iterator<Item = &SocketAddr> {
        self.addrs.iter().filter_map(|addr| match addr {
            TransportAddr::Ip(addr) => Some(addr),
            _ => None,
        })
    }

    /// Returns a list of relay urls of this peer.
    ///
    ///  In practice this is expected to be zero or one home relay for all known cases currently.
    pub fn relay_urls(&self) -> impl Iterator<Item = &RelayUrl> {
        self.addrs.iter().filter_map(|addr| match addr {
            TransportAddr::Relay(url) => Some(url),
            _ => None,
        })
    }
}

impl From<EndpointId> for EndpointAddr {
    fn from(endpoint_id: EndpointId) -> Self {
        EndpointAddr::new(endpoint_id)
    }
}


crates\spt-daemon\tests\access.rs:56:        scope: BindScope::Loopback,
crates\spt-daemon\tests\attach.rs:61:        scope: BindScope::Loopback,
crates\spt-net\src\net\endpoint.rs:75:/// policy. [`BindScope::Loopback`] exists for hermetic tests: a loopback-only
crates\spt-net\src\net\endpoint.rs:145:        Self::bind_scoped(identity, relay, discovery, alpns, BindScope::All).await
crates\spt-net\src\net\endpoint.rs:149:    /// explicit [`BindScope`]. Hermetic tests pass [`BindScope::Loopback`] so the
crates\spt-net\src\net\endpoint.rs:152:    /// [`BindScope::All`].
crates\spt-net\src\net\endpoint.rs:174:            BindScope::All => builder,
crates\spt-net\src\net\endpoint.rs:175:            BindScope::Loopback => builder
crates\spt-net\src\net\endpoint.rs:309:            BindScope::Loopback,
crates\spt\src\wansend.rs:185:            scope: BindScope::Loopback,
crates\spt-daemon\tests\dispatch.rs:69:        scope: BindScope::Loopback,
crates\spt-daemon\tests\notifsync.rs:45:        scope: BindScope::Loopback,
crates\spt-daemon\src\daemon.rs:68:                        scope: BindScope::All, // production: real interfaces
crates\spt-net\src\net\pairing\wire.rs:680:            BindScope::Loopback,
crates\spt-net\src\net\pairing\wire.rs:689:            BindScope::Loopback,
crates\spt-daemon\tests\netstream.rs:40:        scope: BindScope::Loopback,
crates\spt-daemon\tests\presence.rs:39:        scope: BindScope::Loopback,
crates\spt-daemon\tests\netbroker.rs:43:        scope: BindScope::Loopback,
crates\spt-daemon\tests\peerloop.rs:62:        scope: BindScope::Loopback,
crates\spt-daemon\tests\sync.rs:76:        scope: BindScope::Loopback,
crates\spt-daemon\tests\propagate.rs:50:        scope: BindScope::Loopback,
crates\spt-daemon\tests\xfer.rs:44:        scope: BindScope::Loopback,
crates\spt-daemon\tests\wanmsg.rs:65:        scope: BindScope::Loopback,
crates\spt-daemon\tests\replicate.rs:46:        scope: BindScope::Loopback,
crates\spt-daemon\src\nethost.rs:63:    /// Socket bind scope: [`BindScope::All`] in production;
crates\spt-daemon\src\nethost.rs:64:    /// [`BindScope::Loopback`] in hermetic tests (no real interface touched,
crates\spt-daemon\src\nethost.rs:644:            scope: BindScope::Loopback,


87-    /// Open (creating if needed) the canonical node-level store at
88-    \ [`notifs_file`](crate::perch::notifs_file).
89:    pub fn open() -> rusqlite::Result<NotifStore> {
90-        Self::open_at(&crate::perch::notifs_file())
91-    }
--
93-    /// Open at an explicit path (the testable core), creating the parent dir
94-    /// and schema if absent.
95:    pub fn open_at(path: &Path) -> rusqlite::Result<NotifStore> {
96-        if let Some(parent) = path.parent() {
97-            std::fs::create_dir_all(parent)
--
160-    /// a replayed or reordered feed converges to the same row.
161-    // [impl->REQ-NOTIF-1]
162:    pub fn merge_row(&self, incoming: &NotifRow) -> rusqlite::Result<MergeOutcome> {
163-        \ BEGIN IMMEDIATE: the read-join-write must be one writer transaction
164-        // (two concurrent joins on one node must serialize, not lose updates).
--
196-    /// flipped (`false` = already dismissed or unknown id; never un-dismisses).
197-    // [impl->REQ-NOTIF-1]
198:    pub fn dismiss(&self, notif_id: &str) -> rusqlite::Result<bool> {
199-        let n = self.conn.execute(
200-            "UPDATE notifs SET dismissed = 1 WHERE notif_id = ?1 AND dismissed = 0",
--
244-
245-    /// Every row in `subnet`, oldest produce first (created_ms, id tiebreak).
246:    pub fn list(&self, subnet: &str) -> rusqlite::Result<Vec<NotifRow>> {
247-        self.select_rows("subnet = ?1 ORDER BY created_ms ASC, notif_id ASC", params![subnet])
248-    }


crates\spt-daemon\src\attach.rs:203:pub fn request_attach(
crates\spt-daemon\src\attach.rs-204-    brain: &mut Brain,
crates\spt-daemon\src\attach.rs-205-    conn_id: u64,
crates\spt-daemon\src\attach.rs-206-    session_id: u64,
crates\spt-daemon\src\attach.rs-207-    from_seq: u64,
crates\spt-daemon\src\attach.rs-208-    open_op: u64,
crates\spt-daemon\src\attach.rs-209-) -> io::Result<u64> {
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\src\attach.rs-210-    let opened = brain.net_open_stream(conn_id, Some(open_op))?;
crates\spt-daemon\src\attach.rs-211-    let line = ndjson::encode_line(&AttachRecord::Request { session_id, from_seq });
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\src\attach.rs-212-    brain.net_stream_send(opened.stream_id, &line, None, false)?;
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\src\attach.rs-213-    Ok(opened.stream_id)
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\src\attach.rs-214-}
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\src\attach.rs-215-
--
crates\spt-daemon\src\attach.rs:221:pub fn send_attach_input(
crates\spt-daemon\src\attach.rs-222-    brain: &mut Brain,
crates\spt-daemon\src\attach.rs-223-    stream_id: u64,
crates\spt-daemon\src\attach.rs-224-    bytes: &[u8],
crates\spt-daemon\src\attach.rs-225-    wire_op: u64,
crates\spt-daemon\src\attach.rs-226-) -> io::Result<()> {
crates\spt-daemon\src\attach.rs-227-    let line = ndjson::encode_line(&AttachRecord::Input {
crates\spt-daemon\src\attach.rs-228-        data_b64: encode_bytes(bytes),
crates\spt-daemon\src\attach.rs-229-        op_id: wire_op,
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\src\attach.rs-230-    });
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\src\attach.rs-231-    brain.net_stream_send(stream_id, &line, None, false)?;
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\src\attach.rs-232-    Ok(())
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\src\attach.rs-233-}
--
crates\spt-daemon\src\xfer.rs:388:pub fn fetch_file(
crates\spt-daemon\src\xfer.rs-389-    brain: &mut Brain,
crates\spt-daemon\src\xfer.rs-390-    conn_id: u64,
crates\spt-daemon\src\xfer.rs-391-    endpoint: &str,
crates\spt-daemon\src\xfer.rs-392-    remote_path: &str,
crates\spt-daemon\src\xfer.rs-393-    dest: &Path,
crates\spt-daemon\src\xfer.rs-394-    xfer_id: &str,
crates\spt-daemon\src\xfer.rs-395-    open_op: u64,
crates\spt-daemon\src\xfer.rs-396-    progress_dir: &Path,
crates\spt-daemon\src\xfer.rs-397-) -> io::Result<u64> {
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\src\xfer.rs-398-    let opened = brain.net_open_stream(conn_id, Some(open_op))?;
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\src\xfer.rs-399-    let stream_id = opened.stream_id;
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\src\xfer.rs-400-    brain.net_stream_subscribe(stream_id, 0)?;
--
crates\spt-daemon\src\xfer.rs:486:pub fn push_file(
crates\spt-daemon\src\xfer.rs-487-    brain: &mut Brain,
crates\spt-daemon\src\xfer.rs-488-    conn_id: u64,
crates\spt-daemon\src\xfer.rs-489-    endpoint: &str,
crates\spt-daemon\src\xfer.rs-490-    src: &Path,
crates\spt-daemon\src\xfer.rs-491-    remote_path: &str,
crates\spt-daemon\src\xfer.rs-492-    xfer_id: &str,
crates\spt-daemon\src\xfer.rs-493-    open_op: u64,
crates\spt-daemon\src\xfer.rs-494-    progress_dir: &Path,
crates\spt-daemon\src\xfer.rs-495-) -> io::Result<u64> {
crates\spt-daemon\src\xfer.rs-496-    let mut file = std::fs::File::open(src)?;
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\src\xfer.rs-497-    let total = file.metadata()?.len();
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\src\xfer.rs-498-


34:pub fn spt_home() -> PathBuf {
62:pub fn owlery_dir() -> PathBuf {
98:pub fn subnet_file() -> PathBuf {
106:pub fn epoch_file() -> PathBuf {
115:pub fn visibility_file() -> PathBuf {
124:pub fn notifs_file() -> PathBuf {
138:pub fn trust_file() -> PathBuf {


Grep

fn (load|save|default_path)\b|fn load_from|const FILE|join\("


117:    pub fn load() -> TrustStore {
125:    pub fn load_from(path: &Path) -> TrustStore {
133:    pub fn save(&self) -> Result<(), TrustError> {
376:        let path = dir.path().join("identity").join("trust").join("peers.json");
390:        assert!(TrustStore::load_from(&dir.path().join("nope.json")).peers.is_empty());
391:        let corrupt = dir.path().join("corrupt.json");


174:    pub fn add_joined(
175-        &mut self,
176-        name: &str,
177-        seed: [u8; TOTP_SEED_LEN],
178-        epoch: u64,
179-    ) -> Result<&SubnetRecord, SubnetError> {
180-        if self.find(name).is_some() {
181-            return Err(SubnetError::Exists(name.to_string()));
182-        }
183-        self.subnets.push(SubnetRecord {
184-            name: name.to_string(),


Write docs/TWO-HOST-RUNBOOK.md


29:pub mod ratelimit;
30:pub mod rendezvous;
31:pub mod spake;
32:pub mod totp;
33:pub mod transcript;
34:pub mod wire;


86:    pub fn open_or_init() -> std::io::Result<ContextStore> {
87-        Self::open_or_init_in(&tracked_dir())
88-    }
89-
90-    /// Open (or initialize) a store rooted at an explicit `tracked/` dir —
91-    /// the testable seam.
92-    // [impl->REQ-STORE-1]
93:    pub fn open_or_init_in(root: &Path) -> std::io::Result<ContextStore> {
94-        let store = BranchStore::open_or_init(&root.join(".seed.git"))?;
95-        Ok(ContextStore { store, root: root.to_path_buf() })
96-    }
97-
98-    /// The underlying branch-as-KV store (D6c's bundle exchange works on it).
99-    pub fn branch_store(&self) -> &BranchStore {


pub fn new|pub enum TrustOutcome|impl Default for PairingRateLimiter


107-impl PairingRateLimiter {
108-    /// A fresh limiter with no tracked subnets.
109:    pub fn new() -> PairingRateLimiter {
110-        PairingRateLimiter::default()
111-    }


72:pub enum TrustOutcome {
73-    \ First use of this `(subnet, pubkey)`: pinned now. The normal post-ceremony path.
74-    Pinned,
75-    /// This exact `(subnet, pubkey)` was already trusted — an idempotent re-pair.
76-    AlreadyTrusted,
77-    /// A peer under this `(subnet, label)` is already pinned with a **different** key.
78-    \ NOT applied: the caller must warn the user and, only if the change is accepted,
79-    \ call [`repin`](TrustStore::repin). The danger case is a MITM presenting a fresh
80-    /// key under a known node's label.
--
172:    pub fn record(
173-        &mut self,
174-        subnet: &str,
175-        pubkey_hex: &str,
176-        label: Option<&str>,
177-        user: Option<&str>,
178-    ) -> TrustOutcome {
179-        // Exact identity already trusted → idempotent (a key isn't "changed" against
180-        // itself, even when a label is present).


All APIs verified. Writing code. First `BindScope::Port`:


Edit

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BindScope {
    /// Bind the unspecified addresses (`0.0.0.0` / `[::]`) — all interfaces.
    /// The production scope; required for any off-host connectivity.
    All,
    /// Bind `127.0.0.1` / `[::1]` only. Hermetic tests: loopback dials work,
    /// nothing can reach (or prompt about) a real interface.
    Loopback,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BindScope {
    /// Bind the unspecified addresses (`0.0.0.0` / `[::]`) — all interfaces.
    /// The production scope; required for any off-host connectivity.
    All,
    /// Bind `127.0.0.1` / `[::1]` only. Hermetic tests: loopback dials work,
    /// nothing can reach (or prompt about) a real interface.
    Loopback,
    /// Bind all interfaces on an **explicit UDP port** (`0.0.0.0:p` / `[::]:p`)
    /// instead of an ephemeral one. A node whose port is stable can be named by
    /// a static `ip:port` ticket (firewall allow-rules, the two-host rig's
    /// env-derived peer addresses) with no relay/discovery infrastructure.
    Port(u16),
}


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

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BindScope {
    /// Bind the unspecified addresses (`0.0.0.0` / `[::]`) — all interfaces.
    /// The production scope; required for any off-host connectivity.
    All,
    /// Bind `127.0.0.1` / `[::1]` only. Hermetic tests: loopback dials work,
    /// nothing can reach (or prompt about) a real interface.
    Loopback,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BindScope {
    /// Bind the unspecified addresses (`0.0.0.0` / `[::]`) — all interfaces.
    /// The production scope; required for any off-host connectivity.
    All,
    /// Bind `127.0.0.1` / `[::1]` only. Hermetic tests: loopback dials work,
    /// nothing can reach (or prompt about) a real interface.
    Loopback,
    /// Bind all interfaces on an **explicit UDP port** (`0.0.0.0:p` / `[::]:p`)
    /// instead of an ephemeral one. A node whose port is stable can be named by
    /// a static `ip:port` ticket (firewall allow-rules, the two-host rig's
    /// env-derived peer addresses) with no relay/discovery infrastructure.
    Port(u16),
}

+    /// Bind all interfaces on an **explicit UDP port** (`0.0.0.0:p` / `[::]:p`)

+    /// instead of an ephemeral one. A node whose port is stable can be named by

+    /// a static `ip:port` ticket (firewall allow-rules, the two-host rig's

+    /// env-derived peer addresses) with no relay/discovery infrastructure.

/// Failures binding or dialing the endpoint. Wraps iroh's error chain as a message


        let builder = match scope {
            BindScope::All => builder,
            BindScope::Loopback => builder
                .clear_ip_transports()
                .bind_addr("127.0.0.1:0")
                .map_err(|e| NetError::Bind(e.to_string()))?
                .bind_addr("[::1]:0")
                .map_err(|e| NetError::Bind(e.to_string()))?
                .portmapper_config(PortmapperConfig::Disabled),
        };

        let builder = match scope {
            BindScope::All => builder,
            BindScope::Loopback => builder
                .clear_ip_transports()
                .bind_addr("127.0.0.1:0")
                .map_err(|e| NetError::Bind(e.to_string()))?
                .bind_addr("[::1]:0")
                .map_err(|e| NetError::Bind(e.to_string()))?
                .portmapper_config(PortmapperConfig::Disabled),
            // All-interfaces like the production scope, but on a caller-fixed
            // port so the node is dialable by a static ip:port ticket.
            BindScope::Port(p) => builder
                .clear_ip_transports()
                .bind_addr(format!("0.0.0.0:{p}"))
                .map_err(|e| NetError::Bind(e.to_string()))?
                .bind_addr(format!("[::]:{p}"))
                .map_err(|e| NetError::Bind(e.to_string()))?,
        };


        let builder = match scope {
            BindScope::All => builder,
            BindScope::Loopback => builder
                .clear_ip_transports()
                .bind_addr("127.0.0.1:0")
                .map_err(|e| NetError::Bind(e.to_string()))?
                .bind_addr("[::1]:0")
                .map_err(|e| NetError::Bind(e.to_string()))?
                .portmapper_config(PortmapperConfig::Disabled),
        };

        let builder = match scope {
            BindScope::All => builder,
            BindScope::Loopback => builder
                .clear_ip_transports()
                .bind_addr("127.0.0.1:0")
                .map_err(|e| NetError::Bind(e.to_string()))?
                .bind_addr("[::1]:0")
                .map_err(|e| NetError::Bind(e.to_string()))?
                .portmapper_config(PortmapperConfig::Disabled),
            // All-interfaces like the production scope, but on a caller-fixed
            // port so the node is dialable by a static ip:port ticket.
            BindScope::Port(p) => builder
                .clear_ip_transports()
                .bind_addr(format!("0.0.0.0:{p}"))
                .map_err(|e| NetError::Bind(e.to_string()))?
                .bind_addr(format!("[::]:{p}"))
                .map_err(|e| NetError::Bind(e.to_string()))?,
        };

+            // All-interfaces like the production scope, but on a caller-fixed

+            // port so the node is dialable by a static ip:port ticket.

+            BindScope::Port(p) => builder

+                .map_err(|e| NetError::Bind(e.to_string()))?,

// The N0 preset already wires the n0 relay default; only override it when the

// policy asks for self-host or disabled.


Bash

grep -n "pub fn bind_addr" -A 8 /c/Users/decid/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/iroh-0.98.2/src/endpoint.rs | head -20

Check bind_addr signature in iroh


341:    pub fn bind_addr<A>(self, addr: A) -> Result<Self, InvalidSocketAddr>
342-    where
343-        A: ToSocketAddr,
344-        <A as ToSocketAddr>::Err: Into<InvalidSocketAddr>,
345-    {
346-        self.bind_addr_with_opts(addr, BindOpts::default())
347-    }
348-
349-    /// Binds an IP socket at the provided socket address.
--
421:    pub fn bind_addr_with_opts<A>(
422-        mut self,
423-        addr: A,
424-        opts: BindOpts,
425-    ) -> Result<Self, InvalidSocketAddr>
426-    where
427-        A: ToSocketAddr,
428-        <A as ToSocketAddr>::Err: Into<InvalidSocketAddr>,
429-    {


grep -rn "impl ToSocketAddr for\|impl.*ToSocketAddr for" /c/Users/decid/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/iroh-0.98.2/src/ | head

Check ToSocketAddr impls


/c/Users/decid/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/iroh-0.98.2/src/endpoint/bind.rs:135:impl ToSocketAddr for SocketAddr {
/c/Users/decid/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/iroh-0.98.2/src/endpoint/bind.rs:143:impl ToSocketAddr for SocketAddrV4 {
/c/Users/decid/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/iroh-0.98.2/src/endpoint/bind.rs:151:impl ToSocketAddr for SocketAddrV6 {
/c/Users/decid/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/iroh-0.98.2/src/endpoint/bind.rs:159:impl ToSocketAddr for (IpAddr, u16) {
/c/Users/decid/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/iroh-0.98.2/src/endpoint/bind.rs:171:impl ToSocketAddr for (Ipv4Addr, u16) {
/c/Users/decid/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/iroh-0.98.2/src/endpoint/bind.rs:180:impl ToSocketAddr for (Ipv6Addr, u16) {
/c/Users/decid/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/iroh-0.98.2/src/endpoint/bind.rs:189:impl ToSocketAddr for (&str, u16) {
/c/Users/decid/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/iroh-0.98.2/src/endpoint/bind.rs:201:impl ToSocketAddr for (String, u16) {
/c/Users/decid/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/iroh-0.98.2/src/endpoint/bind.rs:209:impl ToSocketAddr for str {
/c/Users/decid/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/iroh-0.98.2/src/endpoint/bind.rs:218:impl<T: ToSocketAddr + ?Sized> ToSocketAddr for &T {


`String` not `ToSocketAddr` — use `SocketAddr` directly:


Edit

            // All-interfaces like the production scope, but on a caller-fixed
            // port so the node is dialable by a static ip:port ticket.
            BindScope::Port(p) => builder
                .clear_ip_transports()
                .bind_addr(format!("0.0.0.0:{p}"))
                .map_err(|e| NetError::Bind(e.to_string()))?
                .bind_addr(format!("[::]:{p}"))
                .map_err(|e| NetError::Bind(e.to_string()))?,

            // All-interfaces like the production scope, but on a caller-fixed
            // port so the node is dialable by a static ip:port ticket.
            BindScope::Port(p) => builder
                .clear_ip_transports()
                .bind_addr(std::net::SocketAddr::from((std::net::Ipv4Addr::UNSPECIFIED, p)))
                .map_err(|e| NetError::Bind(e.to_string()))?
                .bind_addr(std::net::SocketAddr::from((std::net::Ipv6Addr::UNSPECIFIED, p)))
                .map_err(|e| NetError::Bind(e.to_string()))?,


            // All-interfaces like the production scope, but on a caller-fixed
            // port so the node is dialable by a static ip:port ticket.
            BindScope::Port(p) => builder
                .clear_ip_transports()
                .bind_addr(format!("0.0.0.0:{p}"))
                .map_err(|e| NetError::Bind(e.to_string()))?
                .bind_addr(format!("[::]:{p}"))
                .map_err(|e| NetError::Bind(e.to_string()))?,

            // All-interfaces like the production scope, but on a caller-fixed
            // port so the node is dialable by a static ip:port ticket.
            BindScope::Port(p) => builder
                .clear_ip_transports()
                .bind_addr(std::net::SocketAddr::from((std::net::Ipv4Addr::UNSPECIFIED, p)))
                .map_err(|e| NetError::Bind(e.to_string()))?
                .bind_addr(std::net::SocketAddr::from((std::net::Ipv6Addr::UNSPECIFIED, p)))
                .map_err(|e| NetError::Bind(e.to_string()))?,

+                .bind_addr(std::net::SocketAddr::from((std::net::Ipv4Addr::UNSPECIFIED, p)))

+                .bind_addr(std::net::SocketAddr::from((std::net::Ipv6Addr::UNSPECIFIED, p)))


Grep

pub fn tracked_dir|use crate::perch|tracked_dir


crates\spt-store\src\home.rs:24:use crate::perch::{self, ParentHint};
crates\spt-store\src\epoch.rs:20:use crate::perch::epoch_file;
crates\spt-store\src\contextstore.rs:30:use crate::perch::tracked_dir;
crates\spt-store\src\contextstore.rs:85:    /// [`tracked_dir`](crate::perch::tracked_dir).
crates\spt-store\src\contextstore.rs:87:        Self::open_or_init_in(&tracked_dir())
crates\spt-store\src\access.rs:42:use crate::perch::{access_file, recent_outbound_file};
crates\spt-store\src\liveness.rs:33:use crate::perch::{self, ParentHint};
crates\spt-store\src\nodeid.rs:24:use crate::perch::node_key_file;
crates\spt-store\src\perch.rs:71:pub fn tracked_dir() -> PathBuf {
crates\spt-store\src\registry.rs:215:        use crate::perch::{resolve_perch_path_in, ParentHint};

[Showing results with pagination = limit: 10]


118:    pub fn live_context_path(&self, id: &str) -> std::io::Result<PathBuf> {
119-        Self::validated(id)?;
120-        let file = live_context_file(&self.root, id);
121-        let wt = file.parent().expect("live-context file has a worktree parent");
--
146:    pub fn commit_live(&self, id: &str, message: &str) -> std::io::Result<Option<String>> {
147-        Self::validated(id)?;
148-        let wt = self.root.join("agents").join(id);
149-        self.store.commit_in_worktree(&wt, &[], message)


All surfaces verified. Writing `crates/spt-daemon/tests/twohost.rs`: