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

//! 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::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();
    match tokio::time::timeout(
        CEREMONY_TIMEOUT,
        run_responder(
            &conn,
            local_pub,
            &subnets,
            &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"),
                );
            }
            match trust.save() {
                Ok(()) => eprintln!("PAIRED: joiner pinned ({:?})", outcome.trust),
                Err(e) => eprintln!("PAIRING_TRUST_SAVE_FAIL: {e}"),
            }
        }
        // A failed ceremony is normal traffic (wrong code, impostor probe,
        // vanished peer) — logged, never fatal; the limiter charged it.
        Ok(Err(e)) => eprintln!("PAIRING_ATTEMPT_FAIL: {e}"),
        Err(_) => eprintln!("PAIRING_TIMEOUT: ceremony exceeded {CEREMONY_TIMEOUT_SECS}s"),
    }
    // 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();
    // 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,
            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));
            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,
                    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));
                    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}")))?;
    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}");
    }
}

/// 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(_)
        ));
    }
}


<task-notification>
<task-id>a4c974d544b82ff92</task-id>
<tool-use-id>toolu_01P6oStuiGbXXuCNPsESkwQt</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\afad552a-7216-48d3-ba81-3c8294f2a399\tasks\a4c974d544b82ff92.output</output-file>
<status>completed</status>
<summary>Agent "Map roster-relevant code" completed</summary>
<result>Perfect! Now I have all the information. Let me compile the comprehensive report:

---

## Precise Map of Existing Code for REQ-MESH-2: Roster Store + Tombstones + Frame::Seed Extension

### 1. **SubnetStore** — Subnet Record Storage
**File:** `./crates/spt-store/src/subnet.rs` (lines 1–435)

**Struct Definitions:**
- **`SubnetRecord`** (lines 68–90) — Single subnet membership
  - `name: String` — Human label (R-PAIR-4)
  - `seed_hex: String` — 20-byte TOTP seed, lowercase hex
  - `epoch: u64` — Seed generation (monotonic, bumped on rotate)
  - `user: Option<String>` — Cross-user seam (forward v1→multi-user)
  - `created: String` — Unix-seconds stamp
  - `hide_new_endpoints: bool` — ADR-0006 §6 policy (serde default OFF)

- **`SubnetStore`** (lines 102–235) — Collection of all subnets
  - `subnets: Vec<SubnetRecord>` — Multi-subnet membership

**Key Methods:**
- `load()` → `SubnetStore` (line 111) — From canonical `subnet_file()` (absent = empty, corrupt = empty)
- `load_from(path: &Path)` → `SubnetStore` (line 118) — Testable core
- `save()` → `Result<(), SubnetError>` (line 126) — Atomic write to canonical path
- `save_to(path: &Path)` → `Result<(), SubnetError>` (line 133) — Atomic + creates parent dir
- `find(name: &str)` → `Option<&SubnetRecord>` (line 143)
- `create_subnet(name: &str)` → `Result<&SubnetRecord, SubnetError>` (line 155) — Mint seed at epoch 1
- `add_joined(name, seed, epoch)` → `Result<&SubnetRecord, SubnetError>` (line 174) — Joiner side (pairing)
- `rotate_seed(name: &str)` → `Result<&SubnetRecord, SubnetError>` (line 201) — ADR-0005 #10 revocation
- `remove(name: &str)` → `bool` (line 230) — Leave subnet

**Storage:** `<spt_home>/identity/subnet.json` (via `subnet_file()` at `crate::perch::subnet_file`)

---

### 2. **Pairing Wire Frame::Seed** — Seed Transfer Protocol
**File:** `./crates/spt-net/src/net/pairing/wire.rs`

**Frame Enum Variant** (lines 521–524):
```rust
Seed {
    seed: [u8; TOTP_SEED_LEN],
    epoch: u64,
}
```

**Wire Format Constants:**
- `T_SEED: u8 = 7` (line 73) — Frame tag
- `TOTP_SEED_LEN: usize = 20` (from `spt-store::subnet`)

**Encoding** (lines 588–593):
```rust
fn enc_seed(seed: &[u8; TOTP_SEED_LEN], epoch: u64) -> Vec<u8> {
    let mut b = vec![T_SEED];
    b.extend_from_slice(seed);           // raw 20 bytes
    b.extend_from_slice(&epoch.to_be_bytes()); // u64 BE
    b
}
```

**Decoding** (lines 681–688):
```rust
T_SEED => {
    let seed: [u8; TOTP_SEED_LEN] = c.take(TOTP_SEED_LEN)?
        .try_into().expect("took TOTP_SEED_LEN");
    let epoch = c.u64()?;
    Frame::Seed { seed, epoch }
}
```

**Consumption (Joiner Side):**
- **Responder sends** (line 331): `write_frame(send, &enc_seed(&seed, p.epoch))`
- **Initiator receives & applies** (lines 440–449):
  ```rust
  match read_frame(&mut recv).await? {
      Frame::Seed { seed, epoch } => {
          match subnets.add_joined(subnet_name, seed, epoch) {
              Ok(_) | Err(SubnetError::Exists(_)) => {} // success or idempotent re-pair
              Err(e) => return Err(PairWireError::Protocol(format!("seed store: {e}"))),
          }
      }
      other => return Err(unexpected("Seed", other)),
  }
  ```

---

### 3. **Registry Store / Advertise** — Subnet-Wide Roster
**File:** `./crates/spt-net/src/net/registry.rs`

**Instance Structure** (lines 72–142):
```rust
pub struct Instance {
    pub node: String,                          // node-hex qualifier
    pub status: Status,                        // Active/Dormant/Suspended/Offline
    pub epoch: u64,                            // author node's monotonic lease
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub resources: Option<String>,             // D9-4 blurb
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub last_active_ms: Option<u64>,          // M5-D6a presence
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub shell_adapters: Vec<String>,          // M5-D8c broadcast
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub node_label: Option<String>,           // REQ-SUBNET-3 hostname
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub machine_id: Option<String>,           // REQ-SUBNET-7 hashed stable id
}
```

**NodeLabel** (lines 151–156) — Endpoint-less node naming
```rust
pub struct NodeLabel {
    pub label: String,                    // OS hostname
    pub epoch: u64,                       // lease epoch (strictly-greater)
}
```

**SubnetRegistry** (lines 161–170) — Per-subnet registry
```rust
pub struct SubnetRegistry {
    #[serde(default)]
    by_id: BTreeMap<String, Vec<Instance>>,
    #[serde(default)]
    node_labels: BTreeMap<String, NodeLabel>,
}
```

**MergeOutcome** (lines 175–182) — Lease decision
```rust
pub enum MergeOutcome {
    Inserted,  // first (endpoint_id, node) pair
    Updated,   // strictly-newer epoch
    Stale,     // equal/lower epoch → dropped
}
```

**Key Methods:**
- `merge_instance()` (impl line ~250+) — Strictly-greater epoch lease
- `merge_node_label(node, label, epoch)` → `MergeOutcome` (line 249+)
- `evict_nodes(dead_fn)` → `usize` (line 212) — Ghost-row cleanup
- `evict_node_labels(dead_fn)` → `usize` (line 239) — Explicit prune (REQ-SUBNET-6)

**Daemon Integration:**
**File:** `./crates/spt-daemon/src/registryhost.rs`

- **`RegistryHost`** (lines ~85–110) — Daemon per-subnet registry host
- `advertise_local(&self, owlery: &Path) → Vec<RegistryFeedRecord>` (line 321) — Rescan owlery, re-advertise
- `advertise_if_visible(…)` — Visibility gate (ADR-0006 §6)
- `Status` enum carries liveness (line 369+): `Active`, `Dormant`, `Suspended` (M5-D2)

---

### 4. **Atomic Write Pattern** — Canonical Store Example
**File:** `./crates/spt-store/src/atomic.rs` (lines 1–235)

**Function:** `atomic_write_string(path: &Path, body: &str) → io::Result<()>` (lines 49–51)
- Delegates to `atomic_write_bytes(path, body.as_bytes())`

**Core Pattern** (lines 56–70):
```rust
pub fn atomic_write_bytes(path: &Path, body: &[u8]) -> io::Result<()> {
    let tmp = path.with_file_name(format!(
        "{}.tmp",
        path.file_name().and_then(|s| s.to_str()).unwrap_or("epoch")
    ));
    fs::write(&tmp, body)?;
    match rename_with_retry(&tmp, path) {
        Ok(()) => Ok(()),
        Err(e) => {
            let _ = fs::remove_file(&tmp);
            Err(e)
        }
    }
}
```

**Retry Logic** (lines 75–110):
- `rename_with_retry(from, to)` — Max 10 attempts, linear backoff (2ms–50ms)
- `is_transient(error)` — Detects EBUSY (Windows 32) / `PermissionDenied` (POSIX)
- Non-transient errors (NotFound, etc.) fail immediately

**Corrupt-Degrades-Empty Pattern:**
- SubnetStore (line 120): `serde_json::from_str(&s).unwrap_or_default()` on read failure
- PeerAddrStore (line 54): Same — absent/corrupt → empty struct default

---

### 5. **machine_id** — REQ-SUBNET-7 Stable Identifier
**File:** `./crates/spt-daemon/src/machineid.rs` (lines 1–168)

**Function:** `machine_id_hash() → Option<String>` (lines 29–33)
- Returns **domain-separated SHA-256** (never raw id on wire)
- Prefix: `"spt-machine-id:"` (line 24)
- Sources:
  - Unix: `/etc/machine-id` or `/var/lib/dbus/machine-id` (lines 44–52)
  - Windows: `HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Cryptography\MachineGuid` (lines 59–104) via advapi32
  - Fallback: minted UUID persisted at `<identity_dir>/machine-id` (lines 108–130)

**Helper:** `hash_raw(raw: &str) → String` (lines 37–39)

**Node Pubkey + Label Types:**
- Pubkey: `spt_proto::identity::PublicKey` (32 bytes, stable node id)
- Label: `String` — hostname (REQ-SUBNET-3), carried in `Instance::node_label`

---

### 6. **REQ-CONV-1 peer-addrs.json** — The D4 Reconcile Target
**File:** `./crates/spt-store/src/peeraddrs.rs` (lines 1–162)

**Shape:** `PeerAddrStore`
```rust
pub struct PeerAddrStore {
    #[serde(default)]
    pub addrs: BTreeMap<String, serde_json::Value>,  // peer hex → EndpointAddr JSON
}
```

**Location:** `<spt_home>/identity/peer-addrs.json` (line 30)

**Key Methods:**
- `load()` → `PeerAddrStore` (line 46) — Absent = empty
- `put(peer_hex, addr) → bool` (line 82) — Returns true if changed
- `record(path, peer_hex, addr) → io::Result<()>` (line 95) — Load→put→save, skips noop writes

**Corrupt Handling:** `unwrap_or_default()` on parse error (line 55)

---

### 7. **Mesh Modules Layout** — Crate Structure + Net Gating
**Files:**
- `./crates/spt-net/src/net/mesh.rs` (lines 1–12) — Module root
- `./crates/spt-net/src/net/mesh/seedproof.rs` (lines 1–200+) — Mesh-D1 primitives

**Seedproof Core** (seedproof.rs):
- **`MembershipKey`** (lines 54–90) — `MK = HKDF-SHA256(ikm=seed, info=domain‖subnet_id‖epoch)`
  - `derive(seed, subnet_id, seed_epoch) → MembershipKey` (line 65)
  - Constants: `MK_LEN = 32`, `MK_DOMAIN = b"spt-core/mesh/mk/v1"` (lines 38, 46)

- **`SeedProofTranscript`** (lines 119–135) — Mutual proof context
  - `subnet_id, seed_epoch, dialer_pub, acceptor_pub, dialer_nonce, acceptor_nonce`
  - `tag(mk, prover) → [u8; PROOF_TAG_LEN]` (line 143) — HMAC-SHA-256 proof

- Constants:
  - `PROOF_TAG_LEN = 32`, `PROOF_DOMAIN = b"spt-core/mesh/seedproof/v1"` (lines 43, 49)
  - `NONCE_LEN = 32` (line 52)

**Roster Placeholder:** No explicit roster module yet; D1–D2 are crypto primitives only.

**Crate Layout:**
- `spt-proto` ← node pubkey (Identity, PublicKey)
- `spt-store` ← persistent stores (subnet, peer-addrs, registry)
- `spt-net` ← pairing, mesh, endpoint (behind `net` feature)
- `spt-daemon` ← registryhost, machineid, advertise_local

**Net Feature Gating** (./crates/spt-net/Cargo.toml lines 8–13):
```toml
[features]
default = ["net"]
net = ["dep:iroh", "dep:tokio", "dep:hmac", "dep:sha1", "dep:sha2", "dep:hkdf",
        "dep:data-encoding", "dep:spake2", "dep:subtle", "dep:zeroize", "dep:serde_json"]
```
- Mesh-D1 deps gated: `hkdf`, `sha2`, `subtle`, `zeroize`

---

### 8. **Exact Files You'll Most Likely Create/Edit for REQ-MESH-2**

**Create (New):**
1. **`./crates/spt-store/src/roster.rs`** (150–250 lines expected)
   - `RosterStore` struct + load/save (mirror SubnetStore pattern)
   - `RosterRecord` struct (node_hex, epoch, status?, tombstone_epoch?)
   - Atomic write via `atomic_write_string`
   - File: `<spt_home>/identity/roster-<subnet>.json` or `roster.json` (design choice)

2. **`./crates/spt-net/src/net/mesh/roster.rs`** (200–400 lines expected)
   - `MeshRoster` — in-memory subnet-wide roster (grows from seedproof)
   - `merge_member(node_hex, epoch, status)` — Lease semantics (strictly-greater epoch)
   - `evict_node(node_hex)` — Ghost decay
   - Tombstone logic: track deleted members (`deleted_at_epoch`, retention window)

**Modify:**
1. **`./crates/spt-net/src/net/pairing/wire.rs`**
   - Already has `Frame::Seed` — **no change needed** for basic D2f seed transfer
   - Future: extend frame for roster advertisement (D3)

2. **`./crates/spt-net/src/net/mesh.rs`**
   - Add `pub mod roster;` (line 13+)

3. **`./crates/spt-store/src/lib.rs`**
   - Export `pub mod roster;` (mirror existing exports for subnet, peeraddrs, etc.)

4. **`./crates/spt-daemon/src/registryhost.rs`** (future D3+)
   - Hook roster merges into `advertise_local` → seed holder broadcasts roster on re-advertisement

5. **Tests/Integration:**
   - `./crates/spt-daemon/tests/mesh.rs` — add D2e/D2f roster store round-trip tests

---

### Summary: Key Line Numbers & Signatures

| Entity | File | Lines | Signature |
|--------|------|-------|-----------|
| SubnetStore | spt-store/subnet.rs | 102–235 | `pub struct SubnetStore { subnets: Vec<SubnetRecord> }` |
| SubnetStore::load | spt-store/subnet.rs | 111–112 | `pub fn load() -> SubnetStore` |
| SubnetStore::save | spt-store/subnet.rs | 126–128 | `pub fn save(&self) -> Result<(), SubnetError>` |
| SubnetRecord | spt-store/subnet.rs | 68–90 | `{ name, seed_hex, epoch, user, created, hide_new_endpoints }` |
| Frame::Seed | spt-net/pairing/wire.rs | 521–524 | `Seed { seed: [u8; 20], epoch: u64 }` |
| enc_seed | spt-net/pairing/wire.rs | 588–593 | `fn enc_seed(seed, epoch) → Vec<u8>` |
| decode Frame::Seed | spt-net/pairing/wire.rs | 681–688 | Reads 20 bytes + u64 BE |
| atomic_write_string | spt-store/atomic.rs | 49–51 | `pub fn atomic_write_string(path, body) → io::Result<()>` |
| atomic_write_bytes | spt-store/atomic.rs | 56–70 | tmp + rename with retry |
| MembershipKey | spt-net/mesh/seedproof.rs | 54–90 | HKDF-derived per-(subnet, epoch) secret |
| SeedProofTranscript | spt-net/mesh/seedproof.rs | 119–135 | Mutual proof context (binds pubkeys, nonces, epoch) |
| Instance | spt-net/registry.rs | 72–142 | node, status, epoch, node_label, machine_id, … |
| SubnetRegistry | spt-net/registry.rs | 161–170 | `by_id: BTreeMap<endpoint_id, Vec<Instance>>` |
| MergeOutcome | spt-net/registry.rs | 175–182 | Inserted, Updated, Stale |
| NodeLabel | spt-net/registry.rs | 151–156 | label + epoch lease |
| machine_id_hash | spt-daemon/machineid.rs | 29–33 | SHA-256(domain + raw_id) |
| PeerAddrStore | spt-store/peeraddrs.rs | 38–42 | `BTreeMap<peer_hex, EndpointAddr JSON>` |
| PeerAddrStore::load | spt-store/peeraddrs.rs | 46–48 | Absent/corrupt = empty |

This map provides all the foundational structure for implementing a durable, tombstone-aware roster with pairing-driven seed dissemination.</result>
<usage><subagent_tokens>80827</subagent_tokens><tool_uses>29</tool_uses><duration_ms>129477</duration_ms></usage>
</task-notification>


Grep registry for the `lease_epoch` concept (the strictly-greater-wins lease).


No matches found


Prompt:
<task-notification>
<task-id>a4c974d544b82ff92</task-id>
<tool-use-id>toolu_01P6oStuiGbXXuCNPsESkwQt</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\afad552a-7216-48d3-ba81-3c8294f2a399\tasks\a4c974d544b82ff92.output</output-file>
<status>completed</status>
<summary>Agent "Map roster-relevant code" completed</summary>
<result>Perfect! Now I have all the information. Let me compile the comprehensive report:

---

## Precise Map of Existing Code for REQ-MESH-2: Roster Store + Tombstones + Frame::Seed Extension

### 1. **SubnetStore** — Subnet Record Storage
**File:** `./crates/spt-store/src/subnet.rs` (lines 1–435)

**Struct Definitions:**
- **`SubnetRecord`** (lines 68–90) — Single subnet membership
  - `name: String` — Human label (R-PAIR-4)
  - `seed_hex: String` — 20-byte TOTP seed, lowercase hex
  - `epoch: u64` — Seed generation (monotonic, bumped on rotate)
  - `user: Option<String>` — Cross-user seam (forward v1→multi-user)
  - `created: String` — Unix-seconds stamp
  - `hide_new_endpoints: bool` — ADR-0006 §6 policy (serde default OFF)

- **`SubnetStore`** (lines 102–235) — Collection of all subnets
  - `subnets: Vec<SubnetRecord>` — Multi-subnet membership

**Key Methods:**
- `load()` → `SubnetStore` (line 111) — From canonical `subnet_file()` (absent = empty, corrupt = empty)
- `load_from(path: &Path)` → `SubnetStore` (line 118) — Testable core
- `save()` → `Result<(), SubnetError>` (line 126) — Atomic write to canonical path
- `save_to(path: &Path)` → `Result<(), SubnetError>` (line 133) — Atomic + creates parent dir
- `find(name: &str)` → `Option<&SubnetRecord>` (line 143)
- `create_subnet(name: &str)` → `Result<&SubnetRecord, SubnetError>` (line 155) — Mint seed at epoch 1
- `add_joined(name, seed, epoch)` → `Result<&SubnetRecord, SubnetError>` (line 174) — Joiner side (pairing)
- `rotate_seed(name: &str)` → `Result<&SubnetRecord, SubnetError>` (line 201) — ADR-0005 #10 revocation
- `remove(name: &str)` → `bool` (line 230) — Leave subnet

**Storage:** `<spt_home>/identity/subnet.json` (via `subnet_file()` at `crate::perch::subnet_file`)

---

### 2. **Pairing Wire Frame::Seed** — Seed Transfer Protocol
**File:** `./crates/spt-net/src/net/pairing/wire.rs`

**Frame Enum Variant** (lines 521–524):
```rust
Seed {
    seed: [u8; TOTP_SEED_LEN],
    epoch: u64,
}
```

**Wire Format Constants:**
- `T_SEED: u8 = 7` (line 73) — Frame tag
- `TOTP_SEED_LEN: usize = 20` (from `spt-store::subnet`)

**Encoding** (lines 588–593):
```rust
fn enc_seed(seed: &[u8; TOTP_SEED_LEN], epoch: u64) -> Vec<u8> {
    let mut b = vec![T_SEED];
    b.extend_from_slice(seed);           // raw 20 bytes
    b.extend_from_slice(&epoch.to_be_bytes()); // u64 BE
    b
}
```

**Decoding** (lines 681–688):
```rust
T_SEED => {
    let seed: [u8; TOTP_SEED_LEN] = c.take(TOTP_SEED_LEN)?
        .try_into().expect("took TOTP_SEED_LEN");
    let epoch = c.u64()?;
    Frame::Seed { seed, epoch }
}
```

**Consumption (Joiner Side):**
- **Responder sends** (line 331): `write_frame(send, &enc_seed(&seed, p.epoch))`
- **Initiator receives & applies** (lines 440–449):
  ```rust
  match read_frame(&mut recv).await? {
      Frame::Seed { seed, epoch } => {
          match subnets.add_joined(subnet_name, seed, epoch) {
              Ok(_) | Err(SubnetError::Exists(_)) => {} // success or idempotent re-pair
              Err(e) => return Err(PairWireError::Protocol(format!("seed store: {e}"))),
          }
      }
      other => return Err(unexpected("Seed", other)),
  }
  ```

---

### 3. **Registry Store / Advertise** — Subnet-Wide Roster
**File:** `./crates/spt-net/src/net/registry.rs`

**Instance Structure** (lines 72–142):
```rust
pub struct Instance {
    pub node: String,                          // node-hex qualifier
    pub status: Status,                        // Active/Dormant/Suspended/Offline
    pub epoch: u64,                            // author node's monotonic lease
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub resources: Option<String>,             // D9-4 blurb
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub last_active_ms: Option<u64>,          // M5-D6a presence
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub shell_adapters: Vec<String>,          // M5-D8c broadcast
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub node_label: Option<String>,           // REQ-SUBNET-3 hostname
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub machine_id: Option<String>,           // REQ-SUBNET-7 hashed stable id
}
```

**NodeLabel** (lines 151–156) — Endpoint-less node naming
```rust
pub struct NodeLabel {
    pub label: String,                    // OS hostname
    pub epoch: u64,                       // lease epoch (strictly-greater)
}
```

**SubnetRegistry** (lines 161–170) — Per-subnet registry
```rust
pub struct SubnetRegistry {
    #[serde(default)]
    by_id: BTreeMap<String, Vec<Instance>>,
    #[serde(default)]
    node_labels: BTreeMap<String, NodeLabel>,
}
```

**MergeOutcome** (lines 175–182) — Lease decision
```rust
pub enum MergeOutcome {
    Inserted,  // first (endpoint_id, node) pair
    Updated,   // strictly-newer epoch
    Stale,     // equal/lower epoch → dropped
}
```

**Key Methods:**
- `merge_instance()` (impl line ~250+) — Strictly-greater epoch lease
- `merge_node_label(node, label, epoch)` → `MergeOutcome` (line 249+)
- `evict_nodes(dead_fn)` → `usize` (line 212) — Ghost-row cleanup
- `evict_node_labels(dead_fn)` → `usize` (line 239) — Explicit prune (REQ-SUBNET-6)

**Daemon Integration:**
**File:** `./crates/spt-daemon/src/registryhost.rs`

- **`RegistryHost`** (lines ~85–110) — Daemon per-subnet registry host
- `advertise_local(&self, owlery: &Path) → Vec<RegistryFeedRecord>` (line 321) — Rescan owlery, re-advertise
- `advertise_if_visible(…)` — Visibility gate (ADR-0006 §6)
- `Status` enum carries liveness (line 369+): `Active`, `Dormant`, `Suspended` (M5-D2)

---

### 4. **Atomic Write Pattern** — Canonical Store Example
**File:** `./crates/spt-store/src/atomic.rs` (lines 1–235)

**Function:** `atomic_write_string(path: &Path, body: &str) → io::Result<()>` (lines 49–51)
- Delegates to `atomic_write_bytes(path, body.as_bytes())`

**Core Pattern** (lines 56–70):
```rust
pub fn atomic_write_bytes(path: &Path, body: &[u8]) -> io::Result<()> {
    let tmp = path.with_file_name(format!(
        "{}.tmp",
        path.file_name().and_then(|s| s.to_str()).unwrap_or("epoch")
    ));
    fs::write(&tmp, body)?;
    match rename_with_retry(&tmp, path) {
        Ok(()) => Ok(()),
        Err(e) => {
            let _ = fs::remove_file(&tmp);
            Err(e)
        }
    }
}
```

**Retry Logic** (lines 75–110):
- `rename_with_retry(from, to)` — Max 10 attempts, linear backoff (2ms–50ms)
- `is_transient(error)` — Detects EBUSY (Windows 32) / `PermissionDenied` (POSIX)
- Non-transient errors (NotFound, etc.) fail immediately

**Corrupt-Degrades-Empty Pattern:**
- SubnetStore (line 120): `serde_json::from_str(&s).unwrap_or_default()` on read failure
- PeerAddrStore (line 54): Same — absent/corrupt → empty struct default

---

### 5. **machine_id** — REQ-SUBNET-7 Stable Identifier
**File:** `./crates/spt-daemon/src/machineid.rs` (lines 1–168)

**Function:** `machine_id_hash() → Option<String>` (lines 29–33)
- Returns **domain-separated SHA-256** (never raw id on wire)
- Prefix: `"spt-machine-id:"` (line 24)
- Sources:
  - Unix: `/etc/machine-id` or `/var/lib/dbus/machine-id` (lines 44–52)
  - Windows: `HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Cryptography\MachineGuid` (lines 59–104) via advapi32
  - Fallback: minted UUID persisted at `<identity_dir>/machine-id` (lines 108–130)

**Helper:** `hash_raw(raw: &str) → String` (lines 37–39)

**Node Pubkey + Label Types:**
- Pubkey: `spt_proto::identity::PublicKey` (32 bytes, stable node id)
- Label: `String` — hostname (REQ-SUBNET-3), carried in `Instance::node_label`

---

### 6. **REQ-CONV-1 peer-addrs.json** — The D4 Reconcile Target
**File:** `./crates/spt-store/src/peeraddrs.rs` (lines 1–162)

**Shape:** `PeerAddrStore`
```rust
pub struct PeerAddrStore {
    #[serde(default)]
    pub addrs: BTreeMap<String, serde_json::Value>,  // peer hex → EndpointAddr JSON
}
```

**Location:** `<spt_home>/identity/peer-addrs.json` (line 30)

**Key Methods:**
- `load()` → `PeerAddrStore` (line 46) — Absent = empty
- `put(peer_hex, addr) → bool` (line 82) — Returns true if changed
- `record(path, peer_hex, addr) → io::Result<()>` (line 95) — Load→put→save, skips noop writes

**Corrupt Handling:** `unwrap_or_default()` on parse error (line 55)

---

### 7. **Mesh Modules Layout** — Crate Structure + Net Gating
**Files:**
- `./crates/spt-net/src/net/mesh.rs` (lines 1–12) — Module root
- `./crates/spt-net/src/net/mesh/seedproof.rs` (lines 1–200+) — Mesh-D1 primitives

**Seedproof Core** (seedproof.rs):
- **`MembershipKey`** (lines 54–90) — `MK = HKDF-SHA256(ikm=seed, info=domain‖subnet_id‖epoch)`
  - `derive(seed, subnet_id, seed_epoch) → MembershipKey` (line 65)
  - Constants: `MK_LEN = 32`, `MK_DOMAIN = b"spt-core/mesh/mk/v1"` (lines 38, 46)

- **`SeedProofTranscript`** (lines 119–135) — Mutual proof context
  - `subnet_id, seed_epoch, dialer_pub, acceptor_pub, dialer_nonce, acceptor_nonce`
  - `tag(mk, prover) → [u8; PROOF_TAG_LEN]` (line 143) — HMAC-SHA-256 proof

- Constants:
  - `PROOF_TAG_LEN = 32`, `PROOF_DOMAIN = b"spt-core/mesh/seedproof/v1"` (lines 43, 49)
  - `NONCE_LEN = 32` (line 52)

**Roster Placeholder:** No explicit roster module yet; D1–D2 are crypto primitives only.

**Crate Layout:**
- `spt-proto` ← node pubkey (Identity, PublicKey)
- `spt-store` ← persistent stores (subnet, peer-addrs, registry)
- `spt-net` ← pairing, mesh, endpoint (behind `net` feature)
- `spt-daemon` ← registryhost, machineid, advertise_local

**Net Feature Gating** (./crates/spt-net/Cargo.toml lines 8–13):
```toml
[features]
default = ["net"]
net = ["dep:iroh", "dep:tokio", "dep:hmac", "dep:sha1", "dep:sha2", "dep:hkdf",
        "dep:data-encoding", "dep:spake2", "dep:subtle", "dep:zeroize", "dep:serde_json"]
```
- Mesh-D1 deps gated: `hkdf`, `sha2`, `subtle`, `zeroize`

---

### 8. **Exact Files You'll Most Likely Create/Edit for REQ-MESH-2**

**Create (New):**
1. **`./crates/spt-store/src/roster.rs`** (150–250 lines expected)
   - `RosterStore` struct + load/save (mirror SubnetStore pattern)
   - `RosterRecord` struct (node_hex, epoch, status?, tombstone_epoch?)
   - Atomic write via `atomic_write_string`
   - File: `<spt_home>/identity/roster-<subnet>.json` or `roster.json` (design choice)

2. **`./crates/spt-net/src/net/mesh/roster.rs`** (200–400 lines expected)
   - `MeshRoster` — in-memory subnet-wide roster (grows from seedproof)
   - `merge_member(node_hex, epoch, status)` — Lease semantics (strictly-greater epoch)
   - `evict_node(node_hex)` — Ghost decay
   - Tombstone logic: track deleted members (`deleted_at_epoch`, retention window)

**Modify:**
1. **`./crates/spt-net/src/net/pairing/wire.rs`**
   - Already has `Frame::Seed` — **no change needed** for basic D2f seed transfer
   - Future: extend frame for roster advertisement (D3)

2. **`./crates/spt-net/src/net/mesh.rs`**
   - Add `pub mod roster;` (line 13+)

3. **`./crates/spt-store/src/lib.rs`**
   - Export `pub mod roster;` (mirror existing exports for subnet, peeraddrs, etc.)

4. **`./crates/spt-daemon/src/registryhost.rs`** (future D3+)
   - Hook roster merges into `advertise_local` → seed holder broadcasts roster on re-advertisement

5. **Tests/Integration:**
   - `./crates/spt-daemon/tests/mesh.rs` — add D2e/D2f roster store round-trip tests

---

### Summary: Key Line Numbers & Signatures

| Entity | File | Lines | Signature |
|--------|------|-------|-----------|
| SubnetStore | spt-store/subnet.rs | 102–235 | `pub struct SubnetStore { subnets: Vec<SubnetRecord> }` |
| SubnetStore::load | spt-store/subnet.rs | 111–112 | `pub fn load() -> SubnetStore` |
| SubnetStore::save | spt-store/subnet.rs | 126–128 | `pub fn save(&self) -> Result<(), SubnetError>` |
| SubnetRecord | spt-store/subnet.rs | 68–90 | `{ name, seed_hex, epoch, user, created, hide_new_endpoints }` |
| Frame::Seed | spt-net/pairing/wire.rs | 521–524 | `Seed { seed: [u8; 20], epoch: u64 }` |
| enc_seed | spt-net/pairing/wire.rs | 588–593 | `fn enc_seed(seed, epoch) → Vec<u8>` |
| decode Frame::Seed | spt-net/pairing/wire.rs | 681–688 | Reads 20 bytes + u64 BE |
| atomic_write_string | spt-store/atomic.rs | 49–51 | `pub fn atomic_write_string(path, body) → io::Result<()>` |
| atomic_write_bytes | spt-store/atomic.rs | 56–70 | tmp + rename with retry |
| MembershipKey | spt-net/mesh/seedproof.rs | 54–90 | HKDF-derived per-(subnet, epoch) secret |
| SeedProofTranscript | spt-net/mesh/seedproof.rs | 119–135 | Mutual proof context (binds pubkeys, nonces, epoch) |
| Instance | spt-net/registry.rs | 72–142 | node, status, epoch, node_label, machine_id, … |
| SubnetRegistry | spt-net/registry.rs | 161–170 | `by_id: BTreeMap<endpoint_id, Vec<Instance>>` |
| MergeOutcome | spt-net/registry.rs | 175–182 | Inserted, Updated, Stale |
| NodeLabel | spt-net/registry.rs | 151–156 | label + epoch lease |
| machine_id_hash | spt-daemon/machineid.rs | 29–33 | SHA-256(domain + raw_id) |
| PeerAddrStore | spt-store/peeraddrs.rs | 38–42 | `BTreeMap<peer_hex, EndpointAddr JSON>` |
| PeerAddrStore::load | spt-store/peeraddrs.rs | 46–48 | Absent/corrupt = empty |

This map provides all the foundational structure for implementing a durable, tombstone-aware roster with pairing-driven seed dissemination.</result>
<usage><subagent_tokens>80827</subagent_tokens><tool_uses>29</tool_uses><duration_ms>129477</duration_ms></usage>
</task-notification>


Full picture assembled. Explorer confirms: registry lease lives in `spt-net/registry.rs` (`MergeOutcome` Inserted/Updated/Stale, strictly-greater epoch), machine_id in spt-daemon, address-as-opaque-JSON pattern in peeraddrs. The existing `spt/src/roster.rs` is unrelated perch-listing — no conflict.

Write the JIT plan.


Write

# Mesh-D3 — roster store + tombstones + pairing seed-transfer

> JIT plan for `SUBNET-MESH-PLAN.md` §Build phases · phase 3. **Builds the discovery directory.** A durable, pure union-merge grow-set (the member roster) with per-pubkey tombstones, plus the pairing-time transfer: `Frame::Seed` grows to carry the seed-holder's full current roster, and the joiner adopts it. **Activates REQ-MESH-2 (impl, unit).** No gate, no reach, no steady-state gossip — those are D5/D6/D4. D3 only stands up the store and seeds it at the ceremony.

## What a roster is (the model, grounded in the code)

Per-subnet membership directory. Each node **authors its own entry** (pubkey, label, machine_id, last-known address, last-seen) stamped with its **`lease_epoch`** — the same strictly-greater-wins lease the registry already runs for `NodeLabel`/`Instance.epoch` (`spt-net/src/net/registry.rs`, `MergeOutcome::{Inserted,Updated,Stale}`). Merge is a **union grow-set**: convergent, commutative, idempotent, forgery-inert (a fake entry names a pubkey that still can't seed-proof at the D2 gate). Removal can't be by omission (a grow-set re-learns it on the next merge) → it needs a **tombstone**.

**Where it lives:** `crates/spt-store/src/roster.rs` — a durable store mirroring `subnet.rs`/`trust.rs`/`peeraddrs.rs` verbatim (load/load_from/save/save_to, `atomic_write_string`, absent-or-corrupt-degrades-empty). The CRDT merge + tombstone logic is **pure data, no iroh** (address is an opaque `serde_json::Value`, the `peeraddrs.rs` trick) so it stays in spt-store with no new dep edge. NOT the unrelated `crates/spt/src/roster.rs` (that's perch-listing — name collision only, different crate).

## Part A — `RosterStore` (the bulk; pure + fully unit-tested)

```rust
pub struct RosterEntry {
    pub pubkey_hex: String,   // member identity — the merge key within a subnet
    pub subnet: String,       // which subnet's roster
    pub label: String,        // node label (OS hostname)
    pub machine_id: String,   // hashed stable machine id (REQ-SUBNET-7 anchor)
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub address: Option<serde_json::Value>,  // last-known dialable EndpointAddr, opaque (peeraddrs posture)
    pub last_seen: String,    // opaque epoch-seconds stamp
    pub lease_epoch: u64,     // node_label lease — strictly-greater-wins
}
pub struct Tombstone { pub pubkey_hex: String, pub subnet: String, pub stamp: String }
pub struct RosterStore { pub members: Vec<RosterEntry>, pub tombstones: Vec<Tombstone> }
```

Methods (each tagged `// [impl->REQ-MESH-2]`):
- `load()/load_from(path)/save()/save_to(path)` — verbatim `SubnetStore` shape; new path helper `roster_file()` in `perch.rs` → `identity/roster.json` (node-bound secret-adjacent, never synced — same class as subnet.json/peers.json).
- `MergeOutcome` (reuse the registry's `Inserted/Updated/Stale` naming) — `merge_entry(entry) -> MergeOutcome`: keyed `(subnet, pubkey_hex)`; **strictly-greater `lease_epoch` wins** (equal/lower ⇒ `Stale`, dropped); new key ⇒ `Inserted`. A tombstone does **not** block the merge writing the latest entry (convergence keeps the freshest facts) — it gates *visibility/admission*, below.
- `merge(&other)` — full union: every entry through `merge_entry`, every tombstone unioned (dedup by `(subnet, pubkey)`). Commutative + idempotent (the convergence test).
- `upsert_self(subnet, pubkey, label, machine_id, address, lease_epoch)` — author/refresh this node's own entry at its current lease (an `Updated`/`Inserted`; never `Stale` against itself — caller passes a fresh lease).
- `tombstone(subnet, pubkey, stamp)` — add a tombstone (idempotent). **Dominates** the entry: `is_member`/`members_in` exclude it; **suppresses reinsert**: a later `merge_entry` of that pubkey re-records facts but `is_member` stays false while the tombstone stands.
- `clear_tombstone(subnet, pubkey) -> bool` — **clear-on-repair**: remove the tombstone (the deliberate elevated re-pair act); the entry, if present, becomes a member again.
- `is_tombstoned(subnet, pubkey) -> bool` · `is_member(subnet, pubkey) -> bool` (= entry present ∧ ¬tombstoned — **the admission predicate D5's gate will read**) · `members_in(subnet) -> impl Iterator<&RosterEntry>` (visible members only) · `roster_for(subnet) -> (Vec<RosterEntry>, Vec<Tombstone>)` (the slice handed at pairing — members + tombstones for one subnet).

**Tombstone-convergence boundary (document in the module doc):** the tombstone is a grow-set element (propagates, dominates). `clear_tombstone` is a *local* elevated op — cross-node convergence of an *un*-tombstone is **not** D3's job: the compromised path re-pairs under a **fresh never-tombstoned key** (decision 10), so the old tombstone correctly stands; benign N-1 re-seed + timeboxed rotation propagation is **D7**. D3 tests clear-on-repair locally only.

## Part B — `Frame::Seed` grows the roster (additive wire, `spt-net/pairing/wire.rs`)

`Frame::Seed { seed, epoch }` → `Frame::Seed { seed, epoch, roster: Vec<RosterEntry>, tombstones: Vec<Tombstone> }`. Encode appends **after** the existing seed+epoch (the M8-decision-13 additive-trailing discipline already used by `Hello`'s `intro`): a u32 count then per-entry length-prefixed fields (pubkey, label, machine_id, address-as-JSON-bytes [empty = none], last_seen) + u64 lease_epoch; then a u32 tombstone count, each (pubkey, stamp). **Subnet is implicit** — the ceremony's `subnet_name`; wire entries omit it and `adopt` stamps it in. Decode reads the trailing block **only if `c.remaining() > 0`** after epoch ⇒ a legacy/short Seed frame (no roster) decodes as empty roster (the additive guarantee). Malformed (bad count / short field / non-UTF-8) ⇒ `Protocol` error, never a panic (`Cursor` already bounds-checks). New tag consts not needed (rides `T_SEED`).

## Part C — ceremony wiring (mirror the D2f seed-transfer seam exactly)

The seed transfer already proves the pattern: responder reads a store and sends a frame; joiner writes a store. Roster rides the same `Frame::Seed`.

- `run_responder(..)` gains a read-only `roster: &RosterStore` arg → on confirm-success, `roster.roster_for(subnet_name)` rides the Seed frame (the seed is already sent there, line 331).
- `run_initiator(..)` gains `roster: &mut RosterStore` → on receiving Seed, merge the received entries+tombstones (stamping `subnet = subnet_name`) into the store. Mirrors how it already takes `&mut SubnetStore`.
- `pairhost.rs`:
  - **responder leg** (`respond`): `RosterStore::load()`, pass `&roster` to `run_responder`; **post-success** author the joiner's entry from `outcome.intro` (label, machine_id) + `conn_remote_addr` + the joiner's lease (best-effort: missing intro ⇒ skip, like the address seed) and `upsert_self`, then `roster.save()`. (Seed-holder's directory grows by the new member — cheap, the facts are in hand.)
  - **joiner leg** (`join_ceremony`): `RosterStore::load()`, pass `&mut roster` to `run_initiator` (adopts the responder's roster); **post-success** `upsert_self` + record the responder's entry (peer pubkey + `conn_remote_addr`), then `roster.save()`.
- Reuse `now_secs()` for `last_seen`; reuse `os_hostname()`/`machine_id_hash()` already imported for the intro.

## Explicitly NOT D3 (carry-forward)

- **D4** — steady-state on-connect roster exchange/merge over **member** (seed-proof'd) connections; address refresh from live mesh conns; **reconcile with `peer-addrs.json`** (absorb-or-feed — decide there).
- **D5** — the gate reads `is_member` in place of `is_trusted`; hard cutover.
- **D6** — fan-out widen → MESH ON.
- **D7** — revoke CLI, timeboxed seed rotation, confidential seed push, re-seed grace, cross-node un-tombstone propagation.

## Tests

**unit — `spt-store/src/roster.rs`** (`// [unit->REQ-MESH-2]`):
- merge lease: strictly-greater `lease_epoch` updates; equal/lower ⇒ `Stale`, no clobber; new pubkey ⇒ `Inserted`.
- union convergence: `a.merge(b)` == `b.merge(a)`; idempotent (`merge` twice = once); persist-through-silence (an offline member's entry survives a merge that never mentions it, and a save/load round-trip).
- tombstone truth table: dominates (`is_member` false once tombstoned though entry present) · suppresses-reinsert (a fresh-lease `merge_entry` after tombstone keeps `is_member` false) · clear-on-repair (`clear_tombstone` ⇒ member again).
- serde additive: a legacy `roster.json` with no `tombstones` / no `address` loads; absent + corrupt file ⇒ empty store (no panic).
- `is_member` admission predicate (entry ∧ ¬tombstone) — the D5 seam.

**unit — `spt-net/pairing/wire.rs`** (`// [unit->REQ-MESH-2]`):
- extended `Seed` round-trips: with a multi-entry roster (incl. an entry carrying an address JSON, an entry without) + tombstones; with an **empty** roster; a **legacy short** Seed body (seed+epoch only) decodes to empty roster (additive); malformed trailing (bad count / short field) ⇒ `Protocol` error.
- a loopback responder↔initiator ceremony transfers the responder's roster to the joiner's store (extend the existing `#[cfg(test)]` pairing harness; tagged `unit` like the sibling ceremony tests).

## Done when

- `RosterStore` lands pure + unit-green; `Frame::Seed` carries the roster additively; the ceremony transfers + adopts it; `pairhost` loads/passes/saves on both legs.
- `cargo test -p spt-store roster` + `cargo test -p spt-net pairing::wire` + `cargo test -p spt-daemon pairhost` green; `--no-default-features` + workspace build + clippy clean.
- The existing pairing ceremony tests still pass (additive Seed frame = no regression for a peer that sends seed+epoch only).
- `traceable-reqs check` clean with **REQ-MESH-2 `required_stages = ["impl","unit"]`** (D3 activates both). Evidence on the store methods, the wire codec, the ceremony wiring, and the unit tests.
- The five `is_trusted` gates remain **untouched** (D5 owns the swap).
- Commit: `feat(mesh): roster store + tombstones + pairing seed-transfer (REQ-MESH-2) — Mesh-D3`.


File created successfully at: C:/Users/decid/Documents/projects/spt-core/MESH-D3-PLAN.md (file state is current in your context — no need to Read it back)

# Mesh-D3 — roster store + tombstones + pairing seed-transfer

> JIT plan for `SUBNET-MESH-PLAN.md` §Build phases · phase 3. **Builds the discovery directory.** A durable, pure union-merge grow-set (the member roster) with per-pubkey tombstones, plus the pairing-time transfer: `Frame::Seed` grows to carry the seed-holder's full current roster, and the joiner adopts it. **Activates REQ-MESH-2 (impl, unit).** No gate, no reach, no steady-state gossip — those are D5/D6/D4. D3 only stands up the store and seeds it at the ceremony.

## What a roster is (the model, grounded in the code)

Per-subnet membership directory. Each node **authors its own entry** (pubkey, label, machine_id, last-known address, last-seen) stamped with its **`lease_epoch`** — the same strictly-greater-wins lease the registry already runs for `NodeLabel`/`Instance.epoch` (`spt-net/src/net/registry.rs`, `MergeOutcome::{Inserted,Updated,Stale}`). Merge is a **union grow-set**: convergent, commutative, idempotent, forgery-inert (a fake entry names a pubkey that still can't seed-proof at the D2 gate). Removal can't be by omission (a grow-set re-learns it on the next merge) → it needs a **tombstone**.

**Where it lives:** `crates/spt-store/src/roster.rs` — a durable store mirroring `subnet.rs`/`trust.rs`/`peeraddrs.rs` verbatim (load/load_from/save/save_to, `atomic_write_string`, absent-or-corrupt-degrades-empty). The CRDT merge + tombstone logic is **pure data, no iroh** (address is an opaque `serde_json::Value`, the `peeraddrs.rs` trick) so it stays in spt-store with no new dep edge. NOT the unrelated `crates/spt/src/roster.rs` (that's perch-listing — name collision only, different crate).

## Part A — `RosterStore` (the bulk; pure + fully unit-tested)

```rust
pub struct RosterEntry {
    pub pubkey_hex: String,   // member identity — the merge key within a subnet
    pub subnet: String,       // which subnet's roster
    pub label: String,        // node label (OS hostname)
    pub machine_id: String,   // hashed stable machine id (REQ-SUBNET-7 anchor)
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub address: Option<serde_json::Value>,  // last-known dialable EndpointAddr, opaque (peeraddrs posture)
    pub last_seen: String,    // opaque epoch-seconds stamp
    pub lease_epoch: u64,     // node_label lease — strictly-greater-wins
}
pub struct Tombstone { pub pubkey_hex: String, pub subnet: String, pub stamp: String }
pub struct RosterStore { pub members: Vec<RosterEntry>, pub tombstones: Vec<Tombstone> }
```

Methods (each tagged `// [impl->REQ-MESH-2]`):
- `load()/load_from(path)/save()/save_to(path)` — verbatim `SubnetStore` shape; new path helper `roster_file()` in `perch.rs` → `identity/roster.json` (node-bound secret-adjacent, never synced — same class as subnet.json/peers.json).
- `MergeOutcome` (reuse the registry's `Inserted/Updated/Stale` naming) — `merge_entry(entry) -> MergeOutcome`: keyed `(subnet, pubkey_hex)`; **strictly-greater `lease_epoch` wins** (equal/lower ⇒ `Stale`, dropped); new key ⇒ `Inserted`. A tombstone does **not** block the merge writing the latest entry (convergence keeps the freshest facts) — it gates *visibility/admission*, below.
- `merge(&other)` — full union: every entry through `merge_entry`, every tombstone unioned (dedup by `(subnet, pubkey)`). Commutative + idempotent (the convergence test).
- `upsert_self(subnet, pubkey, label, machine_id, address, lease_epoch)` — author/refresh this node's own entry at its current lease (an `Updated`/`Inserted`; never `Stale` against itself — caller passes a fresh lease).
- `tombstone(subnet, pubkey, stamp)` — add a tombstone (idempotent). **Dominates** the entry: `is_member`/`members_in` exclude it; **suppresses reinsert**: a later `merge_entry` of that pubkey re-records facts but `is_member` stays false while the tombstone stands.
- `clear_tombstone(subnet, pubkey) -> bool` — **clear-on-repair**: remove the tombstone (the deliberate elevated re-pair act); the entry, if present, becomes a member again.
- `is_tombstoned(subnet, pubkey) -> bool` · `is_member(subnet, pubkey) -> bool` (= entry present ∧ ¬tombstoned — **the admission predicate D5's gate will read**) · `members_in(subnet) -> impl Iterator<&RosterEntry>` (visible members only) · `roster_for(subnet) -> (Vec<RosterEntry>, Vec<Tombstone>)` (the slice handed at pairing — members + tombstones for one subnet).

**Tombstone-convergence boundary (document in the module doc):** the tombstone is a grow-set element (propagates, dominates). `clear_tombstone` is a *local* elevated op — cross-node convergence of an *un*-tombstone is **not** D3's job: the compromised path re-pairs under a **fresh never-tombstoned key** (decision 10), so the old tombstone correctly stands; benign N-1 re-seed + timeboxed rotation propagation is **D7**. D3 tests clear-on-repair locally only.

## Part B — `Frame::Seed` grows the roster (additive wire, `spt-net/pairing/wire.rs`)

`Frame::Seed { seed, epoch }` → `Frame::Seed { seed, epoch, roster: Vec<RosterEntry>, tombstones: Vec<Tombstone> }`. Encode appends **after** the existing seed+epoch (the M8-decision-13 additive-trailing discipline already used by `Hello`'s `intro`): a u32 count then per-entry length-prefixed fields (pubkey, label, machine_id, address-as-JSON-bytes [empty = none], last_seen) + u64 lease_epoch; then a u32 tombstone count, each (pubkey, stamp). **Subnet is implicit** — the ceremony's `subnet_name`; wire entries omit it and `adopt` stamps it in. Decode reads the trailing block **only if `c.remaining() > 0`** after epoch ⇒ a legacy/short Seed frame (no roster) decodes as empty roster (the additive guarantee). Malformed (bad count / short field / non-UTF-8) ⇒ `Protocol` error, never a panic (`Cursor` already bounds-checks). New tag consts not needed (rides `T_SEED`).

## Part C — ceremony wiring (mirror the D2f seed-transfer seam exactly)

The seed transfer already proves the pattern: responder reads a store and sends a frame; joiner writes a store. Roster rides the same `Frame::Seed`.

- `run_responder(..)` gains a read-only `roster: &RosterStore` arg → on confirm-success, `roster.roster_for(subnet_name)` rides the Seed frame (the seed is already sent there, line 331).
- `run_initiator(..)` gains `roster: &mut RosterStore` → on receiving Seed, merge the received entries+tombstones (stamping `subnet = subnet_name`) into the store. Mirrors how it already takes `&mut SubnetStore`.
- `pairhost.rs`:
  - **responder leg** (`respond`): `RosterStore::load()`, pass `&roster` to `run_responder`; **post-success** author the joiner's entry from `outcome.intro` (label, machine_id) + `conn_remote_addr` + the joiner's lease (best-effort: missing intro ⇒ skip, like the address seed) and `upsert_self`, then `roster.save()`. (Seed-holder's directory grows by the new member — cheap, the facts are in hand.)
  - **joiner leg** (`join_ceremony`): `RosterStore::load()`, pass `&mut roster` to `run_initiator` (adopts the responder's roster); **post-success** `upsert_self` + record the responder's entry (peer pubkey + `conn_remote_addr`), then `roster.save()`.
- Reuse `now_secs()` for `last_seen`; reuse `os_hostname()`/`machine_id_hash()` already imported for the intro.

## Explicitly NOT D3 (carry-forward)

- **D4** — steady-state on-connect roster exchange/merge over **member** (seed-proof'd) connections; address refresh from live mesh conns; **reconcile with `peer-addrs.json`** (absorb-or-feed — decide there).
- **D5** — the gate reads `is_member` in place of `is_trusted`; hard cutover.
- **D6** — fan-out widen → MESH ON.
- **D7** — revoke CLI, timeboxed seed rotation, confidential seed push, re-seed grace, cross-node un-tombstone propagation.

## Tests

**unit — `spt-store/src/roster.rs`** (`// [unit->REQ-MESH-2]`):
- merge lease: strictly-greater `lease_epoch` updates; equal/lower ⇒ `Stale`, no clobber; new pubkey ⇒ `Inserted`.
- union convergence: `a.merge(b)` == `b.merge(a)`; idempotent (`merge` twice = once); persist-through-silence (an offline member's entry survives a merge that never mentions it, and a save/load round-trip).
- tombstone truth table: dominates (`is_member` false once tombstoned though entry present) · suppresses-reinsert (a fresh-lease `merge_entry` after tombstone keeps `is_member` false) · clear-on-repair (`clear_tombstone` ⇒ member again).
- serde additive: a legacy `roster.json` with no `tombstones` / no `address` loads; absent + corrupt file ⇒ empty store (no panic).
- `is_member` admission predicate (entry ∧ ¬tombstone) — the D5 seam.

**unit — `spt-net/pairing/wire.rs`** (`// [unit->REQ-MESH-2]`):
- extended `Seed` round-trips: with a multi-entry roster (incl. an entry carrying an address JSON, an entry without) + tombstones; with an **empty** roster; a **legacy short** Seed body (seed+epoch only) decodes to empty roster (additive); malformed trailing (bad count / short field) ⇒ `Protocol` error.
- a loopback responder↔initiator ceremony transfers the responder's roster to the joiner's store (extend the existing `#[cfg(test)]` pairing harness; tagged `unit` like the sibling ceremony tests).

## Done when

- `RosterStore` lands pure + unit-green; `Frame::Seed` carries the roster additively; the ceremony transfers + adopts it; `pairhost` loads/passes/saves on both legs.
- `cargo test -p spt-store roster` + `cargo test -p spt-net pairing::wire` + `cargo test -p spt-daemon pairhost` green; `--no-default-features` + workspace build + clippy clean.
- The existing pairing ceremony tests still pass (additive Seed frame = no regression for a peer that sends seed+epoch only).
- `traceable-reqs check` clean with **REQ-MESH-2 `required_stages = ["impl","unit"]`** (D3 activates both). Evidence on the store methods, the wire codec, the ceremony wiring, and the unit tests.
- The five `is_trusted` gates remain **untouched** (D5 owns the swap).
- Commit: `feat(mesh): roster store + tombstones + pairing seed-transfer (REQ-MESH-2) — Mesh-D3`.


Plan written. Activate REQ-MESH-2, then build the store.


Edit

id = "REQ-MESH-2"
title = "Member roster: node-level union-merge grow-set (per member: pubkey, label, machine_id, last-known address, last-seen — NOT the seed), the discovery directory the mesh dials by. Seeded IN FULL at pairing (seed-holder hands joiner the whole current roster, incl. offline members — folds in deferred pairing-time hostname capture + post-join address seeding); each node authors its own entry stamped with its lease_epoch, merged strictly-greater-wins (the node_label lease); exchanged only over seed-proof'd member connections; forgery-inert (a fake entry names a pubkey that still can't seed-proof). Removal needs a TOMBSTONE — a per-pubkey revoked marker that propagates, dominates the entry, gates admission (seed-proof ∧ ¬tombstoned), and prevents reinsert; cleared by a completed re-pair of that pubkey. Persists through silence (offline member keeps its entry)."
required_stages = []  # activate at mesh execution. impl = roster store (union-merge + lease, atomic, corrupt-degrades-empty) + pairing-seed full-roster transfer (Frame::Seed gains the roster) + on-connect roster exchange/merge + tombstone propagation + admission suppression. unit = merge lease (strictly-greater self-entry) + union convergence + tombstone dominates/suppresses-reinsert/clears-on-repair + persist-through-silence + serde additive.

id = "REQ-MESH-2"
title = "Member roster: node-level union-merge grow-set (per member: pubkey, label, machine_id, last-known address, last-seen — NOT the seed), the discovery directory the mesh dials by. Seeded IN FULL at pairing (seed-holder hands joiner the whole current roster, incl. offline members — folds in deferred pairing-time hostname capture + post-join address seeding); each node authors its own entry stamped with its lease_epoch, merged strictly-greater-wins (the node_label lease); exchanged only over seed-proof'd member connections; forgery-inert (a fake entry names a pubkey that still can't seed-proof). Removal needs a TOMBSTONE — a per-pubkey revoked marker that propagates, dominates the entry, gates admission (seed-proof ∧ ¬tombstoned), and prevents reinsert; cleared by a completed re-pair of that pubkey. Persists through silence (offline member keeps its entry)."
required_stages = ["impl", "unit"]  # Mesh-D3 activates: impl = roster store (RosterStore union-merge + strictly-greater lease, tombstone dominate/suppress-reinsert/clear-on-repair, atomic, corrupt-degrades-empty) + pairing-seed full-roster transfer (Frame::Seed gains the roster, additive) + ceremony adopt-on-join wiring (pairhost both legs). unit = merge lease (strictly-greater self-entry) + union convergence (commutative/idempotent) + tombstone truth table + persist-through-silence + serde additive + extended-Seed codec round-trip + loopback ceremony roster transfer. D4 enriches impl (on-connect exchange/merge over member conns, peer-addrs reconcile); int lands at D4/D6.