1	//! CLI surface + dispatch for the `spt` binary (REQ-MSG-2).
2	//!
3	//! Stable arg shapes and exit codes are the contract here. Each subcommand is a
4	//! thin shim: read I/O, call the matching `spt-msg` entry point, render the
5	//! structured outcome, return an exit code. No messaging logic lives in this
6	//! layer.
7	//!
8	//! Exit codes: `0` success (delivered, queued, replied, listed) · `1` runtime
9	//! refusal (no perch, empty body, unidentifiable self) · `2` usage error
10	//! (clap, for unknown/missing args).
11	// [impl->REQ-MSG-2]
12
13	use std::io::{Read, Write};
14	use std::time::Duration;
15
16	use clap::{Parser, Subcommand};
17	use spt_msg::deliver::{self, SendOutcome};
18	use spt_msg::ready::ReadyAgent;
19	use spt_msg::ring::{self, RingOutcome};
20	use spt_store::perch::{self, ParentHint};
21	use spt_store::registry;
22
23	use crate::roster;
24
25	#[derive(Parser)]
26	#[command(name = "spt", version, about = "spt-core local messaging", long_about = None)]
27	struct Cli {
28	    #[command(subcommand)]
29	    cmd: Cmd,
30	}
31
32	#[derive(Subcommand)]
33	enum Cmd {
34	    /// Send a message (body read from stdin). Fire-and-forget.
35	    Send {
36	        /// Target perch id. May be omitted when --reply-to is given.
37	        target: Option<String>,
38	        /// Sender id written into __REPLY_TO__ (auto-detected from session if omitted).
39	        #[arg(long)]
40	        from: Option<String>,
41	        /// Reply to this sender; sets the target and labels output REPLIED.
42	        #[arg(long)]
43	        reply_to: Option<String>,
44	        /// Spool only (deferred / hook channel) — no live wake.
45	        #[arg(long)]
46	        deferred: bool,
47	    },
48	    /// Send and block for a reply (body read from stdin; reply printed to stdout).
49	    Ring {
50	        /// Target perch id.
51	        target: String,
52	        /// Sender id (auto-detected from session if omitted).
53	        #[arg(long)]
54	        from: Option<String>,
55	        /// Seconds to wait for a reply before giving up.
56	        #[arg(long, default_value = "60")]
57	        timeout: u64,
58	    },
59	    /// Become reachable: register the perch and listen for messages (blocks).
60	    Ready {
61	        /// This agent's perch id.
62	        id: String,
63	    },
64	    /// Receive messages: drain backlog then listen, printing each to stdout.
65	    Poll {
66	        /// This agent's perch id.
67	        id: String,
68	        /// Run a single drain+receive cycle, then exit (legacy one-shot).
69	        #[arg(long)]
70	        once: bool,
71	    },
72	    /// List perches under the owlery (id, state, address, ready, alive).
73	    List,
74	    /// Soft-stop a perch: remove the ready marker + unregister (spool preserved).
75	    Stop {
76	        /// Perch id to stop.
77	        id: String,
78	    },
79	    /// Print this session's own perch id (from $OWL_SESSION_ID / $SPT_AGENT_ID).
80	    Whoami,
81	    /// Show a daemon-hosted session's live activity buffer (PTY digest) — the
82	    /// at-a-glance "what is this agent doing now" view (ADR-0008). Pulls a
83	    /// snapshot, or `--follow`s the delta-stream. Local addressing only (the
84	    /// `[subnet:]id@node` cross-node form is M4).
85	    Digest {
86	        /// The (local) endpoint id to read.
87	        id: String,
88	        /// Stream live changes instead of a one-shot snapshot (Ctrl-C to stop).
89	        #[arg(long)]
90	        follow: bool,
91	        /// Emit structured JSON instead of the human-glanceable render.
92	        #[arg(long)]
93	        json: bool,
94	    },
95	    /// Harness-contract inbound surface (REQ-API-1): the entry points a
96	    /// harness's hooks fire to mutate on-disk SPT state.
97	    Api(crate::api::ApiArgs),
98	    /// Run the per-machine broker/brain daemon (REQ-DAEMON-3). Internal: an
99	    /// `api` invocation auto-starts this; operators rarely run it directly.
100	    #[command(hide = true)]
101	    Daemon,
102	}
103
104	/// Parse args and dispatch. Returns the process exit code.
105	pub fn run() -> i32 {
106	    let cli = Cli::parse();
107	    match cli.cmd {
108	        Cmd::Send { target, from, reply_to, deferred } => cmd_send(target, from, reply_to, deferred),
109	        Cmd::Ring { target, from, timeout } => cmd_ring(target, from, timeout),
110	        Cmd::Ready { id } => run_listen(&id, false),
111	        Cmd::Poll { id, once } => run_listen(&id, once),
112	        Cmd::List => cmd_list(),
113	        Cmd::Stop { id } => cmd_stop(&id),
114	        Cmd::Whoami => cmd_whoami(),
115	        Cmd::Digest { id, follow, json } => cmd_digest(&id, follow, json),
116	        Cmd::Api(args) => crate::api::run(args),
117	        Cmd::Daemon => cmd_daemon(),
118	    }
119	}
120
121	/// `spt digest <id>` — show a daemon-hosted session's live activity buffer
122	/// (REQ-TERM-4 / ADR-0008). Ensures the daemon is up, then pulls a snapshot or
123	/// follows the delta-stream over the digest-control channel. Local addressing
124	/// only: a qualified `[subnet:]id@node` form is refused (cross-node is M4).
125	// [impl->REQ-TERM-4]
126	fn cmd_digest(id: &str, follow: bool, json: bool) -> i32 {
127	    // Local addressing only — the qualified cross-node form is M4 (spt-net).
128	    if id.contains('@') || id.contains(':') {
129	        eprintln!("CROSS_NODE_M4:{id} — qualified [subnet:]id@node addressing is M4, not yet supported");
130	        return 1;
131	    }
132	    // The digest lives in the daemon; bring it up if absent (best-effort).
133	    if let Err(e) = spt_daemon::ensure_running() {
134	        eprintln!("DAEMON_START_WARN: {e}");
135	    }
136	    let name = spt_daemon::digest_socket_name();
137
138	    if follow {
139	        let res = spt_daemon::follow(&name, id, 0, |up| {
140	            if json {
141	                println!("{}", spt_daemon::update_to_json(up));
142	            } else {
143	                print!("{}", spt_daemon::render_update(up));
144	            }
145	            let _ = std::io::stdout().flush();
146	            true // stream until the connection ends / the user interrupts
147	        });
148	        return match res {
149	            Ok(()) => 0,
150	            Err(e) => {
151	                eprintln!("DIGEST_FOLLOW_FAIL:{id}: {e}");
152	                1
153	            }
154	        };
155	    }
156
157	    match spt_daemon::pull_snapshot(&name, id) {
158	        Ok(Some((digest, version))) => {
159	            if json {
160	                println!("{}", spt_daemon::digest_to_json(&digest));
161	            } else {
162	                print!("{}", spt_daemon::render_digest(&digest));
163	                let _ = std::io::stdout().flush();
164	            }
165	            eprintln!("DIGEST:{id} version={version}");
166	            0
167	        }
168	        Ok(None) => {
169	            eprintln!("NO_DIGEST:{id} has no live activity buffer (not a daemon-hosted session?)");
170	            1
171	        }
172	        Err(e) => {
173	            eprintln!("DIGEST_FAIL:{id}: {e}");
174	            1
175	        }
176	    }
177	}
178
179	/// Run the per-machine daemon (the hidden `spt daemon` entry point). Blocks
180	/// serving the broker + seed-control channels until killed; a bind contention
181	/// (another daemon already owns this home) surfaces as a non-zero exit.
182	// [impl->REQ-DAEMON-3]
183	fn cmd_daemon() -> i32 {
184	    match spt_daemon::Daemon::run() {
185	        Ok(()) => 0,
186	        Err(e) => {
187	            eprintln!("DAEMON_EXIT: {e}");
188	            1
189	        }
190	    }
191	}
192
193	/// Read stdin to a string (message body source for send/ring).
194	fn read_stdin() -> String {
195	    let mut body = String::new();
196	    let _ = std::io::stdin().read_to_string(&mut body);
197	    body
198	}
199
200	/// Resolve the sender id: explicit `--from`, else session auto-detect, else "".
201	fn resolve_from(from: Option<String>) -> String {
202	    from.or_else(roster::detect_self_id).unwrap_or_default()
203	}
204
205	fn cmd_send(target: Option<String>, from: Option<String>, reply_to: Option<String>, deferred: bool) -> i32 {
206	    let is_reply = reply_to.is_some();
207	    // --reply-to provides the target when no positional target is given.
208	    let target = match target.or_else(|| reply_to.clone()) {
209	        Some(t) => t,
210	        None => {
211	            eprintln!("USAGE: a target (or --reply-to <sender>) is required");
212	            return 2;
213	        }
214	    };
215	    let from = resolve_from(from);
216	    let body = read_stdin();
217	    let owlery = perch::owlery_dir();
218
219	    let outcome = if deferred {
220	        deliver::send_deferred(&target, &from, &body)
221	    } else {
222	        deliver::send(&target, &from, &body, &owlery)
223	    };
224
225	    match outcome {
226	        SendOutcome::Sent => {
227	            eprintln!("{}:{}", if is_reply { "REPLIED" } else { "SENT" }, target);
228	            0
229	        }
230	        SendOutcome::Queued => {
231	            let label = if deferred {
232	                "DEFERRED"
233	            } else if is_reply {
234	                "REPLIED(QUEUED)"
235	            } else {
236	                "QUEUED"
237	            };
238	            eprintln!("{}:{}", label, target);
239	            0
240	        }
241	        SendOutcome::NoPerch => {
242	            eprintln!("NO_PERCH:{} is not listening", target);
243	            1
244	        }
245	        SendOutcome::Empty => {
246	            eprintln!("EMPTY_MSG: refusing to send empty message to {}", target);
247	            1
248	        }
249	    }
250	}
251
252	fn cmd_ring(target: String, from: Option<String>, timeout: u64) -> i32 {
253	    let from = resolve_from(from);
254	    if from.is_empty() {
255	        eprintln!("NO_SELF: ring needs a sender id (--from <id> or $OWL_SESSION_ID)");
256	        return 1;
257	    }
258	    let body = read_stdin();
259	    let owlery = perch::owlery_dir();
260
261	    match ring::ring(&target, &from, &body, Duration::from_secs(timeout), &owlery) {
262	        RingOutcome::Replied(reply) => {
263	            print!("{}", reply);
264	            let _ = std::io::stdout().flush();
265	            0
266	        }
267	        RingOutcome::TimedOut => {
268	            eprintln!("TIMEOUT:{} did not reply within {}s", target, timeout);
269	            0
270	        }
271	        RingOutcome::Queued => {
272	            eprintln!("QUEUED:{}", target);
273	            0
274	        }
275	        RingOutcome::Delivered => {
276	            eprintln!("SENT:{} (reply will arrive on your listener)", target);
277	            0
278	        }
279	        RingOutcome::NoPerch => {
280	            eprintln!("NO_PERCH:{} is not listening", target);
281	            1
282	        }
283	        RingOutcome::Empty => {
284	            eprintln!("EMPTY_MSG: refusing to ring {} with an empty message", target);
285	            1
286	        }
287	    }
288	}
289
290	/// Run the listener loop (REQ-MSG-3 lifecycle, driven from the CLI). Blocks
291	/// until the process is killed unless `once` is set. Each received message is
292	/// printed framed to stdout; status goes to stderr.
293	///
294	/// Interim M1 model: the perch is reachable only while this process runs. M3's
295	/// spt-daemon decouples registration from the live process.
296	fn run_listen(id: &str, once: bool) -> i32 {
297	    let (agent, backlog) = match ReadyAgent::start(id) {
298	        Ok(pair) => pair,
299	        Err(e) => {
300	            eprintln!("READY_FAIL:{}: {}", id, e);
301	            return 1;
302	        }
303	    };
304	    eprintln!("READY:{}", id);
305	    for msg in backlog {
306	        emit(&msg);
307	    }
308
309	    if once {
310	        if let Some(msg) = agent.recv() {
311	            emit(&msg);
312	        }
313	        for msg in agent.drain_spool() {
314	            emit(&msg);
315	        }
316	        return 0;
317	    }
318
319	    loop {
320	        if let Some(msg) = agent.recv() {
321	            emit(&msg);
322	        }
323	        for msg in agent.drain_spool() {
324	            emit(&msg);
325	        }
326	    }
327	}
328
329	/// Print a framed message to stdout and flush (one delivery per emit).
330	fn emit(msg: &str) {
331	    print!("{}", msg);
332	    let _ = std::io::stdout().flush();
333	}
334
335	fn cmd_list() -> i32 {
336	    let perches = roster::enumerate();
337	    if perches.is_empty() {
338	        eprintln!("(no perches)");
339	        return 0;
340	    }
341	    for p in perches {
342	        println!(
343	            "{:<28} {:<14} {:<22} ready={} alive={}",
344	            p.id,
345	            p.state,
346	            p.address.as_deref().unwrap_or("-"),
347	            p.ready,
348	            p.alive
349	        );
350	    }
351	    0
352	}
353
354	fn cmd_stop(id: &str) -> i32 {
355	    let owlery = perch::owlery_dir();
356	    // Soft-cleanup (HAZARD-SOFT-CLEANUP 6.2): remove the ready marker +
357	    // unregister the address; spool.db and info.json are preserved.
358	    let removed = std::fs::remove_file(perch::resolve_ready_file(id, ParentHint::Infer)).is_ok();
359	    let _ = registry::unregister_address(id, &owlery);
360	    if removed {
361	        eprintln!("STOPPED:{}", id);
362	    } else {
363	        eprintln!("STOPPED:{} (no ready marker; address unregistered)", id);
364	    }
365	    0
366	}
367
368	fn cmd_whoami() -> i32 {
369	    match roster::detect_self_id() {
370	        Some(id) => {
371	            println!("{}", id);
372	            0
373	        }
374	        None => {
375	            eprintln!("UNKNOWN: no perch matches this session ($OWL_SESSION_ID / $SPT_AGENT_ID)");
376	            1
377	        }
378	    }
379	}
380
381	#[cfg(test)]
382	mod tests {
383	    use super::*;
384
385	    fn parse(args: &[&str]) -> Result<Cli, clap::Error> {
386	        Cli::try_parse_from(args)
387	    }
388
389	    // [unit->REQ-MSG-2] the documented subcommands + arg shapes parse.
390	    #[test]
391	    fn subcommands_parse() {
392	        assert!(matches!(parse(&["spt", "send", "bob", "--from", "alice"]).unwrap().cmd, Cmd::Send { .. }));
393	        assert!(matches!(parse(&["spt", "send", "bob", "--deferred"]).unwrap().cmd, Cmd::Send { deferred: true, .. }));
394	        assert!(matches!(parse(&["spt", "ring", "bob", "--timeout", "5"]).unwrap().cmd, Cmd::Ring { .. }));
395	        assert!(matches!(parse(&["spt", "ready", "bob"]).unwrap().cmd, Cmd::Ready { .. }));
396	        assert!(matches!(parse(&["spt", "poll", "bob", "--once"]).unwrap().cmd, Cmd::Poll { once: true, .. }));
397	        assert!(matches!(parse(&["spt", "list"]).unwrap().cmd, Cmd::List));
398	        assert!(matches!(parse(&["spt", "stop", "bob"]).unwrap().cmd, Cmd::Stop { .. }));
399	        assert!(matches!(parse(&["spt", "whoami"]).unwrap().cmd, Cmd::Whoami));
400	        // The hidden daemon entry point parses (auto-started by `api`).
401	        assert!(matches!(parse(&["spt", "daemon"]).unwrap().cmd, Cmd::Daemon));
402	        // The digest surface + its flags.
403	        assert!(matches!(parse(&["spt", "digest", "doyle"]).unwrap().cmd, Cmd::Digest { .. }));
404	        assert!(matches!(
405	            parse(&["spt", "digest", "doyle", "--follow", "--json"]).unwrap().cmd,
406	            Cmd::Digest { follow: true, json: true, .. }
407	        ));
408	    }
409
410	    // [unit->REQ-TERM-4] digest defaults: a one-shot, human-rendered snapshot.
411	    #[test]
412	    fn digest_flag_defaults() {
413	        match parse(&["spt", "digest", "doyle"]).unwrap().cmd {
414	            Cmd::Digest { id, follow, json } => {
415	                assert_eq!(id, "doyle");
416	                assert!(!follow, "snapshot by default");
417	                assert!(!json, "human render by default");
418	            }
419	            _ => panic!("expected Digest"),
420	        }
421	    }
422
423	    // [unit->REQ-MSG-2] ring timeout defaults to 60s when unspecified.
424	    #[test]
425	    fn ring_timeout_defaults() {
426	        match parse(&["spt", "ring", "bob"]).unwrap().cmd {
427	            Cmd::Ring { timeout, .. } => assert_eq!(timeout, 60),
428	            _ => panic!("expected Ring"),
429	        }
430	    }
431
432	    // [unit->REQ-MSG-2] send target is optional (supplied via --reply-to instead).
433	    #[test]
434	    fn send_target_optional_with_reply_to() {
435	        match parse(&["spt", "send", "--reply-to", "alice"]).unwrap().cmd {
436	            Cmd::Send { target, reply_to, .. } => {
437	                assert!(target.is_none());
438	                assert_eq!(reply_to.as_deref(), Some("alice"));
439	            }
440	            _ => panic!("expected Send"),
441	        }
442	    }
443
444	    // [unit->REQ-MSG-2] unknown subcommand / missing required arg are usage errors.
445	    #[test]
446	    fn invalid_usage_errors() {
447	        assert!(parse(&["spt", "bogus"]).is_err());
448	        assert!(parse(&["spt", "ring"]).is_err()); // missing required target
449	    }
450	}
451

//! CLI surface + dispatch for the `spt` binary (REQ-MSG-2).
//!
//! Stable arg shapes and exit codes are the contract here. Each subcommand is a
//! thin shim: read I/O, call the matching `spt-msg` entry point, render the
//! structured outcome, return an exit code. No messaging logic lives in this
//! layer.
//!
//! Exit codes: `0` success (delivered, queued, replied, listed) · `1` runtime
//! refusal (no perch, empty body, unidentifiable self) · `2` usage error
//! (clap, for unknown/missing args).
// [impl->REQ-MSG-2]

use std::io::{Read, Write};
use std::time::Duration;

use clap::{Parser, Subcommand};
use spt_msg::deliver::{self, SendOutcome};
use spt_msg::ready::ReadyAgent;
use spt_msg::ring::{self, RingOutcome};
use spt_store::perch::{self, ParentHint};
use spt_store::registry;

use crate::roster;

#[derive(Parser)]
#[command(name = "spt", version, about = "spt-core local messaging", long_about = None)]
struct Cli {
    #[command(subcommand)]
    cmd: Cmd,
}

#[derive(Subcommand)]
enum Cmd {
    /// Send a message (body read from stdin). Fire-and-forget.
    Send {
        /// Target perch id. May be omitted when --reply-to is given.
        target: Option<String>,
        /// Sender id written into __REPLY_TO__ (auto-detected from session if omitted).
        #[arg(long)]
        from: Option<String>,
        /// Reply to this sender; sets the target and labels output REPLIED.
        #[arg(long)]
        reply_to: Option<String>,
        /// Spool only (deferred / hook channel) — no live wake.
        #[arg(long)]
        deferred: bool,
    },
    /// Send and block for a reply (body read from stdin; reply printed to stdout).
    Ring {
        /// Target perch id.
        target: String,
        /// Sender id (auto-detected from session if omitted).
        #[arg(long)]
        from: Option<String>,
        /// Seconds to wait for a reply before giving up.
        #[arg(long, default_value = "60")]
        timeout: u64,
    },
    /// Become reachable: register the perch and listen for messages (blocks).
    Ready {
        /// This agent's perch id.
        id: String,
    },
    /// Receive messages: drain backlog then listen, printing each to stdout.
    Poll {
        /// This agent's perch id.
        id: String,
        /// Run a single drain+receive cycle, then exit (legacy one-shot).
        #[arg(long)]
        once: bool,
    },
    /// List perches under the owlery (id, state, address, ready, alive).
    List,
    /// Soft-stop a perch: remove the ready marker + unregister (spool preserved).
    Stop {
        /// Perch id to stop.
        id: String,
    },
    /// Print this session's own perch id (from $OWL_SESSION_ID / $SPT_AGENT_ID).
    Whoami,
    /// Show a daemon-hosted session's live activity buffer (PTY digest) — the
    /// at-a-glance "what is this agent doing now" view (ADR-0008). Pulls a
    /// snapshot, or `--follow`s the delta-stream. Local addressing only (the
    /// `[subnet:]id@node` cross-node form is M4).
    Digest {
        /// The (local) endpoint id to read.
        id: String,
        /// Stream live changes instead of a one-shot snapshot (Ctrl-C to stop).
        #[arg(long)]
        follow: bool,
        /// Emit structured JSON instead of the human-glanceable render.
        #[arg(long)]
        json: bool,
    },
    /// Harness-contract inbound surface (REQ-API-1): the entry points a
    /// harness's hooks fire to mutate on-disk SPT state.
    Api(crate::api::ApiArgs),
    /// Run the per-machine broker/brain daemon (REQ-DAEMON-3). Internal: an
    /// `api` invocation auto-starts this; operators rarely run it directly.
    #[command(hide = true)]
    Daemon,
}

/// Parse args and dispatch. Returns the process exit code.
pub fn run() -> i32 {
    let cli = Cli::parse();
    match cli.cmd {
        Cmd::Send { target, from, reply_to, deferred } => cmd_send(target, from, reply_to, deferred),
        Cmd::Ring { target, from, timeout } => cmd_ring(target, from, timeout),
        Cmd::Ready { id } => run_listen(&id, false),
        Cmd::Poll { id, once } => run_listen(&id, once),
        Cmd::List => cmd_list(),
        Cmd::Stop { id } => cmd_stop(&id),
        Cmd::Whoami => cmd_whoami(),
        Cmd::Digest { id, follow, json } => cmd_digest(&id, follow, json),
        Cmd::Api(args) => crate::api::run(args),
        Cmd::Daemon => cmd_daemon(),
    }
}

/// `spt digest <id>` — show a daemon-hosted session's live activity buffer
/// (REQ-TERM-4 / ADR-0008). Ensures the daemon is up, then pulls a snapshot or
/// follows the delta-stream over the digest-control channel. Local addressing
/// only: a qualified `[subnet:]id@node` form is refused (cross-node is M4).
// [impl->REQ-TERM-4]
fn cmd_digest(id: &str, follow: bool, json: bool) -> i32 {
    // Local addressing only — the qualified cross-node form is M4 (spt-net).
    if id.contains('@') || id.contains(':') {
        eprintln!("CROSS_NODE_M4:{id} — qualified [subnet:]id@node addressing is M4, not yet supported");
        return 1;
    }
    // The digest lives in the daemon; bring it up if absent (best-effort).
    if let Err(e) = spt_daemon::ensure_running() {
        eprintln!("DAEMON_START_WARN: {e}");
    }
    let name = spt_daemon::digest_socket_name();

    if follow {
        let res = spt_daemon::follow(&name, id, 0, |up| {
            if json {
                println!("{}", spt_daemon::update_to_json(up));
            } else {
                print!("{}", spt_daemon::render_update(up));
            }
            let _ = std::io::stdout().flush();
            true // stream until the connection ends / the user interrupts
        });
        return match res {
            Ok(()) => 0,
            Err(e) => {
                eprintln!("DIGEST_FOLLOW_FAIL:{id}: {e}");
                1
            }
        };
    }

    match spt_daemon::pull_snapshot(&name, id) {
        Ok(Some((digest, version))) => {
            if json {
                println!("{}", spt_daemon::digest_to_json(&digest));
            } else {
                print!("{}", spt_daemon::render_digest(&digest));
                let _ = std::io::stdout().flush();
            }
            eprintln!("DIGEST:{id} version={version}");
            0
        }
        Ok(None) => {
            eprintln!("NO_DIGEST:{id} has no live activity buffer (not a daemon-hosted session?)");
            1
        }
        Err(e) => {
            eprintln!("DIGEST_FAIL:{id}: {e}");
            1
        }
    }
}

/// Run the per-machine daemon (the hidden `spt daemon` entry point). Blocks
/// serving the broker + seed-control channels until killed; a bind contention
/// (another daemon already owns this home) surfaces as a non-zero exit.
// [impl->REQ-DAEMON-3]
fn cmd_daemon() -> i32 {
    match spt_daemon::Daemon::run() {
        Ok(()) => 0,
        Err(e) => {
            eprintln!("DAEMON_EXIT: {e}");
            1
        }
    }
}

/// Read stdin to a string (message body source for send/ring).
fn read_stdin() -> String {
    let mut body = String::new();
    let _ = std::io::stdin().read_to_string(&mut body);
    body
}

/// Resolve the sender id: explicit `--from`, else session auto-detect, else "".
fn resolve_from(from: Option<String>) -> String {
    from.or_else(roster::detect_self_id).unwrap_or_default()
}

fn cmd_send(target: Option<String>, from: Option<String>, reply_to: Option<String>, deferred: bool) -> i32 {
    let is_reply = reply_to.is_some();
    // --reply-to provides the target when no positional target is given.
    let target = match target.or_else(|| reply_to.clone()) {
        Some(t) => t,
        None => {
            eprintln!("USAGE: a target (or --reply-to <sender>) is required");
            return 2;
        }
    };
    let from = resolve_from(from);
    let body = read_stdin();
    let owlery = perch::owlery_dir();

    let outcome = if deferred {
        deliver::send_deferred(&target, &from, &body)
    } else {
        deliver::send(&target, &from, &body, &owlery)
    };

    match outcome {
        SendOutcome::Sent => {
            eprintln!("{}:{}", if is_reply { "REPLIED" } else { "SENT" }, target);
            0
        }
        SendOutcome::Queued => {
            let label = if deferred {
                "DEFERRED"
            } else if is_reply {
                "REPLIED(QUEUED)"
            } else {
                "QUEUED"
            };
            eprintln!("{}:{}", label, target);
            0
        }
        SendOutcome::NoPerch => {
            eprintln!("NO_PERCH:{} is not listening", target);
            1
        }
        SendOutcome::Empty => {
            eprintln!("EMPTY_MSG: refusing to send empty message to {}", target);
            1
        }
    }
}

fn cmd_ring(target: String, from: Option<String>, timeout: u64) -> i32 {
    let from = resolve_from(from);
    if from.is_empty() {
        eprintln!("NO_SELF: ring needs a sender id (--from <id> or $OWL_SESSION_ID)");
        return 1;
    }
    let body = read_stdin();
    let owlery = perch::owlery_dir();

    match ring::ring(&target, &from, &body, Duration::from_secs(timeout), &owlery) {
        RingOutcome::Replied(reply) => {
            print!("{}", reply);
            let _ = std::io::stdout().flush();
            0
        }
        RingOutcome::TimedOut => {
            eprintln!("TIMEOUT:{} did not reply within {}s", target, timeout);
            0
        }
        RingOutcome::Queued => {
            eprintln!("QUEUED:{}", target);
            0
        }
        RingOutcome::Delivered => {
            eprintln!("SENT:{} (reply will arrive on your listener)", target);
            0
        }
        RingOutcome::NoPerch => {
            eprintln!("NO_PERCH:{} is not listening", target);
            1
        }
        RingOutcome::Empty => {
            eprintln!("EMPTY_MSG: refusing to ring {} with an empty message", target);
            1
        }
    }
}

/// Run the listener loop (REQ-MSG-3 lifecycle, driven from the CLI). Blocks
/// until the process is killed unless `once` is set. Each received message is
/// printed framed to stdout; status goes to stderr.
///
/// Interim M1 model: the perch is reachable only while this process runs. M3's
/// spt-daemon decouples registration from the live process.
fn run_listen(id: &str, once: bool) -> i32 {
    let (agent, backlog) = match ReadyAgent::start(id) {
        Ok(pair) => pair,
        Err(e) => {
            eprintln!("READY_FAIL:{}: {}", id, e);
            return 1;
        }
    };
    eprintln!("READY:{}", id);
    for msg in backlog {
        emit(&msg);
    }

    if once {
        if let Some(msg) = agent.recv() {
            emit(&msg);
        }
        for msg in agent.drain_spool() {
            emit(&msg);
        }
        return 0;
    }

    loop {
        if let Some(msg) = agent.recv() {
            emit(&msg);
        }
        for msg in agent.drain_spool() {
            emit(&msg);
        }
    }
}

/// Print a framed message to stdout and flush (one delivery per emit).
fn emit(msg: &str) {
    print!("{}", msg);
    let _ = std::io::stdout().flush();
}

fn cmd_list() -> i32 {
    let perches = roster::enumerate();
    if perches.is_empty() {
        eprintln!("(no perches)");
        return 0;
    }
    for p in perches {
        println!(
            "{:<28} {:<14} {:<22} ready={} alive={}",
            p.id,
            p.state,
            p.address.as_deref().unwrap_or("-"),
            p.ready,
            p.alive
        );
    }
    0
}

fn cmd_stop(id: &str) -> i32 {
    let owlery = perch::owlery_dir();
    // Soft-cleanup (HAZARD-SOFT-CLEANUP 6.2): remove the ready marker +
    // unregister the address; spool.db and info.json are preserved.
    let removed = std::fs::remove_file(perch::resolve_ready_file(id, ParentHint::Infer)).is_ok();
    let _ = registry::unregister_address(id, &owlery);
    if removed {
        eprintln!("STOPPED:{}", id);
    } else {
        eprintln!("STOPPED:{} (no ready marker; address unregistered)", id);
    }
    0
}

fn cmd_whoami() -> i32 {
    match roster::detect_self_id() {
        Some(id) => {
            println!("{}", id);
            0
        }
        None => {
            eprintln!("UNKNOWN: no perch matches this session ($OWL_SESSION_ID / $SPT_AGENT_ID)");
            1
        }
    }
}

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

    fn parse(args: &[&str]) -> Result<Cli, clap::Error> {
        Cli::try_parse_from(args)
    }

    // [unit->REQ-MSG-2] the documented subcommands + arg shapes parse.
    #[test]
    fn subcommands_parse() {
        assert!(matches!(parse(&["spt", "send", "bob", "--from", "alice"]).unwrap().cmd, Cmd::Send { .. }));
        assert!(matches!(parse(&["spt", "send", "bob", "--deferred"]).unwrap().cmd, Cmd::Send { deferred: true, .. }));
        assert!(matches!(parse(&["spt", "ring", "bob", "--timeout", "5"]).unwrap().cmd, Cmd::Ring { .. }));
        assert!(matches!(parse(&["spt", "ready", "bob"]).unwrap().cmd, Cmd::Ready { .. }));
        assert!(matches!(parse(&["spt", "poll", "bob", "--once"]).unwrap().cmd, Cmd::Poll { once: true, .. }));
        assert!(matches!(parse(&["spt", "list"]).unwrap().cmd, Cmd::List));
        assert!(matches!(parse(&["spt", "stop", "bob"]).unwrap().cmd, Cmd::Stop { .. }));
        assert!(matches!(parse(&["spt", "whoami"]).unwrap().cmd, Cmd::Whoami));
        // The hidden daemon entry point parses (auto-started by `api`).
        assert!(matches!(parse(&["spt", "daemon"]).unwrap().cmd, Cmd::Daemon));
        // The digest surface + its flags.
        assert!(matches!(parse(&["spt", "digest", "doyle"]).unwrap().cmd, Cmd::Digest { .. }));
        assert!(matches!(
            parse(&["spt", "digest", "doyle", "--follow", "--json"]).unwrap().cmd,
            Cmd::Digest { follow: true, json: true, .. }
        ));
    }

    // [unit->REQ-TERM-4] digest defaults: a one-shot, human-rendered snapshot.
    #[test]
    fn digest_flag_defaults() {
        match parse(&["spt", "digest", "doyle"]).unwrap().cmd {
            Cmd::Digest { id, follow, json } => {
                assert_eq!(id, "doyle");
                assert!(!follow, "snapshot by default");
                assert!(!json, "human render by default");
            }
            _ => panic!("expected Digest"),
        }
    }

    // [unit->REQ-MSG-2] ring timeout defaults to 60s when unspecified.
    #[test]
    fn ring_timeout_defaults() {
        match parse(&["spt", "ring", "bob"]).unwrap().cmd {
            Cmd::Ring { timeout, .. } => assert_eq!(timeout, 60),
            _ => panic!("expected Ring"),
        }
    }

    // [unit->REQ-MSG-2] send target is optional (supplied via --reply-to instead).
    #[test]
    fn send_target_optional_with_reply_to() {
        match parse(&["spt", "send", "--reply-to", "alice"]).unwrap().cmd {
            Cmd::Send { target, reply_to, .. } => {
                assert!(target.is_none());
                assert_eq!(reply_to.as_deref(), Some("alice"));
            }
            _ => panic!("expected Send"),
        }
    }

    // [unit->REQ-MSG-2] unknown subcommand / missing required arg are usage errors.
    #[test]
    fn invalid_usage_errors() {
        assert!(parse(&["spt", "bogus"]).is_err());
        assert!(parse(&["spt", "ring"]).is_err()); // missing required target
    }
}


NOTE fROM THE USER:proceed withnext steps.


Need SubnetStore API + where TotpSeed/seed lives (spt has no spt-net dep). Check spt-store.


Grep

SubnetStore|create_subnet|pub fn.*subnet|TotpSeed|pub use|struct Subnet\b


crates\spt-store\src\perch.rs:80:pub fn subnet_file() -> PathBuf {
crates\spt-store\src\trust.rs:33://! paired with a [seed rotation](crate::subnet::SubnetStore::rotate_seed) (#10), since
crates\spt-store\src\trust.rs:101:    pub user: Option<String>,
crates\spt-store\src\trust.rs:124:    /// [`SubnetStore`](crate::subnet::SubnetStore).
crates\spt-store\src\trust.rs:152:    pub fn is_trusted(&self, subnet: &str, pubkey_hex: &str) -> bool {
crates\spt-store\src\trust.rs:159:    pub fn peers_in<'a>(&'a self, subnet: &'a str) -> impl Iterator<Item = &'a TrustedPeer> {
crates\spt-store\src\trust.rs:230:    /// removed. Pair this with a [seed rotation](crate::subnet::SubnetStore::rotate_seed)
crates\spt-store\src\trust.rs:233:    pub fn revoke(&mut self, subnet: &str, pubkey_hex: &str) -> bool {
crates\spt-store\src\trust.rs:241:/// Current unix-seconds as an opaque stamp string (matches [`SubnetStore`](crate::subnet)).
crates\spt-store\src\subnet.rs:15://! trust-store delete. The fix is [`rotate_seed`](SubnetStore::rotate_seed): removing
crates\spt-store\src\subnet.rs:45:    /// `create_subnet` for a name (within the same user scope) that already exists.
crates\spt-store\src\subnet.rs:74:    /// [`rotate_seed`](SubnetStore::rotate_seed); bound into the pairing transcript.
crates\spt-store\src\subnet.rs:78:    pub user: Option<String>,
crates\spt-store\src\subnet.rs:93:pub struct SubnetStore {
crates\spt-store\src\subnet.rs:99:impl SubnetStore {
crates\spt-store\src\subnet.rs:102:    pub fn load() -> SubnetStore {
crates\spt-store\src\subnet.rs:109:    pub fn load_from(path: &Path) -> SubnetStore {
crates\spt-store\src\subnet.rs:112:            Err(_) => SubnetStore::default(),
crates\spt-store\src\subnet.rs:146:    pub fn create_subnet(&mut self, name: &str) -> Result<&SubnetRecord, SubnetError> {
crates\spt-store\src\subnet.rs:253:    fn create_subnet_starts_at_epoch_one_unique_name() {
crates\spt-store\src\subnet.rs:254:        let mut store = SubnetStore::default();
crates\spt-store\src\subnet.rs:255:        let rec = store.create_subnet("home").expect("create");
crates\spt-store\src\subnet.rs:260:            store.create_subnet("home").unwrap_err(),
crates\spt-store\src\subnet.rs:271:        let mut store = SubnetStore::default();
crates\spt-store\src\subnet.rs:272:        let before = store.create_subnet("home").expect("create").clone();
crates\spt-store\src\subnet.rs:289:        let mut store = SubnetStore::default();
crates\spt-store\src\subnet.rs:308:        let mut store = SubnetStore::default();
crates\spt-store\src\subnet.rs:309:        store.create_subnet("home").unwrap();
crates\spt-store\src\subnet.rs:310:        store.create_subnet("work").unwrap();
crates\spt-store\src\subnet.rs:314:        let reloaded = SubnetStore::load_from(&path);
crates\spt-store\src\subnet.rs:331:        assert!(SubnetStore::load_from(&absent).subnets.is_empty());
crates\spt-store\src\subnet.rs:335:        assert!(SubnetStore::load_from(&corrupt).subnets.is_empty());
crates\spt-store\src\subnet.rs:342:        let mut store = SubnetStore::default();
crates\spt-store\src\subnet.rs:343:        store.create_subnet("home").unwrap();
crates\spt-store\src\subnet.rs:353:        let mut a = SubnetStore::default();
crates\spt-store\src\subnet.rs:354:        let mut b = SubnetStore::default();
crates\spt-store\src\subnet.rs:355:        let sa = a.create_subnet("x").unwrap().seed_hex.clone();
crates\spt-store\src\subnet.rs:356:        let sb = b.create_subnet("x").unwrap().seed_hex.clone();


Read full subnet.rs to learn the record API (seed_hex, fields, list).


40	pub const TOTP_SEED_LEN: usize = 20;
41
42	/// Failures operating on the subnet store.
43	#[derive(Debug, PartialEq, Eq)]
44	pub enum SubnetError {
45	    /// `create_subnet` for a name (within the same user scope) that already exists.
46	    Exists(String),
47	    /// An operation named a subnet not in the store.
48	    NotFound(String),
49	    /// Reading/writing the backing file failed (message preserved).
50	    Io(String),
51	}
52
53	impl std::fmt::Display for SubnetError {
54	    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55	        match self {
56	            SubnetError::Exists(n) => write!(f, "subnet already exists: {n}"),
57	            SubnetError::NotFound(n) => write!(f, "subnet not found: {n}"),
58	            SubnetError::Io(e) => write!(f, "subnet store io: {e}"),
59	        }
60	    }
61	}
62
63	impl std::error::Error for SubnetError {}
64
65	/// One subnet the node belongs to: its human name, the durable TOTP seed (hex), the
66	/// seed epoch, and (forward seam) the owning user.
67	#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
68	pub struct SubnetRecord {
69	    /// Human subnet label (R-PAIR-4) — what the user types when pairing.
70	    pub name: String,
71	    /// The 20-byte TOTP seed, lowercase hex. The subnet secret; never synced.
72	    pub seed_hex: String,
73	    /// Monotonic seed generation (ADR-0005 #10). Bumped on every
74	    /// [`rotate_seed`](SubnetStore::rotate_seed); bound into the pairing transcript.
75	    pub epoch: u64,
76	    /// Owning user — the cross-user seam. `None` = the single implicit v1 user.
77	    #[serde(skip_serializing_if = "Option::is_none")]
78	    pub user: Option<String>,
79	    /// Creation stamp (epoch-seconds string; opaque).
80	    pub created: String,
81	}
82
83	impl SubnetRecord {
84	    /// Decode the seed to raw bytes for the crypto layer. `None` if the stored hex is
85	    /// malformed or the wrong length (defensive — the store always writes valid hex).
86	    pub fn seed_bytes(&self) -> Option<[u8; TOTP_SEED_LEN]> {
87	        decode_hex(&self.seed_hex)
88	    }
89	}
90
91	/// The whole `subnet.json` document: every subnet the node is a member of.
92	#[derive(Debug, Clone, Default, Serialize, Deserialize)]
93	pub struct SubnetStore {
94	    /// All subnets this node belongs to (multi-subnet — N seeds).
95	    #[serde(default)]
96	    pub subnets: Vec<SubnetRecord>,
97	}
98
99	impl SubnetStore {
100	    /// Load the node's subnet store from the canonical [`subnet_file`]. A missing file
101	    /// is the empty store (an unpaired node) — never an error.
102	    pub fn load() -> SubnetStore {
103	        Self::load_from(&subnet_file())
104	    }
105
106	    /// Load from an explicit path (the testable core). Absent ⇒ empty; an unreadable
107	    /// or corrupt file also degrades to empty rather than panicking (a corrupt secret
108	    /// file should not brick the node — re-provision via pairing).
109	    pub fn load_from(path: &Path) -> SubnetStore {
110	        match std::fs::read_to_string(path) {
111	            Ok(s) => serde_json::from_str(&s).unwrap_or_default(),
112	            Err(_) => SubnetStore::default(),
113	        }
114	    }
115
116	    /// Persist to the canonical [`subnet_file`], creating `identity/` if needed.
117	    pub fn save(&self) -> Result<(), SubnetError> {
118	        self.save_to(&subnet_file())
119	    }
120
121	    /// Persist to an explicit path (atomic; creates the parent dir). Atomic write so a
122	    /// concurrent reader never sees a torn secret file (the [`atomic`](crate::atomic)
123	    /// invariant).
124	    pub fn save_to(&self, path: &Path) -> Result<(), SubnetError> {
125	        if let Some(parent) = path.parent() {
126	            std::fs::create_dir_all(parent).map_err(|e| SubnetError::Io(e.to_string()))?;
127	        }
128	        let json =
129	            serde_json::to_string_pretty(self).map_err(|e| SubnetError::Io(e.to_string()))?;
130	        atomic_write_string(path, &json).map_err(|e| SubnetError::Io(e.to_string()))
131	    }
132
133	    /// Find a subnet by name (within the implicit-user scope).
134	    pub fn find(&self, name: &str) -> Option<&SubnetRecord> {
135	        self.subnets.iter().find(|s| s.name == name)
136	    }
137
138	    /// Create a new subnet with a freshly minted seed at epoch 1 (the "create-new"
139	    /// pairing path — this node becomes the sole seed-holder). Errors if the name
140	    /// already exists. Returns the new record.
141	    ///
142	    /// This is **subnet naming on first pairing** (REQ-PAIR-4 / ADR-0006): the subnet is
143	    /// named at creation ("naming moves to link-start"); the name then rides every
144	    /// subsequent pairing ceremony (the joiner types it; the transcript binds it).
145	    // [impl->REQ-HAZARD-PAIR-SEED-ROTATION] [impl->REQ-PAIR-4]
146	    pub fn create_subnet(&mut self, name: &str) -> Result<&SubnetRecord, SubnetError> {
147	        if self.find(name).is_some() {
148	            return Err(SubnetError::Exists(name.to_string()));
149	        }
150	        self.subnets.push(SubnetRecord {
151	            name: name.to_string(),
152	            seed_hex: encode_hex(&random_seed()),
153	            epoch: 1,
154	            user: None,
155	            created: now_stamp(),
156	        });
157	        Ok(self.subnets.last().expect("just pushed"))
158	    }
159
160	    /// Add a subnet whose seed was learned by **joining** an existing subnet (the seed
161	    /// and its current epoch arrive over the pairing channel from a seed-holder).
162	    /// Errors if the name already exists.
163	    // [impl->REQ-HAZARD-PAIR-SEED-ROTATION]
164	    pub fn add_joined(
165	        &mut self,
166	        name: &str,
167	        seed: [u8; TOTP_SEED_LEN],
168	        epoch: u64,
169	    ) -> Result<&SubnetRecord, SubnetError> {
170	        if self.find(name).is_some() {
171	            return Err(SubnetError::Exists(name.to_string()));
172	        }
173	        self.subnets.push(SubnetRecord {
174	            name: name.to_string(),
175	            seed_hex: encode_hex(&seed),
176	            epoch,
177	            user: None,
178	            created: now_stamp(),
179	        });
180	        Ok(self.subnets.last().expect("just pushed"))
181	    }
182
183	    /// Rotate a subnet's seed and bump its epoch (ADR-0005 #10 — the node-removal
184	    /// response). The old seed can no longer compute a valid current code, and the
185	    /// epoch bump means even a captured old transcript won't bind. The new seed must
186	    /// then be redistributed to the *remaining* trusted nodes over the pairing
187	    /// channel; callers pair node-removal (trust-store delete) **with** this rotation,
188	    /// because a trust-delete alone is not revocation.
189	    // [impl->REQ-HAZARD-PAIR-SEED-ROTATION]
190	    pub fn rotate_seed(&mut self, name: &str) -> Result<&SubnetRecord, SubnetError> {
191	        let rec = self
192	            .subnets
193	            .iter_mut()
194	            .find(|s| s.name == name)
195	            .ok_or_else(|| SubnetError::NotFound(name.to_string()))?;
196	        rec.seed_hex = encode_hex(&random_seed());
197	        rec.epoch += 1;
198	        Ok(rec)
199	    }
200
201	    /// Remove a subnet entirely (the node leaves it). Returns whether a record was
202	    /// removed. (Distinct from [`rotate_seed`]: leaving a subnet drops our own
203	    /// membership; rotating evicts *another* node from a subnet we keep.)
204	    pub fn remove(&mut self, name: &str) -> bool {
205	        let before = self.subnets.len();
206	        self.subnets.retain(|s| s.name != name);
207	        self.subnets.len() != before
208	    }
209	}
210
211	/// Mint 20 cryptographically-random seed bytes. Reuses spt-proto's Ed25519 seed
212	/// generator (OsRng) as the CSPRNG — the same no-extra-`rand`-dependency trick used
213	/// elsewhere — taking the first [`TOTP_SEED_LEN`] of its 32 random bytes.
214	fn random_seed() -> [u8; TOTP_SEED_LEN] {
215	    let mut out = [0u8; TOTP_SEED_LEN];
216	    out.copy_from_slice(&Identity::generate().seed()[..TOTP_SEED_LEN]);
217	    out
218	}
219
220	/// Lowercase-hex encode bytes (dependency-free; small fixed inputs).
221	fn encode_hex(bytes: &[u8]) -> String {
222	    bytes.iter().map(|b| format!("{b:02x}")).collect()
223	}
224
225	/// Decode lowercase/uppercase hex into a fixed-length seed. `None` on bad length or
226	/// non-hex digits.
227	fn decode_hex(s: &str) -> Option<[u8; TOTP_SEED_LEN]> {
228	    if s.len() != TOTP_SEED_LEN * 2 {
229	        return None;
230	    }
231	    let mut out = [0u8; TOTP_SEED_LEN];
232	    for (i, slot) in out.iter_mut().enumerate() {
233	        *slot = u8::from_str_radix(&s[i * 2..i * 2 + 2], 16).ok()?;
234	    }
235	    Some(out)
236	}
237
238	/// Current unix-seconds as an opaque stamp string.
239	fn now_stamp() -> String {

pub const TOTP_SEED_LEN: usize = 20;

/// Failures operating on the subnet store.
#[derive(Debug, PartialEq, Eq)]
pub enum SubnetError {
    /// `create_subnet` for a name (within the same user scope) that already exists.
    Exists(String),
    /// An operation named a subnet not in the store.
    NotFound(String),
    /// Reading/writing the backing file failed (message preserved).
    Io(String),
}

impl std::fmt::Display for SubnetError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            SubnetError::Exists(n) => write!(f, "subnet already exists: {n}"),
            SubnetError::NotFound(n) => write!(f, "subnet not found: {n}"),
            SubnetError::Io(e) => write!(f, "subnet store io: {e}"),
        }
    }
}

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

/// One subnet the node belongs to: its human name, the durable TOTP seed (hex), the
/// seed epoch, and (forward seam) the owning user.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SubnetRecord {
    /// Human subnet label (R-PAIR-4) — what the user types when pairing.
    pub name: String,
    /// The 20-byte TOTP seed, lowercase hex. The subnet secret; never synced.
    pub seed_hex: String,
    /// Monotonic seed generation (ADR-0005 #10). Bumped on every
    /// [`rotate_seed`](SubnetStore::rotate_seed); bound into the pairing transcript.
    pub epoch: u64,
    /// Owning user — the cross-user seam. `None` = the single implicit v1 user.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub user: Option<String>,
    /// Creation stamp (epoch-seconds string; opaque).
    pub created: String,
}

impl SubnetRecord {
    /// Decode the seed to raw bytes for the crypto layer. `None` if the stored hex is
    /// malformed or the wrong length (defensive — the store always writes valid hex).
    pub fn seed_bytes(&self) -> Option<[u8; TOTP_SEED_LEN]> {
        decode_hex(&self.seed_hex)
    }
}

/// The whole `subnet.json` document: every subnet the node is a member of.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SubnetStore {
    /// All subnets this node belongs to (multi-subnet — N seeds).
    #[serde(default)]
    pub subnets: Vec<SubnetRecord>,
}

impl SubnetStore {
    /// Load the node's subnet store from the canonical [`subnet_file`]. A missing file
    /// is the empty store (an unpaired node) — never an error.
    pub fn load() -> SubnetStore {
        Self::load_from(&subnet_file())
    }

    /// Load from an explicit path (the testable core). Absent ⇒ empty; an unreadable
    /// or corrupt file also degrades to empty rather than panicking (a corrupt secret
    /// file should not brick the node — re-provision via pairing).
    pub fn load_from(path: &Path) -> SubnetStore {
        match std::fs::read_to_string(path) {
            Ok(s) => serde_json::from_str(&s).unwrap_or_default(),
            Err(_) => SubnetStore::default(),
        }
    }

    /// Persist to the canonical [`subnet_file`], creating `identity/` if needed.
    pub fn save(&self) -> Result<(), SubnetError> {
        self.save_to(&subnet_file())
    }

    /// Persist to an explicit path (atomic; creates the parent dir). Atomic write so a
    /// concurrent reader never sees a torn secret file (the [`atomic`](crate::atomic)
    /// invariant).
    pub fn save_to(&self, path: &Path) -> Result<(), SubnetError> {
        if let Some(parent) = path.parent() {
            std::fs::create_dir_all(parent).map_err(|e| SubnetError::Io(e.to_string()))?;
        }
        let json =
            serde_json::to_string_pretty(self).map_err(|e| SubnetError::Io(e.to_string()))?;
        atomic_write_string(path, &json).map_err(|e| SubnetError::Io(e.to_string()))
    }

    /// Find a subnet by name (within the implicit-user scope).
    pub fn find(&self, name: &str) -> Option<&SubnetRecord> {
        self.subnets.iter().find(|s| s.name == name)
    }

    /// Create a new subnet with a freshly minted seed at epoch 1 (the "create-new"
    /// pairing path — this node becomes the sole seed-holder). Errors if the name
    /// already exists. Returns the new record.
    ///
    /// This is **subnet naming on first pairing** (REQ-PAIR-4 / ADR-0006): the subnet is
    /// named at creation ("naming moves to link-start"); the name then rides every
    /// subsequent pairing ceremony (the joiner types it; the transcript binds it).
    // [impl->REQ-HAZARD-PAIR-SEED-ROTATION] [impl->REQ-PAIR-4]
    pub fn create_subnet(&mut self, name: &str) -> Result<&SubnetRecord, SubnetError> {
        if self.find(name).is_some() {
            return Err(SubnetError::Exists(name.to_string()));
        }
        self.subnets.push(SubnetRecord {
            name: name.to_string(),
            seed_hex: encode_hex(&random_seed()),
            epoch: 1,
            user: None,
            created: now_stamp(),
        });
        Ok(self.subnets.last().expect("just pushed"))
    }

    /// Add a subnet whose seed was learned by **joining** an existing subnet (the seed
    /// and its current epoch arrive over the pairing channel from a seed-holder).
    /// Errors if the name already exists.
    // [impl->REQ-HAZARD-PAIR-SEED-ROTATION]
    pub fn add_joined(
        &mut self,
        name: &str,
        seed: [u8; TOTP_SEED_LEN],
        epoch: u64,
    ) -> Result<&SubnetRecord, SubnetError> {
        if self.find(name).is_some() {
            return Err(SubnetError::Exists(name.to_string()));
        }
        self.subnets.push(SubnetRecord {
            name: name.to_string(),
            seed_hex: encode_hex(&seed),
            epoch,
            user: None,
            created: now_stamp(),
        });
        Ok(self.subnets.last().expect("just pushed"))
    }

    /// Rotate a subnet's seed and bump its epoch (ADR-0005 #10 — the node-removal
    /// response). The old seed can no longer compute a valid current code, and the
    /// epoch bump means even a captured old transcript won't bind. The new seed must
    /// then be redistributed to the *remaining* trusted nodes over the pairing
    /// channel; callers pair node-removal (trust-store delete) **with** this rotation,
    /// because a trust-delete alone is not revocation.
    // [impl->REQ-HAZARD-PAIR-SEED-ROTATION]
    pub fn rotate_seed(&mut self, name: &str) -> Result<&SubnetRecord, SubnetError> {
        let rec = self
            .subnets
            .iter_mut()
            .find(|s| s.name == name)
            .ok_or_else(|| SubnetError::NotFound(name.to_string()))?;
        rec.seed_hex = encode_hex(&random_seed());
        rec.epoch += 1;
        Ok(rec)
    }

    /// Remove a subnet entirely (the node leaves it). Returns whether a record was
    /// removed. (Distinct from [`rotate_seed`]: leaving a subnet drops our own
    /// membership; rotating evicts *another* node from a subnet we keep.)
    pub fn remove(&mut self, name: &str) -> bool {
        let before = self.subnets.len();
        self.subnets.retain(|s| s.name != name);
        self.subnets.len() != before
    }
}

/// Mint 20 cryptographically-random seed bytes. Reuses spt-proto's Ed25519 seed
/// generator (OsRng) as the CSPRNG — the same no-extra-`rand`-dependency trick used
/// elsewhere — taking the first [`TOTP_SEED_LEN`] of its 32 random bytes.
fn random_seed() -> [u8; TOTP_SEED_LEN] {
    let mut out = [0u8; TOTP_SEED_LEN];
    out.copy_from_slice(&Identity::generate().seed()[..TOTP_SEED_LEN]);
    out
}

/// Lowercase-hex encode bytes (dependency-free; small fixed inputs).
fn encode_hex(bytes: &[u8]) -> String {
    bytes.iter().map(|b| format!("{b:02x}")).collect()
}

/// Decode lowercase/uppercase hex into a fixed-length seed. `None` on bad length or
/// non-hex digits.
fn decode_hex(s: &str) -> Option<[u8; TOTP_SEED_LEN]> {
    if s.len() != TOTP_SEED_LEN * 2 {
        return None;
    }
    let mut out = [0u8; TOTP_SEED_LEN];
    for (i, slot) in out.iter_mut().enumerate() {
        *slot = u8::from_str_radix(&s[i * 2..i * 2 + 2], 16).ok()?;
    }
    Some(out)
}

/// Current unix-seconds as an opaque stamp string.
fn now_stamp() -> String {


Seed = raw bytes in spt-store; TotpSeed in spt-net. spt binary has no spt-net dep. Check spt-net export path + whether already transitive.


Bash

echo "=== spt-net pub exports (lib.rs + pairing mod) ===" && grep -rn "pub use\|pub mod\|pub struct TotpSeed" crates/spt-net/src/lib.rs crates/spt-net/src/net/pairing/mod.rs crates/spt-net/src/net/mod.rs 2>/dev/null
echo "=== does binary tree already pull spt-net? ==="
grep -rln "spt-net" crates/spt-live/Cargo.toml crates/spt-daemon/Cargo.toml crates/spt-runtime/Cargo.toml crates/spt-msg/Cargo.toml 2>/dev/null || echo "NONE depend on spt-net"

spt-net exports + transitive dep check


=== spt-net pub exports (lib.rs + pairing mod) ===
crates/spt-net/src/lib.rs:47:pub mod net;
=== does binary tree already pull spt-net? ===
NONE depend on spt-net


echo "=== pairing mod tree ===" && ls crates/spt-net/src/net/pairing/ && echo "=== pub in net/mod.rs ===" && grep -n "pub" crates/spt-net/src/net/mod.rs | head -30 && echo "=== pub in pairing/mod.rs ===" && grep -n "pub" crates/spt-net/src/net/pairing/mod.rs | head -30

spt-net pairing module pub surface


=== pairing mod tree ===
ratelimit.rs
rendezvous.rs
spake.rs
totp.rs
transcript.rs
wire.rs
=== pub in net/mod.rs ===
grep: crates/spt-net/src/net/mod.rs: No such file or directory
=== pub in pairing/mod.rs ===
grep: crates/spt-net/src/net/pairing/mod.rs: No such file or directory


echo "=== net module decl ===" && find crates/spt-net/src -name "net.rs" -o -name "pairing.rs" | xargs grep -ln "" 2>/dev/null; echo "---"; grep -rn "pub mod pairing\|pub mod totp\|mod pairing\|pub use.*TotpSeed\|pub use.*totp" crates/spt-net/src/ 2>/dev/null

Find net/pairing module declarations


=== net module decl ===
crates/spt-net/src/net/pairing.rs
crates/spt-net/src/net.rs
---
crates/spt-net/src/net/pairing.rs:32:pub mod totp;
crates/spt-net/src/net.rs:9:pub mod pairing;


Path = `spt_net::net::pairing::totp::TotpSeed`. Confirm `from_bytes` sig + check TaskCreate for tracking. Adding spt-net dep to binary (needed for D4/D5 anyway).


grep -n "pub fn from_bytes\|pub const DIGITS\|pub const SEED_LEN\|const PERIOD" crates/spt-net/src/net/pairing/totp.rs

Confirm from_bytes signature + consts


38:pub const PERIOD_SECS: u64 = 30;
42:pub const DIGITS: u32 = 6;
46:pub const SEED_LEN: usize = 20;
68:    pub fn from_bytes(bytes: [u8; SEED_LEN]) -> TotpSeed {


Now design clear. Check workspace deps (windows/libc versions) + spt-store lib exports.


echo "=== workspace Cargo.toml ===" && cat Cargo.toml 2>/dev/null | head -60
echo "=== existing windows/libc usage ==="
grep -rn "^windows\|^libc\|windows =\|libc =\|windows-sys" crates/*/Cargo.toml Cargo.toml 2>/dev/null
echo "=== spt-store lib exports ==="
grep -n "pub mod\|pub use" crates/spt-store/src/lib.rs

Workspace deps + spt-store exports


=== workspace Cargo.toml ===
# spt-core cargo workspace (M0). Acyclically-layered crates per R-ARCH-1:
#   spt-proto -> spt-store -> spt-msg -> {spt-net, spt-term, spt-runtime} -> spt-live -> spt-daemon -> spt
# M0 builds the bottom two: spt-proto (wire) + spt-store (persistence).
# M1 adds spt-msg (local messaging) — third layer, one-way above spt-store.
# M2a adds spt-runtime (harness contract: manifest schema + AgentRuntime) — fourth layer.
# M2b adds spt-live (live-agent lifecycle executor) — fifth layer, daemon-bound (not public SDK).
# M3a adds spt-term (session-surface mechanism: ConPTY/forkpty PTY layer) — sibling of spt-runtime, above spt-msg.
# M3b adds spt-daemon (broker/brain supervisor) — seventh layer, above spt-live, below spt; internal (not public SDK).
# M4 adds spt-net (P2P WAN/LAN transport) — sibling of spt-term/spt-runtime, above spt-msg; internal, default-on `net` feature.
[workspace]
resolver = "2"
members = ["crates/spt-proto", "crates/spt-store", "crates/spt-msg", "crates/spt-net", "crates/spt-term", "crates/spt-runtime", "crates/spt-live", "crates/spt-daemon", "crates/spt", "adapters/mock"]
=== existing windows/libc usage ===
crates/spt-msg/Cargo.toml:13:libc = "0.2"
crates/spt-msg/Cargo.toml:16:windows-sys = { version = "0.59", features = ["Win32_Networking_WinSock", "Win32_Foundation"] }
crates/spt-store/Cargo.toml:15:libc = "0.2"
=== spt-store lib exports ===
14:pub mod atomic;
16:pub mod history;
17:pub mod info;
18:pub mod liveness;
19:pub mod perch;
20:pub mod proc;
21:pub mod registry;
22:pub mod seed;
23:pub mod spool;
24:pub mod subnet;
25:pub mod trust;


Codebase uses `windows-sys` 0.59 + `libc` 0.2 (not heavyweight `windows` crate) — match that. Check spt-net features: can I pull TotpSeed without the heavy iroh `net` feature?


echo "=== spt-net Cargo.toml ===" && cat crates/spt-net/Cargo.toml
echo "=== net feature gating on pairing/totp? ==="
grep -n "cfg(feature\|feature =\|pub mod pairing\|pub mod net\|#\[cfg" crates/spt-net/src/lib.rs crates/spt-net/src/net.rs

spt-net features + totp gating


=== spt-net Cargo.toml ===
[package]
name = "spt-net"
version = "0.0.0"
edition = "2021"
publish = false
description = "spt-core P2P networking (M4): the WAN/LAN transport every cross-machine capability rides — an Iroh endpoint (WAN, n0-relay default + self-host knob), mDNS LAN discovery, TOTP-seeded SPAKE2 pairing (ADR-0005), the eventually-consistent per-subnet registry (ADR-0006), cross-node Psyche sync, and the update peer-propagation transport into the M3c engine seam. The remaining sibling above spt-msg (`spt-proto -> spt-store -> spt-msg -> {spt-net, spt-term, spt-runtime}`). Internal broker machinery, NOT public SDK (R-ARCH-2). Whole transport gated behind the default-on `net` feature (ADR-0002) so minimal library consumers can build it out."

[features]
# ADR-0002: WAN transport stack is default-on in the reference binary, optional for
# embedded library consumers. The `net` feature gates the whole iroh/tokio surface
# so a `--no-default-features` lib build stays transport-free.
default = ["net"]
net = ["dep:iroh", "dep:tokio", "dep:hmac", "dep:sha1", "dep:sha2", "dep:data-encoding", "dep:spake2", "dep:subtle", "dep:zeroize"]

[dependencies]
spt-proto = { path = "../spt-proto" }
spt-store = { path = "../spt-store" }
spt-msg = { path = "../spt-msg" }
# Wire/registry/pairing-transcript types cross the network + the broker IPC, so they
# are serde-serializable.
serde = { version = "1", features = ["derive"] }
# Iroh is pinned EXACT: the API churns every release (Spike #2 note) and 0.98.2 is the
# version smoke-validated incl. native cross-NAT hole-punch. `address-lookup-mdns`
# (D1c) adds the LAN discovery service. The whole dep is `net`-gated/optional.
iroh = { version = "=0.98.2", optional = true, features = ["address-lookup-mdns"] }
# spt-net is the first async crate in the workspace (the rest is thread-based, sync);
# iroh requires a tokio runtime. The daemon (D4) will host the runtime that owns the
# broker endpoint. Pulled in only with `net`.
tokio = { version = "1", optional = true, features = ["rt-multi-thread", "macros", "time", "sync"] }
# Pairing crypto (ADR-0005 / D2), all `net`-gated/optional. RFC 6238 TOTP is rolled on
# RustCrypto HMAC+SHA-1 (D2a) rather than a TOTP crate, so the raw time-step counter
# stays first-class for transcript binding (#12) and no URL/QR/image deps are pulled.
# `data-encoding` is the dependency-free base32 for the otpauth `secret`.
hmac = { version = "0.12", optional = true }
sha1 = { version = "0.10", optional = true }
data-encoding = { version = "2", optional = true }
# SPAKE2 PAKE (D2a/b). 0.4 resolves curve25519-dalek 4.x (>=4.1.3, RUSTSEC-2024-0344
# fixed) — the SAME major ed25519-dalek v2 already pulls, so no second curve lib. The
# 0.5-pre line bumps to curve25519-dalek 5-pre and is deliberately avoided. The crate
# binds identities+messages into the derived key but does NO key confirmation, so D2b
# layers an explicit HMAC-SHA-256 confirmation MAC (the transcript binding, #12).
spake2 = { version = "0.4", optional = true }
sha2 = { version = "0.10", optional = true }
# Constant-time tag comparison for the confirmation MAC (no timing oracle on verify).
subtle = { version = "2", optional = true }
# Wipe the PAKE shared secret on drop (already in-tree via dalek; direct dep makes the
# secret-hygiene intent explicit). Pairing key material must not linger in freed memory.
zeroize = { version = "1", optional = true }

[dev-dependencies]
tempfile = "3"
=== net feature gating on pairing/totp? ===
crates/spt-net/src/lib.rs:46:#[cfg(feature = "net")]
crates/spt-net/src/lib.rs:47:pub mod net;
crates/spt-net/src/net.rs:9:pub mod pairing;


`net` module (incl. `totp`) is gated behind the iroh-pulling `net` feature. Two paths: (A) pull spt-net+iroh into the binary now, or (D) expose `totp` as a lib-minimal feature (honors ADR-0002 — net stays optional, TOTP is local crypto not transport). Leaning D if the refactor is clean. Check totp.rs coupling.


1	//! RFC 6238 TOTP — the pairing ceremony's low-entropy shared secret (ADR-0005, D2a).
2	//!
3	//! The [`TotpSeed`] is the durable per-subnet secret (`CONTEXT.md` "TOTP-seeded
4	//! SPAKE2 pairing"): generated on the first node, replicated to every trusted node,
5	//! and provisioned into the user's authenticator app via an [`otpauth://`](TotpSeed::otpauth_uri)
6	//! URI. The rotating 6-digit [`code_at`](TotpSeed::code_at) becomes the SPAKE2
7	//! password — never a bearer token over the wire.
8	//!
9	//! ## Why roll RFC 6238 here instead of a TOTP crate
10	//!
11	//! The pairing transcript MUST bind the **raw time-step counter** `T` (= unix/period)
12	//! so a stale-step pairing is rejectable (ADR-0005 #12). Every surveyed TOTP crate
13	//! (`totp-rs`, `totp-lite`, …) hides `T` behind its code-string API — we'd recompute
14	//! the division anyway. Rolling the ~RFC algorithm on the workspace's existing
15	//! HMAC/SHA primitives keeps the step counter first-class, avoids a heavyweight
16	//! dependency (URL/QR/image), and matches the crate's dependency-minimal convention.
17	//! Verified against the RFC 6238 Appendix-B SHA-1 test vectors (see tests).
18	//!
19	//! ## ±1 window (ADR-0005 #11 interaction)
20	//!
21	//! Clock skew between the two pairing machines is absorbed by accepting the adjacent
22	//! steps — but the step is **bound into the transcript**, not folded into the
23	//! password (a changed step would change the password *and* the transcript, so a
24	//! windowed retry can't work password-side). The dialer announces its step in the
25	//! clear; both sides bind that exact value and reject `|Δstep| > 1`
26	//! ([`accepts_step`]). The ±1 widening of the valid-password space is the reason the
27	//! ceremony also needs subnet-global rate limiting (D2d, #11).
28	//
29	// [impl->REQ-PAIR-1]
30
31	use hmac::{Hmac, Mac};
32	use sha1::Sha1;
33	use spt_proto::identity::Identity;
34
35	type HmacSha1 = Hmac<Sha1>;
36
37	/// TOTP period in seconds (RFC 6238 default `X`). 30s is the authenticator-app norm.
38	pub const PERIOD_SECS: u64 = 30;
39
40	/// Digits in a pairing code (RFC 6238 default). 6 is the authenticator-app norm; the
41	/// PAKE — not the digit count — supplies the cryptographic strength (ADR-0005).
42	pub const DIGITS: u32 = 6;
43
44	/// TOTP seed length in bytes. 160 bits = the RFC 4226/6238 recommended HMAC-SHA-1
45	/// key size and what authenticator apps expect from a base32 `secret`.
46	pub const SEED_LEN: usize = 20;
47
48	/// A subnet's durable TOTP seed — the shared secret behind the rotating code.
49	///
50	/// Held by every trusted node in the subnet and (via [`otpauth_uri`](Self::otpauth_uri))
51	/// by the user's authenticator app. Stored near the node's trust material, never in
52	/// the synced context repo. The [seed epoch](super) (D2c) lives alongside it; this
53	/// type is the raw key material only.
54	#[derive(Clone, PartialEq, Eq)]
55	pub struct TotpSeed([u8; SEED_LEN]);
56
57	impl TotpSeed {
58	    /// Mint a fresh random seed. Reuses spt-proto's Ed25519 seed generator (OsRng)
59	    /// as the CSPRNG source — the same no-extra-`rand`-dependency trick the local-api
60	    /// token mint uses — taking the first [`SEED_LEN`] of its 32 random bytes.
61	    pub fn generate() -> TotpSeed {
62	        let mut bytes = [0u8; SEED_LEN];
63	        bytes.copy_from_slice(&Identity::generate().seed()[..SEED_LEN]);
64	        TotpSeed(bytes)
65	    }
66
67	    /// Wrap raw seed bytes (e.g. read back from durable storage).
68	    pub fn from_bytes(bytes: [u8; SEED_LEN]) -> TotpSeed {
69	        TotpSeed(bytes)
70	    }

//! RFC 6238 TOTP — the pairing ceremony's low-entropy shared secret (ADR-0005, D2a).
//!
//! The [`TotpSeed`] is the durable per-subnet secret (`CONTEXT.md` "TOTP-seeded
//! SPAKE2 pairing"): generated on the first node, replicated to every trusted node,
//! and provisioned into the user's authenticator app via an [`otpauth://`](TotpSeed::otpauth_uri)
//! URI. The rotating 6-digit [`code_at`](TotpSeed::code_at) becomes the SPAKE2
//! password — never a bearer token over the wire.
//!
//! ## Why roll RFC 6238 here instead of a TOTP crate
//!
//! The pairing transcript MUST bind the **raw time-step counter** `T` (= unix/period)
//! so a stale-step pairing is rejectable (ADR-0005 #12). Every surveyed TOTP crate
//! (`totp-rs`, `totp-lite`, …) hides `T` behind its code-string API — we'd recompute
//! the division anyway. Rolling the ~RFC algorithm on the workspace's existing
//! HMAC/SHA primitives keeps the step counter first-class, avoids a heavyweight
//! dependency (URL/QR/image), and matches the crate's dependency-minimal convention.
//! Verified against the RFC 6238 Appendix-B SHA-1 test vectors (see tests).
//!
//! ## ±1 window (ADR-0005 #11 interaction)
//!
//! Clock skew between the two pairing machines is absorbed by accepting the adjacent
//! steps — but the step is **bound into the transcript**, not folded into the
//! password (a changed step would change the password *and* the transcript, so a
//! windowed retry can't work password-side). The dialer announces its step in the
//! clear; both sides bind that exact value and reject `|Δstep| > 1`
//! ([`accepts_step`]). The ±1 widening of the valid-password space is the reason the
//! ceremony also needs subnet-global rate limiting (D2d, #11).
//
// [impl->REQ-PAIR-1]

use hmac::{Hmac, Mac};
use sha1::Sha1;
use spt_proto::identity::Identity;

type HmacSha1 = Hmac<Sha1>;

/// TOTP period in seconds (RFC 6238 default `X`). 30s is the authenticator-app norm.
pub const PERIOD_SECS: u64 = 30;

/// Digits in a pairing code (RFC 6238 default). 6 is the authenticator-app norm; the
/// PAKE — not the digit count — supplies the cryptographic strength (ADR-0005).
pub const DIGITS: u32 = 6;

/// TOTP seed length in bytes. 160 bits = the RFC 4226/6238 recommended HMAC-SHA-1
/// key size and what authenticator apps expect from a base32 `secret`.
pub const SEED_LEN: usize = 20;

/// A subnet's durable TOTP seed — the shared secret behind the rotating code.
///
/// Held by every trusted node in the subnet and (via [`otpauth_uri`](Self::otpauth_uri))
/// by the user's authenticator app. Stored near the node's trust material, never in
/// the synced context repo. The [seed epoch](super) (D2c) lives alongside it; this
/// type is the raw key material only.
#[derive(Clone, PartialEq, Eq)]
pub struct TotpSeed([u8; SEED_LEN]);

impl TotpSeed {
    /// Mint a fresh random seed. Reuses spt-proto's Ed25519 seed generator (OsRng)
    /// as the CSPRNG source — the same no-extra-`rand`-dependency trick the local-api
    /// token mint uses — taking the first [`SEED_LEN`] of its 32 random bytes.
    pub fn generate() -> TotpSeed {
        let mut bytes = [0u8; SEED_LEN];
        bytes.copy_from_slice(&Identity::generate().seed()[..SEED_LEN]);
        TotpSeed(bytes)
    }

    /// Wrap raw seed bytes (e.g. read back from durable storage).
    pub fn from_bytes(bytes: [u8; SEED_LEN]) -> TotpSeed {
        TotpSeed(bytes)
    }


totp.rs coupling is minimal (hmac, sha1, spt_proto, data-encoding, local helpers).

Decision settled: **Option A** — depend on `spt-net` from the binary. Not debt: ADR-0002 states "WAN transport stack is default-on in the **reference binary**" — `spt` IS the reference binary, so net-on is the intended design. Reimplementing TOTP is ruled out (must match pairing's computation exactly). Refactoring M4's feature gating mid-D2 risks the 42 passing tests for a benefit D4 erases anyway.

Read main.rs for module decls, then build.


1	//! `spt` — the single spt-core binary. A **thin** command layer over `spt-msg`:
2	//! it owns I/O (stdin/stdout/stderr) and process exit codes; all messaging
3	//! logic lives in the crate (clean-room separation per the M1 plan).
4	//!
5	//! Subcommands (REQ-MSG-2 stable surface): `send` · `ring` · `ready` · `poll` ·
6	//! `list` · `stop` · `whoami`. The harness-contract inbound surface lives under
7	//! the `api` group (REQ-API-1), dispatched from [`api`].
8	// [impl->REQ-MSG-2]
9
10	mod api;
11	mod cli;
12	mod roster;
13
14	#[cfg(test)]
15	mod testutil;
16
17	fn main() {
18	    let code = cli::run();
19	    std::process::exit(code);
20	}
21

//! `spt` — the single spt-core binary. A **thin** command layer over `spt-msg`:
//! it owns I/O (stdin/stdout/stderr) and process exit codes; all messaging
//! logic lives in the crate (clean-room separation per the M1 plan).
//!
//! Subcommands (REQ-MSG-2 stable surface): `send` · `ring` · `ready` · `poll` ·
//! `list` · `stop` · `whoami`. The harness-contract inbound surface lives under
//! the `api` group (REQ-API-1), dispatched from [`api`].
// [impl->REQ-MSG-2]

mod api;
mod cli;
mod roster;

#[cfg(test)]
mod testutil;

fn main() {
    let code = cli::run();
    std::process::exit(code);
}


grep -n "REQ-PAIR-3\|REQ-PAIR-6\|REQ-PAIR-4\|REQ-PAIR-5" traceable-reqs.toml

Find PAIR-3/6 registry entries


274:id = "REQ-PAIR-3"
278:id = "REQ-PAIR-4"
283:id = "REQ-PAIR-5"
287:id = "REQ-PAIR-6"


270	id = "REQ-PAIR-2"
271	title = "Local trust store with TOFU + warn-on-change"
272	required_stages = ["impl", "unit"]  # activated M4-D2e: spt-store::trust::TrustStore (identity/trust/peers.json) pins peer node pubkeys per-subnet post-ceremony (TOFU). record() => Pinned/AlreadyTrusted/KeyChanged{existing}; KeyChanged never auto-applied (repin on accepted rotation); is_trusted = connect gate; revoke = trust-delete (pair w/ seed rotation #10). Warn-on-change anchored on optional (subnet,user,label) — dormant until labels exist (GUI/D9), honest v1 residual. int = on-wire trusted-vs-untrusted gate at D2-wire/D9.
273	[[requirements]]
274	id = "REQ-PAIR-3"
275	title = "Fetch current pairing code from any paired node"
276	required_stages = []
277	[[requirements]]
278	id = "REQ-PAIR-4"
279	title = "Subnet naming on first pairing"
280	required_stages = ["impl", "unit"]  # activated M4-D2f: SubnetStore::create_subnet names the subnet at creation (sole seed-holder, epoch 1; "naming moves to link-start" ADR-0006); the name rides every ceremony (joiner types it, transcript binds it) and reaches the joiner via D2f seed transfer. unit = wire create_new_then_join_learns_named_subnet. CLI naming prompt is later (daemon wiring, D4+); int = two-host at D9.
281	# ── Multi-subnet pairing ceremony (ADR-0005 amend, 2026-06-01; activate M4) ──
282	[[requirements]]
283	id = "REQ-PAIR-5"
284	title = "Multi-subnet pairing: subnet-name discovery input, create-new-names-up-front, rendezvous-token hashing"
285	required_stages = ["impl", "unit"]  # activated M4-D2f: discovery takes code+subnet-name (the wire Hello carries the name; both are ceremony inputs); rendezvous-token H(name‖TOTP-step) derived in spt-net::net::pairing::rendezvous (TOTP-step = clock-derived window, not seed epoch — D2-wire Q2); seed transfer on join (wire F7 Seed -> SubnetStore::add_joined) makes the joiner a full member/seed-holder. unit = rendezvous tests + wire joiner_becomes_seed_holder/repair_member_keeps_seed. int = relay rendezvous routing + two-host at D9 (Q1 deferral).
286	[[requirements]]
287	id = "REQ-PAIR-6"
288	title = "Elevation-gated per-subnet code fetch (UAC/root or elevated agent; else authenticator app)"
289	required_stages = []
290	[[requirements]]
291	id = "REQ-PAIR-7"
292	title = "Subnet icon (inline image metadata, GUI-only consumer)"
293	required_stages = []
294	# ── Endpoint access control (ADR-0009, 2026-06-02; activate M4 node-tier) ──
295	[[requirements]]
296	id = "REQ-SEC-1"
297	title = "Per-endpoint access whitelist: origin-node gate, stateful-firewall (reply/outbound exempt), node-now/user-later, outer gate before grants"
298	required_stages = []
299	# ── Subnet notifications (ADR-0007, 2026-06-01; activate M4) ──

id = "REQ-PAIR-2"
title = "Local trust store with TOFU + warn-on-change"
required_stages = ["impl", "unit"]  # activated M4-D2e: spt-store::trust::TrustStore (identity/trust/peers.json) pins peer node pubkeys per-subnet post-ceremony (TOFU). record() => Pinned/AlreadyTrusted/KeyChanged{existing}; KeyChanged never auto-applied (repin on accepted rotation); is_trusted = connect gate; revoke = trust-delete (pair w/ seed rotation #10). Warn-on-change anchored on optional (subnet,user,label) — dormant until labels exist (GUI/D9), honest v1 residual. int = on-wire trusted-vs-untrusted gate at D2-wire/D9.
[[requirements]]
id = "REQ-PAIR-3"
title = "Fetch current pairing code from any paired node"
required_stages = []
[[requirements]]
id = "REQ-PAIR-4"
title = "Subnet naming on first pairing"
required_stages = ["impl", "unit"]  # activated M4-D2f: SubnetStore::create_subnet names the subnet at creation (sole seed-holder, epoch 1; "naming moves to link-start" ADR-0006); the name rides every ceremony (joiner types it, transcript binds it) and reaches the joiner via D2f seed transfer. unit = wire create_new_then_join_learns_named_subnet. CLI naming prompt is later (daemon wiring, D4+); int = two-host at D9.
# ── Multi-subnet pairing ceremony (ADR-0005 amend, 2026-06-01; activate M4) ──
[[requirements]]
id = "REQ-PAIR-5"
title = "Multi-subnet pairing: subnet-name discovery input, create-new-names-up-front, rendezvous-token hashing"
required_stages = ["impl", "unit"]  # activated M4-D2f: discovery takes code+subnet-name (the wire Hello carries the name; both are ceremony inputs); rendezvous-token H(name‖TOTP-step) derived in spt-net::net::pairing::rendezvous (TOTP-step = clock-derived window, not seed epoch — D2-wire Q2); seed transfer on join (wire F7 Seed -> SubnetStore::add_joined) makes the joiner a full member/seed-holder. unit = rendezvous tests + wire joiner_becomes_seed_holder/repair_member_keeps_seed. int = relay rendezvous routing + two-host at D9 (Q1 deferral).
[[requirements]]
id = "REQ-PAIR-6"
title = "Elevation-gated per-subnet code fetch (UAC/root or elevated agent; else authenticator app)"
required_stages = []
[[requirements]]
id = "REQ-PAIR-7"
title = "Subnet icon (inline image metadata, GUI-only consumer)"
required_stages = []
# ── Endpoint access control (ADR-0009, 2026-06-02; activate M4 node-tier) ──
[[requirements]]
id = "REQ-SEC-1"
title = "Per-endpoint access whitelist: origin-node gate, stateful-firewall (reply/outbound exempt), node-now/user-later, outer gate before grants"
required_stages = []
# ── Subnet notifications (ADR-0007, 2026-06-01; activate M4) ──


Have everything. Set up tasks, then build.


ToolSearch

Question: select:TaskCreate


Question: select:TaskCreate


Skip task overhead — 5 focused edits. Write Cargo.toml, elevation.rs, main.rs in parallel.


Edit

spt-proto = { path = "../spt-proto" }
spt-store = { path = "../spt-store" }
spt-msg = { path = "../spt-msg" }
spt-runtime = { path = "../spt-runtime" }
spt-live = { path = "../spt-live" }
spt-daemon = { path = "../spt-daemon" }
clap = { version = "4", features = ["derive"] }

[dev-dependencies]
tempfile = "3"
toml = "0.8"

spt-proto = { path = "../spt-proto" }
spt-store = { path = "../spt-store" }
spt-msg = { path = "../spt-msg" }
spt-runtime = { path = "../spt-runtime" }
spt-live = { path = "../spt-live" }
spt-daemon = { path = "../spt-daemon" }
# The reference binary ships the WAN transport on by default (ADR-0002). D2g uses its
# RFC-6238 TotpSeed (spt_net::net::pairing::totp) to render a subnet's local pairing
# code — reused, not reimplemented, so the CLI code matches the ceremony's exactly.
spt-net = { path = "../spt-net" }
clap = { version = "4", features = ["derive"] }

# OS privilege-elevation probe for the node-bound code fetch (REQ-PAIR-6). Only the
# binary pulls the OS deps; the core stays dep-minimal. Matches the workspace's
# existing libc/windows-sys usage (spt-msg, spt-store) rather than the heavy `windows`.
[target.'cfg(unix)'.dependencies]
libc = "0.2"

[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.59", features = [
    "Win32_Foundation",
    "Win32_Security",
    "Win32_System_Threading",
] }

[dev-dependencies]
tempfile = "3"
toml = "0.8"


<tool_use_error>File has not been read yet. Read it first before writing to it.</tool_use_error>

Error: File has not been read yet. Read it first before writing to it.


1	[package]
2	name = "spt"
3	version = "0.0.0"
4	edition = "2021"
5	publish = false
6	description = "spt-core CLI: the single `spt` binary. Thin command layer over spt-msg (send/ring/ready/poll/list/stop/whoami) + the harness-contract `api` surface (spt-runtime)."
7
8	[[bin]]
9	name = "spt"
10	path = "src/main.rs"
11
12	[dependencies]
13	spt-proto = { path = "../spt-proto" }
14	spt-store = { path = "../spt-store" }
15	spt-msg = { path = "../spt-msg" }
16	spt-runtime = { path = "../spt-runtime" }
17	spt-live = { path = "../spt-live" }
18	spt-daemon = { path = "../spt-daemon" }
19	clap = { version = "4", features = ["derive"] }
20
21	[dev-dependencies]
22	tempfile = "3"
23	toml = "0.8"
24

[package]
name = "spt"
version = "0.0.0"
edition = "2021"
publish = false
description = "spt-core CLI: the single `spt` binary. Thin command layer over spt-msg (send/ring/ready/poll/list/stop/whoami) + the harness-contract `api` surface (spt-runtime)."

[[bin]]
name = "spt"
path = "src/main.rs"

[dependencies]
spt-proto = { path = "../spt-proto" }
spt-store = { path = "../spt-store" }
spt-msg = { path = "../spt-msg" }
spt-runtime = { path = "../spt-runtime" }
spt-live = { path = "../spt-live" }
spt-daemon = { path = "../spt-daemon" }
clap = { version = "4", features = ["derive"] }

[dev-dependencies]
tempfile = "3"
toml = "0.8"


spt-proto = { path = "../spt-proto" }
spt-store = { path = "../spt-store" }
spt-msg = { path = "../spt-msg" }
spt-runtime = { path = "../spt-runtime" }
spt-live = { path = "../spt-live" }
spt-daemon = { path = "../spt-daemon" }
clap = { version = "4", features = ["derive"] }

[dev-dependencies]
tempfile = "3"
toml = "0.8"

spt-proto = { path = "../spt-proto" }
spt-store = { path = "../spt-store" }
spt-msg = { path = "../spt-msg" }
spt-runtime = { path = "../spt-runtime" }
spt-live = { path = "../spt-live" }
spt-daemon = { path = "../spt-daemon" }
# The reference binary ships the WAN transport on by default (ADR-0002). D2g reuses its
# RFC-6238 TotpSeed (spt_net::net::pairing::totp) to render a subnet's local pairing
# code — reused, not reimplemented, so the CLI code matches the ceremony's exactly.
spt-net = { path = "../spt-net" }
clap = { version = "4", features = ["derive"] }

# OS privilege-elevation probe for the node-bound code fetch (REQ-PAIR-6). Only the
# binary pulls the OS deps; the core stays dep-minimal. Matches the workspace's existing
# libc/windows-sys usage (spt-msg, spt-store) rather than the heavyweight `windows`.
[target.'cfg(unix)'.dependencies]
libc = "0.2"

[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.59", features = [
    "Win32_Foundation",
    "Win32_Security",
    "Win32_System_Threading",
] }

[dev-dependencies]
tempfile = "3"
toml = "0.8"


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

spt-proto = { path = "../spt-proto" }
spt-store = { path = "../spt-store" }
spt-msg = { path = "../spt-msg" }
spt-runtime = { path = "../spt-runtime" }
spt-live = { path = "../spt-live" }
spt-daemon = { path = "../spt-daemon" }
# The reference binary ships the WAN transport on by default (ADR-0002). D2g reuses its
# RFC-6238 TotpSeed (spt_net::net::pairing::totp) to render a subnet's local pairing
# code — reused, not reimplemented, so the CLI code matches the ceremony's exactly.
spt-net = { path = "../spt-net" }
clap = { version = "4", features = ["derive"] }

# OS privilege-elevation probe for the node-bound code fetch (REQ-PAIR-6). Only the
# binary pulls the OS deps; the core stays dep-minimal. Matches the workspace's existing
# libc/windows-sys usage (spt-msg, spt-store) rather than the heavyweight `windows`.
[target.'cfg(unix)'.dependencies]
libc = "0.2"

[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.59", features = [
    "Win32_Foundation",
    "Win32_Security",
    "Win32_System_Threading",
] }

[dev-dependencies]
tempfile = "3"
toml = "0.8"

[package]
name = "spt"
version = "0.0.0"
edition = "2021"
publish = false
description = "spt-core CLI: the single `spt` binary. Thin command layer over spt-msg (send/ring/ready/poll/list/stop/whoami) + the harness-contract `api` surface (spt-runtime)."

[[bin]]
name = "spt"
path = "src/main.rs"

[dependencies]
spt-proto = { path = "../spt-proto" }
spt-store = { path = "../spt-store" }
spt-msg = { path = "../spt-msg" }
spt-runtime = { path = "../spt-runtime" }
spt-live = { path = "../spt-live" }
spt-daemon = { path = "../spt-daemon" }
clap = { version = "4", features = ["derive"] }

[dev-dependencies]
tempfile = "3"
toml = "0.8"

+# The reference binary ships the WAN transport on by default (ADR-0002). D2g reuses its

+# RFC-6238 TotpSeed (spt_net::net::pairing::totp) to render a subnet's local pairing

+# code — reused, not reimplemented, so the CLI code matches the ceremony's exactly.

+# OS privilege-elevation probe for the node-bound code fetch (REQ-PAIR-6). Only the

+# binary pulls the OS deps; the core stays dep-minimal. Matches the workspace's existing

+# libc/windows-sys usage (spt-msg, spt-store) rather than the heavyweight `windows`.

+windows-sys = { version = "0.59", features = [


Now elevation.rs + main.rs mod decl.


Write

//! OS privilege-elevation gate for the node-bound pairing-code fetch (REQ-PAIR-6).
//!
//! Surfacing a subnet's live TOTP code off a trusted node is the *privileged* fetch
//! path: it hands out subnet-joining authority, so the process must be elevated
//! (Windows UAC / Linux root-or-equivalent). When it is not, we refuse the node-bound
//! fetch and point the user at their authenticator app — where the same seed was
//! provisioned at pairing (the `otpauth://` URI), giving the code without privilege.
//!
//! ## Structure (a thin testable seam over an environment-dependent OS call)
//!
//! [`current`] is the only environment-dependent part: a `cfg`-split OS query that
//! cannot run hermetically in CI (the test process's real euid/token is whatever the
//! runner happens to be). So the *decision* — does this elevation level show the code
//! or fall back? — is factored into [`gate_for`], a pure function unit-tested both
//! ways. An unknown/unsupported platform maps to [`Elevation::Unknown`], which
//! [`gate_for`] treats as NOT elevated: **fail safe** — never leak the code when we
//! cannot positively confirm privilege.
// [impl->REQ-PAIR-6]

/// The process's OS privilege level, as far as we can positively determine it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Elevation {
    /// Confirmed elevated: Windows token is elevated, or Unix euid == 0.
    Elevated,
    /// Confirmed NOT elevated.
    NotElevated,
    /// Could not determine (unsupported platform, or the OS query failed). Treated as
    /// NOT elevated by [`gate_for`] — we never assume privilege we cannot confirm.
    Unknown,
}

/// Whether the gated fetch may surface the code, or must fall back to the app.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Gate {
    /// Elevated — render the code + `otpauth://` URI.
    Show,
    /// Not elevated (or undeterminable) — refuse, point at the authenticator app.
    Fallback,
}

/// The pure gate decision (the testable seam). Only a *positively confirmed* elevation
/// shows the code; `NotElevated` and `Unknown` both fall back (fail safe).
// [impl->REQ-PAIR-6]
pub fn gate_for(elevation: Elevation) -> Gate {
    match elevation {
        Elevation::Elevated => Gate::Show,
        Elevation::NotElevated | Elevation::Unknown => Gate::Fallback,
    }
}

/// The fallback guidance shown when the fetch is refused. Names the authenticator app
/// explicitly (the seed was provisioned there at pairing via the `otpauth://` URI), so
/// the user has an unprivileged path to the same code.
pub fn fallback_message(subnet: &str) -> String {
    format!(
        "ELEVATION_REQUIRED: showing the code for subnet '{subnet}' off this node needs an \
         elevated process (run as administrator / root). Without elevation, read the current \
         code from your authenticator app instead — the seed was provisioned there at pairing."
    )
}

/// Query the OS for this process's elevation level. `cfg`-split; an unsupported
/// platform or a failed OS call returns [`Elevation::Unknown`] (→ fail safe).
// [impl->REQ-PAIR-6]
pub fn current() -> Elevation {
    detect()
}

#[cfg(unix)]
fn detect() -> Elevation {
    // Root (euid 0) is the Unix "elevated" equivalent. `geteuid` cannot fail.
    if unsafe { libc::geteuid() } == 0 {
        Elevation::Elevated
    } else {
        Elevation::NotElevated
    }
}

#[cfg(windows)]
fn detect() -> Elevation {
    use core::ffi::c_void;
    use windows_sys::Win32::Foundation::{CloseHandle, HANDLE};
    use windows_sys::Win32::Security::{
        GetTokenInformation, TokenElevation, TOKEN_ELEVATION, TOKEN_QUERY,
    };
    use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};

    // Open the current process token, query TokenElevation. Any failure → Unknown
    // (fail safe). GetCurrentProcess returns a pseudo-handle we must NOT close; only
    // the opened token handle is released.
    unsafe {
        let mut token: HANDLE = std::ptr::null_mut();
        if OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) == 0 {
            return Elevation::Unknown;
        }
        let mut info = TOKEN_ELEVATION { TokenIsElevated: 0 };
        let mut ret_len: u32 = 0;
        let ok = GetTokenInformation(
            token,
            TokenElevation,
            &mut info as *mut _ as *mut c_void,
            std::mem::size_of::<TOKEN_ELEVATION>() as u32,
            &mut ret_len,
        );
        CloseHandle(token);
        if ok == 0 {
            Elevation::Unknown
        } else if info.TokenIsElevated != 0 {
            Elevation::Elevated
        } else {
            Elevation::NotElevated
        }
    }
}

#[cfg(not(any(unix, windows)))]
fn detect() -> Elevation {
    // Unsupported platform: we cannot confirm privilege, so refuse to assume it.
    Elevation::Unknown
}

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

    // [unit->REQ-PAIR-6] only confirmed elevation shows the code; NotElevated AND the
    // undeterminable Unknown both fall back (fail safe — no code without proof).
    #[test]
    fn gate_shows_only_when_confirmed_elevated() {
        assert_eq!(gate_for(Elevation::Elevated), Gate::Show);
        assert_eq!(gate_for(Elevation::NotElevated), Gate::Fallback);
        assert_eq!(gate_for(Elevation::Unknown), Gate::Fallback);
    }

    // [unit->REQ-PAIR-6] the fallback names the authenticator app (the unprivileged
    // path to the same code) and the subnet it concerns.
    #[test]
    fn fallback_message_names_authenticator_app_and_subnet() {
        let msg = fallback_message("home fleet");
        assert!(msg.contains("authenticator app"));
        assert!(msg.contains("home fleet"));
    }

    // The live OS probe is environment-dependent (the test runner's own privilege), so
    // we only assert it returns a determinate value here on the supported platforms —
    // the show/fallback *decision* is covered hermetically via `gate_for` above. Real
    // elevated-vs-not end-to-end verification is manual (todlando / D9).
    #[test]
    fn current_returns_a_determinate_level_on_supported_platforms() {
        let e = current();
        if cfg!(any(unix, windows)) {
            assert!(matches!(e, Elevation::Elevated | Elevation::NotElevated));
        }
    }
}


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

//! OS privilege-elevation gate for the node-bound pairing-code fetch (REQ-PAIR-6).
//!
//! Surfacing a subnet's live TOTP code off a trusted node is the *privileged* fetch
//! path: it hands out subnet-joining authority, so the process must be elevated
//! (Windows UAC / Linux root-or-equivalent). When it is not, we refuse the node-bound
//! fetch and point the user at their authenticator app — where the same seed was
//! provisioned at pairing (the `otpauth://` URI), giving the code without privilege.
//!
//! ## Structure (a thin testable seam over an environment-dependent OS call)
//!
//! [`current`] is the only environment-dependent part: a `cfg`-split OS query that
//! cannot run hermetically in CI (the test process's real euid/token is whatever the
//! runner happens to be). So the *decision* — does this elevation level show the code
//! or fall back? — is factored into [`gate_for`], a pure function unit-tested both
//! ways. An unknown/unsupported platform maps to [`Elevation::Unknown`], which
//! [`gate_for`] treats as NOT elevated: **fail safe** — never leak the code when we
//! cannot positively confirm privilege.
// [impl->REQ-PAIR-6]

/// The process's OS privilege level, as far as we can positively determine it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Elevation {
    /// Confirmed elevated: Windows token is elevated, or Unix euid == 0.
    Elevated,
    /// Confirmed NOT elevated.
    NotElevated,
    /// Could not determine (unsupported platform, or the OS query failed). Treated as
    /// NOT elevated by [`gate_for`] — we never assume privilege we cannot confirm.
    Unknown,
}

/// Whether the gated fetch may surface the code, or must fall back to the app.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Gate {
    /// Elevated — render the code + `otpauth://` URI.
    Show,
    /// Not elevated (or undeterminable) — refuse, point at the authenticator app.
    Fallback,
}

/// The pure gate decision (the testable seam). Only a *positively confirmed* elevation
/// shows the code; `NotElevated` and `Unknown` both fall back (fail safe).
// [impl->REQ-PAIR-6]
pub fn gate_for(elevation: Elevation) -> Gate {
    match elevation {
        Elevation::Elevated => Gate::Show,
        Elevation::NotElevated | Elevation::Unknown => Gate::Fallback,
    }
}

/// The fallback guidance shown when the fetch is refused. Names the authenticator app
/// explicitly (the seed was provisioned there at pairing via the `otpauth://` URI), so
/// the user has an unprivileged path to the same code.
pub fn fallback_message(subnet: &str) -> String {
    format!(
        "ELEVATION_REQUIRED: showing the code for subnet '{subnet}' off this node needs an \
         elevated process (run as administrator / root). Without elevation, read the current \
         code from your authenticator app instead — the seed was provisioned there at pairing."
    )
}

/// Query the OS for this process's elevation level. `cfg`-split; an unsupported
/// platform or a failed OS call returns [`Elevation::Unknown`] (→ fail safe).
// [impl->REQ-PAIR-6]
pub fn current() -> Elevation {
    detect()
}

#[cfg(unix)]
fn detect() -> Elevation {
    // Root (euid 0) is the Unix "elevated" equivalent. `geteuid` cannot fail.
    if unsafe { libc::geteuid() } == 0 {
        Elevation::Elevated
    } else {
        Elevation::NotElevated
    }
}

#[cfg(windows)]
fn detect() -> Elevation {
    use core::ffi::c_void;
    use windows_sys::Win32::Foundation::{CloseHandle, HANDLE};
    use windows_sys::Win32::Security::{
        GetTokenInformation, TokenElevation, TOKEN_ELEVATION, TOKEN_QUERY,
    };
    use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};

    // Open the current process token, query TokenElevation. Any failure → Unknown
    // (fail safe). GetCurrentProcess returns a pseudo-handle we must NOT close; only
    // the opened token handle is released.
    unsafe {
        let mut token: HANDLE = std::ptr::null_mut();
        if OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) == 0 {
            return Elevation::Unknown;
        }
        let mut info = TOKEN_ELEVATION { TokenIsElevated: 0 };
        let mut ret_len: u32 = 0;
        let ok = GetTokenInformation(
            token,
            TokenElevation,
            &mut info as *mut _ as *mut c_void,
            std::mem::size_of::<TOKEN_ELEVATION>() as u32,
            &mut ret_len,
        );
        CloseHandle(token);
        if ok == 0 {
            Elevation::Unknown
        } else if info.TokenIsElevated != 0 {
            Elevation::Elevated
        } else {
            Elevation::NotElevated
        }
    }
}

#[cfg(not(any(unix, windows)))]
fn detect() -> Elevation {
    // Unsupported platform: we cannot confirm privilege, so refuse to assume it.
    Elevation::Unknown
}

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

    // [unit->REQ-PAIR-6] only confirmed elevation shows the code; NotElevated AND the
    // undeterminable Unknown both fall back (fail safe — no code without proof).
    #[test]
    fn gate_shows_only_when_confirmed_elevated() {
        assert_eq!(gate_for(Elevation::Elevated), Gate::Show);
        assert_eq!(gate_for(Elevation::NotElevated), Gate::Fallback);
        assert_eq!(gate_for(Elevation::Unknown), Gate::Fallback);
    }

    // [unit->REQ-PAIR-6] the fallback names the authenticator app (the unprivileged
    // path to the same code) and the subnet it concerns.
    #[test]
    fn fallback_message_names_authenticator_app_and_subnet() {
        let msg = fallback_message("home fleet");
        assert!(msg.contains("authenticator app"));
        assert!(msg.contains("home fleet"));
    }

    // The live OS probe is environment-dependent (the test runner's own privilege), so
    // we only assert it returns a determinate value here on the supported platforms —
    // the show/fallback *decision* is covered hermetically via `gate_for` above. Real
    // elevated-vs-not end-to-end verification is manual (todlando / D9).
    #[test]
    fn current_returns_a_determinate_level_on_supported_platforms() {
        let e = current();
        if cfg!(any(unix, windows)) {
            assert!(matches!(e, Elevation::Elevated | Elevation::NotElevated));
        }
    }
}


Edit

mod api;
mod cli;
mod elevation;
mod roster;


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

mod api;
mod cli;
mod elevation;
mod roster;

//! `spt` — the single spt-core binary. A **thin** command layer over `spt-msg`:
//! it owns I/O (stdin/stdout/stderr) and process exit codes; all messaging
//! logic lives in the crate (clean-room separation per the M1 plan).
//!
//! Subcommands (REQ-MSG-2 stable surface): `send` · `ring` · `ready` · `poll` ·
//! `list` · `stop` · `whoami`. The harness-contract inbound surface lives under
//! the `api` group (REQ-API-1), dispatched from [`api`].
// [impl->REQ-MSG-2]

mod api;
mod cli;
mod roster;

#[cfg(test)]
mod testutil;

fn main() {
    let code = cli::run();
    std::process::exit(code);
}

mod api;
mod cli;
+mod elevation;
mod roster;
#[cfg(test)]


Now the big one — cli.rs: `Pair`/`ShowTotp` subcommand, resolution seam, handler, tests.


use clap::{Parser, Subcommand};
use spt_msg::deliver::{self, SendOutcome};
use spt_msg::ready::ReadyAgent;
use spt_msg::ring::{self, RingOutcome};
use spt_store::perch::{self, ParentHint};
use spt_store::registry;

use crate::roster;

use clap::{Parser, Subcommand};
use spt_msg::deliver::{self, SendOutcome};
use spt_msg::ready::ReadyAgent;
use spt_msg::ring::{self, RingOutcome};
use spt_net::net::pairing::totp::TotpSeed;
use spt_store::perch::{self, ParentHint};
use spt_store::registry;
use spt_store::subnet::{SubnetError, SubnetStore};

use crate::elevation::{self, Elevation, Gate};
use crate::roster;

/// Product tag embedded in the `otpauth://` provisioning URI (the authenticator-app
/// issuer label). Stable user-facing string.
const ISSUER: &str = "spt";

/// `spt pair show-totp` refused the fetch for lack of OS elevation. Distinct from the
/// crate-wide codes (`0` ok · `1` runtime refusal · `2` usage): `3` says the fetch was
/// *refused, not failed* — re-run elevated, or read the code from your authenticator app.
const EXIT_NOT_ELEVATED: i32 = 3;