1	//! M7 D3 (REQ-SUBNET-2): the join ceremony's **product surface** — the
2	//! daemon-side hosting that turns the rig-proven SPAKE2/TOTP pairing wire
3	//! (`spt_net::net::pairing`) into something a user can actually run.
4	//!
5	//! Three pieces, all riding the broker's one node-identity endpoint:
6	//!
7	//! - **Responder, always-on** (ADR-0005 amendment; M7 plan decision 8): every
8	//!   member daemon accepts the pre-trust [`SPT_PAIR_ALPN`] beside the trusted
9	//!   surface — no arming step, the one-sided UX the ADR intended. The
10	//!   subnet-global [`PairingRateLimiter`] (red-team #11) is the standing-
11	//!   listener guard; [`respond`] additionally sheds concurrent ceremonies at
12	//!   the door (`try_lock`) and bounds each admitted ceremony to the
13	//!   limiter's own [`CEREMONY_TIMEOUT_SECS`] so a stalling peer cannot wedge
14	//!   the responder (the limiter's stale-slot reclaim is the backstop).
15	//! - **Meet rotation** ([`spawn_meet_rotation`]): for each member subnet, a
16	//!   short-lived rendezvous listener bound to the token-derived identity
17	//!   (`pairing::meet`), re-bound every TOTP step — how a joiner that knows
18	//!   nothing but (name, code) finds this responder over LAN or relay.
19	//! - **Joiner leg** ([`join_ceremony`]): dial via rendezvous, run the
20	//!   unchanged initiator ceremony, persist trust + the transferred seed.
21	//!   Invoked over the broker IPC (`pair-join`) because the ceremony binds
22	//!   the REAL node pubkey — and the node-identity endpoint lives here.
23	//!
24	//! Pairing connections never touch the trusted stream table: the ALPN routes
25	//! them to [`respond`] and the connection dies with the ceremony.
26	// [impl->REQ-SUBNET-2]
27
28	use std::sync::Arc;
29	use std::time::Duration;
30
31	use spt_net::net::endpoint::{BindScope, Connection, LocalDiscovery, NetEndpoint, RelayPolicy};
32	use spt_net::net::pairing::meet::{bind_rendezvous, dial_via_rendezvous};
33	use spt_net::net::pairing::ratelimit::{PairingRateLimiter, RateLimited, CEREMONY_TIMEOUT_SECS};
34	use spt_net::net::pairing::totp::{time_step, PERIOD_SECS};
35	use spt_net::net::pairing::wire::{run_initiator, run_responder, PairWireError};
36	use spt_proto::identity::PublicKey;
37	use spt_store::roster::{RosterEntry, RosterStore};
38	use spt_store::subnet::SubnetStore;
39	use spt_store::trust::TrustStore;
40
41	/// The ceremony bound: aligned with the rate limiter's own stale-slot
42	/// reclaim so an abandoned slot and an abandoned task expire together.
43	const CEREMONY_TIMEOUT: Duration = Duration::from_secs(CEREMONY_TIMEOUT_SECS);
44
45	/// How long the responder waits for the joiner to close the connection
46	/// after the ceremony's final frames — the delivery grace for Done/Seed
47	/// (or an abort). Generous for a WAN joiner; a vanished peer costs at most
48	/// this before the conn is released.
49	const CLOSE_GRACE: Duration = Duration::from_secs(10);
50
51	/// Serve one inbound pairing ceremony (the always-on responder's per-
52	/// connection handler). Stores are loaded fresh per ceremony — a subnet
53	/// created or joined a second ago is immediately pairable, and a trust edit
54	/// takes effect without a daemon restart (the same reload-per-use stance as
55	/// the dispatcher gates).
56	///
57	/// Concurrency: ONE ceremony at a time per daemon, enforced at the door —
58	/// a second connection finds the limiter lock held and is closed immediately
59	/// (the joiner sees the stream end and retries; same UX as the limiter's
60	/// `Busy` abort, shed before any frame is read).
61	// [impl->REQ-SUBNET-2]
62	pub async fn respond(
63	    conn: Connection,
64	    local_pub: PublicKey,
65	    rate: Arc<tokio::sync::Mutex<PairingRateLimiter>>,
66	) {
67	    let Ok(mut rate) = rate.try_lock() else {
68	        conn.close(1u32.into(), b"pairing busy");
69	        return;
70	    };
71	    // Serve-state filter (M8 decision 6, REQ-SUBNET-5): a detached subnet
72	    // is not served — the responder sees a store WITHOUT it, so a joiner
73	    // gets the same answer as for a subnet this node does not hold. The
74	    // membership record on disk is untouched.
75	    // [impl->REQ-SUBNET-5]
76	    let mut subnets = SubnetStore::load();
77	    spt_store::attachment::AttachmentStore::load().filter_serving(&mut subnets);
78	    let mut trust = TrustStore::load();
79	    // Roster (REQ-MESH-2): refresh this seed-holder's own entry for every
80	    // served subnet so the joiner always learns at least us, then hand it the
81	    // whole current roster in the Seed frame.
82	    let mut roster = RosterStore::load();
83	    refresh_self_roster(&mut roster, &subnets, &local_pub.to_hex());
84	    match tokio::time::timeout(
85	        CEREMONY_TIMEOUT,
86	        run_responder(
87	            &conn,
88	            local_pub,
89	            &subnets,
90	            &roster,
91	            &mut rate,
92	            &mut trust,
93	            now_secs(),
94	        ),
95	    )
96	    .await
97	    {
98	        Ok(Ok(outcome)) => {
99	            // Address seeding, responder side (M8 decisions 14/20,
100	            // REQ-CONV-1): the ceremony held a live authenticated
101	            // connection — persist the joiner's observed address so the
102	            // pump's post-join first sync dials it directly instead of
103	            // paying the id-only discovery cold start.
104	            // [impl->REQ-CONV-1]
105	            record_ceremony_peer_addr(&conn, &outcome.joiner_hex);
106	            // Re-pair trust overwrite (M8 decision 13, REQ-SUBNET-7): a
107	            // COMPLETED ceremony presenting the same label + machine id as
108	            // an existing row means this MACHINE re-paired under a fresh
109	            // key — the superseded identity's trust + registry rows go.
110	            // Fires only here, post-confirm (a gossiped claim never evicts).
111	            // [impl->REQ-SUBNET-7]
112	            if let Some(intro) = &outcome.intro {
113	                crate::registryhost::repair_evict_superseded(
114	                    &mut trust,
115	                    &outcome.subnet,
116	                    &outcome.joiner_hex,
117	                    intro,
118	                    &spt_store::perch::identity_dir().join("registry"),
119	                );
120	            }
121	            // Grow the seed-holder's roster by the new member (REQ-MESH-2): the
122	            // joiner's identity (its self-intro) + the address observed on the
123	            // live ceremony connection. A completed re-pair of a tombstoned key
124	            // clears the tombstone (decision 10) — the deliberate elevated act.
125	            // [impl->REQ-MESH-2]
126	            roster.clear_tombstone(&outcome.subnet, &outcome.joiner_hex);
127	            record_roster_peer(
128	                &mut roster,
129	                &outcome.subnet,
130	                &outcome.joiner_hex,
131	                outcome.intro.as_ref(),
132	                &conn,
133	            );
134	            match trust.save() {
135	                Ok(()) => eprintln!("PAIRED: joiner pinned ({:?})", outcome.trust),
136	                Err(e) => eprintln!("PAIRING_TRUST_SAVE_FAIL: {e}"),
137	            }
138	            if let Err(e) = roster.save() {
139	                eprintln!("PAIRING_ROSTER_SAVE_FAIL: {e}");
140	            }
141	        }
142	        // A failed ceremony is normal traffic (wrong code, impostor probe,
143	        // vanished peer) — logged, never fatal; the limiter charged it.
144	        Ok(Err(e)) => eprintln!("PAIRING_ATTEMPT_FAIL: {e}"),
145	        Err(_) => eprintln!("PAIRING_TIMEOUT: ceremony exceeded {CEREMONY_TIMEOUT_SECS}s"),
146	    }
147	    // Release the ceremony slot before the close-grace wait — the next
148	    // joiner must not be shed at the door for the lame-duck duration of
149	    // this connection's teardown.
150	    drop(rate);
151	    // Let the JOINER close first (the `meet::serve_once` discipline, bounded):
152	    // `SendStream::finish()` only queues the final Done/Seed (or abort)
153	    // frames — dropping the connection here sends an immediate QUIC CLOSE
154	    // that discards unacked stream data, and the joiner loses the race and
155	    // reads "connection lost" instead of its seed (the exact failure the
156	    // first twohost run of the product-surface rung hit). The joiner drops
157	    // its side once it has read the final frame; only then (or after the
158	    // grace bound, for a vanished peer) does this side release the conn.
159	    let _ = tokio::time::timeout(CLOSE_GRACE, conn.closed()).await;
160	}
161
162	/// The meet-rotation loop: for every member subnet, keep a rendezvous meet
163	/// listener bound under the (name, current-step)-derived identity, answering
164	/// joiner probes with `real_addr` — the responder's actual pairing-endpoint
165	/// address. Re-binds at each TOTP step boundary (window rotation = re-bind,
166	/// `pairing::meet` docs); re-reads the subnet store per window so a freshly
167	/// created/joined subnet starts meeting within one step.
168	///
169	/// Bind failures are soft (logged, that subnet skipped this window): the
170	/// daemon must keep running net-less-for-pairing rather than die. A
171	/// [`BindScope::Port`] scope is mapped to [`BindScope::All`] — the fixed
172	/// port belongs to the main endpoint; a second bind on it would always fail.
173	// [impl->REQ-SUBNET-2]
174	pub fn spawn_meet_rotation(
175	    handle: &tokio::runtime::Handle,
176	    endpoint: Arc<NetEndpoint>,
177	    relay: RelayPolicy,
178	    discovery: LocalDiscovery,
179	    scope: BindScope,
180	) {
181	    let scope = match scope {
182	        BindScope::Port(_) => BindScope::All,
183	        s => s,
184	    };
185	    handle.spawn(async move {
186	        // The PREVIOUS window's listeners, kept serving one extra period
187	        // (M7 acceptance fix): published records outlive listeners by the
188	        // DNS TTL, so a joiner resolving the step-1 token must find a live
189	        // listener there, not a ghost. Overlap = every published id is
190	        // dialable for its full discoverable life.
191	        let mut prev_tasks: Vec<tokio::task::JoinHandle<()>> = Vec::new();
192	        loop {
193	            let step = time_step(now_secs());
194	            // Serve-state filter (REQ-SUBNET-5): a detached subnet's meet
195	            // listener does not rotate — a joiner cannot rendezvous with a
196	            // membership this node deliberately is not serving. Re-read per
197	            // window like the subnet store itself.
198	            let attachment = spt_store::attachment::AttachmentStore::load();
199	            let names: Vec<String> = SubnetStore::load()
200	                .subnets
201	                .iter()
202	                .map(|s| s.name.clone())
203	                .filter(|n| !attachment.is_detached(n))
204	                .collect();
205	            // One listener task per subnet for this window's lifetime.
206	            let mut window_tasks = Vec::new();
207	            for name in names {
208	                let relay = relay.clone();
209	                let real_addr = endpoint.addr();
210	                window_tasks.push(tokio::spawn(async move {
211	                    let listener = match bind_rendezvous(&name, step, relay, discovery, scope).await
212	                    {
213	                        Ok(l) => l,
214	                        Err(e) => {
215	                            eprintln!("PAIR_MEET_BIND_FAIL:{name}: {e}");
216	                            return;
217	                        }
218	                    };
219	                    // Window diagnostics (live-debugging the meet path needs
220	                    // the derived id + what this listener actually advertises
221	                    // — an undiscoverable listener is indistinguishable from
222	                    // a dead one without this line).
223	                    eprintln!(
224	                        "PAIR_MEET_UP:{name} step={step} id={} addr={:?}",
225	                        listener.endpoint().node_id_hex(),
226	                        listener.endpoint().addr()
227	                    );
228	                    // Serve probes until the window task is aborted at the
229	                    // step boundary; each failure only ends one exchange.
230	                    loop {
231	                        match listener.serve_once(&real_addr).await {
232	                            Ok(()) => eprintln!("PAIR_MEET_ANSWERED:{name} step={step}"),
233	                            Err(e) => {
234	                                eprintln!("PAIR_MEET_SERVE_ERR:{name}: {e}");
235	                                // Endpoint closed or a malformed probe — brief
236	                                // pause so a tight error can't spin the loop.
237	                                tokio::time::sleep(Duration::from_millis(200)).await;
238	                            }
239	                        }
240	                    }
241	                }));
242	            }
243	            // Sleep to the next step boundary, then rotate: the window
244	            // BEFORE last dies (its published record has aged out of the
245	            // ±1 acceptance anyway); the window just ended keeps serving
246	            // one more period as `prev_tasks` (overlap, above).
247	            let now = now_secs();
248	            let next = (time_step(now) + 1) * PERIOD_SECS;
249	            tokio::time::sleep(Duration::from_secs(next.saturating_sub(now).max(1))).await;
250	            for t in prev_tasks.drain(..) {
251	                t.abort();
252	            }
253	            prev_tasks = window_tasks;
254	        }
255	    });
256	}
257
258	/// Why a join attempt failed — classified for the CLI's guided UX
259	/// (M7 plan decision 10: wrong code re-prompts, rate limits surface
260	/// honestly, a missing seed-holder gets actionable hints).
261	#[derive(Debug, Clone, PartialEq, Eq)]
262	pub enum JoinFail {
263	    /// No meet listener answered for any window token — no seed-holder
264	    /// reachable under that subnet name right now.
265	    NoSeedHolder(String),
266	    /// The ceremony ran and the confirmation failed — in practice: wrong code.
267	    WrongCode,
268	    /// The responder shed or refused us (busy / backoff `retry_after_secs`).
269	    RateLimited(Option<u64>),
270	    /// The responder does not hold a subnet by that name.
271	    UnknownSubnet(String),
272	    /// The dialer-announced TOTP step was outside the responder's window
273	    /// (clock skew beyond ±1 step).
274	    ClockSkew,
275	    /// Wire/store plumbing failure (stream died, seed write failed…).
276	    Other(String),
277	}
278
279	impl std::fmt::Display for JoinFail {
280	    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
281	        match self {
282	            JoinFail::NoSeedHolder(e) => write!(f, "no seed-holder found: {e}"),
283	            JoinFail::WrongCode => write!(f, "pairing confirmation failed (wrong code?)"),
284	            JoinFail::RateLimited(Some(s)) => write!(f, "rate-limited — retry in {s}s"),
285	            JoinFail::RateLimited(None) => write!(f, "responder busy with another ceremony"),
286	            JoinFail::UnknownSubnet(n) => write!(f, "responder holds no subnet '{n}'"),
287	            JoinFail::ClockSkew => write!(f, "TOTP step rejected — check this machine's clock"),
288	            JoinFail::Other(e) => write!(f, "{e}"),
289	        }
290	    }
291	}
292
293	/// The joiner leg (M7 plan decision 10): find a seed-holder via the LAN+relay
294	/// rendezvous (or dial `responder_addr` directly — the static-`ip:port` rig
295	/// seam, mirroring [`meet_at`](spt_net::net::pairing::meet::meet_at)'s
296	/// explicit-address form), run the unchanged initiator ceremony against its
297	/// real endpoint, and persist the results (peer trust-pinned; the
298	/// transferred seed makes this node a full member / future seed-holder).
299	/// One attempt — the CLI owns the re-prompt loop.
300	// [impl->REQ-SUBNET-2]
301	pub async fn join_ceremony(
302	    endpoint: &NetEndpoint,
303	    subnet_name: &str,
304	    code: &str,
305	    responder_addr: Option<spt_net::net::endpoint::EndpointAddr>,
306	) -> Result<String, JoinFail> {
307	    let mut trust = TrustStore::load();
308	    let mut subnets = SubnetStore::load();
309	    // Roster (REQ-MESH-2): run_initiator merges the seed-holder's transferred
310	    // roster into this store; we add our own self-entry + the seed-holder's
311	    // address after a successful ceremony.
312	    let mut roster = RosterStore::load();
313	    // The joiner's self-intro (M8 decision 13, REQ-SUBNET-7): label +
314	    // hashed machine id — what lets the seed-holder recognize this MACHINE
315	    // re-pairing under a fresh key and evict the superseded identity.
316	    // Best-effort: a label-less or id-less box just sends none (no
317	    // overwrite; manual prune covers it).
318	    // [impl->REQ-SUBNET-7]
319	    let intro = match (
320	        spt_store::hostlabel::os_hostname(),
321	        crate::machineid::machine_id_hash(),
322	    ) {
323	        (Some(label), Some(machine_id)) => {
324	            Some(spt_net::net::pairing::wire::NodeIntro { label, machine_id })
325	        }
326	        _ => None,
327	    };
328	    let conn = connect_seed_holder(endpoint, subnet_name, responder_addr.clone()).await?;
329	    let attempt = tokio::time::timeout(
330	        CEREMONY_TIMEOUT,
331	        run_initiator(
332	            &conn,
333	            endpoint.node_id(),
334	            subnet_name,
335	            code,
336	            &mut trust,
337	            &mut subnets,
338	            &mut roster,
339	            now_secs(),
340	            0,
341	            intro.as_ref(),
342	        ),
343	    )
344	    .await
345	    .map_err(|_| JoinFail::Other(format!("ceremony exceeded {CEREMONY_TIMEOUT_SECS}s")))?;
346	    let outcome = match attempt {
347	        Ok(o) => {
348	            // Address seeding, joiner side (M8 decisions 14/20, REQ-CONV-1):
349	            // persist the seed-holder's observed address from the live
350	            // ceremony connection — the post-join first sync dials it
351	            // directly instead of re-finding the peer from scratch.
352	            // [impl->REQ-CONV-1]
353	            record_ceremony_peer_addr(&conn, &remote_hex(&conn));
354	            // [impl->REQ-MESH-2]
355	            stamp_roster_responder(&mut roster, subnet_name, &conn);
356	            o
357	        }
358	        // The LATE-CODE retry (field report 2026-06-06): the joiner holds no
359	        // seed, so a code read just before a rotation and entered just after
360	        // binds to the wrong step and reads as "wrong code" — before
361	        // bouncing that back at the user, retry the SAME code announced one
362	        // step back (the responder's ±1 window exists for exactly this
363	        // tolerance). The responder runs one ceremony per connection, so the
364	        // retry redials a FRESH one, after the limiter's first-failure
365	        // backoff (1s) has lapsed. Any retry failure surfaces the FIRST
366	        // failure's class — the retry was a guess on the user's behalf, not
367	        // a second user attempt.
368	        Err(PairWireError::Pairing(first)) => {
369	            let first_fail = classify_wire_error(PairWireError::Pairing(first));
370	            drop(conn);
371	            tokio::time::sleep(Duration::from_millis(1_200)).await;
372	            let Ok(retry_conn) = connect_seed_holder(endpoint, subnet_name, responder_addr).await
373	            else {
374	                return Err(first_fail);
375	            };
376	            match tokio::time::timeout(
377	                CEREMONY_TIMEOUT,
378	                run_initiator(
379	                    &retry_conn,
380	                    endpoint.node_id(),
381	                    subnet_name,
382	                    code,
383	                    &mut trust,
384	                    &mut subnets,
385	                    &mut roster,
386	                    now_secs(),
387	                    1,
388	                    intro.as_ref(),
389	                ),
390	            )
391	            .await
392	            {
393	                Ok(Ok(o)) => {
394	                    eprintln!("(code had just rotated — accepted against the previous step)");
395	                    // The retry ran on a FRESH connection — seed from it.
396	                    // [impl->REQ-CONV-1]
397	                    record_ceremony_peer_addr(&retry_conn, &remote_hex(&retry_conn));
398	                    // [impl->REQ-MESH-2]
399	                    stamp_roster_responder(&mut roster, subnet_name, &retry_conn);
400	                    o
401	                }
402	                _ => return Err(first_fail),
403	            }
404	        }
405	        Err(e) => return Err(classify_wire_error(e)),
406	    };
407	    trust
408	        .save()
409	        .map_err(|e| JoinFail::Other(format!("trust save: {e}")))?;
410	    subnets
411	        .save()
412	        .map_err(|e| JoinFail::Other(format!("seed save: {e}")))?;
413	    // Author our own roster self-entry for the now-joined subnet, then persist
414	    // the adopted roster (REQ-MESH-2). `subnets` now holds the joined subnet, so
415	    // refresh covers it.
416	    // [impl->REQ-MESH-2]
417	    refresh_self_roster(&mut roster, &subnets, &endpoint.node_id().to_hex());
418	    roster
419	        .save()
420	        .map_err(|e| JoinFail::Other(format!("roster save: {e}")))?;
421	    Ok(format!("{outcome:?}"))
422	}
423
424	/// Reach a seed-holder: dial `responder_addr` directly when given (the
425	/// static-`ip:port` rig seam), else sweep the rendezvous until the search
426	/// deadline — one sweep races the ±1 window's tokens (bounded probes), and a
427	/// sweep that straddles a rotation simply misses; the next one derives fresh
428	/// tokens. ~2.5 windows of trying before the NoSeedHolder verdict (M7
429	/// acceptance fix).
430	// [impl->REQ-SUBNET-2]
431	async fn connect_seed_holder(
432	    endpoint: &NetEndpoint,
433	    subnet_name: &str,
434	    responder_addr: Option<spt_net::net::endpoint::EndpointAddr>,
435	) -> Result<Connection, JoinFail> {
436	    match responder_addr {
437	        Some(addr) => endpoint
438	            .connect_with_alpn(addr, spt_net::net::pairing::wire::SPT_PAIR_ALPN)
439	            .await
440	            .map_err(|e| JoinFail::NoSeedHolder(e.to_string())),
441	        None => {
442	            const SEARCH_DEADLINE: Duration = Duration::from_secs(75);
443	            let start = std::time::Instant::now();
444	            loop {
445	                match dial_via_rendezvous(endpoint, subnet_name, time_step(now_secs())).await {
446	                    Ok(conn) => return Ok(conn),
447	                    Err(e) if start.elapsed() >= SEARCH_DEADLINE => {
448	                        return Err(JoinFail::NoSeedHolder(e.to_string()));
449	                    }
450	                    Err(_) => tokio::time::sleep(Duration::from_millis(500)).await,
451	                }
452	            }
453	        }
454	    }
455	}
456
457	/// Map the wire error onto the CLI-facing failure classes.
458	fn classify_wire_error(e: PairWireError) -> JoinFail {
459	    match e {
460	        PairWireError::Pairing(_) => JoinFail::WrongCode,
461	        PairWireError::Refused(RateLimited::Busy) => JoinFail::RateLimited(None),
462	        PairWireError::Refused(RateLimited::Backoff { retry_after_secs }) => {
463	            JoinFail::RateLimited(Some(retry_after_secs))
464	        }
465	        PairWireError::UnknownSubnet(n) => JoinFail::UnknownSubnet(n),
466	        PairWireError::StepRejected => JoinFail::ClockSkew,
467	        PairWireError::Io(e) | PairWireError::Protocol(e) => JoinFail::Other(e),
468	    }
469	}
470
471	/// The handshake-proven remote identity of a ceremony connection, as the
472	/// lowercase-hex stable name the stores key on.
473	fn remote_hex(conn: &Connection) -> String {
474	    PublicKey::from_bytes(conn.remote_id().as_bytes())
475	        .expect("an iroh EndpointId is always a valid ed25519 public key")
476	        .to_hex()
477	}
478
479	/// Address-seeding ceremony writer (M8 decisions 14/20, REQ-CONV-1): persist
480	/// the peer's OBSERVED address from the live ceremony connection into
481	/// `peer-addrs.json`. Both ceremony legs call it on success — the connection
482	/// that just carried an authenticated ceremony is the best possible seed for
483	/// the pump's next dial. Best-effort and skip-on-pathless: seeding is an
484	/// optimization; the id-only discovery path is always behind it.
485	// [impl->REQ-CONV-1]
486	fn record_ceremony_peer_addr(conn: &Connection, peer_hex: &str) {
487	    let addr = spt_net::net::endpoint::conn_remote_addr(conn);
488	    if addr.is_empty() {
489	        return; // id-only adds nothing over discovery
490	    }
491	    let Ok(value) = serde_json::to_value(addr) else {
492	        return;
493	    };
494	    let path = spt_store::peeraddrs::peer_addrs_file();
495	    if let Err(e) = spt_store::peeraddrs::PeerAddrStore::record(&path, peer_hex, value) {
496	        eprintln!("PEER_ADDR_SEED_FAIL:{peer_hex}: {e}");
497	    }
498	}
499
500	/// Author this node's own roster self-entry for every subnet it holds
501	/// (REQ-MESH-2), so a joiner receiving the roster always learns at least us.
502	/// Identity only — the self address is captured from live member connections
503	/// (Mesh-D4), not here. Lease = the node's current label epoch (a peek, never a
504	/// consume, so the pairing path can't disturb registry ordering).
505	// [impl->REQ-MESH-2]
506	fn refresh_self_roster(roster: &mut RosterStore, subnets: &SubnetStore, self_hex: &str) {
507	    let label = spt_store::hostlabel::os_hostname().unwrap_or_default();
508	    let machine_id = crate::machineid::machine_id_hash().unwrap_or_default();
509	    let lease = spt_store::epoch::EpochSource::load().current();
510	    let now = now_secs().to_string();
511	    for s in &subnets.subnets {
512	        roster.upsert_self(&s.name, self_hex, &label, &machine_id, None, &now, lease);
513	    }
514	}
515
516	/// Record a freshly-paired member into the seed-holder's roster (REQ-MESH-2):
517	/// the joiner's self-intro identity + the address observed on the live ceremony
518	/// connection. Merged strictly-greater-wins like any peer entry (lease 0 — the
519	/// member re-authors at its real lease over a mesh connection, Mesh-D4).
520	// [impl->REQ-MESH-2]
521	fn record_roster_peer(
522	    roster: &mut RosterStore,
523	    subnet: &str,
524	    peer_hex: &str,
525	    intro: Option<&spt_net::net::pairing::wire::NodeIntro>,
526	    conn: &Connection,
527	) {
528	    let (label, machine_id) = match intro {
529	        Some(i) => (i.label.clone(), i.machine_id.clone()),
530	        None => (String::new(), String::new()),
531	    };
532	    roster.merge_entry(RosterEntry {
533	        pubkey_hex: peer_hex.to_string(),
534	        subnet: subnet.to_string(),
535	        label,
536	        machine_id,
537	        address: conn_addr_json(conn),
538	        last_seen: now_secs().to_string(),
539	        lease_epoch: 0,
540	    });
541	}
542
543	/// Stamp the seed-holder's observed address onto its (already-adopted) roster
544	/// entry on the joiner side (REQ-MESH-2) — the joiner learned the responder's
545	/// identity from the transferred roster, but not its address (a self-entry
546	/// carries none); the live ceremony connection supplies it.
547	// [impl->REQ-MESH-2]
548	fn stamp_roster_responder(roster: &mut RosterStore, subnet: &str, conn: &Connection) {
549	    let peer_hex = remote_hex(conn);
550	    let Some(addr) = conn_addr_json(conn) else {
551	        return;
552	    };
553	    if let Some(existing) = roster
554	        .members
555	        .iter_mut()
556	        .find(|m| m.subnet == subnet && m.pubkey_hex == peer_hex)
557	    {
558	        existing.address = Some(addr);
559	    } else {
560	        roster.merge_entry(RosterEntry {
561	            pubkey_hex: peer_hex,
562	            subnet: subnet.to_string(),
563	            label: String::new(),
564	            machine_id: String::new(),
565	            address: Some(addr),
566	            last_seen: now_secs().to_string(),
567	            lease_epoch: 0,
568	        });
569	    }
570	}
571
572	/// The connection's observed remote address as opaque JSON (the roster's
573	/// address field, same posture as `peer-addrs.json`). `None` if the address is
574	/// pathless or won't serialize.
575	fn conn_addr_json(conn: &Connection) -> Option<serde_json::Value> {
576	    let addr = spt_net::net::endpoint::conn_remote_addr(conn);
577	    if addr.is_empty() {
578	        return None;
579	    }
580	    serde_json::to_value(addr).ok()
581	}
582
583	/// Unix seconds — the ceremony clock (TOTP steps + limiter time). This is
584	/// the NTP-corrected clock (M8 decision 18, REQ-PAIR-8): the system clock
585	/// plus a lazily-queried in-process offset, so a node whose OS clock is
586	/// minutes off (enlyzeam) still derives the steps its peers expect — at the
587	/// announced-step check, the responder's window, AND the meet-rotation token
588	/// derivation (all three read this). NTP unreachable ⇒ plain system clock
589	/// (offline LAN pairing unaffected); the OS clock is never written.
590	// [impl->REQ-PAIR-8]
591	fn now_secs() -> u64 {
592	    spt_net::net::pairing::ntp::ceremony_now_secs()
593	}
594
595	#[cfg(test)]
596	mod tests {
597	    use super::*;
598
599	    // [unit->REQ-SUBNET-2] every wire-error shape lands in the CLI failure
600	    // class the guided join UX switches on — wrong code re-prompts, rate
601	    // limits surface honestly (with the retry window), and plumbing noise
602	    // stays distinguishable from a user-fixable problem.
603	    #[test]
604	    fn wire_errors_classify_for_the_guided_ux() {
605	        use spt_net::net::pairing::spake::PairError;
606	        assert_eq!(
607	            classify_wire_error(PairWireError::Pairing(PairError::Confirm)),
608	            JoinFail::WrongCode
609	        );
610	        assert_eq!(
611	            classify_wire_error(PairWireError::Refused(RateLimited::Busy)),
612	            JoinFail::RateLimited(None)
613	        );
614	        assert_eq!(
615	            classify_wire_error(PairWireError::Refused(RateLimited::Backoff {
616	                retry_after_secs: 8
617	            })),
618	            JoinFail::RateLimited(Some(8))
619	        );
620	        assert_eq!(
621	            classify_wire_error(PairWireError::UnknownSubnet("home".into())),
622	            JoinFail::UnknownSubnet("home".into())
623	        );
624	        assert_eq!(
625	            classify_wire_error(PairWireError::StepRejected),
626	            JoinFail::ClockSkew
627	        );
628	        assert!(matches!(
629	            classify_wire_error(PairWireError::Io("eof".into())),
630	            JoinFail::Other(_)
631	        ));
632	    }
633	}
634

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

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

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

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

/// How long the responder waits for the joiner to close the connection
/// after the ceremony's final frames — the delivery grace for Done/Seed
/// (or an abort). Generous for a WAN joiner; a vanished peer costs at most
/// this before the conn is released.
const CLOSE_GRACE: Duration = Duration::from_secs(10);

/// Serve one inbound pairing ceremony (the always-on responder's per-
/// connection handler). Stores are loaded fresh per ceremony — a subnet
/// created or joined a second ago is immediately pairable, and a trust edit
/// takes effect without a daemon restart (the same reload-per-use stance as
/// the dispatcher gates).
///
/// Concurrency: ONE ceremony at a time per daemon, enforced at the door —
/// a second connection finds the limiter lock held and is closed immediately
/// (the joiner sees the stream end and retries; same UX as the limiter's
/// `Busy` abort, shed before any frame is read).
// [impl->REQ-SUBNET-2]
pub async fn respond(
    conn: Connection,
    local_pub: PublicKey,
    rate: Arc<tokio::sync::Mutex<PairingRateLimiter>>,
) {
    let Ok(mut rate) = rate.try_lock() else {
        conn.close(1u32.into(), b"pairing busy");
        return;
    };
    // Serve-state filter (M8 decision 6, REQ-SUBNET-5): a detached subnet
    // is not served — the responder sees a store WITHOUT it, so a joiner
    // gets the same answer as for a subnet this node does not hold. The
    // membership record on disk is untouched.
    // [impl->REQ-SUBNET-5]
    let mut subnets = SubnetStore::load();
    spt_store::attachment::AttachmentStore::load().filter_serving(&mut subnets);
    let mut trust = TrustStore::load();
    // Roster (REQ-MESH-2): refresh this seed-holder's own entry for every
    // served subnet so the joiner always learns at least us, then hand it the
    // whole current roster in the Seed frame.
    let mut roster = RosterStore::load();
    refresh_self_roster(&mut roster, &subnets, &local_pub.to_hex());
    match tokio::time::timeout(
        CEREMONY_TIMEOUT,
        run_responder(
            &conn,
            local_pub,
            &subnets,
            &roster,
            &mut rate,
            &mut trust,
            now_secs(),
        ),
    )
    .await
    {
        Ok(Ok(outcome)) => {
            // Address seeding, responder side (M8 decisions 14/20,
            // REQ-CONV-1): the ceremony held a live authenticated
            // connection — persist the joiner's observed address so the
            // pump's post-join first sync dials it directly instead of
            // paying the id-only discovery cold start.
            // [impl->REQ-CONV-1]
            record_ceremony_peer_addr(&conn, &outcome.joiner_hex);
            // Re-pair trust overwrite (M8 decision 13, REQ-SUBNET-7): a
            // COMPLETED ceremony presenting the same label + machine id as
            // an existing row means this MACHINE re-paired under a fresh
            // key — the superseded identity's trust + registry rows go.
            // Fires only here, post-confirm (a gossiped claim never evicts).
            // [impl->REQ-SUBNET-7]
            if let Some(intro) = &outcome.intro {
                crate::registryhost::repair_evict_superseded(
                    &mut trust,
                    &outcome.subnet,
                    &outcome.joiner_hex,
                    intro,
                    &spt_store::perch::identity_dir().join("registry"),
                );
            }
            // Grow the seed-holder's roster by the new member (REQ-MESH-2): the
            // joiner's identity (its self-intro) + the address observed on the
            // live ceremony connection. A completed re-pair of a tombstoned key
            // clears the tombstone (decision 10) — the deliberate elevated act.
            // [impl->REQ-MESH-2]
            roster.clear_tombstone(&outcome.subnet, &outcome.joiner_hex);
            record_roster_peer(
                &mut roster,
                &outcome.subnet,
                &outcome.joiner_hex,
                outcome.intro.as_ref(),
                &conn,
            );
            match trust.save() {
                Ok(()) => eprintln!("PAIRED: joiner pinned ({:?})", outcome.trust),
                Err(e) => eprintln!("PAIRING_TRUST_SAVE_FAIL: {e}"),
            }
            if let Err(e) = roster.save() {
                eprintln!("PAIRING_ROSTER_SAVE_FAIL: {e}");
            }
        }
        // A failed ceremony is normal traffic (wrong code, impostor probe,
        // vanished peer) — logged, never fatal; the limiter charged it.
        Ok(Err(e)) => eprintln!("PAIRING_ATTEMPT_FAIL: {e}"),
        Err(_) => eprintln!("PAIRING_TIMEOUT: ceremony exceeded {CEREMONY_TIMEOUT_SECS}s"),
    }
    // Release the ceremony slot before the close-grace wait — the next
    // joiner must not be shed at the door for the lame-duck duration of
    // this connection's teardown.
    drop(rate);
    // Let the JOINER close first (the `meet::serve_once` discipline, bounded):
    // `SendStream::finish()` only queues the final Done/Seed (or abort)
    // frames — dropping the connection here sends an immediate QUIC CLOSE
    // that discards unacked stream data, and the joiner loses the race and
    // reads "connection lost" instead of its seed (the exact failure the
    // first twohost run of the product-surface rung hit). The joiner drops
    // its side once it has read the final frame; only then (or after the
    // grace bound, for a vanished peer) does this side release the conn.
    let _ = tokio::time::timeout(CLOSE_GRACE, conn.closed()).await;
}

/// The meet-rotation loop: for every member subnet, keep a rendezvous meet
/// listener bound under the (name, current-step)-derived identity, answering
/// joiner probes with `real_addr` — the responder's actual pairing-endpoint
/// address. Re-binds at each TOTP step boundary (window rotation = re-bind,
/// `pairing::meet` docs); re-reads the subnet store per window so a freshly
/// created/joined subnet starts meeting within one step.
///
/// Bind failures are soft (logged, that subnet skipped this window): the
/// daemon must keep running net-less-for-pairing rather than die. A
/// [`BindScope::Port`] scope is mapped to [`BindScope::All`] — the fixed
/// port belongs to the main endpoint; a second bind on it would always fail.
// [impl->REQ-SUBNET-2]
pub fn spawn_meet_rotation(
    handle: &tokio::runtime::Handle,
    endpoint: Arc<NetEndpoint>,
    relay: RelayPolicy,
    discovery: LocalDiscovery,
    scope: BindScope,
) {
    let scope = match scope {
        BindScope::Port(_) => BindScope::All,
        s => s,
    };
    handle.spawn(async move {
        // The PREVIOUS window's listeners, kept serving one extra period
        // (M7 acceptance fix): published records outlive listeners by the
        // DNS TTL, so a joiner resolving the step-1 token must find a live
        // listener there, not a ghost. Overlap = every published id is
        // dialable for its full discoverable life.
        let mut prev_tasks: Vec<tokio::task::JoinHandle<()>> = Vec::new();
        loop {
            let step = time_step(now_secs());
            // Serve-state filter (REQ-SUBNET-5): a detached subnet's meet
            // listener does not rotate — a joiner cannot rendezvous with a
            // membership this node deliberately is not serving. Re-read per
            // window like the subnet store itself.
            let attachment = spt_store::attachment::AttachmentStore::load();
            let names: Vec<String> = SubnetStore::load()
                .subnets
                .iter()
                .map(|s| s.name.clone())
                .filter(|n| !attachment.is_detached(n))
                .collect();
            // One listener task per subnet for this window's lifetime.
            let mut window_tasks = Vec::new();
            for name in names {
                let relay = relay.clone();
                let real_addr = endpoint.addr();
                window_tasks.push(tokio::spawn(async move {
                    let listener = match bind_rendezvous(&name, step, relay, discovery, scope).await
                    {
                        Ok(l) => l,
                        Err(e) => {
                            eprintln!("PAIR_MEET_BIND_FAIL:{name}: {e}");
                            return;
                        }
                    };
                    // Window diagnostics (live-debugging the meet path needs
                    // the derived id + what this listener actually advertises
                    // — an undiscoverable listener is indistinguishable from
                    // a dead one without this line).
                    eprintln!(
                        "PAIR_MEET_UP:{name} step={step} id={} addr={:?}",
                        listener.endpoint().node_id_hex(),
                        listener.endpoint().addr()
                    );
                    // Serve probes until the window task is aborted at the
                    // step boundary; each failure only ends one exchange.
                    loop {
                        match listener.serve_once(&real_addr).await {
                            Ok(()) => eprintln!("PAIR_MEET_ANSWERED:{name} step={step}"),
                            Err(e) => {
                                eprintln!("PAIR_MEET_SERVE_ERR:{name}: {e}");
                                // Endpoint closed or a malformed probe — brief
                                // pause so a tight error can't spin the loop.
                                tokio::time::sleep(Duration::from_millis(200)).await;
                            }
                        }
                    }
                }));
            }
            // Sleep to the next step boundary, then rotate: the window
            // BEFORE last dies (its published record has aged out of the
            // ±1 acceptance anyway); the window just ended keeps serving
            // one more period as `prev_tasks` (overlap, above).
            let now = now_secs();
            let next = (time_step(now) + 1) * PERIOD_SECS;
            tokio::time::sleep(Duration::from_secs(next.saturating_sub(now).max(1))).await;
            for t in prev_tasks.drain(..) {
                t.abort();
            }
            prev_tasks = window_tasks;
        }
    });
}

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

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

/// The joiner leg (M7 plan decision 10): find a seed-holder via the LAN+relay
/// rendezvous (or dial `responder_addr` directly — the static-`ip:port` rig
/// seam, mirroring [`meet_at`](spt_net::net::pairing::meet::meet_at)'s
/// explicit-address form), run the unchanged initiator ceremony against its
/// real endpoint, and persist the results (peer trust-pinned; the
/// transferred seed makes this node a full member / future seed-holder).
/// One attempt — the CLI owns the re-prompt loop.
// [impl->REQ-SUBNET-2]
pub async fn join_ceremony(
    endpoint: &NetEndpoint,
    subnet_name: &str,
    code: &str,
    responder_addr: Option<spt_net::net::endpoint::EndpointAddr>,
) -> Result<String, JoinFail> {
    let mut trust = TrustStore::load();
    let mut subnets = SubnetStore::load();
    // Roster (REQ-MESH-2): run_initiator merges the seed-holder's transferred
    // roster into this store; we add our own self-entry + the seed-holder's
    // address after a successful ceremony.
    let mut roster = RosterStore::load();
    // The joiner's self-intro (M8 decision 13, REQ-SUBNET-7): label +
    // hashed machine id — what lets the seed-holder recognize this MACHINE
    // re-pairing under a fresh key and evict the superseded identity.
    // Best-effort: a label-less or id-less box just sends none (no
    // overwrite; manual prune covers it).
    // [impl->REQ-SUBNET-7]
    let intro = match (
        spt_store::hostlabel::os_hostname(),
        crate::machineid::machine_id_hash(),
    ) {
        (Some(label), Some(machine_id)) => {
            Some(spt_net::net::pairing::wire::NodeIntro { label, machine_id })
        }
        _ => None,
    };
    let conn = connect_seed_holder(endpoint, subnet_name, responder_addr.clone()).await?;
    let attempt = tokio::time::timeout(
        CEREMONY_TIMEOUT,
        run_initiator(
            &conn,
            endpoint.node_id(),
            subnet_name,
            code,
            &mut trust,
            &mut subnets,
            &mut roster,
            now_secs(),
            0,
            intro.as_ref(),
        ),
    )
    .await
    .map_err(|_| JoinFail::Other(format!("ceremony exceeded {CEREMONY_TIMEOUT_SECS}s")))?;
    let outcome = match attempt {
        Ok(o) => {
            // Address seeding, joiner side (M8 decisions 14/20, REQ-CONV-1):
            // persist the seed-holder's observed address from the live
            // ceremony connection — the post-join first sync dials it
            // directly instead of re-finding the peer from scratch.
            // [impl->REQ-CONV-1]
            record_ceremony_peer_addr(&conn, &remote_hex(&conn));
            // [impl->REQ-MESH-2]
            stamp_roster_responder(&mut roster, subnet_name, &conn);
            o
        }
        // The LATE-CODE retry (field report 2026-06-06): the joiner holds no
        // seed, so a code read just before a rotation and entered just after
        // binds to the wrong step and reads as "wrong code" — before
        // bouncing that back at the user, retry the SAME code announced one
        // step back (the responder's ±1 window exists for exactly this
        // tolerance). The responder runs one ceremony per connection, so the
        // retry redials a FRESH one, after the limiter's first-failure
        // backoff (1s) has lapsed. Any retry failure surfaces the FIRST
        // failure's class — the retry was a guess on the user's behalf, not
        // a second user attempt.
        Err(PairWireError::Pairing(first)) => {
            let first_fail = classify_wire_error(PairWireError::Pairing(first));
            drop(conn);
            tokio::time::sleep(Duration::from_millis(1_200)).await;
            let Ok(retry_conn) = connect_seed_holder(endpoint, subnet_name, responder_addr).await
            else {
                return Err(first_fail);
            };
            match tokio::time::timeout(
                CEREMONY_TIMEOUT,
                run_initiator(
                    &retry_conn,
                    endpoint.node_id(),
                    subnet_name,
                    code,
                    &mut trust,
                    &mut subnets,
                    &mut roster,
                    now_secs(),
                    1,
                    intro.as_ref(),
                ),
            )
            .await
            {
                Ok(Ok(o)) => {
                    eprintln!("(code had just rotated — accepted against the previous step)");
                    // The retry ran on a FRESH connection — seed from it.
                    // [impl->REQ-CONV-1]
                    record_ceremony_peer_addr(&retry_conn, &remote_hex(&retry_conn));
                    // [impl->REQ-MESH-2]
                    stamp_roster_responder(&mut roster, subnet_name, &retry_conn);
                    o
                }
                _ => return Err(first_fail),
            }
        }
        Err(e) => return Err(classify_wire_error(e)),
    };
    trust
        .save()
        .map_err(|e| JoinFail::Other(format!("trust save: {e}")))?;
    subnets
        .save()
        .map_err(|e| JoinFail::Other(format!("seed save: {e}")))?;
    // Author our own roster self-entry for the now-joined subnet, then persist
    // the adopted roster (REQ-MESH-2). `subnets` now holds the joined subnet, so
    // refresh covers it.
    // [impl->REQ-MESH-2]
    refresh_self_roster(&mut roster, &subnets, &endpoint.node_id().to_hex());
    roster
        .save()
        .map_err(|e| JoinFail::Other(format!("roster save: {e}")))?;
    Ok(format!("{outcome:?}"))
}

/// Reach a seed-holder: dial `responder_addr` directly when given (the
/// static-`ip:port` rig seam), else sweep the rendezvous until the search
/// deadline — one sweep races the ±1 window's tokens (bounded probes), and a
/// sweep that straddles a rotation simply misses; the next one derives fresh
/// tokens. ~2.5 windows of trying before the NoSeedHolder verdict (M7
/// acceptance fix).
// [impl->REQ-SUBNET-2]
async fn connect_seed_holder(
    endpoint: &NetEndpoint,
    subnet_name: &str,
    responder_addr: Option<spt_net::net::endpoint::EndpointAddr>,
) -> Result<Connection, JoinFail> {
    match responder_addr {
        Some(addr) => endpoint
            .connect_with_alpn(addr, spt_net::net::pairing::wire::SPT_PAIR_ALPN)
            .await
            .map_err(|e| JoinFail::NoSeedHolder(e.to_string())),
        None => {
            const SEARCH_DEADLINE: Duration = Duration::from_secs(75);
            let start = std::time::Instant::now();
            loop {
                match dial_via_rendezvous(endpoint, subnet_name, time_step(now_secs())).await {
                    Ok(conn) => return Ok(conn),
                    Err(e) if start.elapsed() >= SEARCH_DEADLINE => {
                        return Err(JoinFail::NoSeedHolder(e.to_string()));
                    }
                    Err(_) => tokio::time::sleep(Duration::from_millis(500)).await,
                }
            }
        }
    }
}

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

/// The handshake-proven remote identity of a ceremony connection, as the
/// lowercase-hex stable name the stores key on.
fn remote_hex(conn: &Connection) -> String {
    PublicKey::from_bytes(conn.remote_id().as_bytes())
        .expect("an iroh EndpointId is always a valid ed25519 public key")
        .to_hex()
}

/// Address-seeding ceremony writer (M8 decisions 14/20, REQ-CONV-1): persist
/// the peer's OBSERVED address from the live ceremony connection into
/// `peer-addrs.json`. Both ceremony legs call it on success — the connection
/// that just carried an authenticated ceremony is the best possible seed for
/// the pump's next dial. Best-effort and skip-on-pathless: seeding is an
/// optimization; the id-only discovery path is always behind it.
// [impl->REQ-CONV-1]
fn record_ceremony_peer_addr(conn: &Connection, peer_hex: &str) {
    let addr = spt_net::net::endpoint::conn_remote_addr(conn);
    if addr.is_empty() {
        return; // id-only adds nothing over discovery
    }
    let Ok(value) = serde_json::to_value(addr) else {
        return;
    };
    let path = spt_store::peeraddrs::peer_addrs_file();
    if let Err(e) = spt_store::peeraddrs::PeerAddrStore::record(&path, peer_hex, value) {
        eprintln!("PEER_ADDR_SEED_FAIL:{peer_hex}: {e}");
    }
}

/// Author this node's own roster self-entry for every subnet it holds
/// (REQ-MESH-2), so a joiner receiving the roster always learns at least us.
/// Identity only — the self address is captured from live member connections
/// (Mesh-D4), not here. Lease = the node's current label epoch (a peek, never a
/// consume, so the pairing path can't disturb registry ordering).
// [impl->REQ-MESH-2]
fn refresh_self_roster(roster: &mut RosterStore, subnets: &SubnetStore, self_hex: &str) {
    let label = spt_store::hostlabel::os_hostname().unwrap_or_default();
    let machine_id = crate::machineid::machine_id_hash().unwrap_or_default();
    let lease = spt_store::epoch::EpochSource::load().current();
    let now = now_secs().to_string();
    for s in &subnets.subnets {
        roster.upsert_self(&s.name, self_hex, &label, &machine_id, None, &now, lease);
    }
}

/// Record a freshly-paired member into the seed-holder's roster (REQ-MESH-2):
/// the joiner's self-intro identity + the address observed on the live ceremony
/// connection. Merged strictly-greater-wins like any peer entry (lease 0 — the
/// member re-authors at its real lease over a mesh connection, Mesh-D4).
// [impl->REQ-MESH-2]
fn record_roster_peer(
    roster: &mut RosterStore,
    subnet: &str,
    peer_hex: &str,
    intro: Option<&spt_net::net::pairing::wire::NodeIntro>,
    conn: &Connection,
) {
    let (label, machine_id) = match intro {
        Some(i) => (i.label.clone(), i.machine_id.clone()),
        None => (String::new(), String::new()),
    };
    roster.merge_entry(RosterEntry {
        pubkey_hex: peer_hex.to_string(),
        subnet: subnet.to_string(),
        label,
        machine_id,
        address: conn_addr_json(conn),
        last_seen: now_secs().to_string(),
        lease_epoch: 0,
    });
}

/// Stamp the seed-holder's observed address onto its (already-adopted) roster
/// entry on the joiner side (REQ-MESH-2) — the joiner learned the responder's
/// identity from the transferred roster, but not its address (a self-entry
/// carries none); the live ceremony connection supplies it.
// [impl->REQ-MESH-2]
fn stamp_roster_responder(roster: &mut RosterStore, subnet: &str, conn: &Connection) {
    let peer_hex = remote_hex(conn);
    let Some(addr) = conn_addr_json(conn) else {
        return;
    };
    if let Some(existing) = roster
        .members
        .iter_mut()
        .find(|m| m.subnet == subnet && m.pubkey_hex == peer_hex)
    {
        existing.address = Some(addr);
    } else {
        roster.merge_entry(RosterEntry {
            pubkey_hex: peer_hex,
            subnet: subnet.to_string(),
            label: String::new(),
            machine_id: String::new(),
            address: Some(addr),
            last_seen: now_secs().to_string(),
            lease_epoch: 0,
        });
    }
}

/// The connection's observed remote address as opaque JSON (the roster's
/// address field, same posture as `peer-addrs.json`). `None` if the address is
/// pathless or won't serialize.
fn conn_addr_json(conn: &Connection) -> Option<serde_json::Value> {
    let addr = spt_net::net::endpoint::conn_remote_addr(conn);
    if addr.is_empty() {
        return None;
    }
    serde_json::to_value(addr).ok()
}

/// Unix seconds — the ceremony clock (TOTP steps + limiter time). This is
/// the NTP-corrected clock (M8 decision 18, REQ-PAIR-8): the system clock
/// plus a lazily-queried in-process offset, so a node whose OS clock is
/// minutes off (enlyzeam) still derives the steps its peers expect — at the
/// announced-step check, the responder's window, AND the meet-rotation token
/// derivation (all three read this). NTP unreachable ⇒ plain system clock
/// (offline LAN pairing unaffected); the OS clock is never written.
// [impl->REQ-PAIR-8]
fn now_secs() -> u64 {
    spt_net::net::pairing::ntp::ceremony_now_secs()
}

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

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


Psyche brief confirms D5b plan + the false-green lesson (don't trust grep exit on cargo; read raw output). Reading remaining touch sites.