1	//! TOTP-seeded SPAKE2 node pairing (ADR-0005) — the first-contact ceremony that
2	//! authorizes a new node into a user's subnet with no account and no central
3	//! operator.
4	//!
5	//! The construction (ADR-0005, `CONTEXT.md` "Pairing & trust"): a durable **TOTP
6	//! seed** is the subnet secret, replicated to every already-trusted node and stored
7	//! in the user's authenticator app. To pair a new node the rotating 6-digit code is
8	//! used as the **password for a SPAKE2 (PAKE) handshake** — *not* a bearer token —
9	//! so the low-entropy code becomes MITM-resistant and an attacker gets one online
10	//! guess per attempt (no offline brute force).
11	//!
12	//! ## Module tree (M4-D2 sub-tasks)
13	//!
14	//!   [`totp`]      RFC 6238 seed + code + ±1 window + step counter + otpauth URI   ← D2a
15	//!   (spake)       SPAKE2 handshake over the pairing ALPN                            D2a
16	//!   (transcript)  confirmation MAC binding roles/pubkeys/subnet/epoch/step (#12)    D2b
17	//!   (epoch)       seed epoch + rotation — trust-delete ≠ revocation (#10)           D2c
18	//!   [`ratelimit`]  subnet-global one-ceremony + shared counter + backoff (#11)      ← D2d
19	//!   [`wire`]       the pairing-ALPN ceremony driver over a real iroh bidi stream    ← D2-wire
20	//!   [`rendezvous`] relay rendezvous-token derivation H(name‖step) (REQ-PAIR-5)       ← D2f
21	//!   [`meet`]       rendezvous routing: derived-identity meet listener + window dial    ← D7a
22	//!
23	//! ## Why a dedicated pre-trust ALPN
24	//!
25	//! Pairing happens *before* mutual trust exists, so it cannot ride the trusted
26	//! [`SPT_NET_ALPN`](super::endpoint::SPT_NET_ALPN). A separate pairing ALPN lets the
27	//! relay permit pre-trust contact (a seed-holder must be reachable by the untrusted
28	//! new node) without exposing the trusted message surface.
29
30	pub mod meet;
31	pub mod ratelimit;
32	pub mod rendezvous;
33	pub mod spake;
34	pub mod totp;
35	pub mod transcript;
36	pub mod wire;
37

//! TOTP-seeded SPAKE2 node pairing (ADR-0005) — the first-contact ceremony that
//! authorizes a new node into a user's subnet with no account and no central
//! operator.
//!
//! The construction (ADR-0005, `CONTEXT.md` "Pairing & trust"): a durable **TOTP
//! seed** is the subnet secret, replicated to every already-trusted node and stored
//! in the user's authenticator app. To pair a new node the rotating 6-digit code is
//! used as the **password for a SPAKE2 (PAKE) handshake** — *not* a bearer token —
//! so the low-entropy code becomes MITM-resistant and an attacker gets one online
//! guess per attempt (no offline brute force).
//!
//! ## Module tree (M4-D2 sub-tasks)
//!
//!   [`totp`]      RFC 6238 seed + code + ±1 window + step counter + otpauth URI   ← D2a
//!   (spake)       SPAKE2 handshake over the pairing ALPN                            D2a
//!   (transcript)  confirmation MAC binding roles/pubkeys/subnet/epoch/step (#12)    D2b
//!   (epoch)       seed epoch + rotation — trust-delete ≠ revocation (#10)           D2c
//!   [`ratelimit`]  subnet-global one-ceremony + shared counter + backoff (#11)      ← D2d
//!   [`wire`]       the pairing-ALPN ceremony driver over a real iroh bidi stream    ← D2-wire
//!   [`rendezvous`] relay rendezvous-token derivation H(name‖step) (REQ-PAIR-5)       ← D2f
//!   [`meet`]       rendezvous routing: derived-identity meet listener + window dial    ← D7a
//!
//! ## Why a dedicated pre-trust ALPN
//!
//! Pairing happens *before* mutual trust exists, so it cannot ride the trusted
//! [`SPT_NET_ALPN`](super::endpoint::SPT_NET_ALPN). A separate pairing ALPN lets the
//! relay permit pre-trust contact (a seed-holder must be reachable by the untrusted
//! new node) without exposing the trusted message surface.

pub mod meet;
pub mod ratelimit;
pub mod rendezvous;
pub mod spake;
pub mod totp;
pub mod transcript;
pub mod wire;


1	//! D2-wire: the pairing ALPN ceremony driver (ADR-0005, REQ-PAIR-1).
2	//!
3	//! This is the transport glue that runs the TOTP-SPAKE2 ceremony (the crypto core of
4	//! [`spake`](super::spake) + [`transcript`](super::transcript)) over a real iroh bidi
5	//! stream on a dedicated pre-trust **pairing ALPN** ([`SPT_PAIR_ALPN`]), gated by the
6	//! subnet-global [rate limiter](super::ratelimit), and writes the confirmed peer into
7	//! each side's [`TrustStore`]. It turns "two machines pair via one TOTP code" from a
8	//! same-process unit test into an over-the-wire exchange.
9	//!
10	//! ## Why a responder `Announce{epoch}` precedes the SPAKE messages
11	//!
12	//! [`Initiator::start`](super::spake::Initiator::start) bakes the **full transcript**
13	//! into `msg_a`, and the transcript binds the seed-rotation [`epoch`] — which is
14	//! *responder-authoritative* (only seed-holders track it; the joiner has just the
15	//! 6-digit code + subnet name, per [`transcript`](super::transcript) docs). So the
16	//! joiner cannot construct `msg_a` until the responder tells it the current epoch. The
17	//! wire therefore opens with the joiner's [`Hello`](Frame::Hello) (subnet name + its
18	//! clock-derived TOTP step), the responder's [`Announce`](Frame::Announce) (the epoch,
19	//! *after* it has admitted the ceremony and accepted the ±1 step), and only then the
20	//! four SPAKE messages. The TOTP **step** is dialer-announced and clock-derived
21	//! (`totp.rs`), so it needs no announce — only the seed epoch does.
22	//!
23	//! ## Authenticated identities
24	//!
25	//! Both node pubkeys bound into the transcript come from the **iroh-authenticated**
26	//! [`Connection::remote_id`] (the QUIC handshake already proves the peer holds the
27	//! Ed25519 key that *is* its node id) plus the local node's own pubkey — never the
28	//! bytes a peer claims in a frame. A MITM substituting its key is rejected by the
29	//! transcript binding (`spake.rs` `substituted_pubkey_fails`).
30	//!
31	//! ## Scope (component-level; WAN is D9)
32	//!
33	//! Like [`endpoint`](super::super::endpoint) tests, this dials by `EndpointAddr` over
34	//! loopback with relays disabled. The relay **rendezvous** (`H(subnet-name ‖ step)`,
35	//! `CONTEXT.md`) that lets two nodes that don't know each other's id *meet* is deferred
36	//! to D9; here the joiner already holds the responder's dialable address. Seed transfer
37	//! to the joiner (so it becomes a future seed-holder) is deferred to D2f — this driver
38	//! delivers the ceremony + mutual trust write only.
39	//
40	// [impl->REQ-PAIR-1]
41
42	use iroh::endpoint::{Connection, RecvStream, SendStream};
43	use spt_proto::identity::PublicKey;
44	use spt_store::trust::{TrustOutcome, TrustStore};
45
46	use spt_store::subnet::{SubnetError, SubnetStore, TOTP_SEED_LEN};
47
48	use super::ratelimit::{PairingRateLimiter, RateLimited};
49	use super::spake::{Initiator, PairError, Responder};
50	use super::totp::{accepts_step, time_step, TotpSeed, DIGITS};
51	use super::transcript::{PairingTranscript, TAG_LEN};
52
53	/// The pre-trust pairing ALPN. Distinct from the trusted
54	/// [`SPT_NET_ALPN`](super::super::endpoint::SPT_NET_ALPN) so the relay can permit
55	/// pre-trust contact (a seed-holder must be reachable by an untrusted new node) without
56	/// exposing the trusted message surface (ADR-0005; `pairing.rs`). Versioned for a clean
57	/// future wire break.
58	pub const SPT_PAIR_ALPN: &[u8] = b"spt-core/pair/0";
59
60	/// Hard cap on a single wire frame. The ceremony's largest field is a SPAKE2 message
61	/// (~tens of bytes) or a subnet name; 4 KiB is generous slack while bounding a hostile
62	/// length prefix so a pre-trust peer cannot force a huge allocation.
63	const MAX_FRAME: usize = 4096;
64
65	// Frame tag bytes.
66	const T_ABORT: u8 = 0;
67	const T_HELLO: u8 = 1;
68	const T_ANNOUNCE: u8 = 2;
69	const T_SPAKE1: u8 = 3;
70	const T_SPAKE2: u8 = 4;
71	const T_CONFIRM: u8 = 5;
72	const T_DONE: u8 = 6;
73	const T_SEED: u8 = 7;
74
75	// Abort reason bytes.
76	const A_BUSY: u8 = 1;
77	const A_BACKOFF: u8 = 2;
78	const A_BAD_STEP: u8 = 3;
79	const A_UNKNOWN_SUBNET: u8 = 4;
80	const A_BAD_MSG: u8 = 5;
81	const A_CONFIRM_FAIL: u8 = 6;
82
83	/// A failure of the pairing wire ceremony. Carries no secret material.
84	#[derive(Debug)]
85	pub enum PairWireError {
86	    /// Stream read/write/framing failure (peer vanished, malformed length, EOF).
87	    Io(String),
88	    /// A frame was the wrong type or malformed for the current ceremony step.
89	    Protocol(String),
90	    /// The responder refused to admit the ceremony (rate-limit): another is in flight
91	    /// ([`Busy`](RateLimited::Busy)) or the backoff window is open
92	    /// ([`Backoff`](RateLimited::Backoff)).
93	    Refused(RateLimited),
94	    /// The dialer's announced TOTP step was outside the responder's ±1 window.
95	    StepRejected,
96	    /// The responder holds no subnet by that name.
97	    UnknownSubnet(String),
98	    /// The SPAKE2 / key-confirmation step failed: wrong code, MITM, wrong subnet, stale
99	    /// step — the one signal a bad pairing gives ([`spake`](super::spake)).
100	    Pairing(PairError),
101	}
102
103	impl std::fmt::Display for PairWireError {
104	    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
105	        match self {
106	            PairWireError::Io(e) => write!(f, "pairing wire io: {e}"),
107	            PairWireError::Protocol(e) => write!(f, "pairing wire protocol error: {e}"),
108	            PairWireError::Refused(r) => write!(f, "pairing refused: {r}"),
109	            PairWireError::StepRejected => {
110	                write!(f, "pairing rejected: TOTP step outside the ±1 window")
111	            }
112	            PairWireError::UnknownSubnet(n) => write!(f, "pairing rejected: unknown subnet {n}"),
113	            PairWireError::Pairing(e) => write!(f, "{e}"),
114	        }
115	    }
116	}
117
118	impl std::error::Error for PairWireError {}
119
120	/// Run the **responder** (seed-holder) side of the ceremony over an accepted connection.

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

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

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

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

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

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

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

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

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

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

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

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


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

/// Run the **responder** (seed-holder) side of the ceremony over an accepted connection.
///
/// `local_pub` is this node's own pubkey (the iroh endpoint id); `subnets` supplies the
/// seed + epoch for the named subnet; `rate` is the subnet-global admission gate; `trust`
/// receives the peer on success. `now` (unix seconds) is injected for deterministic
/// tests, mirroring [`ratelimit`](super::ratelimit) / [`totp`](super::totp).
///
/// On `Ok` the peer was confirmed and pinned (the returned [`TrustOutcome`]); the rate
/// limiter slot is released as a success. Every error path releases the slot too — as a
/// failure when a ceremony was admitted (charging backoff), or not at all when admission
/// itself was refused.
// [impl->REQ-PAIR-1]
pub async fn run_responder(
    conn: &Connection,
    local_pub: PublicKey,
    subnets: &SubnetStore,
    rate: &mut PairingRateLimiter,
    trust: &mut TrustStore,
    now: u64,
) -> Result<TrustOutcome, PairWireError> {
    let (mut send, mut recv) = conn
        .accept_bi()
        .await
        .map_err(|e| PairWireError::Io(format!("accept_bi: {e}")))?;
    let initiator_pub = remote_pub(conn)?;

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

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

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

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

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

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

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

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

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

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

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

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

/// Run the **initiator** (joining node) side of the ceremony over a dialed connection.
///
/// `local_pub` is this node's own pubkey; `subnet_name` + `code` are the two user-entered
/// inputs (the subnet label and the current 6-digit TOTP code); `now` is the local clock.
/// On success the responder is pinned in `trust` AND the transferred subnet seed is written
/// into `subnets` (the joiner becomes a full member / future seed-holder — D2f, REQ-PAIR-5);
/// the returned [`TrustOutcome`] is the TOFU decision.
// [impl->REQ-PAIR-1] [impl->REQ-PAIR-5]
pub async fn run_initiator(
    conn: &Connection,
    local_pub: PublicKey,
    subnet_name: &str,
    code: &str,
    trust: &mut TrustStore,
    subnets: &mut SubnetStore,
    now: u64,
) -> Result<TrustOutcome, PairWireError> {
    let (mut send, mut recv) = conn
        .open_bi()
        .await
        .map_err(|e| PairWireError::Io(format!("open_bi: {e}")))?;


1	//! D7a: relay rendezvous **routing** — the meeting mechanism on top of the
2	//! [`rendezvous`](super::rendezvous) token (M4-D2f Q1 deferral, closed M5-D7;
3	//! ADR-0006; REQ-PAIR-5).
4	//!
5	//! Two not-yet-trusted nodes hold the same two user-entered inputs (subnet
6	//! name + TOTP code) and therefore the same [`rendezvous_token`]. The token
7	//! becomes a **deterministic rendezvous identity**: both sides derive the same
8	//! ephemeral ed25519 keypair from it ([`rendezvous_identity`]), the responder
9	//! (seed-holder) binds a short-lived iroh endpoint under that keypair beside
10	//! its real endpoint ([`bind_rendezvous`]), and the joiner dials the derived
11	//! *public* id relay-first ([`dial_via_rendezvous`] — `RelayPolicy::N0Default`
12	//! carries traffic for unknown-NAT peers; that is the relay's whole job). The
13	//! ±1 TOTP-skew window means the joiner tries all three window tokens; window
14	//! rotation = the responder re-binds per step.
15	//!
16	//! ## Why the ceremony does NOT ride the derived-identity connection
17	//!
18	//! The pairing transcript binds **both real node pubkeys** from the
19	//! iroh-authenticated `Connection::remote_id` (REQ-HAZARD-PAIR-TRANSCRIPT-BIND;
20	//! `wire.rs` "Authenticated identities"), and the initiator pins
21	//! `paired.peer_pub` — the connection's TLS identity — in its trust store. On
22	//! a derived-identity connection that TLS identity is the *token-derived* key,
23	//! which every holder of (name, code-window) can compute: binding or pinning
24	//! it would re-open the unknown-key-share hole the transcript hazard exists to
25	//! close, and would pin an ephemeral key as the peer's durable name. So the
26	//! meet endpoint serves **discovery only**: the joiner asks, the responder
27	//! answers with its real [`EndpointAddr`] (one tiny length-prefixed frame on a
28	//! dedicated pre-trust ALPN, [`SPT_PAIR_MEET_ALPN`]), and the joiner re-dials
29	//! the real endpoint over [`SPT_PAIR_ALPN`](super::wire::SPT_PAIR_ALPN) where
30	//! the existing SPAKE2 ceremony runs **unchanged** — real pubkeys both ways.
31	//!
32	//! ## Security posture (token gates discovery, PAKE gates trust)
33	//!
34	//! Anyone who knows the subnet name and the current code window can derive the
35	//! meet keypair, impersonate the listener, and hand the joiner any address —
36	//! and the impostor's responder then **fails SPAKE2** (no TOTP seed), exactly
37	//! the ADR-0006 collision story: a wasted ceremony and a retry, never a trust
38	//! write. The redirect frame carries no secret (a public address the relay
39	//! could observe anyway), and the joiner's subsequent ceremony authenticates
40	//! the real responder end-to-end. Rate limiting stays where it is: the meet
41	//! listener answers with an address only; admission control gates the real
42	//! ceremony (`ratelimit`, REQ-HAZARD-PAIR-RATE-LIMIT).
43	//
44	// [impl->REQ-PAIR-5]
45
46	use iroh::{EndpointAddr, EndpointId};
47	use sha2::{Digest, Sha256};
48	use spt_proto::identity::{Identity, PublicKey};
49
50	use super::super::endpoint::{
51	    BindScope, Connection, LocalDiscovery, NetEndpoint, NetError, RelayPolicy,
52	};
53	use super::rendezvous::{rendezvous_token, rendezvous_window};
54	use super::wire::SPT_PAIR_ALPN;
55
56	/// The pre-trust **meet** ALPN — the derived-identity endpoint speaks only this
57	/// (never the trusted surface, never the ceremony ALPN), so a relay can permit
58	/// rendezvous contact without exposing anything else. Versioned for a clean
59	/// future wire break.
60	pub const SPT_PAIR_MEET_ALPN: &[u8] = b"spt-core/pair-meet/0";
61
62	/// Domain-separation tag for the token→keypair derivation. Distinct from the
63	/// token's own domain so the (relay-visible) routing value and the key seed
64	/// are never the same bytes.
65	const RENDEZVOUS_ID_DOMAIN: &[u8] = b"spt-core/pairing/rendezvous-id/v1";
66
67	/// Hard cap on the meet reply frame — a serialized [`EndpointAddr`] is a few
68	/// hundred bytes; 4 KiB bounds a hostile length prefix (same stance as
69	/// `wire.rs` MAX_FRAME).
70	const MAX_MEET_FRAME: usize = 4096;
71
72	/// A failure of the meet exchange. Carries no secret material.
73	#[derive(Debug)]
74	pub enum MeetError {
75	    /// Binding or dialing the meet endpoint failed.
76	    Net(NetError),
77	    /// The meet exchange itself misbehaved (framing, oversize, bad JSON).
78	    Protocol(String),
79	}
80
81	impl std::fmt::Display for MeetError {
82	    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83	        match self {
84	            MeetError::Net(e) => write!(f, "rendezvous meet: {e}"),
85	            MeetError::Protocol(e) => write!(f, "rendezvous meet protocol error: {e}"),
86	        }
87	    }
88	}
89
90	impl std::error::Error for MeetError {}
91
92	/// The ephemeral rendezvous keypair both sides derive from a token:
93	/// `seed = SHA-256(domain ‖ token)`. Deterministic — the joiner computes the
94	/// same keypair (and therefore the *public* id it dials); the keypair gates
95	/// nothing but discovery (module docs).
96	// [impl->REQ-PAIR-5]
97	pub fn rendezvous_identity(token: &[u8; 32]) -> Identity {
98	    let mut h = Sha256::new();
99	    h.update(RENDEZVOUS_ID_DOMAIN);
100	    h.update(token);
101	    let seed: [u8; 32] = h.finalize().into();
102	    Identity::from_seed(&seed)
103	}
104
105	/// The derived rendezvous endpoint id for `(subnet_name, totp_step)` — the
106	/// public name the responder listens under and the joiner dials.
107	// [impl->REQ-PAIR-5]
108	pub fn rendezvous_endpoint_id(subnet_name: &str, totp_step: u64) -> PublicKey {
109	    rendezvous_identity(&rendezvous_token(subnet_name, totp_step)).public_key()
110	}
111
112	/// The responder's short-lived meet listener: a second iroh endpoint bound to
113	/// the token-derived identity, speaking only [`SPT_PAIR_MEET_ALPN`]. Lives for
114	/// one pairing window; rotation = drop and re-bind at the next step.
115	pub struct RendezvousListener {
116	    ep: NetEndpoint,
117	}
118
119	/// Bind the meet listener for `(subnet_name, totp_step)` under the derived
120	/// identity. The relay/discovery/scope knobs mirror
121	/// [`NetEndpoint::bind_scoped`]; production passes the node's configured
122	/// [`RelayPolicy`] so the derived id is relay-reachable (and n0-DNS
123	/// resolvable) by a joiner that knows nothing but the two user inputs.
124	// [impl->REQ-PAIR-5]
125	pub async fn bind_rendezvous(
126	    subnet_name: &str,
127	    totp_step: u64,
128	    relay: RelayPolicy,
129	    discovery: LocalDiscovery,
130	    scope: BindScope,

//! D7a: relay rendezvous **routing** — the meeting mechanism on top of the
//! [`rendezvous`](super::rendezvous) token (M4-D2f Q1 deferral, closed M5-D7;
//! ADR-0006; REQ-PAIR-5).
//!
//! Two not-yet-trusted nodes hold the same two user-entered inputs (subnet
//! name + TOTP code) and therefore the same [`rendezvous_token`]. The token
//! becomes a **deterministic rendezvous identity**: both sides derive the same
//! ephemeral ed25519 keypair from it ([`rendezvous_identity`]), the responder
//! (seed-holder) binds a short-lived iroh endpoint under that keypair beside
//! its real endpoint ([`bind_rendezvous`]), and the joiner dials the derived
//! *public* id relay-first ([`dial_via_rendezvous`] — `RelayPolicy::N0Default`
//! carries traffic for unknown-NAT peers; that is the relay's whole job). The
//! ±1 TOTP-skew window means the joiner tries all three window tokens; window
//! rotation = the responder re-binds per step.
//!
//! ## Why the ceremony does NOT ride the derived-identity connection
//!
//! The pairing transcript binds **both real node pubkeys** from the
//! iroh-authenticated `Connection::remote_id` (REQ-HAZARD-PAIR-TRANSCRIPT-BIND;
//! `wire.rs` "Authenticated identities"), and the initiator pins
//! `paired.peer_pub` — the connection's TLS identity — in its trust store. On
//! a derived-identity connection that TLS identity is the *token-derived* key,
//! which every holder of (name, code-window) can compute: binding or pinning
//! it would re-open the unknown-key-share hole the transcript hazard exists to
//! close, and would pin an ephemeral key as the peer's durable name. So the
//! meet endpoint serves **discovery only**: the joiner asks, the responder
//! answers with its real [`EndpointAddr`] (one tiny length-prefixed frame on a
//! dedicated pre-trust ALPN, [`SPT_PAIR_MEET_ALPN`]), and the joiner re-dials
//! the real endpoint over [`SPT_PAIR_ALPN`](super::wire::SPT_PAIR_ALPN) where
//! the existing SPAKE2 ceremony runs **unchanged** — real pubkeys both ways.
//!
//! ## Security posture (token gates discovery, PAKE gates trust)
//!
//! Anyone who knows the subnet name and the current code window can derive the
//! meet keypair, impersonate the listener, and hand the joiner any address —
//! and the impostor's responder then **fails SPAKE2** (no TOTP seed), exactly
//! the ADR-0006 collision story: a wasted ceremony and a retry, never a trust
//! write. The redirect frame carries no secret (a public address the relay
//! could observe anyway), and the joiner's subsequent ceremony authenticates
//! the real responder end-to-end. Rate limiting stays where it is: the meet
//! listener answers with an address only; admission control gates the real
//! ceremony (`ratelimit`, REQ-HAZARD-PAIR-RATE-LIMIT).
//
// [impl->REQ-PAIR-5]

use iroh::{EndpointAddr, EndpointId};
use sha2::{Digest, Sha256};
use spt_proto::identity::{Identity, PublicKey};

use super::super::endpoint::{
    BindScope, Connection, LocalDiscovery, NetEndpoint, NetError, RelayPolicy,
};
use super::rendezvous::{rendezvous_token, rendezvous_window};
use super::wire::SPT_PAIR_ALPN;

/// The pre-trust **meet** ALPN — the derived-identity endpoint speaks only this
/// (never the trusted surface, never the ceremony ALPN), so a relay can permit
/// rendezvous contact without exposing anything else. Versioned for a clean
/// future wire break.
pub const SPT_PAIR_MEET_ALPN: &[u8] = b"spt-core/pair-meet/0";

/// Domain-separation tag for the token→keypair derivation. Distinct from the
/// token's own domain so the (relay-visible) routing value and the key seed
/// are never the same bytes.
const RENDEZVOUS_ID_DOMAIN: &[u8] = b"spt-core/pairing/rendezvous-id/v1";

/// Hard cap on the meet reply frame — a serialized [`EndpointAddr`] is a few
/// hundred bytes; 4 KiB bounds a hostile length prefix (same stance as
/// `wire.rs` MAX_FRAME).
const MAX_MEET_FRAME: usize = 4096;

/// A failure of the meet exchange. Carries no secret material.
#[derive(Debug)]
pub enum MeetError {
    /// Binding or dialing the meet endpoint failed.
    Net(NetError),
    /// The meet exchange itself misbehaved (framing, oversize, bad JSON).
    Protocol(String),
}

impl std::fmt::Display for MeetError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            MeetError::Net(e) => write!(f, "rendezvous meet: {e}"),
            MeetError::Protocol(e) => write!(f, "rendezvous meet protocol error: {e}"),
        }
    }
}

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

/// The ephemeral rendezvous keypair both sides derive from a token:
/// `seed = SHA-256(domain ‖ token)`. Deterministic — the joiner computes the
/// same keypair (and therefore the *public* id it dials); the keypair gates
/// nothing but discovery (module docs).
// [impl->REQ-PAIR-5]
pub fn rendezvous_identity(token: &[u8; 32]) -> Identity {
    let mut h = Sha256::new();
    h.update(RENDEZVOUS_ID_DOMAIN);
    h.update(token);
    let seed: [u8; 32] = h.finalize().into();
    Identity::from_seed(&seed)
}

/// The derived rendezvous endpoint id for `(subnet_name, totp_step)` — the
/// public name the responder listens under and the joiner dials.
// [impl->REQ-PAIR-5]
pub fn rendezvous_endpoint_id(subnet_name: &str, totp_step: u64) -> PublicKey {
    rendezvous_identity(&rendezvous_token(subnet_name, totp_step)).public_key()
}

/// The responder's short-lived meet listener: a second iroh endpoint bound to
/// the token-derived identity, speaking only [`SPT_PAIR_MEET_ALPN`]. Lives for
/// one pairing window; rotation = drop and re-bind at the next step.
pub struct RendezvousListener {
    ep: NetEndpoint,
}

/// Bind the meet listener for `(subnet_name, totp_step)` under the derived
/// identity. The relay/discovery/scope knobs mirror
/// [`NetEndpoint::bind_scoped`]; production passes the node's configured
/// [`RelayPolicy`] so the derived id is relay-reachable (and n0-DNS
/// resolvable) by a joiner that knows nothing but the two user inputs.
// [impl->REQ-PAIR-5]
pub async fn bind_rendezvous(
    subnet_name: &str,
    totp_step: u64,
    relay: RelayPolicy,
    discovery: LocalDiscovery,
    scope: BindScope,


130	    scope: BindScope,
131	) -> Result<RendezvousListener, NetError> {
132	    let id = rendezvous_identity(&rendezvous_token(subnet_name, totp_step));
133	    let ep = NetEndpoint::bind_scoped(
134	        &id,
135	        relay,
136	        discovery,
137	        vec![SPT_PAIR_MEET_ALPN.to_vec()],
138	        scope,
139	    )
140	    .await?;
141	    Ok(RendezvousListener { ep })
142	}
143
144	impl RendezvousListener {
145	    /// The underlying meet endpoint (its own addr/online/close surface).
146	    pub fn endpoint(&self) -> &NetEndpoint {
147	        &self.ep
148	    }
149
150	    /// Serve one meet request: accept a connection, accept its bidi stream,
151	    /// and answer with `real_addr` — the responder's REAL pairing endpoint
152	    /// address the joiner re-dials for the unchanged ceremony. Returns when
153	    /// one reply has been flushed; the daemon's pairing-window loop calls this
154	    /// repeatedly for the window's lifetime.
155	    // [impl->REQ-PAIR-5]
156	    pub async fn serve_once(&self, real_addr: &EndpointAddr) -> Result<(), MeetError> {
157	        let incoming = self
158	            .ep
159	            .accept()
160	            .await
161	            .ok_or_else(|| MeetError::Net(NetError::Bind("meet endpoint closed".into())))?;
162	        let conn = incoming
163	            .accept()
164	            .map_err(|e| MeetError::Protocol(format!("accept: {e}")))?
165	            .await
166	            .map_err(|e| MeetError::Protocol(format!("connecting: {e}")))?;
167	        let (mut send, mut recv) = conn
168	            .accept_bi()
169	            .await
170	            .map_err(|e| MeetError::Protocol(format!("accept_bi: {e}")))?;
171	        // Drain the joiner's probe byte (the dialer must write first for
172	        // accept_bi to fire); content is irrelevant.
173	        let _ = recv
174	            .read_to_end(MAX_MEET_FRAME)
175	            .await
176	            .map_err(|e| MeetError::Protocol(format!("read probe: {e}")))?;
177	        let json = serde_json::to_vec(real_addr)
178	            .map_err(|e| MeetError::Protocol(format!("encode addr: {e}")))?;
179	        let mut frame = (json.len() as u32).to_be_bytes().to_vec();
180	        frame.extend_from_slice(&json);
181	        send.write_all(&frame)
182	            .await
183	            .map_err(|e| MeetError::Protocol(format!("write addr: {e}")))?;
184	        send.finish()
185	            .map_err(|e| MeetError::Protocol(format!("finish: {e}")))?;
186	        conn.closed().await;
187	        Ok(())
188	    }
189	}
190
191	/// Ask one meet listener (an explicit, already-resolvable meet address) for
192	/// the responder's real pairing address. The production joiner goes through
193	/// [`dial_via_rendezvous`]; this explicit-address form is the seam for
194	/// hermetic tests and static-`ip:port` rigs where no relay/discovery exists.
195	// [impl->REQ-PAIR-5]
196	pub async fn meet_at(dialer: &NetEndpoint, meet_addr: EndpointAddr) -> Result<EndpointAddr, MeetError> {
197	    let conn = dialer
198	        .connect_with_alpn(meet_addr, SPT_PAIR_MEET_ALPN)
199	        .await
200	        .map_err(MeetError::Net)?;
201	    let (mut send, mut recv) = conn
202	        .open_bi()
203	        .await
204	        .map_err(|e| MeetError::Protocol(format!("open_bi: {e}")))?;
205	    // The probe byte that opens the stream (dialer writes first).
206	    send.write_all(&[1u8])
207	        .await
208	        .map_err(|e| MeetError::Protocol(format!("write probe: {e}")))?;
209	    send.finish()
210	        .map_err(|e| MeetError::Protocol(format!("finish probe: {e}")))?;
211	    let mut len_buf = [0u8; 4];
212	    recv.read_exact(&mut len_buf)
213	        .await
214	        .map_err(|e| MeetError::Protocol(format!("read len: {e}")))?;
215	    let len = u32::from_be_bytes(len_buf) as usize;
216	    if len > MAX_MEET_FRAME {
217	        return Err(MeetError::Protocol(format!("meet reply too large: {len}")));
218	    }
219	    let mut json = vec![0u8; len];
220	    recv.read_exact(&mut json)
221	        .await
222	        .map_err(|e| MeetError::Protocol(format!("read addr: {e}")))?;
223	    let addr: EndpointAddr = serde_json::from_slice(&json)
224	        .map_err(|e| MeetError::Protocol(format!("decode addr: {e}")))?;
225	    conn.close(0u32.into(), b"met");
226	    Ok(addr)
227	}
228
229	/// The production joiner path: derive the ±1-window rendezvous ids for
230	/// `(subnet_name, totp_step)`, dial each derived id by **id alone** (relay +
231	/// n0 DNS / mDNS resolve it — no address ticket), fetch the responder's real
232	/// address from the first listener that answers, and dial THAT over the
233	/// ceremony ALPN. The returned connection is ready for
234	/// [`run_initiator`](super::wire::run_initiator) — the ceremony itself is
235	/// unchanged (module docs).
236	// [impl->REQ-PAIR-5]
237	pub async fn dial_via_rendezvous(
238	    dialer: &NetEndpoint,
239	    subnet_name: &str,
240	    totp_step: u64,
241	) -> Result<Connection, MeetError> {
242	    let mut last = MeetError::Protocol("rendezvous window empty".into());
243	    for token in rendezvous_window(subnet_name, totp_step) {
244	        let pk = rendezvous_identity(&token).public_key();
245	        let id = EndpointId::from_bytes(&pk.to_bytes())
246	            .map_err(|e| MeetError::Protocol(format!("derived id invalid: {e}")))?;
247	        match meet_at(dialer, EndpointAddr::new(id)).await {
248	            Ok(real_addr) => {
249	                return dialer
250	                    .connect_with_alpn(real_addr, SPT_PAIR_ALPN)
251	                    .await
252	                    .map_err(MeetError::Net);
253	            }
254	            Err(e) => last = e,
255	        }
256	    }
257	    Err(last)
258	}
259
260	#[cfg(test)]
261	mod tests {
262	    use super::*;
263	    use std::net::SocketAddr;
264
265	    // [unit->REQ-PAIR-5] both sides independently derive the SAME rendezvous
266	    // keypair from (name, step) — the joiner can compute the public id the
267	    // responder listens under, with nothing but the two user inputs.
268	    #[test]
269	    fn derived_identity_is_deterministic_and_input_bound() {

    scope: BindScope,
) -> Result<RendezvousListener, NetError> {
    let id = rendezvous_identity(&rendezvous_token(subnet_name, totp_step));
    let ep = NetEndpoint::bind_scoped(
        &id,
        relay,
        discovery,
        vec![SPT_PAIR_MEET_ALPN.to_vec()],
        scope,
    )
    .await?;
    Ok(RendezvousListener { ep })
}

impl RendezvousListener {
    /// The underlying meet endpoint (its own addr/online/close surface).
    pub fn endpoint(&self) -> &NetEndpoint {
        &self.ep
    }

    /// Serve one meet request: accept a connection, accept its bidi stream,
    /// and answer with `real_addr` — the responder's REAL pairing endpoint
    /// address the joiner re-dials for the unchanged ceremony. Returns when
    /// one reply has been flushed; the daemon's pairing-window loop calls this
    /// repeatedly for the window's lifetime.
    // [impl->REQ-PAIR-5]
    pub async fn serve_once(&self, real_addr: &EndpointAddr) -> Result<(), MeetError> {
        let incoming = self
            .ep
            .accept()
            .await
            .ok_or_else(|| MeetError::Net(NetError::Bind("meet endpoint closed".into())))?;
        let conn = incoming
            .accept()
            .map_err(|e| MeetError::Protocol(format!("accept: {e}")))?
            .await
            .map_err(|e| MeetError::Protocol(format!("connecting: {e}")))?;
        let (mut send, mut recv) = conn
            .accept_bi()
            .await
            .map_err(|e| MeetError::Protocol(format!("accept_bi: {e}")))?;
        // Drain the joiner's probe byte (the dialer must write first for
        // accept_bi to fire); content is irrelevant.
        let _ = recv
            .read_to_end(MAX_MEET_FRAME)
            .await
            .map_err(|e| MeetError::Protocol(format!("read probe: {e}")))?;
        let json = serde_json::to_vec(real_addr)
            .map_err(|e| MeetError::Protocol(format!("encode addr: {e}")))?;
        let mut frame = (json.len() as u32).to_be_bytes().to_vec();
        frame.extend_from_slice(&json);
        send.write_all(&frame)
            .await
            .map_err(|e| MeetError::Protocol(format!("write addr: {e}")))?;
        send.finish()
            .map_err(|e| MeetError::Protocol(format!("finish: {e}")))?;
        conn.closed().await;
        Ok(())
    }
}

/// Ask one meet listener (an explicit, already-resolvable meet address) for
/// the responder's real pairing address. The production joiner goes through
/// [`dial_via_rendezvous`]; this explicit-address form is the seam for
/// hermetic tests and static-`ip:port` rigs where no relay/discovery exists.
// [impl->REQ-PAIR-5]
pub async fn meet_at(dialer: &NetEndpoint, meet_addr: EndpointAddr) -> Result<EndpointAddr, MeetError> {
    let conn = dialer
        .connect_with_alpn(meet_addr, SPT_PAIR_MEET_ALPN)
        .await
        .map_err(MeetError::Net)?;
    let (mut send, mut recv) = conn
        .open_bi()
        .await
        .map_err(|e| MeetError::Protocol(format!("open_bi: {e}")))?;
    // The probe byte that opens the stream (dialer writes first).
    send.write_all(&[1u8])
        .await
        .map_err(|e| MeetError::Protocol(format!("write probe: {e}")))?;
    send.finish()
        .map_err(|e| MeetError::Protocol(format!("finish probe: {e}")))?;
    let mut len_buf = [0u8; 4];
    recv.read_exact(&mut len_buf)
        .await
        .map_err(|e| MeetError::Protocol(format!("read len: {e}")))?;
    let len = u32::from_be_bytes(len_buf) as usize;
    if len > MAX_MEET_FRAME {
        return Err(MeetError::Protocol(format!("meet reply too large: {len}")));
    }
    let mut json = vec![0u8; len];
    recv.read_exact(&mut json)
        .await
        .map_err(|e| MeetError::Protocol(format!("read addr: {e}")))?;
    let addr: EndpointAddr = serde_json::from_slice(&json)
        .map_err(|e| MeetError::Protocol(format!("decode addr: {e}")))?;
    conn.close(0u32.into(), b"met");
    Ok(addr)
}

/// The production joiner path: derive the ±1-window rendezvous ids for
/// `(subnet_name, totp_step)`, dial each derived id by **id alone** (relay +
/// n0 DNS / mDNS resolve it — no address ticket), fetch the responder's real
/// address from the first listener that answers, and dial THAT over the
/// ceremony ALPN. The returned connection is ready for
/// [`run_initiator`](super::wire::run_initiator) — the ceremony itself is
/// unchanged (module docs).
// [impl->REQ-PAIR-5]
pub async fn dial_via_rendezvous(
    dialer: &NetEndpoint,
    subnet_name: &str,
    totp_step: u64,
) -> Result<Connection, MeetError> {
    let mut last = MeetError::Protocol("rendezvous window empty".into());
    for token in rendezvous_window(subnet_name, totp_step) {
        let pk = rendezvous_identity(&token).public_key();
        let id = EndpointId::from_bytes(&pk.to_bytes())
            .map_err(|e| MeetError::Protocol(format!("derived id invalid: {e}")))?;
        match meet_at(dialer, EndpointAddr::new(id)).await {
            Ok(real_addr) => {
                return dialer
                    .connect_with_alpn(real_addr, SPT_PAIR_ALPN)
                    .await
                    .map_err(MeetError::Net);
            }
            Err(e) => last = e,
        }
    }
    Err(last)
}

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

    // [unit->REQ-PAIR-5] both sides independently derive the SAME rendezvous
    // keypair from (name, step) — the joiner can compute the public id the
    // responder listens under, with nothing but the two user inputs.
    #[test]
    fn derived_identity_is_deterministic_and_input_bound() {


D3 = daemon-hosted responder + meet rotation + broker IPC + CLI initiator. Heavy recon needed — nethost ALPN routing + dispatch:


Bash

grep -n "ALPN\|alpn" crates/spt-daemon/src/nethost.rs | head -20; echo ---; grep -n "SPT_PAIR_ALPN\|SPT_PAIR_MEET_ALPN\|pairing" crates/spt-daemon/src/*.rs | head -20

Find ALPN handling in daemon


36:    SendStream, SPT_NET_ALPN,
392:    /// accept loop (every inbound `SPT_NET_ALPN` connection joins the table).
405:                vec![SPT_NET_ALPN.to_vec()],
457:    /// Dial a peer over `SPT_NET_ALPN`, registering the connection in the table.
---
crates/spt-daemon/src/registryhost.rs:237:        // subnet record; no pairing-protocol threading needed), unhomed Self
crates/spt-daemon/src/shellwake.rs:71:/// `spt-net::pairing::ratelimit` recipe). Zero failures ⇒ no wait.


380	    endpoint: Arc<NetEndpoint>,
381	    shared: Arc<NetShared>,
382	    /// `op_id → conn_id` for exactly-once dial replies: a deduped replay of a
383	    /// journaled dial is answered with the connection the first apply opened.
384	    dial_ops: Mutex<HashMap<u64, u64>>,
385	    /// `op_id → stream_id` for exactly-once stream-open replies (D4b), same
386	    /// discipline as `dial_ops`. Shares the one net op-id namespace.
387	    stream_ops: Mutex<HashMap<u64, u64>>,
388	}
389
390	impl NetHost {
391	    /// Bind the broker's endpoint on a fresh dedicated runtime and start the
392	    /// accept loop (every inbound `SPT_NET_ALPN` connection joins the table).
393	    // [impl->REQ-DAEMON-2]
394	    pub fn start(cfg: NetConfig) -> io::Result<NetHost> {
395	        let runtime = tokio::runtime::Builder::new_multi_thread()
396	            .worker_threads(2)
397	            .thread_name("spt-broker-net")
398	            .enable_all()
399	            .build()?;
400	        let endpoint = runtime
401	            .block_on(NetEndpoint::bind_scoped(
402	                &cfg.identity,
403	                cfg.relay,
404	                cfg.discovery,
405	                vec![SPT_NET_ALPN.to_vec()],
406	                cfg.scope,
407	            ))
408	            .map_err(|e| io::Error::other(e.to_string()))?;
409	        let endpoint = Arc::new(endpoint);
410	        let shared = Arc::new(NetShared::new());
411
412	        // Accept loop: lives for the endpoint's lifetime on the host's runtime.
413	        // Each handshake completes on its own task so a slow peer can't stall
414	        // the loop; a failed handshake is simply not registered.
415	        let ep = Arc::clone(&endpoint);
416	        let sh = Arc::clone(&shared);
417	        runtime.spawn(async move {
418	            while let Some(incoming) = ep.accept().await {
419	                let sh = Arc::clone(&sh);
420	                tokio::spawn(async move {
421	                    let Ok(connecting) = incoming.accept() else { return };
422	                    let Ok(conn) = connecting.await else { return };
423	                    register_conn(&sh, conn);
424	                });
425	            }
426	        });
427
428	        Ok(NetHost {
429	            runtime,
430	            endpoint,
431	            shared,
432	            dial_ops: Mutex::new(HashMap::new()),
433	            stream_ops: Mutex::new(HashMap::new()),
434	        })
435	    }
436
437	    /// The node's stable name (lowercase-hex pubkey == iroh EndpointId).
438	    pub fn node_id_hex(&self) -> String {
439	        self.endpoint.node_id_hex()
440	    }
441
442	    /// The endpoint's dialable address (id + currently-known direct/relay paths).
443	    pub fn addr(&self) -> EndpointAddr {
444	        self.endpoint.addr()
445	    }
446
447	    /// [`Self::addr`] as the opaque JSON the IPC status reply carries.
448	    pub fn addr_json(&self) -> serde_json::Value {
449	        serde_json::to_value(self.endpoint.addr()).unwrap_or(serde_json::Value::Null)
450	    }
451
452	    /// Live connections (accepted + dialed) currently held.
453	    pub fn conn_count(&self) -> u64 {
454	        self.shared.conns.lock().unwrap().len() as u64
455	    }
456
457	    /// Dial a peer over `SPT_NET_ALPN`, registering the connection in the table.
458	    /// Bridges sync→async via the host's own runtime (the broker's dispatch
459	    /// threads block here; the future runs on the net worker threads).
460	    // [impl->REQ-DAEMON-2]
461	    pub fn dial(&self, addr: EndpointAddr) -> io::Result<(u64, String)> {
462	        let endpoint = Arc::clone(&self.endpoint);
463	        let shared = Arc::clone(&self.shared);
464	        self.runtime.handle().block_on(async move {
465	            let conn = endpoint
466	                .connect(addr)
467	                .await
468	                .map_err(|e| io::Error::other(e.to_string()))?;
469	            Ok(register_conn(&shared, conn))
470	        })
471	    }
472
473	    /// Record that journaled dial `op_id` opened `conn_id` (called inside the
474	    /// `apply_once` closure, so a concurrent deduped replay always finds it).
475	    pub fn record_dial_op(&self, op_id: u64, conn_id: u64) {
476	        self.dial_ops.lock().unwrap().insert(op_id, conn_id);
477	    }
478
479	    /// The connection a journaled dial `op_id` opened, if this broker process
480	    /// still holds it (a broker restart drops connections; the journal alone
481	    /// survives — see `Broker::dispatch_net_dial`).
482	    pub fn dial_op_conn(&self, op_id: u64) -> Option<u64> {
483	        self.dial_ops.lock().unwrap().get(&op_id).copied()
484	    }
485
486	    /// The remote node id (hex) of a held connection.
487	    pub fn conn_remote_hex(&self, conn_id: u64) -> Option<String> {
488	        self.shared.conns.lock().unwrap().get(&conn_id).map(|e| e.remote_id_hex.clone())
489	    }
490
491	    /// Open a bidi stream on a held connection, registering it (with its read
492	    /// pump) in the stream table (D4b). Note QUIC streams are lazy: the peer
493	    /// sees the stream on its first write.
494	    // [impl->REQ-DAEMON-2]
495	    pub fn open_stream(&self, conn_id: u64) -> io::Result<u64> {
496	        let (conn, remote_id_hex) = {
497	            let conns = self.shared.conns.lock().unwrap();
498	            let e = conns.get(&conn_id).ok_or_else(|| {
499	                io::Error::new(io::ErrorKind::NotFound, format!("no such conn {conn_id}"))

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

impl NetHost {
    /// Bind the broker's endpoint on a fresh dedicated runtime and start the
    /// accept loop (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()),
        })
    }

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

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

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

    /// Live connections (accepted + dialed) currently held.
    pub fn conn_count(&self) -> u64 {
        self.shared.conns.lock().unwrap().len() as u64
    }

    /// Dial a peer over `SPT_NET_ALPN`, registering the connection in the table.
    /// Bridges sync→async via the host's own runtime (the broker's dispatch
    /// threads block here; the future runs on the net worker threads).
    // [impl->REQ-DAEMON-2]
    pub fn dial(&self, addr: EndpointAddr) -> io::Result<(u64, String)> {
        let endpoint = Arc::clone(&self.endpoint);
        let shared = Arc::clone(&self.shared);
        self.runtime.handle().block_on(async move {
            let conn = endpoint
                .connect(addr)
                .await
                .map_err(|e| io::Error::other(e.to_string()))?;
            Ok(register_conn(&shared, conn))
        })
    }

    /// Record that journaled dial `op_id` opened `conn_id` (called inside the
    /// `apply_once` closure, so a concurrent deduped replay always finds it).
    pub fn record_dial_op(&self, op_id: u64, conn_id: u64) {
        self.dial_ops.lock().unwrap().insert(op_id, conn_id);
    }

    /// The connection a journaled dial `op_id` opened, if this broker process
    /// still holds it (a broker restart drops connections; the journal alone
    /// survives — see `Broker::dispatch_net_dial`).
    pub fn dial_op_conn(&self, op_id: u64) -> Option<u64> {
        self.dial_ops.lock().unwrap().get(&op_id).copied()
    }

    /// The remote node id (hex) of a held connection.
    pub fn conn_remote_hex(&self, conn_id: u64) -> Option<String> {
        self.shared.conns.lock().unwrap().get(&conn_id).map(|e| e.remote_id_hex.clone())
    }

    /// Open a bidi stream on a held connection, registering it (with its read
    /// pump) in the stream table (D4b). Note QUIC streams are lazy: the peer
    /// sees the stream on its first write.
    // [impl->REQ-DAEMON-2]
    pub fn open_stream(&self, conn_id: u64) -> io::Result<u64> {
        let (conn, remote_id_hex) = {
            let conns = self.shared.conns.lock().unwrap();
            let e = conns.get(&conn_id).ok_or_else(|| {
                io::Error::new(io::ErrorKind::NotFound, format!("no such conn {conn_id}"))


60	        }
61
62	        // The broker's network host (D4a, ADR-0004 §B): the node's iroh endpoint
63	        // + live QUIC connections live in the broker so they survive a brain
64	        // restart. Identity is the durable node.key (stable name across daemon
65	        // restarts); relay stance comes from daemon.json; mDNS rides the
66	        // endpoint (closing the §B mDNS/relay-session row by construction). A
67	        // net failure degrades to a net-less broker — it must not cost PTY
68	        // hosting (mirrors the broker-bind soft-fail below).
69	        // [impl->REQ-DAEMON-2]
70	        let mut node_hex: Option<String> = None;
71	        let net = {
72	            let cfg = DaemonConfig::load();
73	            let relay = match cfg.relay {
74	                RelayChoice::N0 => RelayPolicy::N0Default,
75	                RelayChoice::Disabled => RelayPolicy::Disabled,
76	            };
77	            match spt_store::nodeid::load_or_create() {
78	                Ok(identity) => {
79	                    node_hex = Some(identity.public_key().to_hex());
80	                    let net_cfg = NetConfig {
81	                        identity,
82	                        relay,
83	                        discovery: LocalDiscovery::Mdns,
84	                        scope: BindScope::All, // production: real interfaces
85	                    };
86	                    match NetHost::start(net_cfg) {
87	                        Ok(host) => Some(host),
88	                        Err(e) => {
89	                            eprintln!("NET_BIND_FAIL: {e} — broker runs net-less");
90	                            None
91	                        }
92	                    }
93	                }
94	                Err(e) => {
95	                    eprintln!("NODE_KEY_FAIL: {e} — broker runs net-less");
96	                    None
97	                }
98	            }
99	        };
100
101	        // The broker hosts PTYs (B1/B2) + the net endpoint (D4a). Serve it on a
102	        // background thread; a bind failure there must not stop the seed channel
103	        // (the B6 surface) — the PTY-hosting consumers arrive in B7/B8.
104	        let net_up = net.is_some();
105	        match Broker::bind_in_with_net(&broker_socket_name(), default_journal_path(), net) {
106	            Ok(broker) => {
107	                thread::spawn(move || {
108	                    let _ = broker.serve();
109	                });
110	                // Inbound net dispatch + outbound peer pump (D9-1): the
111	                // production trigger loops. Only meaningful with a net host —
112	                // a net-less broker accepts nothing and dials nobody.
113	                if net_up {
114	                    let hex = node_hex.clone().unwrap_or_default();
115	                    let registry = Arc::new(crate::registryhost::RegistryHost::new(&hex));
116	                    let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
117	                    let _ = crate::dispatch::spawn_dispatcher(
118	                        broker_socket_name(),
119	                        Arc::clone(&registry),
120	                        Arc::clone(&stop),
121	                    );
122	                    let cfg = DaemonConfig::load();
123	                    if cfg.peer_pump {
124	                        let _ = crate::peerloop::spawn_peer_pump(
125	                            broker_socket_name(),
126	                            registry,
127	                            cfg,
128	                            stop,
129	                        );
130	                    }
131	                }
132	            }
133	            Err(e) => eprintln!("BROKER_BIND_FAIL: {e}"),
134	        }
135
136	        // The digest hub (B8) holds the live per-endpoint PTY-digest projections;
137	        // the digest-control channel serves `spt digest <id>` snapshot pulls +
138	        // delta-stream subscribes against it. Served on a background thread like
139	        // the broker. The per-session feed that *populates* the hub is driven by
140	        // the brain (the parse is logic — ADR-0004 §B), wired as daemon-hosted
141	        // sessions are spawned through the broker; a bare daemon serves the
142	        // channel and answers "no live digest" until a session is hosted.
143	        let digest_hub = Arc::new(DigestHub::new());
144	        {
145	            let hub = Arc::clone(&digest_hub);
146	            thread::spawn(move || {
147	                let _ = digesthub::serve_digest_control(&digest_socket_name(), hub);
148	            });
149	        }
150
151	        // Shell wake-watchers (M5-D4b): the daemon hosts the offline half of
152	        // the shell online/offline exclusivity — a reconcile loop that runs
153	        // each offline instance's manifest `wake_command` as a supervised
154	        // watcher child (exit-opcode → wake resolution) and stops it when the
155	        // binary side wins. Boot sweep doubles as orphan cleanup after a
156	        // daemon crash. The daemon runs until killed, so the stop flag here
157	        // is held for symmetry with the dispatcher loops, never raised.
158	        // [impl->REQ-SHELL-2]
159	        {
160	            let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
161	            let _ = crate::shellwake::spawn_wake_host(stop);
162	        }
163
164	        // Best-effort breadcrumb: record our pid for management/cleanup. Liveness
165	        // is the socket ping, never this file, so a stale value is harmless.
166	        let _ = std::fs::write(daemon_pid_path(), std::process::id().to_string());
167
168	        // The seed-control channel is the foreground loop. Binding it is what
169	        // arbitrates the single-daemon-per-home invariant: a second daemon racing
170	        // to start fails this bind and exits, leaving the winner serving.
171	        seedmap::serve_seed_control(&seed_socket_name(), reg)
172	    }
173	}
174
175	/// Whether a daemon is already serving the seed-control channel for this home.
176	pub fn is_running() -> bool {
177	    seedmap::ping(&seed_socket_name()).is_ok()
178	}
179
180	/// Auto-start (REQ-DAEMON-3): ensure a daemon is up for this home. A no-op if one
181	/// already answers; otherwise spawn `spt daemon` detached and wait (bounded) for
182	/// it to bind. Idempotent + race-tolerant: if two callers spawn at once, one
183	/// wins the bind and the other exits — and this check passes either way.
184	pub fn ensure_running() -> io::Result<()> {
185	    if is_running() {
186	        return Ok(());
187	    }
188	    spawn_detached()?;
189	    let mut waited = Duration::ZERO;
190	    while waited < START_TIMEOUT {
191	        if is_running() {
192	            return Ok(());
193	        }
194	        thread::sleep(START_POLL);
195	        waited += START_POLL;
196	    }
197	    Err(io::Error::new(
198	        io::ErrorKind::TimedOut,
199	        "daemon did not come up within the start window",

        }

        // The broker's network host (D4a, ADR-0004 §B): the node's iroh endpoint
        // + live QUIC connections live in the broker so they survive a brain
        // restart. Identity is the durable node.key (stable name across daemon
        // restarts); relay stance comes from daemon.json; mDNS rides the
        // endpoint (closing the §B mDNS/relay-session row by construction). A
        // net failure degrades to a net-less broker — it must not cost PTY
        // hosting (mirrors the broker-bind soft-fail below).
        // [impl->REQ-DAEMON-2]
        let mut node_hex: Option<String> = None;
        let net = {
            let cfg = DaemonConfig::load();
            let relay = match cfg.relay {
                RelayChoice::N0 => RelayPolicy::N0Default,
                RelayChoice::Disabled => RelayPolicy::Disabled,
            };
            match spt_store::nodeid::load_or_create() {
                Ok(identity) => {
                    node_hex = Some(identity.public_key().to_hex());
                    let net_cfg = NetConfig {
                        identity,
                        relay,
                        discovery: LocalDiscovery::Mdns,
                        scope: BindScope::All, // production: real interfaces
                    };
                    match NetHost::start(net_cfg) {
                        Ok(host) => Some(host),
                        Err(e) => {
                            eprintln!("NET_BIND_FAIL: {e} — broker runs net-less");
                            None
                        }
                    }
                }
                Err(e) => {
                    eprintln!("NODE_KEY_FAIL: {e} — broker runs net-less");
                    None
                }
            }
        };

        // The broker hosts PTYs (B1/B2) + the net endpoint (D4a). Serve it on a
        // background thread; a bind failure there must not stop the seed channel
        // (the B6 surface) — the PTY-hosting consumers arrive in B7/B8.
        let net_up = net.is_some();
        match Broker::bind_in_with_net(&broker_socket_name(), default_journal_path(), net) {
            Ok(broker) => {
                thread::spawn(move || {
                    let _ = broker.serve();
                });
                // Inbound net dispatch + outbound peer pump (D9-1): the
                // production trigger loops. Only meaningful with a net host —
                // a net-less broker accepts nothing and dials nobody.
                if net_up {
                    let hex = node_hex.clone().unwrap_or_default();
                    let registry = Arc::new(crate::registryhost::RegistryHost::new(&hex));
                    let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
                    let _ = crate::dispatch::spawn_dispatcher(
                        broker_socket_name(),
                        Arc::clone(&registry),
                        Arc::clone(&stop),
                    );
                    let cfg = DaemonConfig::load();
                    if cfg.peer_pump {
                        let _ = crate::peerloop::spawn_peer_pump(
                            broker_socket_name(),
                            registry,
                            cfg,
                            stop,
                        );
                    }
                }
            }
            Err(e) => eprintln!("BROKER_BIND_FAIL: {e}"),
        }

        // The digest hub (B8) holds the live per-endpoint PTY-digest projections;
        // the digest-control channel serves `spt digest <id>` snapshot pulls +
        // delta-stream subscribes against it. Served on a background thread like
        // the broker. The per-session feed that *populates* the hub is driven by
        // the brain (the parse is logic — ADR-0004 §B), wired as daemon-hosted
        // sessions are spawned through the broker; a bare daemon serves the
        // channel and answers "no live digest" until a session is hosted.
        let digest_hub = Arc::new(DigestHub::new());
        {
            let hub = Arc::clone(&digest_hub);
            thread::spawn(move || {
                let _ = digesthub::serve_digest_control(&digest_socket_name(), hub);
            });
        }

        // Shell wake-watchers (M5-D4b): the daemon hosts the offline half of
        // the shell online/offline exclusivity — a reconcile loop that runs
        // each offline instance's manifest `wake_command` as a supervised
        // watcher child (exit-opcode → wake resolution) and stops it when the
        // binary side wins. Boot sweep doubles as orphan cleanup after a
        // daemon crash. The daemon runs until killed, so the stop flag here
        // is held for symmetry with the dispatcher loops, never raised.
        // [impl->REQ-SHELL-2]
        {
            let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
            let _ = crate::shellwake::spawn_wake_host(stop);
        }

        // Best-effort breadcrumb: record our pid for management/cleanup. Liveness
        // is the socket ping, never this file, so a stale value is harmless.
        let _ = std::fs::write(daemon_pid_path(), std::process::id().to_string());

        // The seed-control channel is the foreground loop. Binding it is what
        // arbitrates the single-daemon-per-home invariant: a second daemon racing
        // to start fails this bind and exits, leaving the winner serving.
        seedmap::serve_seed_control(&seed_socket_name(), reg)
    }
}

/// Whether a daemon is already serving the seed-control channel for this home.
pub fn is_running() -> bool {
    seedmap::ping(&seed_socket_name()).is_ok()
}

/// Auto-start (REQ-DAEMON-3): ensure a daemon is up for this home. A no-op if one
/// already answers; otherwise spawn `spt daemon` detached and wait (bounded) for
/// it to bind. Idempotent + race-tolerant: if two callers spawn at once, one
/// wins the bind and the other exits — and this check passes either way.
pub fn ensure_running() -> io::Result<()> {
    if is_running() {
        return Ok(());
    }
    spawn_detached()?;
    let mut waited = Duration::ZERO;
    while waited < START_TIMEOUT {
        if is_running() {
            return Ok(());
        }
        thread::sleep(START_POLL);
        waited += START_POLL;
    }
    Err(io::Error::new(
        io::ErrorKind::TimedOut,
        "daemon did not come up within the start window",


grep -n "KIND_NET" crates/spt-daemon/src/*.rs | head; echo ---; grep -n "fn dispatch_net_dial\|KIND_NET_DIAL =>" crates/spt-daemon/src/broker.rs | head; echo ---; grep -n "pub fn alpn\|fn connect_with_alpn\|pub struct NetEndpoint\|pub fn accept" crates/spt-net/src/net/endpoint.rs

Locate IPC kinds + endpoint ALPN surface


crates/spt-daemon/src/brain.rs:40:    KIND_APPLIED, KIND_ERROR, KIND_EXIT, KIND_INPUT, KIND_KILL, KIND_NET_DIAL, KIND_NET_DIALED,
crates/spt-daemon/src/brain.rs:41:    KIND_NET_PRESENCE_EVENT, KIND_NET_PRESENCE_SUBSCRIBE, KIND_NET_SENT, KIND_NET_STATUS,
crates/spt-daemon/src/brain.rs:42:    KIND_NET_STATUS_REPLY, KIND_NET_STREAMS, KIND_NET_STREAMS_REPLY, KIND_NET_STREAM_DATA,
crates/spt-daemon/src/brain.rs:43:    KIND_NET_STREAM_EOF, KIND_NET_STREAM_OPEN, KIND_NET_STREAM_OPENED, KIND_NET_STREAM_SEND,
crates/spt-daemon/src/brain.rs:44:    KIND_NET_STREAM_SUBSCRIBE, KIND_OUTPUT, KIND_RESIZE, KIND_SESSIONS, KIND_SESSIONS_REPLY,
crates/spt-daemon/src/brain.rs:346:                KIND_NET_STATUS_REPLY => {
crates/spt-daemon/src/brain.rs:351:                KIND_NET_DIALED => {
crates/spt-daemon/src/brain.rs:356:                KIND_NET_STREAM_OPENED => {
crates/spt-daemon/src/brain.rs:361:                KIND_NET_SENT => {
crates/spt-daemon/src/brain.rs:366:                KIND_NET_STREAMS_REPLY => {
---
300:                KIND_NET_DIAL => {
554:    fn dispatch_net_dial(&self, env: Envelope, send: &SharedSend) -> Result<(), String> {
---
117:pub struct NetEndpoint {
268:    pub async fn connect_with_alpn(


110	        }
111	    }
112	}
113
114	impl std::error::Error for NetError {}
115
116	/// A bound spt-core network endpoint: the node's reachable presence on the subnet.
117	pub struct NetEndpoint {
118	    inner: Endpoint,
119	}
120
121	impl NetEndpoint {
122	    /// Bind an endpoint to this node's identity under the given relay policy.
123	    ///
124	    /// The iroh secret key is derived from the identity's 32-byte ed25519 seed, so
125	    /// the resulting `EndpointId` equals the node's [`PublicKey`] — the address and
126	    /// the name are the same value.
127	    // [impl->REQ-NET-1] [impl->REQ-NET-2]
128	    pub async fn bind(
129	        identity: &Identity,
130	        relay: RelayPolicy,
131	        discovery: LocalDiscovery,
132	    ) -> Result<NetEndpoint, NetError> {
133	        Self::bind_with_alpns(identity, relay, discovery, vec![SPT_NET_ALPN.to_vec()]).await
134	    }
135
136	    /// Bind on an explicit ALPN set instead of the default trusted [`SPT_NET_ALPN`].
137	    ///
138	    /// The pairing driver uses this to stand up a **dedicated pre-trust endpoint** bound
139	    /// only to the pairing ALPN (`super::pairing::wire::SPT_PAIR_ALPN`) — the pre-trust
140	    /// accept loop must not share the trusted message surface (ADR-0005; `pairing.rs`
141	    /// "Why a dedicated pre-trust ALPN"). All relay/discovery handling is identical to
142	    /// [`bind`](Self::bind).
143	    // [impl->REQ-NET-1] [impl->REQ-NET-2]
144	    pub async fn bind_with_alpns(
145	        identity: &Identity,
146	        relay: RelayPolicy,
147	        discovery: LocalDiscovery,
148	        alpns: Vec<Vec<u8>>,
149	    ) -> Result<NetEndpoint, NetError> {
150	        Self::bind_scoped(identity, relay, discovery, alpns, BindScope::All).await
151	    }
152
153	    /// The full bind form: [`bind_with_alpns`](Self::bind_with_alpns) plus an
154	    /// explicit [`BindScope`]. Hermetic tests pass [`BindScope::Loopback`] so the
155	    /// endpoint never listens on a real interface (and never triggers a Windows
156	    /// firewall prompt per test binary); everything else delegates here with
157	    /// [`BindScope::All`].
158	    // [impl->REQ-NET-1] [impl->REQ-NET-2]
159	    pub async fn bind_scoped(
160	        identity: &Identity,
161	        relay: RelayPolicy,
162	        discovery: LocalDiscovery,
163	        alpns: Vec<Vec<u8>>,
164	        scope: BindScope,
165	    ) -> Result<NetEndpoint, NetError> {
166	        // iroh and spt-proto both build the ed25519 keypair from the same 32-byte
167	        // seed, so this preserves the node's stable public-key name on the wire.
168	        let secret = SecretKey::from_bytes(&identity.seed());
169	        let builder = Endpoint::builder(presets::N0)
170	            .secret_key(secret)
171	            .alpns(alpns);
172	        // Loopback scope replaces the builder's pre-configured unspecified binds
173	        // (0.0.0.0 / [::]) with loopback-only sockets, AND disables iroh's
174	        // portmapper (UPnP/PCP/NAT-PMP — on by default), which would otherwise
175	        // bind real-interface UDP sockets for its SSDP/NAT-PMP probes: useless
176	        // on loopback, and the other source of per-test-binary Windows firewall
177	        // prompts.
178	        let builder = match scope {
179	            BindScope::All => builder,
180	            BindScope::Loopback => builder
181	                .clear_ip_transports()
182	                .bind_addr("127.0.0.1:0")
183	                .map_err(|e| NetError::Bind(e.to_string()))?
184	                .bind_addr("[::1]:0")
185	                .map_err(|e| NetError::Bind(e.to_string()))?
186	                .portmapper_config(PortmapperConfig::Disabled),
187	            // All-interfaces like the production scope, but on a caller-fixed
188	            // port so the node is dialable by a static ip:port ticket.
189	            BindScope::Port(p) => builder
190	                .clear_ip_transports()
191	                .bind_addr(std::net::SocketAddr::from((std::net::Ipv4Addr::UNSPECIFIED, p)))
192	                .map_err(|e| NetError::Bind(e.to_string()))?
193	                .bind_addr(std::net::SocketAddr::from((std::net::Ipv6Addr::UNSPECIFIED, p)))
194	                .map_err(|e| NetError::Bind(e.to_string()))?,
195	        };
196	        // The N0 preset already wires the n0 relay default; only override it when the
197	        // policy asks for self-host or disabled.
198	        let builder = match relay {
199	            RelayPolicy::N0Default => builder,
200	            RelayPolicy::SelfHosted(map) => builder.relay_mode(RelayMode::Custom(map)),
201	            RelayPolicy::Disabled => builder.relay_mode(RelayMode::Disabled),
202	        };
203	        // mDNS rides alongside the preset's n0 DNS discovery as an extra address
204	        // lookup, giving LAN-local advertise + resolve-by-id.
205	        let builder = match discovery {
206	            LocalDiscovery::Mdns => {
207	                builder.address_lookup(MdnsAddressLookup::builder().advertise(true))
208	            }
209	            LocalDiscovery::Off => builder,
210	        };
211	        let inner = builder
212	            .bind()
213	            .await
214	            .map_err(|e| NetError::Bind(e.to_string()))?;
215	        Ok(NetEndpoint { inner })
216	    }
217
218	    /// This node's public key — its stable subnet name, derived from the live
219	    /// endpoint id. Always a valid ed25519 point (iroh guarantees it).
220	    // [impl->REQ-NET-1]
221	    pub fn node_id(&self) -> PublicKey {
222	        PublicKey::from_bytes(self.inner.id().as_bytes())
223	            .expect("an iroh EndpointId is always a valid ed25519 public key")
224	    }
225
226	    /// The node's stable name as lowercase hex (the `CONTEXT.md` SptNode string).
227	    pub fn node_id_hex(&self) -> String {
228	        self.node_id().to_hex()
229	    }

        }
    }
}

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

/// A bound spt-core network endpoint: the node's reachable presence on the subnet.
pub struct NetEndpoint {
    inner: Endpoint,
}

impl NetEndpoint {
    /// Bind an endpoint to this node's identity under the given relay policy.
    ///
    /// The iroh secret key is derived from the identity's 32-byte ed25519 seed, so
    /// the resulting `EndpointId` equals the node's [`PublicKey`] — the address and
    /// the name are the same value.
    // [impl->REQ-NET-1] [impl->REQ-NET-2]
    pub async fn bind(
        identity: &Identity,
        relay: RelayPolicy,
        discovery: LocalDiscovery,
    ) -> Result<NetEndpoint, NetError> {
        Self::bind_with_alpns(identity, relay, discovery, vec![SPT_NET_ALPN.to_vec()]).await
    }

    /// Bind on an explicit ALPN set instead of the default trusted [`SPT_NET_ALPN`].
    ///
    /// The pairing driver uses this to stand up a **dedicated pre-trust endpoint** bound
    /// only to the pairing ALPN (`super::pairing::wire::SPT_PAIR_ALPN`) — the pre-trust
    /// accept loop must not share the trusted message surface (ADR-0005; `pairing.rs`
    /// "Why a dedicated pre-trust ALPN"). All relay/discovery handling is identical to
    /// [`bind`](Self::bind).
    // [impl->REQ-NET-1] [impl->REQ-NET-2]
    pub async fn bind_with_alpns(
        identity: &Identity,
        relay: RelayPolicy,
        discovery: LocalDiscovery,
        alpns: Vec<Vec<u8>>,
    ) -> Result<NetEndpoint, NetError> {
        Self::bind_scoped(identity, relay, discovery, alpns, BindScope::All).await
    }

    /// The full bind form: [`bind_with_alpns`](Self::bind_with_alpns) plus an
    /// explicit [`BindScope`]. Hermetic tests pass [`BindScope::Loopback`] so the
    /// endpoint never listens on a real interface (and never triggers a Windows
    /// firewall prompt per test binary); everything else delegates here with
    /// [`BindScope::All`].
    // [impl->REQ-NET-1] [impl->REQ-NET-2]
    pub async fn bind_scoped(
        identity: &Identity,
        relay: RelayPolicy,
        discovery: LocalDiscovery,
        alpns: Vec<Vec<u8>>,
        scope: BindScope,
    ) -> Result<NetEndpoint, NetError> {
        // iroh and spt-proto both build the ed25519 keypair from the same 32-byte
        // seed, so this preserves the node's stable public-key name on the wire.
        let secret = SecretKey::from_bytes(&identity.seed());
        let builder = Endpoint::builder(presets::N0)
            .secret_key(secret)
            .alpns(alpns);
        // Loopback scope replaces the builder's pre-configured unspecified binds
        // (0.0.0.0 / [::]) with loopback-only sockets, AND disables iroh's
        // portmapper (UPnP/PCP/NAT-PMP — on by default), which would otherwise
        // bind real-interface UDP sockets for its SSDP/NAT-PMP probes: useless
        // on loopback, and the other source of per-test-binary Windows firewall
        // prompts.
        let builder = match scope {
            BindScope::All => builder,
            BindScope::Loopback => builder
                .clear_ip_transports()
                .bind_addr("127.0.0.1:0")
                .map_err(|e| NetError::Bind(e.to_string()))?
                .bind_addr("[::1]:0")
                .map_err(|e| NetError::Bind(e.to_string()))?
                .portmapper_config(PortmapperConfig::Disabled),
            // All-interfaces like the production scope, but on a caller-fixed
            // port so the node is dialable by a static ip:port ticket.
            BindScope::Port(p) => builder
                .clear_ip_transports()
                .bind_addr(std::net::SocketAddr::from((std::net::Ipv4Addr::UNSPECIFIED, p)))
                .map_err(|e| NetError::Bind(e.to_string()))?
                .bind_addr(std::net::SocketAddr::from((std::net::Ipv6Addr::UNSPECIFIED, p)))
                .map_err(|e| NetError::Bind(e.to_string()))?,
        };
        // The N0 preset already wires the n0 relay default; only override it when the
        // policy asks for self-host or disabled.
        let builder = match relay {
            RelayPolicy::N0Default => builder,
            RelayPolicy::SelfHosted(map) => builder.relay_mode(RelayMode::Custom(map)),
            RelayPolicy::Disabled => builder.relay_mode(RelayMode::Disabled),
        };
        // mDNS rides alongside the preset's n0 DNS discovery as an extra address
        // lookup, giving LAN-local advertise + resolve-by-id.
        let builder = match discovery {
            LocalDiscovery::Mdns => {
                builder.address_lookup(MdnsAddressLookup::builder().advertise(true))
            }
            LocalDiscovery::Off => builder,
        };
        let inner = builder
            .bind()
            .await
            .map_err(|e| NetError::Bind(e.to_string()))?;
        Ok(NetEndpoint { inner })
    }

    /// This node's public key — its stable subnet name, derived from the live
    /// endpoint id. Always a valid ed25519 point (iroh guarantees it).
    // [impl->REQ-NET-1]
    pub fn node_id(&self) -> PublicKey {
        PublicKey::from_bytes(self.inner.id().as_bytes())
            .expect("an iroh EndpointId is always a valid ed25519 public key")
    }

    /// The node's stable name as lowercase hex (the `CONTEXT.md` SptNode string).
    pub fn node_id_hex(&self) -> String {
        self.node_id().to_hex()
    }


Grep

pub async fn accept|pub fn alpn|alpn\(\)|pub struct Incoming


246:    pub async fn accept(&self) -> Option<Incoming> {
247-        self.inner.accept().await
248-    }
249-
250-    \ Gracefully close the endpoint: every held connection sends its QUIC
251-    /// CLOSE frame so peers observe the disconnect promptly (a bare drop may
252-    /// tear the runtime down before the close ever flushes, leaving peers to


76:use spt_net::net::pairing::wire::{run_initiator, run_responder, SPT_PAIR_ALPN};
546:            let Some(incoming) = ep.accept().await else {
549:            let Ok(connecting) = incoming.accept() else { continue };
552:            match run_responder(&conn, rig.id_b.public_key(), &subnets, &mut rate, &mut trust, now_secs())
593:    // dismissal replicates BACK over B's pump (the acceptance leg).
679:    // the shell's [session.notif] template — the M5-PLAN acceptance line.
910:    // ── Rung 7b — notif acceptance leg: B's dismissal replicated back to A.