500	    // fresh stamp rides B's advertisement rows (REQ-PRES-1).
501	    info::set_last_active(&perch_b, now_ms()).expect("stamp B's recency");
502
503	    // ── Broker on the fixed port + the remote-drive target session. ──
504	    let broker_name = format!("spt-twohost-b-{}.sock", std::process::id());
505	    let _broker = rig_broker(&broker_name, rig.id_b.clone(), rig.port_b, scratch.path());
506	    let mut host_brain = connect_retry(&broker_name);
507	    #[cfg(unix)]
508	    let (program, args) = ("cat".to_string(), Vec::<String>::new());
509	    #[cfg(windows)]
510	    let (program, args) = ("findstr".to_string(), vec![".".to_string()]);
511	    let sid = host_brain
512	        .spawn_session(spt_daemon::msg::SpawnReq {
513	            program,
514	            args,
515	            rows: 24,
516	            cols: 80,
517	            endpoint: ID_B.to_string(),
518	        })
519	        .expect("spawn drive target");
520	    // Publish the session id where A's file-fetch rung reads it (the fetch IS
521	    // the first remote-drive leg).
522	    let xroot = spt_store::xfer::transfers_dir();
523	    std::fs::create_dir_all(&xroot).expect("transfer root");
524	    std::fs::write(xroot.join("sid.txt"), sid.to_string()).expect("write sid");
525	    println!("TWOHOST role B: drive session {sid} ready");
526
527	    // ── Rung 1 — pair (responder). The REAL TOTP-seeded SPAKE2 ceremony over
528	    // the pre-trust ALPN, cross-host. Trust lands in the canonical store the
529	    // dispatcher gates read.
530	    // [int->REQ-PAIR-1]
531	    let rt = tokio::runtime::Runtime::new().expect("tokio rt");
532	    rt.block_on(async {
533	        let ep = NetEndpoint::bind_scoped(
534	            &rig.id_b,
535	            RelayPolicy::Disabled,
536	            LocalDiscovery::Off,
537	            vec![SPT_PAIR_ALPN.to_vec()],
538	            BindScope::Port(rig.port_pair),
539	        )
540	        .await
541	        .expect("pairing endpoint bind");
542	        let end = Instant::now() + rig.wait;
543	        let mut rate = PairingRateLimiter::new();
544	        loop {
545	            assert!(Instant::now() < end, "no pairing ceremony arrived within the rig window");
546	            let Some(incoming) = ep.accept().await else {
547	                panic!("pairing endpoint closed before a ceremony arrived")
548	            };
549	            let Ok(connecting) = incoming.accept() else { continue };
550	            let Ok(conn) = connecting.await else { continue };
551	            let mut trust = TrustStore::load();
552	            match run_responder(&conn, rig.id_b.public_key(), &subnets, &mut rate, &mut trust, now_secs())
553	                .await
554	            {
555	                Ok(outcome) => {
556	                    trust.save().expect("save trust");
557	                    println!("TWOHOST OK: pairing (responder) — {outcome:?}");
558	                    break;
559	                }
560	                Err(e) => eprintln!("pairing attempt failed (joiner may retry): {e}"),
561	            }
562	        }
563	        ep.close().await;
564	    });
565	    let trust_now = TrustStore::load();
566	    assert!(
567	        trust_now.is_trusted(&rig.subnet, &rig.a_hex()),
568	        "the ceremony pinned A in B's canonical trust store"
569	    );
570
571	    // ── Production loops: dispatcher (serves A's pulls/feeds) + pump (pushes
572	    // B's registry/notif/mind, bootstrap-pulls A's). ──
573	    let (registry, stop) =
574	        start_loops(&broker_name, &rig.b_hex(), rig.a_hex(), rig.peer_broker_addr("b"), scratch.path());
575
576	    // ── Rung 2 — register: A's perch row replicates into B's registry through
577	    // the gated inbound feed (epoch-leased, trust-gated).
578	    // [int->REQ-INST-7]
579	    rig_wait("register: A's perch row replicated to B", rig.wait, || {
580	        registry.rows(&rig.subnet, ID_A).iter().any(|i| i.node == rig.a_hex())
581	    });
582
583	    // ── Rung 3 — cross-node message: A's WAN send lands in B's perch spool
584	    // (the atomic offline path) through the dispatcher funnel.
585	    // [int->REQ-NET-1]
586	    rig_wait("message: A's WAN message spooled at B", rig.wait, || {
587	        spool::peek_all_at(&perch_b)
588	            .map(|rows| rows.iter().any(|(_, _, body, _)| body == WAN_BODY))
589	            .unwrap_or(false)
590	    });
591
592	    // ── Rung 7a — notif: A's insert fires here, then B dismisses and the
593	    // dismissal replicates BACK over B's pump (the acceptance leg).
594	    // [int->REQ-NOTIF-1]
595	    let store_b = NotifStore::open().expect("B notif store");
596	    let mut ping_id = String::new();
597	    rig_wait("notif: A's insert fired on B", rig.wait, || {
598	        match store_b.list(&rig.subnet) {
599	            Ok(rows) => match rows.iter().find(|r| r.body == NOTIF_BODY) {
600	                Some(r) => {
601	                    ping_id = r.notif_id.clone();
602	                    true
603	                }
604	                None => false,
605	            },
606	            Err(_) => false,
607	        }
608	    });
609	    assert!(store_b.dismiss(&ping_id).expect("dismiss"), "B dismisses A's notif (latches once)");

    // fresh stamp rides B's advertisement rows (REQ-PRES-1).
    info::set_last_active(&perch_b, now_ms()).expect("stamp B's recency");

    // ── Broker on the fixed port + the remote-drive target session. ──
    let broker_name = format!("spt-twohost-b-{}.sock", std::process::id());
    let _broker = rig_broker(&broker_name, rig.id_b.clone(), rig.port_b, scratch.path());
    let mut host_brain = connect_retry(&broker_name);
    #[cfg(unix)]
    let (program, args) = ("cat".to_string(), Vec::<String>::new());
    #[cfg(windows)]
    let (program, args) = ("findstr".to_string(), vec![".".to_string()]);
    let sid = host_brain
        .spawn_session(spt_daemon::msg::SpawnReq {
            program,
            args,
            rows: 24,
            cols: 80,
            endpoint: ID_B.to_string(),
        })
        .expect("spawn drive target");
    // Publish the session id where A's file-fetch rung reads it (the fetch IS
    // the first remote-drive leg).
    let xroot = spt_store::xfer::transfers_dir();
    std::fs::create_dir_all(&xroot).expect("transfer root");
    std::fs::write(xroot.join("sid.txt"), sid.to_string()).expect("write sid");
    println!("TWOHOST role B: drive session {sid} ready");

    // ── Rung 1 — pair (responder). The REAL TOTP-seeded SPAKE2 ceremony over
    // the pre-trust ALPN, cross-host. Trust lands in the canonical store the
    // dispatcher gates read.
    // [int->REQ-PAIR-1]
    let rt = tokio::runtime::Runtime::new().expect("tokio rt");
    rt.block_on(async {
        let ep = NetEndpoint::bind_scoped(
            &rig.id_b,
            RelayPolicy::Disabled,
            LocalDiscovery::Off,
            vec![SPT_PAIR_ALPN.to_vec()],
            BindScope::Port(rig.port_pair),
        )
        .await
        .expect("pairing endpoint bind");
        let end = Instant::now() + rig.wait;
        let mut rate = PairingRateLimiter::new();
        loop {
            assert!(Instant::now() < end, "no pairing ceremony arrived within the rig window");
            let Some(incoming) = ep.accept().await else {
                panic!("pairing endpoint closed before a ceremony arrived")
            };
            let Ok(connecting) = incoming.accept() else { continue };
            let Ok(conn) = connecting.await else { continue };
            let mut trust = TrustStore::load();
            match run_responder(&conn, rig.id_b.public_key(), &subnets, &mut rate, &mut trust, now_secs())
                .await
            {
                Ok(outcome) => {
                    trust.save().expect("save trust");
                    println!("TWOHOST OK: pairing (responder) — {outcome:?}");
                    break;
                }
                Err(e) => eprintln!("pairing attempt failed (joiner may retry): {e}"),
            }
        }
        ep.close().await;
    });
    let trust_now = TrustStore::load();
    assert!(
        trust_now.is_trusted(&rig.subnet, &rig.a_hex()),
        "the ceremony pinned A in B's canonical trust store"
    );

    // ── Production loops: dispatcher (serves A's pulls/feeds) + pump (pushes
    // B's registry/notif/mind, bootstrap-pulls A's). ──
    let (registry, stop) =
        start_loops(&broker_name, &rig.b_hex(), rig.a_hex(), rig.peer_broker_addr("b"), scratch.path());

    // ── Rung 2 — register: A's perch row replicates into B's registry through
    // the gated inbound feed (epoch-leased, trust-gated).
    // [int->REQ-INST-7]
    rig_wait("register: A's perch row replicated to B", rig.wait, || {
        registry.rows(&rig.subnet, ID_A).iter().any(|i| i.node == rig.a_hex())
    });

    // ── Rung 3 — cross-node message: A's WAN send lands in B's perch spool
    // (the atomic offline path) through the dispatcher funnel.
    // [int->REQ-NET-1]
    rig_wait("message: A's WAN message spooled at B", rig.wait, || {
        spool::peek_all_at(&perch_b)
            .map(|rows| rows.iter().any(|(_, _, body, _)| body == WAN_BODY))
            .unwrap_or(false)
    });

    // ── Rung 7a — notif: A's insert fires here, then B dismisses and the
    // dismissal replicates BACK over B's pump (the acceptance leg).
    // [int->REQ-NOTIF-1]
    let store_b = NotifStore::open().expect("B notif store");
    let mut ping_id = String::new();
    rig_wait("notif: A's insert fired on B", rig.wait, || {
        match store_b.list(&rig.subnet) {
            Ok(rows) => match rows.iter().find(|r| r.body == NOTIF_BODY) {
                Some(r) => {
                    ping_id = r.notif_id.clone();
                    true
                }
                None => false,
            },
            Err(_) => false,
        }
    });
    assert!(store_b.dismiss(&ping_id).expect("dismiss"), "B dismisses A's notif (latches once)");


Bash

grep -n "KIND_NET_STATUS\|KIND_NET_DIAL\b\|pub const KIND" crates/spt-daemon/src/msg.rs | head -40

List IPC kind constants


22:pub const KIND_SPAWN: &str = "spawn";
24:pub const KIND_SPAWNED: &str = "spawned";
26:pub const KIND_SUBSCRIBE: &str = "subscribe";
28:pub const KIND_INPUT: &str = "input";
30:pub const KIND_RESIZE: &str = "resize";
32:pub const KIND_KILL: &str = "kill";
34:pub const KIND_OUTPUT: &str = "output";
36:pub const KIND_EXIT: &str = "exit";
41:pub const KIND_APPLIED: &str = "applied";
43:pub const KIND_ERROR: &str = "error";
47:pub const KIND_NET_STATUS: &str = "net-status";
49:pub const KIND_NET_STATUS_REPLY: &str = "net-status-reply";
52:pub const KIND_NET_DIAL: &str = "net-dial";
55:pub const KIND_NET_DIALED: &str = "net-dialed";
58:pub const KIND_NET_STREAM_OPEN: &str = "net-stream-open";
60:pub const KIND_NET_STREAM_OPENED: &str = "net-stream-opened";
63:pub const KIND_NET_STREAM_SEND: &str = "net-stream-send";
65:pub const KIND_NET_SENT: &str = "net-sent";
69:pub const KIND_NET_STREAMS: &str = "net-streams";
71:pub const KIND_NET_STREAMS_REPLY: &str = "net-streams-reply";
74:pub const KIND_NET_STREAM_SUBSCRIBE: &str = "net-stream-subscribe";
76:pub const KIND_NET_STREAM_DATA: &str = "net-stream-data";
79:pub const KIND_NET_STREAM_EOF: &str = "net-stream-eof";
84:pub const KIND_NET_PRESENCE_SUBSCRIBE: &str = "net-presence-subscribe";
86:pub const KIND_NET_PRESENCE_EVENT: &str = "net-presence-event";
95:pub const KIND_SESSIONS: &str = "sessions";
97:pub const KIND_SESSIONS_REPLY: &str = "sessions-reply";


280	                KIND_SUBSCRIBE => match self.dispatch_subscribe(env, &send) {
281	                    Ok(id) => my_subs.push(id),
282	                    Err(msg) => send_error(&send, &msg),
283	                },
284	                KIND_INPUT => {
285	                    if let Err(msg) = self.dispatch_input(env, &send) {
286	                        send_error(&send, &msg);
287	                    }
288	                }
289	                KIND_RESIZE => {
290	                    if let Err(msg) = self.dispatch_resize(env) {
291	                        send_error(&send, &msg);
292	                    }
293	                }
294	                KIND_KILL => {
295	                    if let Err(msg) = self.dispatch_kill(env) {
296	                        send_error(&send, &msg);
297	                    }
298	                }
299	                KIND_NET_STATUS => self.dispatch_net_status(&send),
300	                KIND_NET_DIAL => {
301	                    if let Err(msg) = self.dispatch_net_dial(env, &send) {
302	                        send_error(&send, &msg);
303	                    }
304	                }
305	                KIND_NET_STREAM_OPEN => {
306	                    if let Err(msg) = self.dispatch_net_stream_open(env, &send) {
307	                        send_error(&send, &msg);
308	                    }
309	                }
310	                KIND_NET_STREAM_SEND => {
311	                    if let Err(msg) = self.dispatch_net_stream_send(env, &send) {
312	                        send_error(&send, &msg);
313	                    }
314	                }
315	                KIND_NET_STREAMS => {
316	                    if let Err(msg) = self.dispatch_net_streams(&send) {
317	                        send_error(&send, &msg);
318	                    }
319	                }
320	                KIND_SESSIONS => {
321	                    let sessions = self.sessions.lock().unwrap();
322	                    let reply = SessionsReply {
323	                        sessions: sessions
324	                            .iter()
325	                            .map(|(id, h)| SessionInfo {
326	                                session_id: *id,
327	                                endpoint: h.endpoint.clone(),
328	                            })
329	                            .collect(),
330	                    };
331	                    drop(sessions);
332	                    let frame = Envelope::new(
333	                        KIND_SESSIONS_REPLY,
334	                        serde_json::to_value(reply).expect("SessionsReply serializes"),
335	                    );
336	                    send_frame(&send, &frame);
337	                }
338	                KIND_NET_STREAM_SUBSCRIBE => {
339	                    match self.dispatch_net_stream_subscribe(env, &send) {
340	                        Ok(id) => my_stream_subs.push(id),
341	                        Err(msg) => send_error(&send, &msg),
342	                    }
343	                }
344	                KIND_NET_PRESENCE_SUBSCRIBE => {
345	                    match self.dispatch_net_presence_subscribe(env, &send) {
346	                        Ok(()) => my_presence_sub = true,
347	                        Err(msg) => send_error(&send, &msg),
348	                    }
349	                }
350	                // An unknown kind is tolerated (forward-compat): report, don't die.
351	                other => send_error(&send, &format!("unknown command kind {other:?}")),
352	            }
353	        }
354
355	        // Brain gone: detach as subscriber but leave the sessions running.
356	        let sessions = self.sessions.lock().unwrap();
357	        for id in &my_subs {
358	            if let Some(h) = sessions.get(id) {
359	                h.log.lock().unwrap().detach_if(&send);
360	            }
361	        }
362	        drop(sessions);
363	        // Same for net streams: the streams + their rings persist (D4b).
364	        if let Some(host) = &self.net {
365	            for id in &my_stream_subs {
366	                host.detach_stream_sub(*id, &send);
367	            }
368	            // And presence: the liveness log + its ring persist (D4c).
369	            if my_presence_sub {

                KIND_SUBSCRIBE => match self.dispatch_subscribe(env, &send) {
                    Ok(id) => my_subs.push(id),
                    Err(msg) => send_error(&send, &msg),
                },
                KIND_INPUT => {
                    if let Err(msg) = self.dispatch_input(env, &send) {
                        send_error(&send, &msg);
                    }
                }
                KIND_RESIZE => {
                    if let Err(msg) = self.dispatch_resize(env) {
                        send_error(&send, &msg);
                    }
                }
                KIND_KILL => {
                    if let Err(msg) = self.dispatch_kill(env) {
                        send_error(&send, &msg);
                    }
                }
                KIND_NET_STATUS => self.dispatch_net_status(&send),
                KIND_NET_DIAL => {
                    if let Err(msg) = self.dispatch_net_dial(env, &send) {
                        send_error(&send, &msg);
                    }
                }
                KIND_NET_STREAM_OPEN => {
                    if let Err(msg) = self.dispatch_net_stream_open(env, &send) {
                        send_error(&send, &msg);
                    }
                }
                KIND_NET_STREAM_SEND => {
                    if let Err(msg) = self.dispatch_net_stream_send(env, &send) {
                        send_error(&send, &msg);
                    }
                }
                KIND_NET_STREAMS => {
                    if let Err(msg) = self.dispatch_net_streams(&send) {
                        send_error(&send, &msg);
                    }
                }
                KIND_SESSIONS => {
                    let sessions = self.sessions.lock().unwrap();
                    let reply = SessionsReply {
                        sessions: sessions
                            .iter()
                            .map(|(id, h)| SessionInfo {
                                session_id: *id,
                                endpoint: h.endpoint.clone(),
                            })
                            .collect(),
                    };
                    drop(sessions);
                    let frame = Envelope::new(
                        KIND_SESSIONS_REPLY,
                        serde_json::to_value(reply).expect("SessionsReply serializes"),
                    );
                    send_frame(&send, &frame);
                }
                KIND_NET_STREAM_SUBSCRIBE => {
                    match self.dispatch_net_stream_subscribe(env, &send) {
                        Ok(id) => my_stream_subs.push(id),
                        Err(msg) => send_error(&send, &msg),
                    }
                }
                KIND_NET_PRESENCE_SUBSCRIBE => {
                    match self.dispatch_net_presence_subscribe(env, &send) {
                        Ok(()) => my_presence_sub = true,
                        Err(msg) => send_error(&send, &msg),
                    }
                }
                // An unknown kind is tolerated (forward-compat): report, don't die.
                other => send_error(&send, &format!("unknown command kind {other:?}")),
            }
        }

        // Brain gone: detach as subscriber but leave the sessions running.
        let sessions = self.sessions.lock().unwrap();
        for id in &my_subs {
            if let Some(h) = sessions.get(id) {
                h.log.lock().unwrap().detach_if(&send);
            }
        }
        drop(sessions);
        // Same for net streams: the streams + their rings persist (D4b).
        if let Some(host) = &self.net {
            for id in &my_stream_subs {
                host.detach_stream_sub(*id, &send);
            }
            // And presence: the liveness log + its ring persist (D4c).
            if my_presence_sub {


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 {
410	        A_BUSY => PairWireError::Refused(RateLimited::Busy),
411	        A_BACKOFF => PairWireError::Refused(RateLimited::Backoff {
412	            retry_after_secs: retry_after,
413	        }),
414	        A_BAD_STEP => PairWireError::StepRejected,
415	        A_UNKNOWN_SUBNET => PairWireError::UnknownSubnet(subnet_name.to_string()),
416	        A_BAD_MSG | A_CONFIRM_FAIL => PairWireError::Pairing(PairError::Confirm),
417	        r => PairWireError::Protocol(format!("unknown abort reason {r}")),
418	    }
419	}
420
421	fn unexpected(want: &str, got: Frame) -> PairWireError {
422	    PairWireError::Protocol(format!("expected {want}, got {}", got.kind()))
423	}
424
425	// ── Frame model + codec ──────────────────────────────────────────────────────────
426
427	/// A decoded ceremony frame. The wire form is a u32-BE total length followed by a tag
428	/// byte and the tag's fields; variable fields are themselves u32-BE length-prefixed
429	/// (the same convention as [`transcript`](super::transcript)).
430	enum Frame {
431	    Hello { subnet_name: String, step: u64 },
432	    Announce { epoch: u64 },
433	    Spake1 { msg_a: Vec<u8> },
434	    Spake2 { msg_b: Vec<u8>, tag: [u8; TAG_LEN] },
435	    Confirm { tag: [u8; TAG_LEN] },
436	    Done { ok: bool },
437	    Seed { seed: [u8; TOTP_SEED_LEN], epoch: u64 },
438	    Abort { reason: u8, retry_after: u64 },
439	}
440
441	impl Frame {
442	    fn kind(&self) -> &'static str {
443	        match self {
444	            Frame::Hello { .. } => "Hello",
445	            Frame::Announce { .. } => "Announce",
446	            Frame::Spake1 { .. } => "Spake1",
447	            Frame::Spake2 { .. } => "Spake2",
448	            Frame::Confirm { .. } => "Confirm",

        .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 {
        A_BUSY => PairWireError::Refused(RateLimited::Busy),
        A_BACKOFF => PairWireError::Refused(RateLimited::Backoff {
            retry_after_secs: retry_after,
        }),
        A_BAD_STEP => PairWireError::StepRejected,
        A_UNKNOWN_SUBNET => PairWireError::UnknownSubnet(subnet_name.to_string()),
        A_BAD_MSG | A_CONFIRM_FAIL => PairWireError::Pairing(PairError::Confirm),
        r => PairWireError::Protocol(format!("unknown abort reason {r}")),
    }
}

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

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

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

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


1	//! Subnet-global pairing rate limit (ADR-0005 SERIOUS #11, D2d) — the gate that makes
2	//! SPAKE2's "one online guess per ceremony" bound actually hold, and the justification
3	//! for keeping the ±1 TOTP window.
4	//!
5	//! ## The threat (ADR-0005 #11)
6	//!
7	//! SPAKE2 leaks at most one password guess per *completed* ceremony — but only while
8	//! ceremonies cannot be run in parallel or in a tight loop. A public pre-trust relay
9	//! lets any peer reach a seed-holder, and the [±1 window](super::totp::accepts_step)
10	//! widens the valid-code set from 1 to 3 of `10^6` (~21 bits → ~19). With no gate an
11	//! attacker drives back-to-back ceremonies (and, across multiple seed-holders, in
12	//! parallel) to brute the rotating code inside its 30s life. The required mitigation:
13	//! each seed-holder admits at most **one in-flight ceremony per subnet**, keeps a
14	//! **shared failure counter**, and imposes **exponential backoff** between attempts.
15	//!
16	//! ## Why this justifies the ±1 window
17	//!
18	//! The window triples the per-guess success probability (to `3 / 10^6 ≈ 3e-6`).
19	//! Serialization plus exponential backoff caps the guess *rate*, so the expected
20	//! guesses-to-break stays astronomically beyond a 30s code's lifetime regardless of
21	//! the 3× factor — #11's "justify or drop the ±1 window" clause is met by
22	//! justification, not by dropping the skew tolerance.
23	//!
24	//! ## Scope: per-node, per-subnet (the residual)
25	//!
26	//! State is in-memory and node-local: it bounds one seed-holder's admitted rate. With
27	//! `N` seed-holders the aggregate is `N×` a single node's bound — still a hard cap,
28	//! since every node independently serializes and backs off. A truly shared cross-node
29	//! counter needs the trusted P2P mesh (post-D3) and is deferred; this is the required
30	//! *local* gate each seed-holder runs. Backoff is ephemeral — a process restart resets
31	//! it — which is acceptable because restarting a seed-holder is not remotely
32	//! attacker-driven (unlike the relay-reachable ceremony surface itself).
33	//
34	// [impl->REQ-HAZARD-PAIR-RATE-LIMIT]
35
36	use std::collections::HashMap;
37
38	/// Backoff after the first failed/abandoned ceremony (seconds). Doubles per failure.
39	pub const BACKOFF_BASE_SECS: u64 = 1;
40
41	/// Backoff ceiling (seconds). One hour caps the exponential so a long attack settles
42	/// at a fixed (still punishing) retry interval instead of overflowing.
43	pub const BACKOFF_MAX_SECS: u64 = 3600;
44
45	/// A ceremony in flight longer than this is treated as abandoned and reclaimed (the
46	/// caller crashed, the peer vanished, or an attacker opened a ceremony and stalled to
47	/// wedge the subnet). The four-message ceremony is sub-second on a live link; 60s is
48	/// generous slack while still freeing a stuck slot.
49	pub const CEREMONY_TIMEOUT_SECS: u64 = 60;
50
51	/// Why a [`begin`](PairingRateLimiter::begin) was refused. Carries no secret material.
52	#[derive(Debug, PartialEq, Eq)]
53	pub enum RateLimited {
54	    /// Another ceremony for this subnet is already in flight (one-at-a-time): the
55	    /// serialization that denies parallel SPAKE2 guessing.
56	    Busy,
57	    /// Too many recent failures; the exponential-backoff window has not elapsed. Retry
58	    /// no sooner than `retry_after_secs` from the queried time.
59	    Backoff { retry_after_secs: u64 },
60	}
61
62	impl std::fmt::Display for RateLimited {
63	    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64	        match self {
65	            RateLimited::Busy => write!(f, "a pairing ceremony is already active for this subnet"),
66	            RateLimited::Backoff { retry_after_secs } => {
67	                write!(f, "pairing rate-limited — retry in {retry_after_secs}s")
68	            }
69	        }
70	    }
71	}
72
73	impl std::error::Error for RateLimited {}
74
75	/// Per-subnet gate state: the one-at-a-time slot, the shared failure counter, and the
76	/// next time a ceremony may be admitted.
77	#[derive(Debug, Default, Clone)]
78	struct SubnetGate {
79	    /// `Some(start_unix)` while a ceremony holds the slot; `None` when free.
80	    active_since: Option<u64>,
81	    /// Consecutive failed/abandoned ceremonies — the exponential-backoff exponent.
82	    failures: u32,
83	    /// Earliest unix-second a new ceremony may be admitted (the backoff barrier).
84	    next_allowed: u64,
85	}
86
87	impl SubnetGate {
88	    /// Record a failed (or abandoned) ceremony: bump the counter and push the backoff
89	    /// barrier out from `now`.
90	    fn register_failure(&mut self, now: u64) {
91	        self.failures = self.failures.saturating_add(1);
92	        self.next_allowed = now.saturating_add(backoff_secs(self.failures));
93	    }
94	}
95
96	/// Subnet-keyed pairing admission control. The seed-holder (responder) side owns one
97	/// of these for the life of the process and consults it around every ceremony.
98	///
99	/// Time is passed in (`now`, unix seconds) rather than read from a clock, so backoff
100	/// is deterministically testable — the same dependency-injection the rest of
101	/// [`pairing`](super) uses for the TOTP step.
102	#[derive(Debug, Default)]
103	pub struct PairingRateLimiter {
104	    gates: HashMap<String, SubnetGate>,
105	}
106
107	impl PairingRateLimiter {
108	    /// A fresh limiter with no tracked subnets.
109	    pub fn new() -> PairingRateLimiter {
110	        PairingRateLimiter::default()

//! Subnet-global pairing rate limit (ADR-0005 SERIOUS #11, D2d) — the gate that makes
//! SPAKE2's "one online guess per ceremony" bound actually hold, and the justification
//! for keeping the ±1 TOTP window.
//!
//! ## The threat (ADR-0005 #11)
//!
//! SPAKE2 leaks at most one password guess per *completed* ceremony — but only while
//! ceremonies cannot be run in parallel or in a tight loop. A public pre-trust relay
//! lets any peer reach a seed-holder, and the [±1 window](super::totp::accepts_step)
//! widens the valid-code set from 1 to 3 of `10^6` (~21 bits → ~19). With no gate an
//! attacker drives back-to-back ceremonies (and, across multiple seed-holders, in
//! parallel) to brute the rotating code inside its 30s life. The required mitigation:
//! each seed-holder admits at most **one in-flight ceremony per subnet**, keeps a
//! **shared failure counter**, and imposes **exponential backoff** between attempts.
//!
//! ## Why this justifies the ±1 window
//!
//! The window triples the per-guess success probability (to `3 / 10^6 ≈ 3e-6`).
//! Serialization plus exponential backoff caps the guess *rate*, so the expected
//! guesses-to-break stays astronomically beyond a 30s code's lifetime regardless of
//! the 3× factor — #11's "justify or drop the ±1 window" clause is met by
//! justification, not by dropping the skew tolerance.
//!
//! ## Scope: per-node, per-subnet (the residual)
//!
//! State is in-memory and node-local: it bounds one seed-holder's admitted rate. With
//! `N` seed-holders the aggregate is `N×` a single node's bound — still a hard cap,
//! since every node independently serializes and backs off. A truly shared cross-node
//! counter needs the trusted P2P mesh (post-D3) and is deferred; this is the required
//! *local* gate each seed-holder runs. Backoff is ephemeral — a process restart resets
//! it — which is acceptable because restarting a seed-holder is not remotely
//! attacker-driven (unlike the relay-reachable ceremony surface itself).
//
// [impl->REQ-HAZARD-PAIR-RATE-LIMIT]

use std::collections::HashMap;

/// Backoff after the first failed/abandoned ceremony (seconds). Doubles per failure.
pub const BACKOFF_BASE_SECS: u64 = 1;

/// Backoff ceiling (seconds). One hour caps the exponential so a long attack settles
/// at a fixed (still punishing) retry interval instead of overflowing.
pub const BACKOFF_MAX_SECS: u64 = 3600;

/// A ceremony in flight longer than this is treated as abandoned and reclaimed (the
/// caller crashed, the peer vanished, or an attacker opened a ceremony and stalled to
/// wedge the subnet). The four-message ceremony is sub-second on a live link; 60s is
/// generous slack while still freeing a stuck slot.
pub const CEREMONY_TIMEOUT_SECS: u64 = 60;

/// Why a [`begin`](PairingRateLimiter::begin) was refused. Carries no secret material.
#[derive(Debug, PartialEq, Eq)]
pub enum RateLimited {
    /// Another ceremony for this subnet is already in flight (one-at-a-time): the
    /// serialization that denies parallel SPAKE2 guessing.
    Busy,
    /// Too many recent failures; the exponential-backoff window has not elapsed. Retry
    /// no sooner than `retry_after_secs` from the queried time.
    Backoff { retry_after_secs: u64 },
}

impl std::fmt::Display for RateLimited {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            RateLimited::Busy => write!(f, "a pairing ceremony is already active for this subnet"),
            RateLimited::Backoff { retry_after_secs } => {
                write!(f, "pairing rate-limited — retry in {retry_after_secs}s")
            }
        }
    }
}

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

/// Per-subnet gate state: the one-at-a-time slot, the shared failure counter, and the
/// next time a ceremony may be admitted.
#[derive(Debug, Default, Clone)]
struct SubnetGate {
    /// `Some(start_unix)` while a ceremony holds the slot; `None` when free.
    active_since: Option<u64>,
    /// Consecutive failed/abandoned ceremonies — the exponential-backoff exponent.
    failures: u32,
    /// Earliest unix-second a new ceremony may be admitted (the backoff barrier).
    next_allowed: u64,
}

impl SubnetGate {
    /// Record a failed (or abandoned) ceremony: bump the counter and push the backoff
    /// barrier out from `now`.
    fn register_failure(&mut self, now: u64) {
        self.failures = self.failures.saturating_add(1);
        self.next_allowed = now.saturating_add(backoff_secs(self.failures));
    }
}

/// Subnet-keyed pairing admission control. The seed-holder (responder) side owns one
/// of these for the life of the process and consults it around every ceremony.
///
/// Time is passed in (`now`, unix seconds) rather than read from a clock, so backoff
/// is deterministically testable — the same dependency-injection the rest of
/// [`pairing`](super) uses for the TOTP step.
#[derive(Debug, Default)]
pub struct PairingRateLimiter {
    gates: HashMap<String, SubnetGate>,
}

impl PairingRateLimiter {
    /// A fresh limiter with no tracked subnets.
    pub fn new() -> PairingRateLimiter {
        PairingRateLimiter::default()


TaskCreate

D3a: pairhost module + nethost multi-ALPN routing + meet rotation

crates/spt-daemon/src/pairhost.rs: respond() handler (tokio Mutex limiter try_lock shed, 60s timeout aligned w/ CEREMONY_TIMEOUT_SECS), meet rotation loop per member subnet (re-bound per TOTP step, soft-fail binds, skip on BindScope::Port), join_ceremony() joiner leg. NetHost binds [SPT_NET_ALPN, SPT_PAIR_ALPN], accept loop routes by conn.alpn().


Task #9 created successfully: D3a: pairhost module + nethost multi-ALPN routing + meet rotation


D3b: pair-join broker IPC op + brain client method

msg.rs KIND_PAIR_JOIN/JOINED + PairJoinReq/Reply (ok, class, detail). broker.rs dispatch arm → NetHost::pair_join. brain.rs client pair_join().


Task #10 created successfully: D3b: pair-join broker IPC op + brain client method


D3c: spt subnet join CLI + elevation gates + REQ-SUBNET-2/4 activation

join [NAME] [--code] — elevation gate (exit 3), interactive prompts when tty, bounded wrong-code re-prompt, honest rate-limit + no-seed-holder hints, home adoption on success. Status hint footer gains join line. Activate REQ-SUBNET-2 [impl] (+unit), REQ-SUBNET-4 [impl,unit]; tag create/join gates.


Task #11 created successfully: D3c: spt subnet join CLI + elevation gates + REQ-SUBNET-2/4 activation


D3d: ADR-0005 amendment + full checks + commit

Record always-on responder + elevation gates amendment in docs/adr/0005. cargo tests, xtask gen/check, traceable-reqs check, commit.


Task #12 created successfully: D3d: ADR-0005 amendment + full checks + commit


Updated task #9 status


Bash

grep -n "pub mod" crates/spt-daemon/src/lib.rs | head -40; grep -n "fn begin" -A20 crates/spt-net/src/net/pairing/ratelimit.rs | head -30

Daemon module list + limiter begin semantics


106:pub mod access;
107:pub mod adapter_update;
108:pub mod applyhost;
109:pub mod attach;
110:pub mod brain;
111:pub mod broker;
112:pub mod codec;
113:pub mod config;
114:pub mod consent;
115:pub mod daemon;
116:pub mod digest;
117:pub mod digesthub;
118:pub mod dispatch;
119:pub mod effect;
120:pub mod endpoint;
121:pub mod frame;
122:pub mod grants;
123:pub mod lifecycle;
124:pub mod linkhost;
125:pub mod msg;
126:pub mod nethost;
127:pub mod notif;
128:pub mod notifsync;
129:pub mod peerloop;
130:pub mod presence;
131:pub mod propagate;
132:pub mod psyrelay;
133:pub mod reconcile;
134:pub mod registryhost;
135:pub mod relay;
136:pub mod relcache;
137:pub mod release;
138:pub mod resthost;
139:pub mod resting;
140:pub mod seedmap;
141:pub mod shellchan;
142:pub mod shelldisc;
143:pub mod shellhost;
144:pub mod shellwake;
145:pub mod sync;
123:    pub fn begin(&mut self, subnet: &str, now: u64) -> Result<(), RateLimited> {
124-        let gate = self.gates.entry(subnet.to_string()).or_default();
125-        if let Some(start) = gate.active_since {
126-            if now.saturating_sub(start) < CEREMONY_TIMEOUT_SECS {
127-                return Err(RateLimited::Busy);
128-            }
129-            // Stale slot: the previous ceremony was abandoned. Reclaim it and charge a
130-            // failure so an attacker can't dodge backoff by simply never releasing.
131-            gate.active_since = None;
132-            gate.register_failure(now);
133-        }
134-        if now < gate.next_allowed {
135-            return Err(RateLimited::Backoff {
136-                retry_after_secs: gate.next_allowed - now,
137-            });
138-        }
139-        gate.active_since = Some(now);
140-        Ok(())
141-    }
142-
143-    /// Release the slot after a *successful* ceremony: a confirmed pairing clears the


grep -n "pub enum RelayPolicy\|pub enum LocalDiscovery\|pub enum BindScope" -A8 crates/spt-net/src/net/endpoint.rs | head -40; grep -n "derive" -B1 -A0 crates/spt-net/src/net/endpoint.rs | grep -B1 "RelayPolicy\|LocalDiscovery\|BindScope"; grep -n "pub struct NetConfig" -A12 crates/spt-daemon/src/nethost.rs

Check enum derives + NetConfig shape


51:pub enum RelayPolicy {
52-    /// n0's public production relays — the zero-config "just works" default.
53-    N0Default,
54-    /// Self-hosted relay(s) — the principled escape hatch from the n0 dependency.
55-    SelfHosted(RelayMap),
56-    /// No relays: direct/LAN connectivity only. Used by air-gapped LANs and the
57-    /// hermetic loopback tests (no network reached at all).
58-    Disabled,
59-}
--
67:pub enum LocalDiscovery {
68-    /// Advertise + resolve over mDNS (swarm-discovery) in addition to n0 DNS.
69-    Mdns,
70-    /// No mDNS — WAN discovery only (and direct addresses). Used by hermetic tests.
71-    Off,
72-}
73-
74-/// Which interfaces the endpoint's UDP sockets bind — a mechanical knob, not
75-/// policy. [`BindScope::Loopback`] exists for hermetic tests: a loopback-only
--
80:pub enum BindScope {
81-    /// Bind the unspecified addresses (`0.0.0.0` / `[::]`) — all interfaces.
82-    /// The production scope; required for any off-host connectivity.
83-    All,
84-    /// Bind `127.0.0.1` / `[::1]` only. Hermetic tests: loopback dials work,
85-    /// nothing can reach (or prompt about) a real interface.
86-    Loopback,
87-    /// Bind all interfaces on an **explicit UDP port** (`0.0.0.0:p` / `[::]:p`)
88-    /// instead of an ephemeral one. A node whose port is stable can be named by
56:pub struct NetConfig {
57-    /// The node identity the endpoint binds as (seed → same ed25519 keypair).
58-    pub identity: Identity,
59-    /// Relay stance (ADR-0002): n0 default / self-host / disabled.
60-    pub relay: RelayPolicy,
61-    /// mDNS LAN discovery on/off (off in hermetic tests).
62-    pub discovery: LocalDiscovery,
63-    /// Socket bind scope: [`BindScope::All`] in production;
64-    /// [`BindScope::Loopback`] in hermetic tests (no real interface touched,
65-    /// no per-test-binary Windows firewall prompt).
66-    pub scope: BindScope,
67-}
68-


grep -n "#\[derive" -A1 crates/spt-net/src/net/endpoint.rs | grep -A1 "derive" | head -20; grep -rn "impl Clone for RelayPolicy\|Clone.*RelayPolicy" crates/spt-net/src/net/endpoint.rs | head -3; grep -n "derive" crates/spt-net/src/net/endpoint.rs | head

Check derives on the knob enums


50:#[derive(Debug, Clone)]
51-pub enum RelayPolicy {
--
66:#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67-pub enum LocalDiscovery {
--
79:#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80-pub enum BindScope {
--
97:#[derive(Debug)]
98-pub enum NetError {
50:#[derive(Debug, Clone)]
66:#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79:#[derive(Debug, Clone, Copy, PartialEq, Eq)]
90:    /// env-derived peer addresses) with no relay/discovery infrastructure.
97:#[derive(Debug)]
124:    /// The iroh secret key is derived from the identity's 32-byte ed25519 seed, so
218:    /// This node's public key — its stable subnet name, derived from the live


Write

//! M7 D3 (REQ-SUBNET-2): the join ceremony's **product surface** — the
//! daemon-side hosting that turns the rig-proven SPAKE2/TOTP pairing wire
//! (`spt_net::net::pairing`) into something a user can actually run.
//!
//! Three pieces, all riding the broker's one node-identity endpoint:
//!
//! - **Responder, always-on** (ADR-0005 amendment; M7 plan decision 8): every
//!   member daemon accepts the pre-trust [`SPT_PAIR_ALPN`] beside the trusted
//!   surface — no arming step, the one-sided UX the ADR intended. The
//!   subnet-global [`PairingRateLimiter`] (red-team #11) is the standing-
//!   listener guard; [`respond`] additionally sheds concurrent ceremonies at
//!   the door (`try_lock`) and bounds each admitted ceremony to the
//!   limiter's own [`CEREMONY_TIMEOUT_SECS`] so a stalling peer cannot wedge
//!   the responder (the limiter's stale-slot reclaim is the backstop).
//! - **Meet rotation** ([`spawn_meet_rotation`]): for each member subnet, a
//!   short-lived rendezvous listener bound to the token-derived identity
//!   (`pairing::meet`), re-bound every TOTP step — how a joiner that knows
//!   nothing but (name, code) finds this responder over LAN or relay.
//! - **Joiner leg** ([`join_ceremony`]): dial via rendezvous, run the
//!   unchanged initiator ceremony, persist trust + the transferred seed.
//!   Invoked over the broker IPC (`pair-join`) because the ceremony binds
//!   the REAL node pubkey — and the node-identity endpoint lives here.
//!
//! Pairing connections never touch the trusted stream table: the ALPN routes
//! them to [`respond`] and the connection dies with the ceremony.
// [impl->REQ-SUBNET-2]

use std::sync::Arc;
use std::time::Duration;

use spt_net::net::endpoint::{BindScope, Connection, LocalDiscovery, NetEndpoint, RelayPolicy};
use spt_net::net::pairing::meet::{bind_rendezvous, dial_via_rendezvous};
use spt_net::net::pairing::ratelimit::{PairingRateLimiter, RateLimited, CEREMONY_TIMEOUT_SECS};
use spt_net::net::pairing::totp::{time_step, STEP_SECS};
use spt_net::net::pairing::wire::{run_initiator, run_responder, PairWireError};
use spt_proto::identity::PublicKey;
use spt_store::subnet::SubnetStore;
use spt_store::trust::TrustStore;

/// The ceremony bound: aligned with the rate limiter's own stale-slot
/// reclaim so an abandoned slot and an abandoned task expire together.
const CEREMONY_TIMEOUT: Duration = Duration::from_secs(CEREMONY_TIMEOUT_SECS);

/// Serve one inbound pairing ceremony (the always-on responder's per-
/// connection handler). Stores are loaded fresh per ceremony — a subnet
/// created or joined a second ago is immediately pairable, and a trust edit
/// takes effect without a daemon restart (the same reload-per-use stance as
/// the dispatcher gates).
///
/// Concurrency: ONE ceremony at a time per daemon, enforced at the door —
/// a second connection finds the limiter lock held and is closed immediately
/// (the joiner sees the stream end and retries; same UX as the limiter's
/// `Busy` abort, shed before any frame is read).
// [impl->REQ-SUBNET-2]
pub async fn respond(
    conn: Connection,
    local_pub: PublicKey,
    rate: Arc<tokio::sync::Mutex<PairingRateLimiter>>,
) {
    let Ok(mut rate) = rate.try_lock() else {
        conn.close(1u32.into(), b"pairing busy");
        return;
    };
    let subnets = SubnetStore::load();
    let mut trust = TrustStore::load();
    match tokio::time::timeout(
        CEREMONY_TIMEOUT,
        run_responder(&conn, local_pub, &subnets, &mut rate, &mut trust, now_secs()),
    )
    .await
    {
        Ok(Ok(outcome)) => match trust.save() {
            Ok(()) => eprintln!("PAIRED: joiner pinned ({outcome:?})"),
            Err(e) => eprintln!("PAIRING_TRUST_SAVE_FAIL: {e}"),
        },
        // A failed ceremony is normal traffic (wrong code, impostor probe,
        // vanished peer) — logged, never fatal; the limiter charged it.
        Ok(Err(e)) => eprintln!("PAIRING_ATTEMPT_FAIL: {e}"),
        Err(_) => eprintln!("PAIRING_TIMEOUT: ceremony exceeded {CEREMONY_TIMEOUT_SECS}s"),
    }
}

/// The meet-rotation loop: for every member subnet, keep a rendezvous meet
/// listener bound under the (name, current-step)-derived identity, answering
/// joiner probes with `real_addr` — the responder's actual pairing-endpoint
/// address. Re-binds at each TOTP step boundary (window rotation = re-bind,
/// `pairing::meet` docs); re-reads the subnet store per window so a freshly
/// created/joined subnet starts meeting within one step.
///
/// Bind failures are soft (logged, that subnet skipped this window): the
/// daemon must keep running net-less-for-pairing rather than die. A
/// [`BindScope::Port`] scope is mapped to [`BindScope::All`] — the fixed
/// port belongs to the main endpoint; a second bind on it would always fail.
// [impl->REQ-SUBNET-2]
pub fn spawn_meet_rotation(
    handle: &tokio::runtime::Handle,
    endpoint: Arc<NetEndpoint>,
    relay: RelayPolicy,
    discovery: LocalDiscovery,
    scope: BindScope,
) {
    let scope = match scope {
        BindScope::Port(_) => BindScope::All,
        s => s,
    };
    handle.spawn(async move {
        loop {
            let step = time_step(now_secs());
            let names: Vec<String> =
                SubnetStore::load().subnets.iter().map(|s| s.name.clone()).collect();
            // One listener task per subnet for this window's lifetime.
            let mut window_tasks = Vec::new();
            for name in names {
                let relay = relay.clone();
                let real_addr = endpoint.addr();
                window_tasks.push(tokio::spawn(async move {
                    let listener =
                        match bind_rendezvous(&name, step, relay, discovery, scope).await {
                            Ok(l) => l,
                            Err(e) => {
                                eprintln!("PAIR_MEET_BIND_FAIL:{name}: {e}");
                                return;
                            }
                        };
                    // Serve probes until the window task is aborted at the
                    // step boundary; each failure only ends one exchange.
                    loop {
                        if listener.serve_once(&real_addr).await.is_err() {
                            // Endpoint closed or a malformed probe — brief
                            // pause so a tight error can't spin the loop.
                            tokio::time::sleep(Duration::from_millis(200)).await;
                        }
                    }
                }));
            }
            // Sleep to the next step boundary, then rotate (drop the old
            // window's listeners; their derived ids expire with the code).
            let now = now_secs();
            let next = (time_step(now) + 1) * STEP_SECS;
            tokio::time::sleep(Duration::from_secs(next.saturating_sub(now).max(1))).await;
            for t in window_tasks {
                t.abort();
            }
        }
    });
}

/// Why a join attempt failed — classified for the CLI's guided UX
/// (M7 plan decision 10: wrong code re-prompts, rate limits surface
/// honestly, a missing seed-holder gets actionable hints).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum JoinFail {
    /// No meet listener answered for any window token — no seed-holder
    /// reachable under that subnet name right now.
    NoSeedHolder(String),
    /// The ceremony ran and the confirmation failed — in practice: wrong code.
    WrongCode,
    /// The responder shed or refused us (busy / backoff `retry_after_secs`).
    RateLimited(Option<u64>),
    /// The responder does not hold a subnet by that name.
    UnknownSubnet(String),
    /// The dialer-announced TOTP step was outside the responder's window
    /// (clock skew beyond ±1 step).
    ClockSkew,
    /// Wire/store plumbing failure (stream died, seed write failed…).
    Other(String),
}

impl std::fmt::Display for JoinFail {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            JoinFail::NoSeedHolder(e) => write!(f, "no seed-holder found: {e}"),
            JoinFail::WrongCode => write!(f, "pairing confirmation failed (wrong code?)"),
            JoinFail::RateLimited(Some(s)) => write!(f, "rate-limited — retry in {s}s"),
            JoinFail::RateLimited(None) => write!(f, "responder busy with another ceremony"),
            JoinFail::UnknownSubnet(n) => write!(f, "responder holds no subnet '{n}'"),
            JoinFail::ClockSkew => write!(f, "TOTP step rejected — check this machine's clock"),
            JoinFail::Other(e) => write!(f, "{e}"),
        }
    }
}

/// The joiner leg (M7 plan decision 10): find a seed-holder via the LAN+relay
/// rendezvous, run the unchanged initiator ceremony against its real
/// endpoint, and persist the results (peer trust-pinned; the transferred
/// seed makes this node a full member / future seed-holder). One attempt —
/// the CLI owns the re-prompt loop.
// [impl->REQ-SUBNET-2]
pub async fn join_ceremony(
    endpoint: &NetEndpoint,
    subnet_name: &str,
    code: &str,
) -> Result<String, JoinFail> {
    let conn = dial_via_rendezvous(endpoint, subnet_name, time_step(now_secs()))
        .await
        .map_err(|e| JoinFail::NoSeedHolder(e.to_string()))?;
    let mut trust = TrustStore::load();
    let mut subnets = SubnetStore::load();
    let outcome = tokio::time::timeout(
        CEREMONY_TIMEOUT,
        run_initiator(
            &conn,
            endpoint.node_id(),
            subnet_name,
            code,
            &mut trust,
            &mut subnets,
            now_secs(),
        ),
    )
    .await
    .map_err(|_| JoinFail::Other(format!("ceremony exceeded {CEREMONY_TIMEOUT_SECS}s")))?
    .map_err(classify_wire_error)?;
    trust.save().map_err(|e| JoinFail::Other(format!("trust save: {e}")))?;
    subnets.save().map_err(|e| JoinFail::Other(format!("seed save: {e}")))?;
    Ok(format!("{outcome:?}"))
}

/// Map the wire error onto the CLI-facing failure classes.
fn classify_wire_error(e: PairWireError) -> JoinFail {
    match e {
        PairWireError::Pairing(_) => JoinFail::WrongCode,
        PairWireError::Refused(RateLimited::Busy) => JoinFail::RateLimited(None),
        PairWireError::Refused(RateLimited::Backoff { retry_after_secs }) => {
            JoinFail::RateLimited(Some(retry_after_secs))
        }
        PairWireError::UnknownSubnet(n) => JoinFail::UnknownSubnet(n),
        PairWireError::StepRejected => JoinFail::ClockSkew,
        PairWireError::Io(e) | PairWireError::Protocol(e) => JoinFail::Other(e),
    }
}

/// Unix seconds (the ceremony clock — TOTP steps + limiter time).
fn now_secs() -> u64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_secs())
        .unwrap_or(0)
}

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

    // [unit->REQ-SUBNET-2] every wire-error shape lands in the CLI failure
    // class the guided join UX switches on — wrong code re-prompts, rate
    // limits surface honestly (with the retry window), and plumbing noise
    // stays distinguishable from a user-fixable problem.
    #[test]
    fn wire_errors_classify_for_the_guided_ux() {
        use spt_net::net::pairing::spake::PairError;
        assert_eq!(
            classify_wire_error(PairWireError::Pairing(PairError::Confirm)),
            JoinFail::WrongCode
        );
        assert_eq!(
            classify_wire_error(PairWireError::Refused(RateLimited::Busy)),
            JoinFail::RateLimited(None)
        );
        assert_eq!(
            classify_wire_error(PairWireError::Refused(RateLimited::Backoff {
                retry_after_secs: 8
            })),
            JoinFail::RateLimited(Some(8))
        );
        assert_eq!(
            classify_wire_error(PairWireError::UnknownSubnet("home".into())),
            JoinFail::UnknownSubnet("home".into())
        );
        assert_eq!(classify_wire_error(PairWireError::StepRejected), JoinFail::ClockSkew);
        assert!(matches!(
            classify_wire_error(PairWireError::Io("eof".into())),
            JoinFail::Other(_)
        ));
    }
}


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

//! M7 D3 (REQ-SUBNET-2): the join ceremony's **product surface** — the
//! daemon-side hosting that turns the rig-proven SPAKE2/TOTP pairing wire
//! (`spt_net::net::pairing`) into something a user can actually run.
//!
//! Three pieces, all riding the broker's one node-identity endpoint:
//!
//! - **Responder, always-on** (ADR-0005 amendment; M7 plan decision 8): every
//!   member daemon accepts the pre-trust [`SPT_PAIR_ALPN`] beside the trusted
//!   surface — no arming step, the one-sided UX the ADR intended. The
//!   subnet-global [`PairingRateLimiter`] (red-team #11) is the standing-
//!   listener guard; [`respond`] additionally sheds concurrent ceremonies at
//!   the door (`try_lock`) and bounds each admitted ceremony to the
//!   limiter's own [`CEREMONY_TIMEOUT_SECS`] so a stalling peer cannot wedge
//!   the responder (the limiter's stale-slot reclaim is the backstop).
//! - **Meet rotation** ([`spawn_meet_rotation`]): for each member subnet, a
//!   short-lived rendezvous listener bound to the token-derived identity
//!   (`pairing::meet`), re-bound every TOTP step — how a joiner that knows
//!   nothing but (name, code) finds this responder over LAN or relay.
//! - **Joiner leg** ([`join_ceremony`]): dial via rendezvous, run the
//!   unchanged initiator ceremony, persist trust + the transferred seed.
//!   Invoked over the broker IPC (`pair-join`) because the ceremony binds
//!   the REAL node pubkey — and the node-identity endpoint lives here.
//!
//! Pairing connections never touch the trusted stream table: the ALPN routes
//! them to [`respond`] and the connection dies with the ceremony.
// [impl->REQ-SUBNET-2]

use std::sync::Arc;
use std::time::Duration;

use spt_net::net::endpoint::{BindScope, Connection, LocalDiscovery, NetEndpoint, RelayPolicy};
use spt_net::net::pairing::meet::{bind_rendezvous, dial_via_rendezvous};
use spt_net::net::pairing::ratelimit::{PairingRateLimiter, RateLimited, CEREMONY_TIMEOUT_SECS};
use spt_net::net::pairing::totp::{time_step, STEP_SECS};
use spt_net::net::pairing::wire::{run_initiator, run_responder, PairWireError};
use spt_proto::identity::PublicKey;
use spt_store::subnet::SubnetStore;
use spt_store::trust::TrustStore;

/// The ceremony bound: aligned with the rate limiter's own stale-slot
/// reclaim so an abandoned slot and an abandoned task expire together.
const CEREMONY_TIMEOUT: Duration = Duration::from_secs(CEREMONY_TIMEOUT_SECS);

/// Serve one inbound pairing ceremony (the always-on responder's per-
/// connection handler). Stores are loaded fresh per ceremony — a subnet
/// created or joined a second ago is immediately pairable, and a trust edit
/// takes effect without a daemon restart (the same reload-per-use stance as
/// the dispatcher gates).
///
/// Concurrency: ONE ceremony at a time per daemon, enforced at the door —
/// a second connection finds the limiter lock held and is closed immediately
/// (the joiner sees the stream end and retries; same UX as the limiter's
/// `Busy` abort, shed before any frame is read).
// [impl->REQ-SUBNET-2]
pub async fn respond(
    conn: Connection,
    local_pub: PublicKey,
    rate: Arc<tokio::sync::Mutex<PairingRateLimiter>>,
) {
    let Ok(mut rate) = rate.try_lock() else {
        conn.close(1u32.into(), b"pairing busy");
        return;
    };
    let subnets = SubnetStore::load();
    let mut trust = TrustStore::load();
    match tokio::time::timeout(
        CEREMONY_TIMEOUT,
        run_responder(&conn, local_pub, &subnets, &mut rate, &mut trust, now_secs()),
    )
    .await
    {
        Ok(Ok(outcome)) => match trust.save() {
            Ok(()) => eprintln!("PAIRED: joiner pinned ({outcome:?})"),
            Err(e) => eprintln!("PAIRING_TRUST_SAVE_FAIL: {e}"),
        },
        // A failed ceremony is normal traffic (wrong code, impostor probe,
        // vanished peer) — logged, never fatal; the limiter charged it.
        Ok(Err(e)) => eprintln!("PAIRING_ATTEMPT_FAIL: {e}"),
        Err(_) => eprintln!("PAIRING_TIMEOUT: ceremony exceeded {CEREMONY_TIMEOUT_SECS}s"),
    }
}

/// The meet-rotation loop: for every member subnet, keep a rendezvous meet
/// listener bound under the (name, current-step)-derived identity, answering
/// joiner probes with `real_addr` — the responder's actual pairing-endpoint
/// address. Re-binds at each TOTP step boundary (window rotation = re-bind,
/// `pairing::meet` docs); re-reads the subnet store per window so a freshly
/// created/joined subnet starts meeting within one step.
///
/// Bind failures are soft (logged, that subnet skipped this window): the
/// daemon must keep running net-less-for-pairing rather than die. A
/// [`BindScope::Port`] scope is mapped to [`BindScope::All`] — the fixed
/// port belongs to the main endpoint; a second bind on it would always fail.
// [impl->REQ-SUBNET-2]
pub fn spawn_meet_rotation(
    handle: &tokio::runtime::Handle,
    endpoint: Arc<NetEndpoint>,
    relay: RelayPolicy,
    discovery: LocalDiscovery,
    scope: BindScope,
) {
    let scope = match scope {
        BindScope::Port(_) => BindScope::All,
        s => s,
    };
    handle.spawn(async move {
        loop {
            let step = time_step(now_secs());
            let names: Vec<String> =
                SubnetStore::load().subnets.iter().map(|s| s.name.clone()).collect();
            // One listener task per subnet for this window's lifetime.
            let mut window_tasks = Vec::new();
            for name in names {
                let relay = relay.clone();
                let real_addr = endpoint.addr();
                window_tasks.push(tokio::spawn(async move {
                    let listener =
                        match bind_rendezvous(&name, step, relay, discovery, scope).await {
                            Ok(l) => l,
                            Err(e) => {
                                eprintln!("PAIR_MEET_BIND_FAIL:{name}: {e}");
                                return;
                            }
                        };
                    // Serve probes until the window task is aborted at the
                    // step boundary; each failure only ends one exchange.
                    loop {
                        if listener.serve_once(&real_addr).await.is_err() {
                            // Endpoint closed or a malformed probe — brief
                            // pause so a tight error can't spin the loop.
                            tokio::time::sleep(Duration::from_millis(200)).await;
                        }
                    }
                }));
            }
            // Sleep to the next step boundary, then rotate (drop the old
            // window's listeners; their derived ids expire with the code).
            let now = now_secs();
            let next = (time_step(now) + 1) * STEP_SECS;
            tokio::time::sleep(Duration::from_secs(next.saturating_sub(now).max(1))).await;
            for t in window_tasks {
                t.abort();
            }
        }
    });
}

/// Why a join attempt failed — classified for the CLI's guided UX
/// (M7 plan decision 10: wrong code re-prompts, rate limits surface
/// honestly, a missing seed-holder gets actionable hints).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum JoinFail {
    /// No meet listener answered for any window token — no seed-holder
    /// reachable under that subnet name right now.
    NoSeedHolder(String),
    /// The ceremony ran and the confirmation failed — in practice: wrong code.
    WrongCode,
    /// The responder shed or refused us (busy / backoff `retry_after_secs`).
    RateLimited(Option<u64>),
    /// The responder does not hold a subnet by that name.
    UnknownSubnet(String),
    /// The dialer-announced TOTP step was outside the responder's window
    /// (clock skew beyond ±1 step).
    ClockSkew,
    /// Wire/store plumbing failure (stream died, seed write failed…).
    Other(String),
}

impl std::fmt::Display for JoinFail {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            JoinFail::NoSeedHolder(e) => write!(f, "no seed-holder found: {e}"),
            JoinFail::WrongCode => write!(f, "pairing confirmation failed (wrong code?)"),
            JoinFail::RateLimited(Some(s)) => write!(f, "rate-limited — retry in {s}s"),
            JoinFail::RateLimited(None) => write!(f, "responder busy with another ceremony"),
            JoinFail::UnknownSubnet(n) => write!(f, "responder holds no subnet '{n}'"),
            JoinFail::ClockSkew => write!(f, "TOTP step rejected — check this machine's clock"),
            JoinFail::Other(e) => write!(f, "{e}"),
        }
    }
}

/// The joiner leg (M7 plan decision 10): find a seed-holder via the LAN+relay
/// rendezvous, run the unchanged initiator ceremony against its real
/// endpoint, and persist the results (peer trust-pinned; the transferred
/// seed makes this node a full member / future seed-holder). One attempt —
/// the CLI owns the re-prompt loop.
// [impl->REQ-SUBNET-2]
pub async fn join_ceremony(
    endpoint: &NetEndpoint,
    subnet_name: &str,
    code: &str,
) -> Result<String, JoinFail> {
    let conn = dial_via_rendezvous(endpoint, subnet_name, time_step(now_secs()))
        .await
        .map_err(|e| JoinFail::NoSeedHolder(e.to_string()))?;
    let mut trust = TrustStore::load();
    let mut subnets = SubnetStore::load();
    let outcome = tokio::time::timeout(
        CEREMONY_TIMEOUT,
        run_initiator(
            &conn,
            endpoint.node_id(),
            subnet_name,
            code,
            &mut trust,
            &mut subnets,
            now_secs(),
        ),
    )
    .await
    .map_err(|_| JoinFail::Other(format!("ceremony exceeded {CEREMONY_TIMEOUT_SECS}s")))?
    .map_err(classify_wire_error)?;
    trust.save().map_err(|e| JoinFail::Other(format!("trust save: {e}")))?;
    subnets.save().map_err(|e| JoinFail::Other(format!("seed save: {e}")))?;
    Ok(format!("{outcome:?}"))
}

/// Map the wire error onto the CLI-facing failure classes.
fn classify_wire_error(e: PairWireError) -> JoinFail {
    match e {
        PairWireError::Pairing(_) => JoinFail::WrongCode,
        PairWireError::Refused(RateLimited::Busy) => JoinFail::RateLimited(None),
        PairWireError::Refused(RateLimited::Backoff { retry_after_secs }) => {
            JoinFail::RateLimited(Some(retry_after_secs))
        }
        PairWireError::UnknownSubnet(n) => JoinFail::UnknownSubnet(n),
        PairWireError::StepRejected => JoinFail::ClockSkew,
        PairWireError::Io(e) | PairWireError::Protocol(e) => JoinFail::Other(e),
    }
}

/// Unix seconds (the ceremony clock — TOTP steps + limiter time).
fn now_secs() -> u64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_secs())
        .unwrap_or(0)
}

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

    // [unit->REQ-SUBNET-2] every wire-error shape lands in the CLI failure
    // class the guided join UX switches on — wrong code re-prompts, rate
    // limits surface honestly (with the retry window), and plumbing noise
    // stays distinguishable from a user-fixable problem.
    #[test]
    fn wire_errors_classify_for_the_guided_ux() {
        use spt_net::net::pairing::spake::PairError;
        assert_eq!(
            classify_wire_error(PairWireError::Pairing(PairError::Confirm)),
            JoinFail::WrongCode
        );
        assert_eq!(
            classify_wire_error(PairWireError::Refused(RateLimited::Busy)),
            JoinFail::RateLimited(None)
        );
        assert_eq!(
            classify_wire_error(PairWireError::Refused(RateLimited::Backoff {
                retry_after_secs: 8
            })),
            JoinFail::RateLimited(Some(8))
        );
        assert_eq!(
            classify_wire_error(PairWireError::UnknownSubnet("home".into())),
            JoinFail::UnknownSubnet("home".into())
        );
        assert_eq!(classify_wire_error(PairWireError::StepRejected), JoinFail::ClockSkew);
        assert!(matches!(
            classify_wire_error(PairWireError::Io("eof".into())),
            JoinFail::Other(_)
        ));
    }
}


Bash

grep -n "STEP_SECS\|pub use\|pub type Connection\|pub struct Connection" crates/spt-net/src/net/totp.rs crates/spt-net/src/net/pairing/totp.rs crates/spt-net/src/net/endpoint.rs 2>/dev/null | head

Check STEP_SECS + Connection export


crates/spt-net/src/net/endpoint.rs:28:pub use iroh::endpoint::{Connection, RecvStream, SendStream};
crates/spt-net/src/net/endpoint.rs:29:pub use iroh::EndpointAddr;


grep -n "STEP\|pub const" crates/spt-net/src/net/pairing/totp.rs | head

Find step constant in totp.rs


38:pub const PERIOD_SECS: u64 = 30;
42:pub const DIGITS: u32 = 6;
46:pub const SEED_LEN: usize = 20;


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

use spt_net::net::endpoint::{BindScope, Connection, LocalDiscovery, NetEndpoint, RelayPolicy};

use spt_net::net::pairing::ratelimit::{PairingRateLimiter, RateLimited, CEREMONY_TIMEOUT_SECS};

use spt_net::net::pairing::wire::{run_initiator, run_responder, PairWireError};


Edit

            let now = now_secs();
            let next = (time_step(now) + 1) * STEP_SECS;

            let now = now_secs();
            let next = (time_step(now) + 1) * PERIOD_SECS;


            let now = now_secs();
            let next = (time_step(now) + 1) * STEP_SECS;

            let now = now_secs();
            let next = (time_step(now) + 1) * PERIOD_SECS;

// Sleep to the next step boundary, then rotate (drop the old

// window's listeners; their derived ids expire with the code).

-            let next = (time_step(now) + 1) * STEP_SECS;

+            let next = (time_step(now) + 1) * PERIOD_SECS;


Bash

sed -i 's/^pub mod nethost;/pub mod nethost;\npub mod pairhost;/' crates/spt-daemon/src/lib.rs && grep -n pairhost crates/spt-daemon/src/lib.rs

Register pairhost module


127:pub mod pairhost;


sed -i '/^pub mod pairhost;$/d' crates/spt-daemon/src/lib.rs && sed -i 's/^pub mod notifsync;/pub mod notifsync;\npub mod pairhost;/' crates/spt-daemon/src/lib.rs && grep -n "notifsync\|pairhost\|peerloop" crates/spt-daemon/src/lib.rs

Fix module ordering


128:pub mod notifsync;
129:pub mod pairhost;
130:pub mod peerloop;
184:pub use notifsync::{apply_notif_feed, emit_notif_feed, NotifApplyVerdict, NotifPolicy};


crates/spt-daemon/src/pairhost.rs: respond() handler (tokio Mutex limiter try_lock shed, 60s timeout aligned w/ CEREMONY_TIMEOUT_SECS), meet rotation loop per member subnet (re-bound per TOTP step, soft-fail binds, skip on BindScope::Port), join_ceremony() joiner leg. NetHost binds [SPT_NET_ALPN, SPT_PAIR_ALPN], accept loop routes by conn.alpn().

msg.rs KIND_PAIR_JOIN/JOINED + PairJoinReq/Reply (ok, class, detail). broker.rs dispatch arm → NetHost::pair_join. brain.rs client pair_join().

join [NAME] [--code] — elevation gate (exit 3), interactive prompts when tty, bounded wrong-code re-prompt, honest rate-limit + no-seed-holder hints, home adoption on success. Status hint footer gains join line. Activate REQ-SUBNET-2 [impl] (+unit), REQ-SUBNET-4 [impl,unit]; tag create/join gates.

Record always-on responder + elevation gates amendment in docs/adr/0005. cargo tests, xtask gen/check, traceable-reqs check, commit.


Now NetHost: multi-ALPN bind + routing + pair_join + rotation spawn:


20	//!
21	//! ## Ownership closes two §B rows by construction
22	//!
23	//! `MdnsAddressLookup` and the relay session are both constructed *inside* the
24	//! iroh endpoint, so broker ownership of the endpoint closes the ADR-0004 §B
25	//! "mDNS socket / relay session" row with no extra machinery: there is no
26	//! separate socket to own.
27	// [impl->REQ-DAEMON-2]
28
29	use std::collections::{HashMap, VecDeque};
30	use std::io;
31	use std::sync::atomic::{AtomicU64, Ordering};
32	use std::sync::{Arc, Mutex};
33
34	use spt_net::net::endpoint::{
35	    BindScope, Connection, EndpointAddr, LocalDiscovery, NetEndpoint, RecvStream, RelayPolicy,
36	    SendStream, SPT_NET_ALPN,
37	};
38	use spt_proto::identity::{Identity, PublicKey};
39
40	use crate::broker::SharedSend;
41	use crate::codec::write_frame;
42	use crate::msg::{
43	    net_presence_event_envelope, net_stream_data_envelope, net_stream_eof_envelope,
44	    NetPresenceEvent, NetStreamInfo, PRESENCE_CONNECTED, PRESENCE_DISCONNECTED,
45	};
46
47	/// The reserved [`crate::effect::EffectKey`] session namespace for net-scoped
48	/// effects (a dial has no PTY session). Broker session ids are minted from 1
49	/// (`Broker::next_id` starts at 1), so `0` can never collide with a real session.
50	pub const NET_EFFECT_SESSION: u64 = 0;
51
52	/// Everything needed to stand the broker's endpoint up: the node's identity
53	/// (the endpoint *is* the node's name — EndpointId == pubkey), the relay stance
54	/// (from `daemon.json` in production; [`RelayPolicy::Disabled`] in hermetic
55	/// tests), and whether mDNS LAN discovery rides along.
56	pub struct NetConfig {
57	    /// The node identity the endpoint binds as (seed → same ed25519 keypair).
58	    pub identity: Identity,
59	    /// Relay stance (ADR-0002): n0 default / self-host / disabled.
60	    pub relay: RelayPolicy,
61	    /// mDNS LAN discovery on/off (off in hermetic tests).
62	    pub discovery: LocalDiscovery,
63	    /// Socket bind scope: [`BindScope::All`] in production;
64	    /// [`BindScope::Loopback`] in hermetic tests (no real interface touched,
65	    /// no per-test-binary Windows firewall prompt).
66	    pub scope: BindScope,
67	}
68
69	/// Default bounded stream-read ring depth (chunks) — same sizing rationale as
70	/// the PTY `OutputLog`: large enough that a brain-restart window never evicts
71	/// unreplayed chunks in practice; bounded so an unsubscribed stream can't
72	/// buffer without limit.
73	const DEFAULT_STREAM_RING_CHUNKS: usize = 4096;
74
75	/// Max bytes pulled per stream read (one ring chunk).
76	const STREAM_READ_CHUNK: usize = 64 * 1024;
77
78	/// One live QUIC connection the broker holds.
79	struct ConnEntry {

//!
//! ## Ownership closes two §B rows by construction
//!
//! `MdnsAddressLookup` and the relay session are both constructed *inside* the
//! iroh endpoint, so broker ownership of the endpoint closes the ADR-0004 §B
//! "mDNS socket / relay session" row with no extra machinery: there is no
//! separate socket to own.
// [impl->REQ-DAEMON-2]

use std::collections::{HashMap, VecDeque};
use std::io;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};

use spt_net::net::endpoint::{
    BindScope, Connection, EndpointAddr, LocalDiscovery, NetEndpoint, RecvStream, RelayPolicy,
    SendStream, SPT_NET_ALPN,
};
use spt_proto::identity::{Identity, PublicKey};

use crate::broker::SharedSend;
use crate::codec::write_frame;
use crate::msg::{
    net_presence_event_envelope, net_stream_data_envelope, net_stream_eof_envelope,
    NetPresenceEvent, NetStreamInfo, PRESENCE_CONNECTED, PRESENCE_DISCONNECTED,
};

/// The reserved [`crate::effect::EffectKey`] session namespace for net-scoped
/// effects (a dial has no PTY session). Broker session ids are minted from 1
/// (`Broker::next_id` starts at 1), so `0` can never collide with a real session.
pub const NET_EFFECT_SESSION: u64 = 0;

/// Everything needed to stand the broker's endpoint up: the node's identity
/// (the endpoint *is* the node's name — EndpointId == pubkey), the relay stance
/// (from `daemon.json` in production; [`RelayPolicy::Disabled`] in hermetic
/// tests), and whether mDNS LAN discovery rides along.
pub struct NetConfig {
    /// The node identity the endpoint binds as (seed → same ed25519 keypair).
    pub identity: Identity,
    /// Relay stance (ADR-0002): n0 default / self-host / disabled.
    pub relay: RelayPolicy,
    /// mDNS LAN discovery on/off (off in hermetic tests).
    pub discovery: LocalDiscovery,
    /// Socket bind scope: [`BindScope::All`] in production;
    /// [`BindScope::Loopback`] in hermetic tests (no real interface touched,
    /// no per-test-binary Windows firewall prompt).
    pub scope: BindScope,
}

/// Default bounded stream-read ring depth (chunks) — same sizing rationale as
/// the PTY `OutputLog`: large enough that a brain-restart window never evicts
/// unreplayed chunks in practice; bounded so an unsubscribed stream can't
/// buffer without limit.
const DEFAULT_STREAM_RING_CHUNKS: usize = 4096;

/// Max bytes pulled per stream read (one ring chunk).
const STREAM_READ_CHUNK: usize = 64 * 1024;

/// One live QUIC connection the broker holds.
struct ConnEntry {


Edit

impl NetHost {
    /// Bind the broker's endpoint on a fresh dedicated runtime and start the
    /// accept loop (every inbound `SPT_NET_ALPN` connection joins the table).
    // [impl->REQ-DAEMON-2]
    pub fn start(cfg: NetConfig) -> io::Result<NetHost> {
        let runtime = tokio::runtime::Builder::new_multi_thread()
            .worker_threads(2)
            .thread_name("spt-broker-net")
            .enable_all()
            .build()?;
        let endpoint = runtime
            .block_on(NetEndpoint::bind_scoped(
                &cfg.identity,
                cfg.relay,
                cfg.discovery,
                vec![SPT_NET_ALPN.to_vec()],
                cfg.scope,
            ))
            .map_err(|e| io::Error::other(e.to_string()))?;
        let endpoint = Arc::new(endpoint);
        let shared = Arc::new(NetShared::new());

        // Accept loop: lives for the endpoint's lifetime on the host's runtime.
        // Each handshake completes on its own task so a slow peer can't stall
        // the loop; a failed handshake is simply not registered.
        let ep = Arc::clone(&endpoint);
        let sh = Arc::clone(&shared);
        runtime.spawn(async move {
            while let Some(incoming) = ep.accept().await {
                let sh = Arc::clone(&sh);
                tokio::spawn(async move {
                    let Ok(connecting) = incoming.accept() else { return };
                    let Ok(conn) = connecting.await else { return };
                    register_conn(&sh, conn);
                });
            }
        });

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

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

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

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

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

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


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

impl NetHost {
    /// Bind the broker's endpoint on a fresh dedicated runtime and start the
    /// accept loop (every inbound `SPT_NET_ALPN` connection joins the table).
    // [impl->REQ-DAEMON-2]
    pub fn start(cfg: NetConfig) -> io::Result<NetHost> {
        let runtime = tokio::runtime::Builder::new_multi_thread()
            .worker_threads(2)
            .thread_name("spt-broker-net")
            .enable_all()
            .build()?;
        let endpoint = runtime
            .block_on(NetEndpoint::bind_scoped(
                &cfg.identity,
                cfg.relay,
                cfg.discovery,
                vec![SPT_NET_ALPN.to_vec()],
                cfg.scope,
            ))
            .map_err(|e| io::Error::other(e.to_string()))?;
        let endpoint = Arc::new(endpoint);
        let shared = Arc::new(NetShared::new());

        // Accept loop: lives for the endpoint's lifetime on the host's runtime.
        // Each handshake completes on its own task so a slow peer can't stall
        // the loop; a failed handshake is simply not registered.
        let ep = Arc::clone(&endpoint);
        let sh = Arc::clone(&shared);
        runtime.spawn(async move {
            while let Some(incoming) = ep.accept().await {
                let sh = Arc::clone(&sh);
                tokio::spawn(async move {
                    let Ok(connecting) = incoming.accept() else { return };
                    let Ok(conn) = connecting.await else { return };
                    register_conn(&sh, conn);
                });
            }
        });

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

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

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

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

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

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

-    /// accept loop (every inbound `SPT_NET_ALPN` connection joins the table).

+    /// accept loop. The endpoint speaks TWO ALPNs (M7 D3, REQ-SUBNET-2): the

+    /// trusted [`SPT_NET_ALPN`] (connections join the table as before) and

+    /// the pre-trust pairing ALPN — routed to the always-on responder

+    /// ([`crate::pairhost::respond`]), never registered in the stream table.

+    /// One endpoint for both is deliberate: the pairing transcript binds and

+    /// pins the REAL node pubkey, and the relay can only route one endpoint

+    /// per key — the ALPN is the pre-trust/trusted boundary (ADR-0005).

+    /// The meet-rotation listener loop rides the same runtime.

-                vec![SPT_NET_ALPN.to_vec()],

+                    SPT_NET_ALPN.to_vec(),

+                    spt_net::net::pairing::wire::SPT_PAIR_ALPN.to_vec(),

+        let local_pub = cfg.identity.public_key();

+        let pair_rate = Arc::new(tokio::sync::Mutex::new(

+            spt_net::net::pairing::ratelimit::PairingRateLimiter::new(),

-        // the loop; a failed handshake is simply not registered.

+        // the loop; a failed handshake is simply not registered. The pairing

+        // ALPN routes to the responder; everything else is the trusted table.

+        let rate = Arc::clone(&pair_rate);

+                let rate = Arc::clone(&rate);

-                    register_conn(&sh, conn);

+                    if conn.alpn().as_deref()

+                        == Some(spt_net::net::pairing::wire::SPT_PAIR_ALPN)

+                        crate::pairhost::respond(conn, local_pub, rate).await;

+                        register_conn(&sh, conn);

+        // The meet-rotation loop (M7 D3): how a joiner that knows only

+        // (subnet name, code) finds this responder. Idles with no subnets.

+        crate::pairhost::spawn_meet_rotation(

+    /// Run the joiner leg of the pairing ceremony (M7 D3, REQ-SUBNET-2) on

+    /// the host's runtime — the broker IPC `pair-join` arm. The ceremony

+    /// must run HERE because it binds and pins this endpoint's real node

+    /// pubkey. Returns the rendered trust outcome, or the classified failure.

+    ) -> Result<String, crate::pairhost::JoinFail> {

+        let endpoint = Arc::clone(&self.endpoint);

+            .block_on(async move { crate::pairhost::join_ceremony(&endpoint, subnet, code).await })

/// The node's stable name (lowercase-hex pubkey == iroh EndpointId).


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

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

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


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

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

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

+        // The limiter Arc lives on in the accept loop; the host keeps no

+        // handle (per-ceremony state is the responder's, not the table's).


Bash

cargo check -p spt-daemon 2>&1 | grep -E "^error|^warning: unused" -A4 | head -40

Check spt-daemon compiles


error[E0599]: no method named `as_deref` found for reference `&[u8]` in the current scope
   --> crates\spt-daemon\src\nethost.rs:441:36
    |
441 |                     if conn.alpn().as_deref()
    |                                    ^^^^^^^^
--
error: could not compile `spt-daemon` (lib) due to 1 previous error


Edit

                    if conn.alpn().as_deref()
                        == Some(spt_net::net::pairing::wire::SPT_PAIR_ALPN)
                    {

if conn.alpn() == spt_net::net::pairing::wire::SPT_PAIR_ALPN {


                    if conn.alpn().as_deref()
                        == Some(spt_net::net::pairing::wire::SPT_PAIR_ALPN)
                    {

let Ok(connecting) = incoming.accept() else { return };

let Ok(conn) = connecting.await else { return };

-                    if conn.alpn().as_deref()

-                        == Some(spt_net::net::pairing::wire::SPT_PAIR_ALPN)

+                    if conn.alpn() == spt_net::net::pairing::wire::SPT_PAIR_ALPN {


Bash

cargo check -p spt-daemon 2>&1 | grep -E "^error" -A6 | head -30; cargo check -p spt-daemon 2>&1 | tail -1

Recheck spt-daemon


Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.43s


D3a compiles. Now IPC (task 10):


Updated task #9 status