1	//! The brain's live-agent Psyche host (M11-W0.1, REQ-DAEMON-1) — re-host the
2	//! daemon-hosted LiveAgent lifecycle from disk and reconcile the host-set.
3	//!
4	//! This is the production caller `run_pulse_loop` lacked: the brain instantiates
5	//! a [`BrainLifecycle`] per hosted **live** endpoint and drives its config-paced
6	//! pulse loop on a supervised thread. It is the **harness-endpoint mirror** of
7	//! [`crate::shellwake::reconcile_once`] (which re-hosts offline shells from
8	//! disk): same shape — enumerate Self perches, resolve each manifest from its
9	//! `info.json` `adapter` option, hold a stop flag per supervised thread, and run
10	//! one bounded reconcile sweep at boot and every tick. Where the shell waker
11	//! hosts **offline** shells, this hosts **online** live endpoints — `status` is
12	//! daemon-authoritative liveness (CONTEXT:194 / KH 2.5), so re-hosting the
13	//! online ones re-hosts exactly what was live before a brain (re)start, and
14	//! never resurrects a gracefully-departed (offline) agent.
15	//!
16	//! **Online-only is staged behind the interim.** The interim `spt api listen`
17	//! process still first-hosts its live agents today; those perches are
18	//! `status`-ABSENT (not "online"), so this scan ignores them — no double-host in
19	//! the W0.1→W0.2 window. W0.2 retires the interim and makes the brain the
20	//! first-host (it sets `status=online` on a live `listen`/`bind`); from then this
21	//! reconcile reacts within one tick, the same way the shell waker reacts to a
22	//! CLI-process status flip. The full bind→online→brain-restart→re-host arc is
23	//! proven through the real brain process by the W0.4 production int.
24	// [impl->REQ-DAEMON-1]
25
26	use std::collections::HashMap;
27	use std::path::Path;
28	use std::sync::atomic::{AtomicBool, Ordering};
29	use std::sync::{Arc, Mutex};
30	use std::thread::JoinHandle;
31	use std::time::Duration;
32
33	use spt_runtime::registry::AdapterRecord;
34	use spt_runtime::Manifest;
35	use spt_store::liveness::STATUS_ONLINE;
36	use spt_store::perch::{self, ParentHint};
37
38	use crate::brainproc::StartReason;
39	use crate::config::DaemonConfig;
40	use crate::lifecycle::BrainLifecycle;
41
42	/// The `InfoJson::state` tag of a live agent — only these get a daemon-hosted
43	/// Psyche; a `ready_agent` (messaging-only) never does.
44	const LIVE_AGENT_STATE: &str = "live_agent";
45
46	/// The reconcile cadence (ms): a lifecycle flip lands in a CLI process, so the
47	/// daemon loop is the invariant holder — a flip is at most one tick stale
48	/// (mirrors [`crate::shellwake::RECONCILE_INTERVAL_MS`]).
49	pub const LIVE_RECONCILE_INTERVAL_MS: u64 = 5_000;
50
51	/// One brain-hosted live lifecycle: its config-paced pulse-driver thread plus the
52	/// held stop flag (tripped to un-host on an offline-transition).
53	struct HostedLife {
54	    stop: Arc<AtomicBool>,
55	    thread: JoinHandle<()>,
56	}
57
58	/// The brain's hosted live-endpoint table, keyed by endpoint id. Shared between
59	/// the reconcile loop and anything that must stop a host. Mirror of
60	/// [`crate::shellwake::WakeSet`].
61	#[derive(Default)]
62	pub struct LiveSet {
63	    inner: Mutex<HashMap<String, HostedLife>>,
64	}
65
66	impl LiveSet {
67	    pub fn new() -> Self {
68	        Self::default()
69	    }
70
71	    /// How many lifecycles are hosted (finished driver threads pruned first).
72	    pub fn len(&self) -> usize {
73	        let mut map = self.inner.lock().unwrap_or_else(|p| p.into_inner());
74	        map.retain(|_, h| !h.thread.is_finished());
75	        map.len()
76	    }
77
78	    pub fn is_empty(&self) -> bool {
79	        self.len() == 0
80	    }
81
82	    /// Is `id` currently hosted (driver thread alive)? Prunes a finished entry so
83	    /// a crashed driver is re-hosted next sweep.
84	    fn contains(&self, id: &str) -> bool {
85	        let mut map = self.inner.lock().unwrap_or_else(|p| p.into_inner());
86	        match map.get(id) {
87	            Some(h) if !h.thread.is_finished() => true,
88	            Some(_) => {
89	                map.remove(id);
90	                false
91	            }
92	            None => false,
93	        }
94	    }
95
96	    fn insert(&self, id: &str, handle: HostedLife) {
97	        let mut map = self.inner.lock().unwrap_or_else(|p| p.into_inner());
98	        map.insert(id.to_string(), handle);
99	    }
100
101	    /// The currently-tabled ids (finished pruned) — the stop-side reconcile input.
102	    fn ids(&self) -> Vec<String> {
103	        let mut map = self.inner.lock().unwrap_or_else(|p| p.into_inner());
104	        map.retain(|_, h| !h.thread.is_finished());
105	        map.keys().cloned().collect()
106	    }
107
108	    /// Un-host one lifecycle: trip its stop flag, join the driver, de-table. The
109	    /// pulse loop checks the flag each sleep-slice, so the join is prompt. We do
110	    /// NOT re-stamp `offline` — the perch already left the online set (graceful
111	    /// signoff cleared it, or the perch is gone); a brain *crash* deliberately
112	    /// leaves `status=online` so the next brain rehydrates it (CONTEXT:194).
113	    fn stop_host(&self, id: &str) {
114	        let handle = {
115	            let mut map = self.inner.lock().unwrap_or_else(|p| p.into_inner());
116	            map.remove(id)
117	        };
118	        if let Some(h) = handle {
119	            h.stop.store(true, Ordering::SeqCst);
120	            let _ = h.thread.join();
121	        }
122	    }
123	}
124
125	/// One reconcile sweep — the brain's live host-set invariant holder, run at boot
126	/// and every [`LIVE_RECONCILE_INTERVAL_MS`]. The complete set-diff against the
127	/// on-disk **online** live endpoints (mirror of [`crate::shellwake::reconcile_once`]):
128	///
129	/// - **un-host** (tabled ∧ ¬online) — stop the driver + de-table (exit guard: a
130	///   signed-off / removed endpoint must not keep pulsing — thread + dead-agent leak).
131	/// - **host** (online ∧ ¬tabled) — spawn the Psyche + start the driver.
132	/// - **leave-alone** (online ∧ tabled).
133	///
134	/// `status` is daemon-authoritative (CONTEXT:194); interim `listen` perches are
135	/// `status`-ABSENT so they are never hosted here (no double-host) until W0.2.
136	// [impl->REQ-DAEMON-1]
137	pub fn reconcile_once(
138	    owlery: &Path,
139	    registered: &[(AdapterRecord, Manifest)],
140	    adapters_dir: &Path,
141	    set: &Arc<LiveSet>,
142	    cfg: &DaemonConfig,
143	    reason: StartReason,
144	) {
145	    // Stop side first: hosted endpoints that DEFINITIVELY left the online set —
146	    // trip the flag, join, de-table. Un-host on a definitive departure ONLY, never
147	    // on a TRANSIENT read failure (M11-W0.4 (ii)): a momentarily-unreadable
148	    // info.json must not stop+respawn the driver (Psyche churn on a disk blip). The
149	    // discriminator is the perch DIR:
150	    //   - dir gone        → torn down (teardown removed the perch) → un-host;
151	    //   - dir present, read_info Some(status≠online) → signed-off/offline → un-host;
152	    //   - dir present, read_info None (transient/torn read) → LEAVE the driver, retry.
153	    // [impl->REQ-DAEMON-1]
154	    for id in set.ids() {
155	        let perch = perch::resolve_perch_path(&id, ParentHint::Infer);
156	        let unhost = if !perch.exists() {
157	            true // the perch dir is gone — torn down
158	        } else {
159	            match spt_store::info::read_info(&perch) {
160	                Some(info) => info.status.as_deref() != Some(STATUS_ONLINE),
161	                None => false, // dir present but unreadable: transient → keep hosting
162	            }
163	        };
164	        if unhost {
165	            set.stop_host(&id);
166	        }
167	    }
168
169	    // Start side: every online live endpoint not yet hosted.
170	    for id in perch::list_self_perch_ids(owlery) {
171	        if set.contains(&id) {
172	            continue; // already hosted (leave-alone)
173	        }
174	        let perch = perch::resolve_perch_path(&id, ParentHint::Infer);
175	        let Some(info) = spt_store::info::read_info(&perch) else {
176	            continue;
177	        };
178	        if info.state != LIVE_AGENT_STATE {
179	            continue; // not a live agent (ready_agent etc.)
180	        }
181	        if info.status.as_deref() != Some(STATUS_ONLINE) {
182	            continue; // interim (status-absent) or signed-off (offline)
183	        }
184	        let Some(adapter) = info.adapter.as_deref() else {
185	            continue; // adapterless endpoint — never live-capable
186	        };
187	        let Ok(manifest) = spt_runtime::registry::resolve_option_in(registered, adapters_dir, adapter)
188	        else {
189	            continue; // deregistered adapter: nothing to host
190	        };
191	        // [impl->REQ-INSTALL-11] resolve the Psyche role program against the
192	        // adapter install dir — the registry record's precise `source_dir`, the
193	        // same dir Feature E's api seam uses (mod.rs::resolve_ctx_manifest). The
194	        // daemon-host site is the heir to the retired in-process spawn's
195	        // install-dir resolution: without it the daemon-hosted Psyche resolves a
196	        // copy-mode psyche binary only via PATH, not its install dir.
197	        let parent = adapter.split(':').next().unwrap_or(adapter);
198	        let install_dir = registered
199	            .iter()
200	            .find(|(rec, _)| rec.name == parent)
201	            .map(|(rec, _)| std::path::PathBuf::from(&rec.source_dir));
202	        let Some(lifecycle) =
203	            BrainLifecycle::with_config_in(&manifest, &id, cfg.clone(), install_dir.as_deref())
204	        else {
205	            continue; // manifest declares no [session.psyche_init]
206	        };
207	        host_one(set, &id, lifecycle, info.session_id, reason);
208	    }
209	}
210
211	/// Host one live endpoint: sweep a stale signoff (3.2), spawn the Psyche (the
212	/// daemon online-stamp makes liveness daemon-authoritative), then start the
213	/// config-paced pulse driver on its own supervised thread with a held stop flag.
214	/// A Psyche-spawn failure tables nothing — the next sweep retries.
215	// [impl->REQ-DAEMON-1]
216	fn host_one(
217	    set: &Arc<LiveSet>,
218	    id: &str,
219	    lifecycle: BrainLifecycle,
220	    session_id: String,
221	    reason: StartReason,
222	) {
223	    lifecycle.sweep_on_start();
224	    match lifecycle.spawn_psyche(Some(&session_id), None) {
225	        Ok(h) => eprintln!("LIVEHOST_PSYCHE:{id} pid={}", h.pid),
226	        Err(e) => {
227	            eprintln!("LIVEHOST_PSYCHE_FAIL:{id}: {e}");
228	            return; // no Psyche → no driver; retried next reconcile
229	        }
230	    }
231	    let stop = Arc::new(AtomicBool::new(false));
232	    let handle = {
233	        let stop = Arc::clone(&stop);
234	        std::thread::spawn(move || {
235	            lifecycle.run_pulse_loop(Some(&session_id), &stop, reason, |_report| {});
236	        })
237	    };
238	    set.insert(id, HostedLife { stop, thread: handle });
239	}
240
241	/// Spawn the brain's live host: one thread sweeping [`reconcile_once`] at boot
242	/// and every [`LIVE_RECONCILE_INTERVAL_MS`] until `stop`. The registered set +
243	/// config are re-read each sweep (adapter / pulse-period changes land between
244	/// ticks). Mirror of [`crate::shellwake::spawn_wake_host`]; the stop flag is held
245	/// for symmetry — the threads die with the brain process and the next brain
246	/// rehydrates from disk.
247	// [impl->REQ-DAEMON-1]
248	pub fn spawn_live_host(stop: Arc<AtomicBool>, reason: StartReason) -> JoinHandle<()> {
249	    std::thread::spawn(move || {
250	        let set = Arc::new(LiveSet::new());
251	        while !stop.load(Ordering::SeqCst) {
252	            let owlery = perch::owlery_dir();
253	            let adapters_dir = perch::adapters_dir();
254	            let registered = spt_runtime::registry::registered(&adapters_dir);
255	            let cfg = DaemonConfig::load();
256	            reconcile_once(&owlery, &registered, &adapters_dir, &set, &cfg, reason);
257	            // Sleep in slices so a stop lands promptly.
258	            let mut left = LIVE_RECONCILE_INTERVAL_MS;
259	            while left > 0 && !stop.load(Ordering::SeqCst) {
260	                let step = left.min(100);
261	                std::thread::sleep(Duration::from_millis(step));
262	                left -= step;
263	            }
264	        }
265	    })
266	}
267
268	#[cfg(test)]
269	mod tests {
270	    use super::*;
271	    use crate::test_home::with_home;
272	    use std::time::{Duration, Instant};
273
274	    // A no-op Psyche summarizer command: spawns + exits 0 (we never depend on its
275	    // output — the daemon online-stamp is what hosts it, not the pid).
276	    #[cfg(windows)]
277	    const NOOP_CMD: &str = "cmd /C rem";
278	    #[cfg(unix)]
279	    const NOOP_CMD: &str = "true";
280
281	    /// Register a live-capable adapter `name` (declares `[session.psyche_init]` +
282	    /// a watched commune/signoff dir) so `resolve_option_in` finds it from disk.
283	    fn register_live_adapter(name: &str, drops: &Path) {
284	        let dir = drops.to_string_lossy().replace('\\', "/");
285	        let toml = format!(
286	            "[adapter]\nname=\"{name}\"\nkind=\"harness\"\nversion=\"1\"\n\
287	             min_spt_core_version=\"0\"\n\n\
288	             [session]\ncommune_dir=\"{dir}\"\nsignoff_dir=\"{dir}\"\n\n\
289	             [session.psyche_init]\ncommand='{NOOP_CMD}'\ncwd=\"{{psyche_dir}}\"\nkeys=[]\n"
290	        );
291	        let src = perch::spt_home().join("srcs").join(name);
292	        std::fs::create_dir_all(&src).unwrap();
293	        std::fs::write(src.join("manifest.toml"), toml).unwrap();
294	        spt_runtime::registry::register(&perch::adapters_dir(), &src, 1000).unwrap();
295	    }
296
297	    /// Seed a Self **live** perch on disk: `state=live_agent`, the given status,
298	    /// and the adapter option that resolves the manifest.
299	    fn seed_live_perch(id: &str, adapter: &str, status: &str) {
300	        let perch = perch::resolve_perch_path(id, ParentHint::Infer);
301	        std::fs::create_dir_all(&perch).unwrap();
302	        let mut rec = spt_store::info::InfoJson::new(id, "t", std::process::id(), "sid-1", "live_agent");
303	        rec.adapter = Some(adapter.to_string());
304	        spt_store::info::write_info(&perch, &rec).unwrap();
305	        spt_store::info::set_status(&perch, status).unwrap();
306	    }
307
308	    /// A fast-pulse config so the driver loop ticks quickly in tests.
309	    fn fast_cfg() -> DaemonConfig {
310	        DaemonConfig {
311	            pulse_period: Duration::from_millis(15),
312	            ..DaemonConfig::default()
313	        }
314	    }
315
316	    fn registered_now() -> Vec<(AdapterRecord, Manifest)> {
317	        spt_runtime::registry::registered(&perch::adapters_dir())
318	    }
319
320	    /// Poll a predicate up to `bound`, sleeping 20ms between checks.
321	    fn wait_until(bound: Duration, mut pred: impl FnMut() -> bool) -> bool {
322	        let deadline = Instant::now() + bound;
323	        while Instant::now() < deadline {
324	            if pred() {
325	                return true;
326	            }
327	            std::thread::sleep(Duration::from_millis(20));
328	        }
329	        pred()
330	    }
331
332	    // [unit->REQ-DAEMON-1] (a) FIRST-HOST scan: an online live endpoint on disk is
333	    // hosted by the reconcile — the Psyche is spawned AND the config-paced pulse
334	    // driver actually runs (it stamps last_active_ms on the Self perch each tick).
335	    #[test]
336	    fn reconcile_hosts_an_online_live_endpoint_and_drives_its_pulse() {
337	        with_home(|_| {
338	            let drops = perch::spt_home().join("drops");
339	            std::fs::create_dir_all(&drops).unwrap();
340	            register_live_adapter("mock", &drops);
341	            seed_live_perch("doyle", "mock", STATUS_ONLINE);
342
343	            let set = Arc::new(LiveSet::new());
344	            let cfg = fast_cfg();
345	            reconcile_once(
346	                &perch::owlery_dir(),
347	                &registered_now(),
348	                &perch::adapters_dir(),
349	                &set,
350	                &cfg,
351	                StartReason::Cold,
352	            );
353	            assert_eq!(set.len(), 1, "the online live endpoint is hosted");
354
355	            // The driver pulse runs: an Active perch stamps last_active_ms each tick.
356	            let self_perch = perch::resolve_perch_path("doyle", ParentHint::Infer);
357	            let ran = wait_until(Duration::from_secs(3), || {
358	                spt_store::info::read_info(&self_perch)
359	                    .and_then(|i| i.last_active_ms)
360	                    .is_some()
361	            });
362	            assert!(ran, "the pulse driver loop ticks (stamps last_active_ms)");
363
364	            // Teardown so the test process doesn't leak the driver thread.
365	            set.stop_host("doyle");
366	        });
367	    }
368
369	    // [unit->REQ-DAEMON-1] (b) ENTRY GUARD: a never-hosted OFFLINE live endpoint is
370	    // skipped — online-only. (Also covers the interim status-absent case shape.)
371	    #[test]
372	    fn reconcile_skips_an_offline_live_endpoint() {
373	        with_home(|_| {
374	            let drops = perch::spt_home().join("drops");
375	            std::fs::create_dir_all(&drops).unwrap();
376	            register_live_adapter("mock", &drops);
377	            seed_live_perch("doyle", "mock", spt_store::liveness::STATUS_OFFLINE);
378
379	            let set = Arc::new(LiveSet::new());
380	            reconcile_once(
381	                &perch::owlery_dir(),
382	                &registered_now(),
383	                &perch::adapters_dir(),
384	                &set,
385	                &fast_cfg(),
386	                StartReason::Cold,
387	            );
388	            assert!(set.is_empty(), "an offline live endpoint is never hosted");
389	        });
390	    }
391
392	    // [unit->REQ-DAEMON-1] (c) NO DOUBLE-HOST: a second reconcile over an
393	    // already-tabled endpoint leaves exactly one host (the contains-guard).
394	    #[test]
395	    fn reconcile_does_not_double_host() {
396	        with_home(|_| {
397	            let drops = perch::spt_home().join("drops");
398	            std::fs::create_dir_all(&drops).unwrap();
399	            register_live_adapter("mock", &drops);
400	            seed_live_perch("doyle", "mock", STATUS_ONLINE);
401
402	            let set = Arc::new(LiveSet::new());
403	            let cfg = fast_cfg();
404	            let run = || {
405	                reconcile_once(
406	                    &perch::owlery_dir(),
407	                    &registered_now(),
408	                    &perch::adapters_dir(),
409	                    &set,
410	                    &cfg,
411	                    StartReason::Cold,
412	                )
413	            };
414	            run();
415	            run();
416	            assert_eq!(set.len(), 1, "the same endpoint is hosted exactly once");
417	            set.stop_host("doyle");
418	        });
419	    }
420
421	    // [unit->REQ-DAEMON-1] (d) REHYDRATE: a FRESH LiveSet (a restarted brain) over a
422	    // perch left `status=online` by a prior brain re-hosts it — distinct from (a)
423	    // in that the table starts empty against an already-online perch (the
424	    // brain-restart path), not a freshly-flipped one.
425	    #[test]
426	    fn fresh_brain_rehydrates_an_online_live_endpoint() {
427	        with_home(|_| {
428	            let drops = perch::spt_home().join("drops");
429	            std::fs::create_dir_all(&drops).unwrap();
430	            register_live_adapter("mock", &drops);
431	            seed_live_perch("doyle", "mock", STATUS_ONLINE);
432
433	            // First brain hosts it.
434	            let first = Arc::new(LiveSet::new());
435	            let cfg = fast_cfg();
436	            reconcile_once(
437	                &perch::owlery_dir(),
438	                &registered_now(),
439	                &perch::adapters_dir(),
440	                &first,
441	                &cfg,
442	                StartReason::Cold,
443	            );
444	            assert_eq!(first.len(), 1);
445	            // Brain "restart": drop the table (threads die with the process), the
446	            // perch stays online on disk.
447	            first.stop_host("doyle");
448	            drop(first);
449
450	            // Fresh brain rehydrates from the still-online perch.
451	            let second = Arc::new(LiveSet::new());
452	            reconcile_once(
453	                &perch::owlery_dir(),
454	                &registered_now(),
455	                &perch::adapters_dir(),
456	                &second,
457	                &cfg,
458	                StartReason::Crash,
459	            );
460	            assert_eq!(second.len(), 1, "a fresh brain re-hosts the online endpoint");
461	            set_then_teardown(&second);
462	        });
463	    }
464
465	    fn set_then_teardown(set: &Arc<LiveSet>) {
466	        set.stop_host("doyle");
467	    }
468
469	    // [unit->REQ-DAEMON-1] (e) EXIT GUARD / UN-HOST: a hosted endpoint that flips to
470	    // status=offline (graceful signoff) is un-hosted by the next reconcile — the
471	    // driver thread is stopped + de-tabled (no forever-pulsing dead agent).
472	    #[test]
473	    fn reconcile_unhosts_on_offline_transition() {
474	        with_home(|_| {
475	            let drops = perch::spt_home().join("drops");
476	            std::fs::create_dir_all(&drops).unwrap();
477	            register_live_adapter("mock", &drops);
478	            seed_live_perch("doyle", "mock", STATUS_ONLINE);
479
480	            let set = Arc::new(LiveSet::new());
481	            // FAST pulse: the driver HAMMERS set_last_active every tick while the
482	            // test marks the perch offline — the un-host-UNDER-CONTENTION proof. The
483	            // W0.4 mutate_info sentinel lock makes status reliably stay offline, so
484	            // un-host reliably fires; the earlier long-pulse+settle workaround is
485	            // removed (the lock, not timing, is what makes this deterministic).
486	            let cfg = fast_cfg();
487	            let run = |reason| {
488	                reconcile_once(
489	                    &perch::owlery_dir(),
490	                    &registered_now(),
491	                    &perch::adapters_dir(),
492	                    &set,
493	                    &cfg,
494	                    reason,
495	                )
496	            };
497	            run(StartReason::Cold);
498	            assert_eq!(set.len(), 1, "hosted while online");
499
500	            // Graceful signoff flips the Self perch offline WHILE the driver pulses.
501	            let self_perch = perch::resolve_perch_path("doyle", ParentHint::Infer);
502	            spt_store::info::set_status(&self_perch, spt_store::liveness::STATUS_OFFLINE).unwrap();
503
504	            run(StartReason::Cold);
505	            assert!(
506	                set.is_empty(),
507	                "an offline-transitioned endpoint is un-hosted (driver stopped + de-tabled)"
508	            );
509	        });
510	    }
511
512	    // [unit->REQ-DAEMON-1] (f) READ-ERROR discrimination (M11-W0.4 (ii)): a hosted
513	    // endpoint whose info.json becomes momentarily UNREADABLE while its perch DIR
514	    // remains is NOT un-hosted (transient → keep the driver, no Psyche stop/respawn
515	    // churn on a disk blip); only a GONE perch dir (torn down) un-hosts.
516	    #[test]
517	    fn reconcile_keeps_driver_on_transient_read_fail_unhosts_on_dir_gone() {
518	        with_home(|_| {
519	            let drops = perch::spt_home().join("drops");
520	            std::fs::create_dir_all(&drops).unwrap();
521	            register_live_adapter("mock", &drops);
522	            seed_live_perch("doyle", "mock", STATUS_ONLINE);
523
524	            let set = Arc::new(LiveSet::new());
525	            let cfg = fast_cfg();
526	            let run = || {
527	                reconcile_once(
528	                    &perch::owlery_dir(),
529	                    &registered_now(),
530	                    &perch::adapters_dir(),
531	                    &set,
532	                    &cfg,
533	                    StartReason::Cold,
534	                )
535	            };
536	            run();
537	            assert_eq!(set.len(), 1, "hosted while online");
538
539	            // TRANSIENT read failure: info.json gone, but the perch DIR remains.
540	            let self_perch = perch::resolve_perch_path("doyle", ParentHint::Infer);
541	            std::fs::remove_file(self_perch.join("info.json")).unwrap();
542	            run();
543	            assert_eq!(
544	                set.len(),
545	                1,
546	                "a transient read failure (dir present) must NOT un-host — driver kept"
547	            );
548
549	            // DEFINITIVE: the perch dir is gone (torn down) → un-host. The fast-pulse
550	            // driver holds perch files open between pulses; on Windows that
551	            // transiently blocks dir removal (os err 32) — retry past the µs window
552	            // (production teardown stops the host first, so this race is test-only).
553	            let mut removed = false;
554	            for _ in 0..100 {
555	                if std::fs::remove_dir_all(&self_perch).is_ok() {
556	                    removed = true;
557	                    break;
558	                }
559	                std::thread::sleep(std::time::Duration::from_millis(20));
560	            }
561	            assert!(removed, "could not remove the perch dir for the torn-down case");
562	            run();
563	            assert!(set.is_empty(), "a gone perch dir un-hosts the driver");
564	        });
565	    }
566	}
567


1	//! v0.8.1 boot-race REPRODUCER (REQ-DAEMON-1) — a harness-hosted live agent goes
2	//! `status=online` but the REAL daemon brain never hosts its Psyche on a net-less
3	//! / unpaired node.
4	//!
5	//! This is the full-stack production-path E2E that the existing analogs stop short
6	//! of (doyle's mandate):
7	//!
8	//!   - `daemon_lifecycle_real_brain.rs` runs `run_brain` on a THREAD, uses a NOOP
9	//!     `cmd /C rem` psyche (a shell builtin that always resolves — it never
10	//!     exercises install-dir binary resolution), and HAND-WRITES the online perch.
11	//!   - This test launches the REAL `spt daemon run` broker PROCESS (which spawns a
12	//!     real supervised `spt daemon brain` CHILD), drives the REAL `api seed` +
13	//!     `api listen` establish→online path, and uses a REAL psyche binary referenced
14	//!     by a BARE program token that lives ONLY in the adapter install dir — so the
15	//!     daemon-host site MUST resolve it via REQ-INSTALL-11 install-dir resolution,
16	//!     not PATH.
17	//!
18	//! The disk-observable proxy for "the brain hosted the Psyche" is the nested
19	//! `<id>-psyche` perch's `info.json` appearing (mirrors `daemon_lifecycle_real_brain.rs`).
20	//! On a buggy tree this never appears within the bounded deadline.
21	//!
22	//! Bounded by design: every captured `spt` call has a deadline and the whole host
23	//! wait is a 25s polling loop, so a reproduced failure is a clean assert, never a
24	//! hang. All spawned processes (broker + supervised brain + listen) are reaped.
25	// [int->REQ-DAEMON-1]
26
27	use std::path::{Path, PathBuf};
28	use std::process::{Child, Command, Output, Stdio};
29	use std::time::{Duration, Instant};
30
31	mod common;
32	use common::CommandNoWindowExt;
33
34	use spt_store::perch::{self, ParentHint};
35
36	/// Best-effort cross-platform kill of a pid tree (mirrors the other E2E helpers).
37	/// Scoped to a SINGLE pid we spawned — never a machine-wide reap (a live broker
38	/// shares this box).
39	fn kill_pid(pid: u32) {
40	    #[cfg(windows)]
41	    let _ = Command::new("taskkill")
42	        .no_window()
43	        .args(["/PID", &pid.to_string(), "/F", "/T"])
44	        .output();
45	    #[cfg(unix)]
46	    let _ = Command::new("kill").args(["-9", &pid.to_string()]).output();
47	}
48
49	/// `Command::output()` with a deadline, run off-thread — bounds a hang into a
50	/// clean failure (the `live_firsthost_e2e` pattern).
51	fn output_bounded(mut cmd: Command, deadline: Duration) -> Output {
52	    let (tx, rx) = std::sync::mpsc::channel();
53	    std::thread::spawn(move || {
54	        let _ = tx.send(cmd.output());
55	    });
56	    rx.recv_timeout(deadline)
57	        .expect("captured spt call must complete within its deadline")
58	        .expect("run spt")
59	}
60
61	/// Read the brain `pid` out of a `brain.ready` body (self-describing JSON since
62	/// D6-1b). The come-up proof: the broker spawned a brain child.
63	fn ready_pid(body: &str) -> Option<u32> {
64	    serde_json::from_str::<serde_json::Value>(body)
65	        .ok()?
66	        .get("pid")?
67	        .as_u64()
68	        .map(|p| p as u32)
69	}
70
71	/// Poll `brain.ready` for a pid, up to `budget`. `None` if it never appears.
72	fn wait_for_ready_pid(path: &Path, budget: Duration) -> Option<u32> {
73	    let deadline = Instant::now() + budget;
74	    while Instant::now() < deadline {
75	        if let Ok(s) = std::fs::read_to_string(path) {
76	            if let Some(pid) = ready_pid(&s) {
77	                return Some(pid);
78	            }
79	        }
80	        std::thread::sleep(Duration::from_millis(50));
81	    }
82	    None
83	}
84
85	#[test]
86	fn netless_online_live_agent_is_hosted_by_the_real_daemon_brain() {
87	    // ── (1) Isolated SPT_HOME, NET-LESS / UNPAIRED: we create no subnet and run no
88	    //    pairing. The whole defect window is "online live agent on a node with no
89	    //    net". One test in this binary owns the process-global SPT_HOME; children
90	    //    are passed it explicitly via .env() anyway. ──
91	    let home = tempfile::tempdir().unwrap();
92	    std::env::set_var("SPT_HOME", home.path());
93
94	    // Force the truest NET-LESS / UNPAIRED condition: a CORRUPT node.key makes
95	    // `nodeid::load_or_create()` error, so `Daemon::run` computes `node_hex = None`
96	    // → it NEVER calls `try_start_net` and arms NO net-retry (daemon.rs:153-161,
97	    // "NODE_KEY_FAIL: identity unavailable — broker runs net-less, no retry"). The
98	    // brain's `net-status` therefore never reports `enabled`, so BRAIN_NET_CONSUMERS_UP
99	    // never fires — the exact net-starved node perri hit. (A corrupt key is NEVER
100	    // auto-regenerated by design — nodeid.rs module docs.)
101	    let identity_dir = home.path().join("identity");
102	    std::fs::create_dir_all(&identity_dir).unwrap();
103	    std::fs::write(identity_dir.join("node.key"), "not-a-valid-seed").unwrap();
104
105	    let spt_bin = PathBuf::from(env!("CARGO_BIN_EXE_spt"));
106	    let exe_suffix = spt_bin
107	        .extension()
108	        .map(|e| format!(".{}", e.to_string_lossy()))
109	        .unwrap_or_default();
110
111	    // ── (2) A live-capable adapter in COPY mode whose [session.psyche_init].command
112	    //    is a BARE program token (`psychebin`) naming a REAL psyche binary that
113	    //    ships in the adapter's install dir (= the registered `source_dir`). The
114	    //    fixture is a copy of the real `spt` binary invoked as `psychebin --version`
115	    //    (a real executable that exits 0 immediately, no side effects). The bare
116	    //    token is NOT on PATH, so spawn_psyche can ONLY resolve it via REQ-INSTALL-11
117	    //    install-dir resolution. ──
118	    let src = perch::spt_home().join("srcs").join("mocklive");
119	    std::fs::create_dir_all(&src).unwrap();
120
121	    // The real psyche binary fixture, by a BARE name, IN the install dir only.
122	    let psyche_bin = src.join(format!("psychebin{exe_suffix}"));
123	    std::fs::copy(&spt_bin, &psyche_bin).expect("stage the real psyche binary fixture");
124	    assert!(
125	        psyche_bin.is_file(),
126	        "the psyche binary fixture must exist in the install dir"
127	    );
128
129	    // The drops dir (commune/signoff watch) — irrelevant to the host proof but the
130	    // manifest needs a [session] block.
131	    let drops = home.path().join("drops");
132	    std::fs::create_dir_all(&drops).unwrap();
133	    let dir = drops.to_string_lossy().replace('\\', "/");
134
135	    // Command is the BARE token `psychebin` + a harmless fast-exit arg. NOT an
136	    // absolute path, NOT a shell builtin: it resolves only if install-dir
137	    // resolution fires.
138	    let manifest_toml = format!(
139	        "[adapter]\nname=\"mocklive\"\nkind=\"harness\"\nversion=\"1\"\n\
140	         min_spt_core_version=\"0\"\n\n\
141	         [session]\ncommune_dir=\"{dir}\"\nsignoff_dir=\"{dir}\"\n\n\
142	         [session.psyche_init]\ncommand='psychebin --version'\ncwd=\"{{psyche_dir}}\"\nkeys=[]\n"
143	    );
144	    let manifest_path = src.join("manifest.toml");
145	    std::fs::write(&manifest_path, &manifest_toml).unwrap();
146	    // Register in COPY mode (no [update] avenue ⇒ copy). The record's `source_dir`
147	    // is `src` — the install dir the brain resolves `psychebin` against.
148	    spt_runtime::registry::register(&perch::adapters_dir(), &src, 1000).unwrap();
149
150	    // Sanity: install-dir resolution of the bare token DOES find the fixture (so a
151	    // failure later is the WIRING not the fixture).
152	    let resolved =
153	        spt_runtime::registry::resolve_option(&perch::adapters_dir(), "mocklive").map(|(rec, _)| {
154	            spt_runtime::runtime::resolve_program_in_dir("psychebin", Path::new(&rec.source_dir))
155	        });
156	    assert!(
157	        resolved
158	            .as_ref()
159	            .map(|p| Path::new(p).is_file())
160	            .unwrap_or(false),
161	        "install-dir resolution must find the bare psychebin in source_dir (got {resolved:?})"
162	    );
163
164	    // ── (3) Launch the REAL daemon broker PROCESS. `spt daemon run` is the broker;
165	    //    it spawns a supervised `spt daemon brain` CHILD. Redirect the broker's
166	    //    stderr to a log file in the tempdir — the brain child inherits the broker's
167	    //    stderr (spawn_brain_child sets no stdio), so BRAIN_UP / LIVEHOST_PSYCHE /
168	    //    LIVEHOST_PSYCHE_FAIL (all eprintln! to the brain's stderr) land here. ──
169	    let brain_log = home.path().join("brain.stderr.log");
170	    let brain_log_file = std::fs::File::create(&brain_log).expect("create brain stderr log");
171	    let mut broker: Child = Command::new(&spt_bin)
172	        .no_window()
173	        .args(["daemon", "run"])
174	        .env("SPT_HOME", home.path())
175	        .stdout(Stdio::null())
176	        .stderr(Stdio::from(brain_log_file))
177	        .spawn()
178	        .expect("spawn spt daemon run (broker process)");
179
180	    // Come-up: the broker spawned a brain child that signalled ready. This is the
181	    // precondition — if the brain never comes up, the host claim is moot.
182	    let brain_pid = match wait_for_ready_pid(&home.path().join("brain.ready"), Duration::from_secs(30))
183	    {
184	        Some(p) => p,
185	        None => {
186	            let _ = broker.kill();
187	            let _ = broker.wait();
188	            panic!(
189	                "PRECONDITION FAILED: the real broker never spawned a ready brain child \
190	                 (brain.ready absent). Brain stderr:\n{}",
191	                std::fs::read_to_string(&brain_log).unwrap_or_default()
192	            );
193	        }
194	    };
195
196	    // ── (4) The REAL harness establish→online path: `api seed` then `api listen`.
197	    //    Seed keyed by THIS process's pid (alive — passes the recycle guard). The
198	    //    listen is the PERSISTENT (non --once) form: it marks the perch online and
199	    //    then BLOCKS in the relay loop until killed (the real harness leaves it
200	    //    running). Run it as a captured child whose stderr we keep, so we can see
201	    //    its BOUND/READY markers and reap it. ──
202	    let id = "bootrace-agent";
203	    let pid = std::process::id().to_string();
204	    let mp = manifest_path.to_string_lossy().to_string();
205
206	    let seed = {
207	        let mut cmd = Command::new(&spt_bin);
208	        cmd.no_window()
209	            .args([
210	                "api",
211	                "--adapter",
212	                "mocklive",
213	                "seed",
214	                "--pid",
215	                &pid,
216	                "--session-id",
217	                "sid-bootrace",
218	            ])
219	            .env("SPT_HOME", home.path());
220	        output_bounded(cmd, Duration::from_secs(30))
221	    };
222	    if !seed.status.success() {
223	        let _ = broker.kill();
224	        let _ = broker.wait();
225	        panic!(
226	            "PRECONDITION FAILED: api seed failed: {}",
227	            String::from_utf8_lossy(&seed.stderr)
228	        );
229	    }
230
231	    // The persistent live `api listen` — marks the perch status=online (live-capable
232	    // because the manifest declares [session.psyche_init]) then blocks. The harness
233	    // keeps this process alive for the session's lifetime.
234	    let listen_log = home.path().join("listen.stderr.log");
235	    let listen_log_file = std::fs::File::create(&listen_log).expect("create listen stderr log");
236	    let mut listen: Child = Command::new(&spt_bin)
237	        .no_window()
238	        .args([
239	            "api",
240	            "--adapter",
241	            "mocklive",
242	            "--manifest",
243	            &mp,
244	            "listen",
245	            id,
246	            "--parent-pid",
247	            &pid,
248	        ])
249	        .env("SPT_HOME", home.path())
250	        .stdout(Stdio::null())
251	        .stderr(Stdio::from(listen_log_file))
252	        .spawn()
253	        .expect("spawn the persistent api listen (harness-hosted live agent)");
254
255	    // Confirm the establish→online actually took: the Self perch carries
256	    // status=online (written by cmd_listen). Poll briefly — `listen` writes it
257	    // right after BOUND.
258	    let self_perch = perch::resolve_perch_path(id, ParentHint::Infer);
259	    let online = {
260	        let deadline = Instant::now() + Duration::from_secs(15);
261	        loop {
262	            let st = spt_store::info::read_info(&self_perch).and_then(|i| i.status);
263	            if st.as_deref() == Some(spt_store::liveness::STATUS_ONLINE) {
264	                break true;
265	            }
266	            if Instant::now() >= deadline {
267	                break false;
268	            }
269	            std::thread::sleep(Duration::from_millis(100));
270	        }
271	    };
272	    if !online {
273	        kill_pid(listen.id());
274	        let _ = listen.wait();
275	        let _ = broker.kill();
276	        let _ = broker.wait();
277	        panic!(
278	            "PRECONDITION FAILED: the persistent `api listen` never marked the perch online.\n\
279	             listen stderr:\n{}",
280	            std::fs::read_to_string(&listen_log).unwrap_or_default()
281	        );
282	    }
283
284	    // ── (5) THE ASSERTION: the REAL daemon brain must host the Psyche. The
285	    //    disk-observable proxy is the nested `<id>-psyche` perch's info.json
286	    //    appearing (spawn_and_bind writes it AFTER a successful psyche spawn; a
287	    //    spawn failure logs LIVEHOST_PSYCHE_FAIL and writes nothing). Bounded 25s
288	    //    poll — on a buggy tree this SHOULD time out. ──
289	    let psyche_perch = perch::resolve_perch_path(
290	        &format!("{id}-psyche"),
291	        ParentHint::Explicit(id),
292	    );
293	    let deadline = Instant::now() + Duration::from_secs(25);
294	    let mut hosted = false;
295	    while Instant::now() < deadline {
296	        if spt_store::info::read_info(&psyche_perch).is_some() {
297	            hosted = true;
298	            break;
299	        }
300	        std::thread::sleep(Duration::from_millis(200));
301	    }
302
303	    // Capture diagnostics BEFORE teardown so the report has the real markers.
304	    let brain_stderr = std::fs::read_to_string(&brain_log).unwrap_or_default();
305	    let listen_stderr = std::fs::read_to_string(&listen_log).unwrap_or_default();
306
307	    // Always surface the captured markers (the reproduction evidence).
308	    eprintln!("=== DIAGNOSTIC: hosted={hosted} brain_pid={brain_pid} ===");
309	    eprintln!("=== brain stderr (BRAIN_UP / LIVEHOST_PSYCHE / LIVEHOST_PSYCHE_FAIL) ===\n{brain_stderr}");
310	    eprintln!("=== listen stderr ===\n{listen_stderr}");
311	    eprintln!(
312	        "=== psyche perch info.json present: {} (path {}) ===",
313	        psyche_perch.join("info.json").exists(),
314	        psyche_perch.display()
315	    );
316
317	    // ── (6) Reap everything we spawned — scoped to OUR pids only. ──
318	    kill_pid(listen.id());
319	    let _ = listen.wait();
320	    kill_pid(brain_pid);
321	    let _ = broker.kill();
322	    let _ = broker.wait();
323
324	    assert!(
325	        hosted,
326	        "REQ-DAEMON-1: the REAL daemon brain must host the Psyche for a net-less ONLINE \
327	         live endpoint (the nested `{id}-psyche` perch must appear) within 25s — it did NOT.\n\
328	         === brain stderr (BRAIN_UP / LIVEHOST_PSYCHE / LIVEHOST_PSYCHE_FAIL land here) ===\n{}\n\
329	         === listen stderr ===\n{}",
330	        brain_stderr, listen_stderr,
331	    );
332	}
333


Now let me look at how timestamps are produced and check the traceable-reqs.toml and the render code.


950:id = "REQ-HAZARD-LIVEHOST-BOOT-RACE"
951-[Omitted long context line]
952-required_stages = []  # rule 5: minted inactive 2026-06-16 (v0.8.1 work pending); todlando activates [impl,unit,int] per wave as the net-independent reconcile fix + the real-detached-daemon E2E land
953-[[requirements]]
954-id = "REQ-HAZARD-EPOCH-RESET"
955-title = "Advertisement-epoch reset strands a node: peers' higher last-seen epoch drops the reset node's fresh advertisements as Stale until the counter outruns history. Common case (full reinstall/re-pair) is mitigated by REQ-SUBNET-7's ceremony eviction (peer-side epoch memory dies with the deleted row — acceptance-verified); the residual narrow slice (epoch file lost, identity kept) is documented, guard deferred to a field hit (4.11)"
956-required_stages = []  # rule 5 + M8 decision 24: documented hazard (KNOWN-HAZARDS 4.11 at D5), guard waits for a field hit of the narrow slice; the re-pair eviction path is tested under REQ-SUBNET-7


crates\spt-store\src\access.rs:205:    /// Record one outbound: `endpoint` sent to `node` at `now_ms`. Refreshes
crates\spt-store\src\access.rs:210:    pub fn record(&mut self, endpoint: &str, node: &str, now_ms: u64, window_ms: u64) {
crates\spt-store\src\access.rs:212:            .retain(|r| now_ms.saturating_sub(r.ts_ms) <= window_ms);
crates\spt-store\src\access.rs:218:            Some(r) => r.ts_ms = now_ms,
crates\spt-store\src\access.rs:222:                ts_ms: now_ms,
crates\spt-store\src\access.rs:232:    /// Whether inbound to `endpoint` from `node` at `now_ms` correlates to a
crates\spt-store\src\access.rs:236:    pub fn correlates(&self, endpoint: &str, node: &str, now_ms: u64, window_ms: u64) -> bool {
crates\spt-store\src\access.rs:238:            r.endpoint == endpoint && r.node == node && now_ms.saturating_sub(r.ts_ms) <= window_ms
crates\spt-store\src\atomic.rs:59:        path.file_name().and_then(|s| s.to_str()).unwrap_or("epoch")
crates\spt-store\src\branchstore.rs:14://! modeled, exactly like [`crate::epoch::EpochSource`].
crates\spt-store\src\contextmark.rs:14://! - `vector` — a per-node **version vector**: one monotonic epoch per node
crates\spt-store\src\contextmark.rs:15://!   ([`crate::epoch::EpochSource`], the same counter behind the D3b registry
crates\spt-store\src\contextmark.rs:55:/// A per-node version vector: `node id → that node's epoch at write time`.
crates\spt-store\src\contextmark.rs:74:    /// Render as the marker token value: `node:epoch,…` sorted by node
crates\spt-store\src\contextmark.rs:90:            if let Some((node, epoch)) = entry.rsplit_once(':') {
crates\spt-store\src\contextmark.rs:91:                if let Ok(e) = epoch.trim().parse::<u64>() {
crates\spt-store\src\contextmark.rs:139:    /// Advance `node`'s entry to `epoch` (the author's fresh
crates\spt-store\src\contextmark.rs:140:    /// [`EpochSource`](crate::epoch::EpochSource) stamp).
crates\spt-store\src\contextmark.rs:141:    pub fn bump(&mut self, node: &str, epoch: u64) {
crates\spt-store\src\contextmark.rs:143:        if epoch > *slot {
crates\spt-store\src\contextmark.rs:144:            *slot = epoch;
crates\spt-store\src\epoch.rs:1://! Per-node monotonic epoch counter (D3b) — the ordering authority behind the
crates\spt-store\src\epoch.rs:4://! A single strictly-increasing `u64` persisted at [`epoch_file`](crate::perch::epoch_file).
crates\spt-store\src\epoch.rs:6://! context snapshot) stamps the value [`next_epoch`](EpochSource::next_epoch) returns. Comparing two
crates\spt-store\src\epoch.rs:15://! epoch while it is alive** (the registry pump + advertise loops run brain-side), and
crates\spt-store\src\epoch.rs:25:use crate::perch::epoch_file;
crates\spt-store\src\epoch.rs:27:/// A node's persisted monotonic epoch counter.
crates\spt-store\src\epoch.rs:34:    /// Load (or initialize) the counter from the canonical [`epoch_file`].
crates\spt-store\src\epoch.rs:36:        Self::load_from(&epoch_file())
crates\spt-store\src\epoch.rs:40:    /// (first [`next_epoch`] yields `1`); a corrupt/unparsable file ALSO degrades to `0`
crates\spt-store\src\epoch.rs:42:    /// from 0 is safe-by-construction: see [`next_epoch`]'s monotonicity note.
crates\spt-store\src\epoch.rs:54:    /// The last issued epoch without advancing (peek). `0` means none issued yet.
crates\spt-store\src\epoch.rs:59:    /// Advance and return the next epoch, persisting it **before** updating the
crates\spt-store\src\epoch.rs:61:    /// returned, no epoch is consumed). Strictly greater than every prior return on
crates\spt-store\src\epoch.rs:63:    pub fn next_epoch(&mut self) -> std::io::Result<u64> {
crates\spt-store\src\epoch.rs:67:            .expect("epoch counter overflowed u64 — unreachable in any real lifetime");
crates\spt-store\src\epoch.rs:88:        let mut src = EpochSource::load_from(&dir.path().join("epoch"));
crates\spt-store\src\epoch.rs:89:        let a = src.next_epoch().unwrap();
crates\spt-store\src\epoch.rs:90:        let b = src.next_epoch().unwrap();
crates\spt-store\src\epoch.rs:91:        let c = src.next_epoch().unwrap();
crates\spt-store\src\epoch.rs:95:            "epoch is a small counter, not a unix timestamp"
crates\spt-store\src\epoch.rs:100:    // up where the prior source left off, never reissuing a consumed epoch.
crates\spt-store\src\epoch.rs:104:        let path = dir.path().join("epoch");
crates\spt-store\src\epoch.rs:107:            assert_eq!(src.next_epoch().unwrap(), 1);
crates\spt-store\src\epoch.rs:108:            assert_eq!(src.next_epoch().unwrap(), 2);
crates\spt-store\src\epoch.rs:113:            reloaded.next_epoch().unwrap(),
crates\spt-store\src\epoch.rs:127:        let corrupt = dir.path().join("epoch");
crates\spt-store\src\epoch.rs:131:        assert_eq!(src.next_epoch().unwrap(), 1, "recovers without panic");
crates\spt-store\src\roster.rs:9://! [`lease_epoch`](RosterEntry::lease_epoch) — the same strictly-greater-wins
crates\spt-store\src\roster.rs:75:    /// A strictly-newer `lease_epoch` for an existing member — replaced.
crates\spt-store\src\roster.rs:77:    /// Equal/older `lease_epoch` (or a tombstoned-but-still-merged fact) — the
crates\spt-store\src\roster.rs:83:/// every other member's roster strictly-greater-`lease_epoch`-wins.
crates\spt-store\src\roster.rs:102:    /// Last-seen stamp (opaque epoch-seconds string).
crates\spt-store\src\roster.rs:106:    pub lease_epoch: u64,
crates\spt-store\src\roster.rs:117:    /// When it was written (opaque epoch-seconds string).
crates\spt-store\src\roster.rs:210:    /// current `lease_epoch`); lease ordering only governs how *others* merge it.
crates\spt-store\src\roster.rs:221:        lease_epoch: u64,
crates\spt-store\src\roster.rs:230:            lease_epoch,
crates\spt-store\src\roster.rs:243:    /// `lease_epoch` wins. A new `(subnet, pubkey)` is [`Inserted`]; a
crates\spt-store\src\roster.rs:259:                if incoming.lease_epoch > existing.lease_epoch {
crates\spt-store\src\roster.rs:343:            lease_epoch: lease,
crates\spt-store\src\roster.rs:347:    // [unit->REQ-MESH-2] the lease: a strictly-greater lease_epoch updates an
crates\spt-store\src\roster.rs:357:        assert_eq!(r.find("home", "aa").unwrap().lease_epoch, 5);
crates\spt-store\src\roster.rs:366:        assert_eq!(r.find("home", "aa").unwrap().lease_epoch, 6);
crates\spt-store\src\roster.rs:391:                ab.find("home", pk).map(|e| e.lease_epoch),
crates\spt-store\src\roster.rs:392:                ba.find("home", pk).map(|e| e.lease_epoch),
crates\spt-store\src\roster.rs:396:        assert_eq!(ab.find("home", "bb").unwrap().lease_epoch, 9, "newer bb wins");
crates\spt-store\src\roster.rs:400:        let lease_sum: u64 = ab.members.iter().map(|e| e.lease_epoch).sum();
crates\spt-store\src\roster.rs:403:        let after_sum: u64 = ab.members.iter().map(|e| e.lease_epoch).sum();
crates\spt-store\src\roster.rs:428:        assert_eq!(reloaded.find("home", "online").unwrap().lease_epoch, 2);
crates\spt-store\src\roster.rs:510:        assert_eq!(me.lease_epoch, 3);
crates\spt-store\src\roster.rs:537:        let legacy = r#"{"members":[{"pubkey_hex":"aa","subnet":"home","label":"h","machine_id":"m","last_seen":"1","lease_epoch":1}]}"#;
crates\spt-store\src\rotation.rs:9://! deadline — so a burst of revocations costs exactly **one** epoch bump. That
crates\spt-store\src\rotation.rs:38:    /// The wall-clock deadline (epoch-ms) at/after which the pump fires the
crates\spt-store\src\rotation.rs:95:    /// deadline, so the burst still fires exactly one rotation (one epoch bump).
crates\spt-store\src\rotation.rs:116:    /// The subnets whose rotation is **due** at `now_ms` (deadline reached),
crates\spt-store\src\rotation.rs:120:    pub fn due_subnets(&self, now_ms: u64) -> Vec<String> {
crates\spt-store\src\rotation.rs:123:            .filter(|(_, p)| p.deadline_ms <= now_ms)
crates\spt-store\src\rotation.rs:150:    // deadline is kept — so the burst fires one rotation (one epoch bump).
crates\spt-store\src\info.rs:62:    /// Last-active wall-clock stamp (epoch ms), refreshed by the daemon on this
crates\spt-store\src\info.rs:96:    /// When the instance entered dormancy (epoch ms) — the anchor the opt-in
crates\spt-store\src\info.rs:213:pub fn set_last_active(perch_path: &Path, now_ms: u64) -> std::io::Result<()> {
crates\spt-store\src\info.rs:214:    mutate_info(perch_path, |rec| rec.last_active_ms = Some(now_ms))
crates\spt-store\src\registry.rs:45:fn now_stamp() -> String {
crates\spt-store\src\registry.rs:46:    std::time::SystemTime::now()
crates\spt-store\src\registry.rs:47:        .duration_since(std::time::UNIX_EPOCH)
crates\spt-store\src\hostlabel.rs:7://! (a hostname change reaches peers as ordinary epoch-bumped updates) —
crates\spt-store\src\perch.rs:137:/// The per-node monotonic-epoch counter file: `<spt_home>/identity/epoch`. A single
crates\spt-store\src\perch.rs:141:pub fn epoch_file() -> PathBuf {
crates\spt-store\src\perch.rs:142:    identity_dir().join("epoch")
crates\spt-store\src\lib.rs:21:pub mod epoch;
crates\spt-store\src\notif.rs:11://!   (`<node-hex>:<epoch>`, stamped from the node's monotonic
crates\spt-store\src\notif.rs:12://!   [`EpochSource`](crate::epoch::EpochSource) — the same per-node epoch
crates\spt-store\src\notif.rs:36:use crate::epoch::EpochSource;
crates\spt-store\src\notif.rs:44:    /// `<node-hex>:<epoch>` — unique subnet-wide, the idempotent insert key.
crates\spt-store\src\notif.rs:120:    /// [`EpochSource`] (`<node_hex>:<epoch>` — unique, replicable), insert it
crates\spt-store\src\notif.rs:122:    /// first-fire and/or the replication feed). The epoch is consumed only on
crates\spt-store\src\notif.rs:124:    /// counter already durably advanced (a skipped epoch is harmless; a
crates\spt-store\src\notif.rs:131:        epochs: &mut EpochSource,
crates\spt-store\src\notif.rs:137:        let epoch = epochs
crates\spt-store\src\notif.rs:138:            .next_epoch()
crates\spt-store\src\notif.rs:141:            notif_id: format!("{node_hex}:{epoch}"),
crates\spt-store\src\notif.rs:146:            created_ms: now_ms(),
crates\spt-store\src\notif.rs:236:    /// Advance `last_surfaced_ms` to `now_ms` if greater (max-merge — a lagging
crates\spt-store\src\notif.rs:239:    pub fn touch_surfaced(&self, notif_id: &str, now_ms: u64) -> rusqlite::Result<bool> {
crates\spt-store\src\notif.rs:242:            params![notif_id, now_ms as i64],
crates\spt-store\src\notif.rs:344:fn now_ms() -> u64 {
crates\spt-store\src\notif.rs:345:    std::time::SystemTime::now()
crates\spt-store\src\notif.rs:346:        .duration_since(std::time::UNIX_EPOCH)
crates\spt-store\src\notif.rs:360:    fn epochs(dir: &Path) -> EpochSource {
crates\spt-store\src\notif.rs:361:        EpochSource::load_from(&dir.join("epoch"))
crates\spt-store\src\notif.rs:379:    // [unit->REQ-NOTIF-1] produce mints unique `<node-hex>:<epoch>` ids from
crates\spt-store\src\notif.rs:386:        let mut e = epochs(dir.path());
crates\spt-store\src\notif.rs:423:        let mut e = epochs(dir.path());
crates\spt-store\src\notif.rs:525:            let mut e = epochs(dir.path());
crates\spt-store\src\spool.rs:28:/// Current epoch seconds.
crates\spt-store\src\spool.rs:29:fn now_epoch() -> i64 {
crates\spt-store\src\spool.rs:30:    std::time::SystemTime::now()
crates\spt-store\src\spool.rs:31:        .duration_since(std::time::UNIX_EPOCH)
crates\spt-store\src\spool.rs:32:        .expect("system clock before UNIX epoch")
crates\spt-store\src\spool.rs:108:        params![from_id, body, now_epoch(), DEFAULT_TTL_SECONDS],
crates\spt-store\src\spool.rs:125:        params![from_id, body, now_epoch(), DEFAULT_TTL_SECONDS],
crates\spt-store\src\spool.rs:145:    let now_ms = std::time::SystemTime::now()
crates\spt-store\src\spool.rs:146:        .duration_since(std::time::UNIX_EPOCH)
crates\spt-store\src\spool.rs:147:        .expect("system clock before UNIX epoch")
crates\spt-store\src\spool.rs:153:        params![op_id, now_ms],
crates\spt-store\src\spool.rs:162:        params![from_id, body, now_epoch(), DEFAULT_TTL_SECONDS],
crates\spt-store\src\spool.rs:185:    let now_ms = std::time::SystemTime::now()
crates\spt-store\src\spool.rs:186:        .duration_since(std::time::UNIX_EPOCH)
crates\spt-store\src\spool.rs:187:        .expect("system clock before UNIX epoch")
crates\spt-store\src\spool.rs:192:        params![op_id, now_ms],
crates\spt-store\src\spool.rs:199:    let now = now_epoch();
crates\spt-store\src\spool.rs:313:    let now = now_epoch();
crates\spt-store\src\spool.rs:326:    let now = now_epoch();
crates\spt-store\src\spool.rs:506:            params!["t", "ok", now_epoch(), 0, 0, 1],
crates\spt-store\src\subnet.rs:10://! ## Seed epoch + rotation (KNOWN-HAZARDS / ADR-0005 #10)
crates\spt-store\src\subnet.rs:12://! Each record carries a monotonic [`epoch`](SubnetRecord::epoch). Stage-A red-team
crates\spt-store\src\subnet.rs:16://! a node **rotates the subnet seed and bumps the epoch**, so the old seed computes
crates\spt-store\src\subnet.rs:18://! (which binds the epoch) rejects an old-epoch rejoin. The epoch is the
crates\spt-store\src\subnet.rs:66:/// seed epoch, and (forward seam) the owning user.
crates\spt-store\src\subnet.rs:75:    pub epoch: u64,
crates\spt-store\src\subnet.rs:79:    /// the prior epoch is implicitly [`epoch`](Self::epoch)` - 1`. Cleared past
crates\spt-store\src\subnet.rs:88:    /// Creation stamp (epoch-seconds string; opaque).
crates\spt-store\src\subnet.rs:109:    /// grace material), paired with its epoch (`epoch - 1`). `None` when there is
crates\spt-store\src\subnet.rs:115:        let prior_epoch = self.epoch.checked_sub(1)?;
crates\spt-store\src\subnet.rs:116:        Some((bytes, prior_epoch))
crates\spt-store\src\subnet.rs:167:    /// Create a new subnet with a freshly minted seed at epoch 1 (the "create-new"
crates\spt-store\src\subnet.rs:182:            epoch: 1,
crates\spt-store\src\subnet.rs:192:    /// and its current epoch arrive over the pairing channel from a seed-holder).
crates\spt-store\src\subnet.rs:199:        epoch: u64,
crates\spt-store\src\subnet.rs:207:            epoch,
crates\spt-store\src\subnet.rs:216:    /// Rotate a subnet's seed and bump its epoch (ADR-0005 #10 — the node-removal
crates\spt-store\src\subnet.rs:218:    /// epoch bump means even a captured old transcript won't bind. The new seed must
crates\spt-store\src\subnet.rs:234:        rec.epoch += 1;
crates\spt-store\src\subnet.rs:240:    /// `{seed, epoch}` over the member control stream and adopts it here. The old
crates\spt-store\src\subnet.rs:242:    /// still find a usable prior generation) and the epoch jumps to `epoch`.
crates\spt-store\src\subnet.rs:243:    /// A no-op (returns `false`) when the incoming epoch is not strictly newer —
crates\spt-store\src\subnet.rs:250:        epoch: u64,
crates\spt-store\src\subnet.rs:257:        if epoch <= rec.epoch {
crates\spt-store\src\subnet.rs:261:        rec.epoch = epoch;
crates\spt-store\src\subnet.rs:318:fn now_stamp() -> String {
crates\spt-store\src\subnet.rs:319:    std::time::SystemTime::now()
crates\spt-store\src\subnet.rs:320:        .duration_since(std::time::UNIX_EPOCH)
crates\spt-store\src\subnet.rs:329:    // [unit->REQ-HAZARD-PAIR-SEED-ROTATION] a created subnet starts at epoch 1 with a
crates\spt-store\src\subnet.rs:332:    fn create_subnet_starts_at_epoch_one_unique_name() {
crates\spt-store\src\subnet.rs:335:        assert_eq!(rec.epoch, 1);
crates\spt-store\src\subnet.rs:345:    // the epoch bumps and the seed CHANGES, so an old-seed/old-epoch node can no
crates\spt-store\src\subnet.rs:346:    // longer compute a valid code (the transcript binds the epoch). Trust-delete
crates\spt-store\src\subnet.rs:349:    fn rotate_bumps_epoch_and_changes_seed() {
crates\spt-store\src\subnet.rs:353:        assert_eq!(after.epoch, before.epoch + 1, "epoch bumps");
crates\spt-store\src\subnet.rs:356:        // The outgoing seed is retained one-deep, paired with its prior epoch —
crates\spt-store\src\subnet.rs:365:            Some(before.epoch),
crates\spt-store\src\subnet.rs:366:            "prior epoch is epoch-1"
crates\spt-store\src\subnet.rs:376:    // epoch} handed over the grace, retaining its own outgoing seed one-deep; an
crates\spt-store\src\subnet.rs:385:        // Stale node on epoch 1 adopts the rotator's epoch 2.
crates\spt-store\src\subnet.rs:388:        assert_eq!(after.epoch, 2, "epoch jumps to the pushed generation");
crates\spt-store\src\subnet.rs:417:    // [unit->REQ-HAZARD-PAIR-SEED-ROTATION] a joined subnet stores the seed + epoch
crates\spt-store\src\subnet.rs:419:    // on the current epoch, not epoch 1).
crates\spt-store\src\subnet.rs:421:    fn add_joined_preserves_seed_and_epoch() {
crates\spt-store\src\subnet.rs:425:        assert_eq!(rec.epoch, 5);
crates\spt-store\src\subnet.rs:435:    // the epoch survives, so revocation is durable.
crates\spt-store\src\subnet.rs:450:            reloaded.find("home").unwrap().epoch,
crates\spt-store\src\subnet.rs:454:        assert_eq!(reloaded.find("work").unwrap().epoch, 1);
crates\spt-store\src\subnet.rs:468:        let legacy = r#"{"subnets":[{"name":"old","seed_hex":"00","epoch":1,"created":"0"}]}"#;
crates\spt-store\src\timefmt.rs:9:use std::time::{SystemTime, UNIX_EPOCH};
crates\spt-store\src\timefmt.rs:11:/// Format a Unix-epoch-seconds instant as `YYYY-MM-DDTHH:MM:SSZ` (RFC3339, UTC).
crates\spt-store\src\timefmt.rs:34:pub fn now_rfc3339() -> String {
crates\spt-store\src\timefmt.rs:35:    let secs = SystemTime::now()
crates\spt-store\src\timefmt.rs:36:        .duration_since(UNIX_EPOCH)
crates\spt-store\src\xfer.rs:70:    // xfer_id is sender-minted ("<node-hex>:<epoch>" by convention): keep the


There's a `timefmt::now_rfc3339()` helper for RFC3339 string timestamps. The `ts` field should use that. Let me view it and the traceable-reqs entry, plus find the render code.


1	//! Minimal, dependency-free RFC3339-UTC timestamp formatting.
2	//!
3	//! The digest-record contract's `ts` ordering key (ADR-0019) and the session
4	//! ledger / boundary markers are RFC3339-UTC strings so they sort lexically and
5	//! interleave with adapter-emitted timestamps. The library crates stay
6	//! chrono-free (only the `spt` binary pulls chrono), so this is a small
7	//! self-contained formatter rather than a new dependency.
8
9	use std::time::{SystemTime, UNIX_EPOCH};
10
11	/// Format a Unix-epoch-seconds instant as `YYYY-MM-DDTHH:MM:SSZ` (RFC3339, UTC).
12	/// Uses Howard Hinnant's `civil_from_days` algorithm (proleptic Gregorian).
13	pub fn rfc3339_utc(secs: u64) -> String {
14	    let days = (secs / 86_400) as i64;
15	    let rem = secs % 86_400;
16	    let (hh, mm, ss) = (rem / 3600, (rem % 3600) / 60, rem % 60);
17
18	    // civil_from_days: days since 1970-01-01 → (year, month, day).
19	    let z = days + 719_468;
20	    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
21	    let doe = z - era * 146_097; // [0, 146096]
22	    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399]
23	    let y = yoe + era * 400;
24	    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
25	    let mp = (5 * doy + 2) / 153; // [0, 11]
26	    let d = doy - (153 * mp + 2) / 5 + 1; // [1, 31]
27	    let m = if mp < 10 { mp + 3 } else { mp - 9 }; // [1, 12]
28	    let y = if m <= 2 { y + 1 } else { y };
29
30	    format!("{y:04}-{m:02}-{d:02}T{hh:02}:{mm:02}:{ss:02}Z")
31	}
32
33	/// The current wall-clock instant as an RFC3339-UTC string.
34	pub fn now_rfc3339() -> String {
35	    let secs = SystemTime::now()
36	        .duration_since(UNIX_EPOCH)
37	        .map(|d| d.as_secs())
38	        .unwrap_or(0);
39	    rfc3339_utc(secs)
40	}
41
42	#[cfg(test)]
43	mod tests {
44	    use super::*;
45
46	    #[test]
47	    fn formats_known_instants() {
48	        assert_eq!(rfc3339_utc(0), "1970-01-01T00:00:00Z");
49	        assert_eq!(rfc3339_utc(1_700_000_000), "2023-11-14T22:13:20Z");
50	        // A leap-year date: 2024-02-29.
51	        assert_eq!(rfc3339_utc(1_709_208_000), "2024-02-29T12:00:00Z");
52	    }
53
54	    #[test]
55	    fn lexical_order_matches_chronological_order() {
56	        let a = rfc3339_utc(1_700_000_000);
57	        let b = rfc3339_utc(1_700_000_001);
58	        assert!(a < b, "RFC3339-UTC strings sort chronologically");
59	    }
60	}
61


945	id = "REQ-DAEMON-9"
946	title = "Net-bind boot-race resilience: a daemon that comes up net-less (NetHost::start failed — e.g. the systemd unit autostarted before the network/DNS stack was ready, `Failed to create an address lookup service`) must SELF-HEAL — retry the net bring-up in the background with capped backoff and, on success, attach net to the broker + spawn the dispatcher/peer-pump (which today are gated on `net_up` at boot and so never start, leaving the node silently unreachable until a manual restart — kitsubito 2026-06-08). Status surfaces the net-less state honestly (a net-less broker renders as 'no connection', not only a pump-STALLED line with a bogus pre-boot heartbeat age). The installer's autostart unit waits for the network (`Wants=/After=network-online.target`) as belt-and-suspenders."
947	required_stages = ["impl", "unit"]  # activate v0.3.1. impl = Broker.net → OnceLock<NetHost> + attach_net(); Daemon::run factors spawn_net_consumers(), fast-path attaches at boot, slow-path spawns a background NetHost::start retry supervisor (capped backoff) that attaches + spawns consumers on success (NET_BIND_RETRY/NET_ATTACHED logs); cmd_daemon_status renders net-less as 'no connection' + treats a heartbeat older than daemon start as 'no tick yet' not a giant age; install.sh unit gains network-online ordering. unit = backoff ladder caps + OnceLock single-attach (consumers spawn exactly once) + the status net-less/heartbeat-age render classifier.
948	# --- Added 2026-06-16 (v0.8.1): perri's v0.8.0 dogfood surfaced a harness-hosted live agent that goes ONLINE but never gets a Psyche on a net-less/unpaired node (boot-race); rule 3 register-before-satisfy, rule 5 activate per wave as evidence lands ---
949	[[requirements]]
950	id = "REQ-HAZARD-LIVEHOST-BOOT-RACE"
951	title = "The brain's live-host reconcile runs net-INDEPENDENTLY and survives the net-less boot-race: a harness-hosted live agent that is state=live_agent + status=online with a resolvable adapter manifest (psyche_init present) MUST get a daemon-hosted Psyche (LIVEHOST_PSYCHE) on a net-less / unpaired / peer-pump-STALLED node — the Psyche lifecycle needs no net/pump, so whatever upstream net-attach starves the brain from reaching livehost::spawn_live_host (brainproc.rs:230) must self-heal exactly as the dispatcher/peer-pump boot-race self-heal does (REQ-DAEMON-9). Silent-failure was masked because state=live_agent + alive=true are stamped UNCONDITIONALLY, independent of the psyche-host online-stamp (perri's diagnostic: host-predicate fully passes — state/status/adapter/psyche_init all resolve — yet reconcile_once never produced a Psyche). The establish→online ints exercise an IN-PROCESS daemon and the real-brain test hand-writes the online perch, so the production broker→brain-child→run_brain→spawn_live_host→reconcile_once boot path was NEVER E2E-exercised end-to-end; closing the test seam (a REAL detached daemon: api seed + listen a live agent on a net-less/unpaired node → assert LIVEHOST_PSYCHE) is part of this hazard."
952	required_stages = []  # rule 5: minted inactive 2026-06-16 (v0.8.1 work pending); todlando activates [impl,unit,int] per wave as the net-independent reconcile fix + the real-detached-daemon E2E land
953	[[requirements]]
954	id = "REQ-HAZARD-EPOCH-RESET"
955	title = "Advertisement-epoch reset strands a node: peers' higher last-seen epoch drops the reset node's fresh advertisements as Stale until the counter outruns history. Common case (full reinstall/re-pair) is mitigated by REQ-SUBNET-7's ceremony eviction (peer-side epoch memory dies with the deleted row — acceptance-verified); the residual narrow slice (epoch file lost, identity kept) is documented, guard deferred to a field hit (4.11)"
956	required_stages = []  # rule 5 + M8 decision 24: documented hazard (KNOWN-HAZARDS 4.11 at D5), guard waits for a field hit of the narrow slice; the re-pair eviction path is tested under REQ-SUBNET-7


Grep

endpoint list|fn cmd_endpoint|driven_by|controlled by|resources


Found 8 files
crates\spt\src\cli.rs
crates\spt\src\api\mod.rs
crates\spt\src\api\reporting.rs
crates\spt\src\wansend.rs
crates\spt\src\rc.rs
crates\spt\src\picker\data.rs
crates\spt\src\picker\view.rs
crates\spt\src\picker\model.rs


Let me find the actual `endpoint list` and `whoami` render code in cli.rs.


fn cmd_endpoint_list|fn cmd_whoami|fn render_endpoint|Endpoint::List|controlled by|\(\+\{.*viewing|status.*online|driven_by


1574:fn cmd_endpoint_list(local: bool, subnet: Option<String>, detail: bool) -> i32 {
2916:fn cmd_whoami() -> i32 {
3598:/// `subnet status --nodes` still-"online" confusion). The zero-subnet view is
5994:                    eprintln!("SHELL_SPAWNED:{id} owner={owner} pid={pid} status=offline (online at bind)");
6394:                    eprintln!("SHELL_RELINKED:{id} owner={owner} pid={pid} status=offline (online at bind)");


1574	fn cmd_endpoint_list(local: bool, subnet: Option<String>, detail: bool) -> i32 {
1575	    use spt_net::net::registry::resource_projection;
1576	    use spt_store::visibility::VisibilityStore;
1577
1578	    if local {
1579	        return cmd_list_local();
1580	    }
1581	    let subnets = SubnetStore::load();
1582	    if let Some(n) = &subnet {
1583	        if subnets.find(n).is_none() {
1584	            eprintln!("NO_SUCH_SUBNET:{n}");
1585	            return 2;
1586	        }
1587	    }
1588
1589	    // SELF pin (decision 2): the session's own endpoint renders first and
1590	    // distinctly, whatever subnet groups follow. `spt whoami` is a thin alias for
1591	    // this command (REQ-WHOAMI-1), so the SELF pin additionally carries the Self
1592	    // endpoint's authored description — the "who am I" answer.
1593	    if let Some(self_id) = roster::detect_self_id() {
1594	        let state = roster::enumerate()
1595	            .into_iter()
1596	            .find(|p| p.id == self_id)
1597	            .map(|p| format!("{}  ready={} alive={}", p.state, p.ready, p.alive))
1598	            .unwrap_or_else(|| "(no local perch)".to_string());
1599	        let desc = spt_store::info::read_info(&perch::resolve_perch_path(&self_id, ParentHint::Infer))
1600	            .and_then(|rec| rec.resources);
1601	        println!("{}", render_self_pin(&self_id, &state, desc.as_deref()));
1602	    }
1603
1604	    let regs = crate::wansend::load_snapshots(&perch::identity_dir().join("registry"));
1605	    let vis = VisibilityStore::load();
1606	    let names: Vec<String> = match subnet {
1607	        Some(one) => vec![one],
1608	        None => subnets.subnets.iter().map(|s| s.name.clone()).collect(),
1609	    };
1610	    if names.is_empty() {
1611	        eprintln!("(no subnets — `spt endpoint list --local` lists this node's perches)");
1612	        return 0;
1613	    }
1614	    let mut shown = 0usize;
1615	    for name in names {
1616	        println!("SUBNET {name}");
1617	        let Some(reg) = regs.get(&name) else {
1618	            println!("  (no gossip yet)");
1619	            continue;
1620	        };
1621	        let hide_new = subnets
1622	            .find(&name)
1623	            .map(|s| s.hide_new_endpoints)
1624	            .unwrap_or(false);
1625	        let rows = resource_projection(reg, |id| vis.hidden(id, &name, hide_new));
1626	        if rows.is_empty() {
1627	            println!("  (no endpoints)");
1628	            continue;
1629	        }
1630	        for row in rows {
1631	            let node = node_display(&row.node, None, false);
1632	            if detail {
1633	                println!(
1634	                    "  {}\t{}\t{:?}\t{}",
1635	                    row.endpoint_id,
1636	                    node,
1637	                    row.status,
1638	                    row.resources.as_deref().unwrap_or("-")
1639	                );
1640	            } else {
1641	                println!("  {}\t{}\t{:?}", row.endpoint_id, node, row.status);
1642	            }
1643	            shown += 1;
1644	        }
1645	    }
1646	    eprintln!("ENDPOINTS:{shown}");
1647	    0
1648	}
1649
1650	/// `spt suspend <id>` / `spt wake <id>` — the manual resting-state edges
1651	/// (REQ-INST-3): apply the event against the local endpoint's durable record
1652	/// through the daemon-side transition host (echo gate-armed for the
1653	/// endpoint's own pulse loop; wake fires the boundary resurface + freshness
1654	/// pull). A qualified `[subnet:]id@node` rides the remote rest arm (M5-D5b,
1655	/// REQ-INST-6): resolve the peer from the registry snapshots, dial, and let
1656	/// the TARGET node's transition host run the edge — exit codes mirror local
1657	/// (edge / NO_EDGE = 0, refusal or failure = 1).
1658	// [impl->REQ-INST-3]
1659	// [impl->REQ-INST-6]
1660	fn cmd_rest(id: &str, event: spt_daemon::RestEvent, verb: &str) -> i32 {
1661	    if id.contains('@') || id.contains(':') {
1662	        let tag = match event {
1663	            spt_daemon::RestEvent::Suspend => spt_net::net::rest::REST_EVENT_SUSPEND,
1664	            spt_daemon::RestEvent::Wake => spt_net::net::rest::REST_EVENT_WAKE,
1665	            // Only the manual verbs route here; the other events are
1666	            // daemon-internal feeds with no CLI surface.
1667	            _ => {
1668	                eprintln!("{verb}_FAIL:{id}: event has no remote arm");
1669	                return 1;
1670	            }
1671	        };
1672	        return match crate::wansend::wan_rest(id, tag) {
1673	            crate::wansend::WanRestOutcome::Edge { node, detail } => {
1674	                eprintln!("{verb}:{id} ({detail}) at {node}");
1675	                0
1676	            }
1677	            crate::wansend::WanRestOutcome::NoEdge { node } => {
1678	                eprintln!("NO_EDGE:{id} (already in the target state) at {node}");
1679	                0
1680	            }
1681	            crate::wansend::WanRestOutcome::NoReply { node } => {
1682	                eprintln!(
1683	                    "{verb}_REFUSED:{id}: {node} closed the stream without a reply (access gate)"
1684	                );
1685	                1
1686	            }
1687	            crate::wansend::WanRestOutcome::RemoteFail { node, detail } => {
1688	                eprintln!("{verb}_FAIL:{id} at {node}: {detail}");
1689	                1
1690	            }
1691	            crate::wansend::WanRestOutcome::Ambiguous(a) => {
1692	                eprintln!("AMBIGUOUS:{id} — {a}");
1693	                1
1694	            }
1695	            crate::wansend::WanRestOutcome::NotFound => {
1696	                eprintln!("NO_ENDPOINT:{id}: no routable remote instance in any visible subnet");
1697	                1
1698	            }
1699	            crate::wansend::WanRestOutcome::Failed(e) => {
1700	                eprintln!("{verb}_FAIL:{id}: {e}");
1701	                1
1702	            }
1703	        };
1704	    }
1705	    let knob = spt_daemon::DaemonConfig::load().auto_suspend_after_ms;
1706	    match spt_daemon::daemon_rest_event(id, event, knob) {
1707	        Ok(Some(report)) => {
1708	            eprintln!("{verb}:{id} ({:?} -> {:?})", report.from, report.to);
1709	            0
1710	        }
1711	        Ok(None) => {
1712	            // Idempotent UX: the machine reports no edge (already there).
1713	            eprintln!("NO_EDGE:{id} (already in the target state)");
1714	            0
1715	        }
1716	        Err(e) => {
1717	            eprintln!("{verb}_FAIL:{id}: {e}");
1718	            1
1719	        }
1720	    }
1721	}
1722
1723	/// `spt shutdown [id]` — the agent's graceful self-suspend verb (M5-D4d,
1724	/// CONTEXT §Shell model: "an agent gracefully shuts down *its own* endpoint
1725	/// → suspended; fires the transition echo-commune; cascades its persistent
1726	/// shells offline"). Two legs through existing machinery: (1) soft-stop the
1727	/// listener (drop the ready marker — the session-over half `spt suspend`
1728	/// does not do); (2) the suspend edge via the daemon-side transition host —
1729	/// which arms the echo gate and runs the D4a shell cascade. Idempotent like
1730	/// every rest verb.
1731	// [impl->REQ-SHELL-2]
1732	fn cmd_shutdown(id: Option<String>) -> i32 {
1733	    let Some(id) = id.or_else(roster::detect_self_id) else {
1734	        eprintln!(
1735	            "NO_SELF: shutdown needs an endpoint (spt endpoint shutdown <id> or $OWL_SESSION_ID)"
1736	        );
1737	        return 1;
1738	    };
1739	    if id.contains('@') || id.contains(':') {
1740	        // Deliberately NOT lifted with the D5b remote rest arm: an agent
1741	        // shuts down *its own* endpoint — remote shutdown is not a surface;
1742	        // `spt endpoint suspend <id@node>` covers the remote need.
1743	        eprintln!(
1744	            "LOCAL_ONLY:{id} — shutdown is an endpoint's own verb; use \
1745	             `spt endpoint suspend {id}` remotely"
1746	        );
1747	        return 1;
1748	    }
1749	    // Leg 1: soft-stop — the listener marker goes first so nothing new is
1750	    // accepted while the edge runs.
1751	    let _ = std::fs::remove_file(perch::resolve_ready_file(&id, ParentHint::Infer));
1752	    // Leg 2: the suspend edge (echo gate + shell cascade live in the host).
1753	    cmd_rest(&id, spt_daemon::RestEvent::Suspend, "SHUTDOWN")


The `endpoint list` (cross-node, registry-projection) path doesn't read local info.json — it works from gossip rows. The SELF pin and `cmd_list_local` are where local info.json is read. The psyche_host_error lives on the local Self perch's info.json, so it surfaces best at the SELF pin and in `cmd_list_local`. Let me look at `render_self_pin` and `cmd_list_local`.


fn render_self_pin|fn cmd_list_local|fn render_self_pin


2861:fn cmd_list_local() -> i32 {
2904:fn render_self_pin(self_id: &str, state: &str, description: Option<&str>) -> String {


2861	fn cmd_list_local() -> i32 {
2862	    let perches = roster::enumerate();
2863	    if perches.is_empty() {
2864	        eprintln!("(no perches)");
2865	        return 0;
2866	    }
2867	    for p in perches {
2868	        println!(
2869	            "{:<28} {:<14} {:<22} ready={} alive={}",
2870	            p.id,
2871	            p.state,
2872	            p.address.as_deref().unwrap_or("-"),
2873	            p.ready,
2874	            p.alive
2875	        );
2876	    }
2877	    0
2878	}
2879
2880	fn cmd_stop(id: &str) -> i32 {
2881	    let owlery = perch::owlery_dir();
2882	    // Soft-cleanup (HAZARD-SOFT-CLEANUP 6.2): remove the ready marker +
2883	    // unregister the address; spool.db and info.json are preserved.
2884	    let removed = std::fs::remove_file(perch::resolve_ready_file(id, ParentHint::Infer)).is_ok();
2885	    let _ = registry::unregister_address(id, &owlery);
2886	    // Going offline: wake the pump's registry leg so peers see the flip
2887	    // within seconds, not a cadence (M8 decision 15, REQ-CONV-2).
2888	    // [impl->REQ-CONV-2]
2889	    spt_daemon::registryhost::request_advertise_now();
2890	    if removed {
2891	        eprintln!("STOPPED:{}", id);
2892	    } else {
2893	        eprintln!("STOPPED:{} (no ready marker; address unregistered)", id);
2894	    }
2895	    0
2896	}
2897
2898	/// Render the SELF-pin block for `endpoint list` / `whoami`: the session
2899	/// endpoint's id + liveness state, plus its authored description inline when one
2900	/// is present (an empty/whitespace blurb is treated as absent). Pure over its
2901	/// inputs so the rendered shape is unit-tested; the caller supplies the live
2902	/// state + blurb from the roster + info store.
2903	/// [impl->REQ-WHOAMI-1]
2904	fn render_self_pin(self_id: &str, state: &str, description: Option<&str>) -> String {
2905	    match description.map(str::trim).filter(|d| !d.is_empty()) {
2906	        Some(d) => format!("SELF: {self_id}  {state}  — {d}"),
2907	        None => format!("SELF: {self_id}  {state}"),
2908	    }
2909	}
2910
2911	/// `spt whoami` — a thin ALIAS for `spt endpoint list` (REQ-WHOAMI-1): the full
2912	/// view, SELF pinned first (the pin carries this endpoint's description). The
2913	/// standalone bare-id command is gone — no agent captured `id=$(spt whoami)`
2914	/// (env vars don't survive between tool calls), so there is no contract to keep.
2915	/// [impl->REQ-WHOAMI-1]
2916	fn cmd_whoami() -> i32 {
2917	    cmd_endpoint_list(false, None, false)
2918	}
2919
2920	/// What `show-code` resolved its arguments to against the node's subnet store — the
2921	/// platform-independent half of the command, decided before any OS elevation call so
2922	/// it can be unit-tested hermetically.
2923	enum Resolved {
2924	    /// A subnet to surface: its name + raw seed bytes. `minted` = freshly created by
2925	    /// `subnet create` (persist it only once the code is actually shown).
2926	    Subnet {
2927	        name: String,
2928	        seed: [u8; spt_store::subnet::TOTP_SEED_LEN],
2929	        minted: bool,
2930	    },
2931	    /// Several subnets and no `--subnet` — refuse rather than guess.
2932	    Ambiguous(Vec<String>),
2933	    /// The node belongs to no subnet at all.
2934	    Empty,
2935	    /// A named subnet is not in the store.
2936	    NotFound(String),
2937	    /// `subnet create <name>` for a subnet that already exists.
2938	    Exists(String),
2939	    /// The stored seed is unreadable (corrupt hex/length) — re-pair to re-provision.
2940	    Corrupt(String),
2941	}
2942
2943	/// The rendered outcome of `show-code`: an exit code, the optional stdout block (the
2944	/// code + URI — present ONLY on the elevated show path, never leaked otherwise), a
2945	/// stderr status line, and whether to persist a freshly-minted subnet. Pure over its
2946	/// inputs (no IO, no OS calls) so every branch is unit-tested.
2947	struct ShowResult {
2948	    code: i32,
2949	    out: Option<String>,
2950	    msg: String,
2951	    save: bool,
2952	}
2953
2954	/// Resolve `show-code`/`create` arguments against the store (mutating it iff a create
2955	/// mints). The mint is in-memory only; the caller persists it just on the show path.
2956	// [impl->REQ-PAIR-3]
2957	fn resolve(
2958	    store: &mut SubnetStore,
2959	    subnet: Option<String>,
2960	    create_new: Option<String>,
2961	) -> Resolved {
2962	    if let Some(name) = create_new {
2963	        return match store.create_subnet(&name) {
2964	            Ok(rec) => match rec.seed_bytes() {
2965	                Some(seed) => Resolved::Subnet {
2966	                    name: rec.name.clone(),
2967	                    seed,
2968	                    minted: true,
2969	                },
2970	                None => Resolved::Corrupt(rec.name.clone()),
2971	            },
2972	            Err(SubnetError::Exists(n)) => Resolved::Exists(n),
2973	            Err(e) => Resolved::Corrupt(e.to_string()),
2974	        };
2975	    }
2976	    if let Some(name) = subnet {
2977	        return match store.find(&name) {
2978	            Some(rec) => match rec.seed_bytes() {
2979	                Some(seed) => Resolved::Subnet {
2980	                    name: rec.name.clone(),


Now I have a clear picture. The SELF pin reads info.json for `resources`; I'll add `psyche_host_error` reading there. The render is `render_self_pin`. I'll thread the psyche_host_error annotation through it. Let me check `render_self_pin` tests and confirm structure of the field rendering.

Now let me implement. First, the info.rs changes — add the `PsycheHostError` struct, the field, and set/clear helpers.


Edit

/// On-disk shape of a perch's `info.json`. Field order is locked by declaration
/// (serde_json `preserve_order`). Optional fields are omitted when `None`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct InfoJson {

/// The current psyche-host-failure state of a daemon-hosted live perch
/// (v0.8.1, REQ-HAZARD-LIVEHOST-BOOT-RACE). The brain's live-host reconcile
/// stamps this on a Self perch when `spawn_psyche` fails, so the failure is
/// **harness-reachable** — a harness (and a human via `spt endpoint list` /
/// `spt whoami`) reads PERCH STATE, never the brain's invisible stderr where
/// `LIVEHOST_PSYCHE_FAIL` is also logged. It is a CURRENT-STATE stamp
/// (overwritten each retry, `attempts` incremented; cleared to `None` on a
/// later successful host), NOT an append log. Distinct from `status`:
/// `status` (online/offline) stays daemon-authoritative liveness (KH 2.5) and
/// is **never** touched by this — an online live agent with no Psyche reads
/// `status=online` AND a `psyche_host_error`, exposing the boot-race that was
/// previously silent.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PsycheHostError {
    /// The `spawn_psyche` error string from the most recent failed host attempt.
    pub reason: String,
    /// RFC3339-UTC stamp of the most recent failed attempt (the same clock
    /// [`crate::timefmt::now_rfc3339`] stamps every other info.json string ts).
    pub ts: String,
    /// How many consecutive host attempts have failed (incremented each retry;
    /// `1` on the first failure).
    pub attempts: u32,
}

/// On-disk shape of a perch's `info.json`. Field order is locked by declaration
/// (serde_json `preserve_order`). Optional fields are omitted when `None`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct InfoJson {


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


    /// adapter-at-creation rule): recorded when a creation/bind path knows
    /// it, changed **only** via launch/resume-under-new (the bind paths are
    /// the sole writers; no standalone setter). Absent ⇒ created by an
    /// adapterless surface (`spt ready`) or a pre-D9-5 record.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub adapter: Option<String>,
}

    /// adapter-at-creation rule): recorded when a creation/bind path knows
    /// it, changed **only** via launch/resume-under-new (the bind paths are
    /// the sole writers; no standalone setter). Absent ⇒ created by an
    /// adapterless surface (`spt ready`) or a pre-D9-5 record.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub adapter: Option<String>,
    /// The CURRENT psyche-host-failure state (v0.8.1,
    /// REQ-HAZARD-LIVEHOST-BOOT-RACE): present ⇒ the brain's live-host
    /// reconcile failed to spawn this perch's Psyche on its most recent
    /// attempt (the boot-race / missing-psyche-binary failure that was
    /// otherwise silent on the brain's stderr). Cleared (`None`) on a later
    /// successful host, so a recovered live agent shows clean. `default` so
    /// every pre-v0.8.1 record still parses (N-1-safe, additive). Set/cleared
    /// only through [`set_psyche_host_error`].
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub psyche_host_error: Option<PsycheHostError>,
}


Now update the `InfoJson::new` constructor and the BUSY test struct literal:


            resources: None,
            home_subnet: None,
            adapter: None,
        }
    }
}

            resources: None,
            home_subnet: None,
            adapter: None,
            psyche_host_error: None,
        }
    }
}


            resources: None,
            home_subnet: None,
            adapter: None,
        };
        write_info(d.path(), &info).unwrap();
        assert_eq!(
            read_info(d.path()).unwrap().pid,

            resources: None,
            home_subnet: None,
            adapter: None,
            psyche_host_error: None,
        };
        write_info(d.path(), &info).unwrap();
        assert_eq!(
            read_info(d.path()).unwrap().pid,


Now add the `set_psyche_host_error` helper. I'll place it after `set_resources`. It needs to handle increment logic. The spec says: set/clear with a helper. The increment-on-failure logic (read prior attempts +1) is best done in `host_one` reading the prior value, but to keep the timestamp clock reuse and make a clean unit-testable helper, I'll make the helper take an `Option<PsycheHostError>` (set or clear) via `mutate_info`. The increment is computed by the caller. But the spec also says "analogous to set_status" and the unit test should test "set/clear/increment helper". Let me make the helper do the increment internally — take a reason `Option<&str>`: `Some(reason)` → set/overwrite with attempts=prior+1, ts=now; `None` → clear.


/// Number of read attempts before giving up (torn-read retry).
const READ_ATTEMPTS: u32 = 5;

/// Stamp (`Some(reason)`) or clear (`None`) the psyche-host-failure state on a
/// live perch (v0.8.1, REQ-HAZARD-LIVEHOST-BOOT-RACE) — the harness-reachable
/// mirror of the brain's invisible `LIVEHOST_PSYCHE_FAIL` stderr line.
///
/// - `Some(reason)` — set/overwrite the CURRENT-STATE stamp: `reason` is the
///   `spawn_psyche` error string, `ts` is now ([`crate::timefmt::now_rfc3339`]
///   — the same clock every other info.json string stamp uses), and `attempts`
///   is the prior `attempts + 1` (read inside the locked RMW so concurrent
///   retries can't lose a count), `1` on the first failure.
/// - `None` — clear it (a later successful host shows clean).
///
/// `status` is **never** touched — daemon liveness stays authoritative
/// (KH 2.5). Serialized via [`mutate_info`]; errors if the record is absent —
/// the daemon only stamps perches it hosts.
// [impl->REQ-HAZARD-LIVEHOST-BOOT-RACE]
pub fn set_psyche_host_error(perch_path: &Path, reason: Option<&str>) -> std::io::Result<()> {
    mutate_info(perch_path, |rec| match reason {
        Some(r) => {
            let attempts = rec
                .psyche_host_error
                .as_ref()
                .map(|e| e.attempts.saturating_add(1))
                .unwrap_or(1);
            rec.psyche_host_error = Some(PsycheHostError {
                reason: r.to_string(),
                ts: crate::timefmt::now_rfc3339(),
                attempts,
            });
        }
        None => rec.psyche_host_error = None,
    })
}

/// Number of read attempts before giving up (torn-read retry).
const READ_ATTEMPTS: u32 = 5;


Now confirm `timefmt` is exported from the spt-store crate lib.


45:pub mod timefmt;


Now add the unit test for the round-trip + set/clear/increment helper:


    // [unit->REQ-HAZARD-INFO-JSON-TORN-READ] read_pid tolerates a schema-minimal
    // {"pid": N} record (wrapper-owned listener shape).
    #[test]
    fn read_pid_tolerates_schema_minimal() {

    // [unit->REQ-HAZARD-LIVEHOST-BOOT-RACE] the psyche-host-failure field round-trips
    // and the set/clear/increment helper behaves: absent by default (additive,
    // N-1-safe — a pre-v0.8.1 record parses with the field None); a first failure
    // stamps attempts=1 + a non-empty reason + an RFC3339 ts; each retry OVERWRITES
    // reason+ts and INCREMENTS attempts (current-state stamp, not an append log);
    // clearing with None drops the field (a recovered host shows clean); and a
    // missing record errors (never a silent write).
    #[test]
    fn psyche_host_error_round_trips_and_set_clear_increments() {
        let d = perch();
        write_info(
            d.path(),
            &InfoJson::new("doyle", "t", 4242, "sid", "live_agent"),
        )
        .unwrap();
        // Absent by default — not serialized.
        let raw = std::fs::read_to_string(d.path().join("info.json")).unwrap();
        assert!(
            !raw.contains("psyche_host_error"),
            "absent by default (additive field): {raw}"
        );
        assert_eq!(read_info(d.path()).unwrap().psyche_host_error, None);

        // First failure → attempts = 1, reason captured, ts present.
        set_psyche_host_error(d.path(), Some("psychebin: program not found")).unwrap();
        let e = read_info(d.path()).unwrap().psyche_host_error.unwrap();
        assert_eq!(e.attempts, 1);
        assert_eq!(e.reason, "psychebin: program not found");
        assert!(!e.ts.is_empty() && e.ts.ends_with('Z'), "RFC3339-UTC ts: {}", e.ts);

        // Retry → OVERWRITES reason + ts, INCREMENTS attempts (current-state, not a log).
        set_psyche_host_error(d.path(), Some("psychebin: still missing")).unwrap();
        let e = read_info(d.path()).unwrap().psyche_host_error.unwrap();
        assert_eq!(e.attempts, 2, "attempts increments per retry");
        assert_eq!(e.reason, "psychebin: still missing", "reason overwritten");

        // Success → cleared.
        set_psyche_host_error(d.path(), None).unwrap();
        assert_eq!(
            read_info(d.path()).unwrap().psyche_host_error,
            None,
            "a recovered host shows clean"
        );

        // Round-trip a fully-populated struct through write/read.
        let mut info = InfoJson::new("doyle", "t", 1, "sid", "live_agent");
        info.psyche_host_error = Some(PsycheHostError {
            reason: "boom".to_string(),
            ts: "2026-06-16T00:00:00Z".to_string(),
            attempts: 3,
        });
        write_info(d.path(), &info).unwrap();
        assert_eq!(read_info(d.path()).unwrap(), info);

        // N-1 forward tolerance: a pre-v0.8.1 record (no field) parses clean.
        let old = perch();
        atomic_write_string(
            &old.path().join("info.json"),
            r#"{"id":"doyle","started":"t","pid":1,"session_id":"sid","state":"live_agent"}"#,
        )
        .unwrap();
        assert_eq!(read_info(old.path()).unwrap().psyche_host_error, None);

        // No record → error, never a silent write.
        let empty = perch();
        assert!(set_psyche_host_error(empty.path(), Some("x")).is_err());
    }

    // [unit->REQ-HAZARD-INFO-JSON-TORN-READ] read_pid tolerates a schema-minimal
    // {"pid": N} record (wrapper-owned listener shape).
    #[test]
    fn read_pid_tolerates_schema_minimal() {


Now refinement 2: update `host_one` in livehost.rs to write on failure and clear on success.


    lifecycle.sweep_on_start();
    match lifecycle.spawn_psyche(Some(&session_id), None) {
        Ok(h) => eprintln!("LIVEHOST_PSYCHE:{id} pid={}", h.pid),
        Err(e) => {
            eprintln!("LIVEHOST_PSYCHE_FAIL:{id}: {e}");
            return; // no Psyche → no driver; retried next reconcile
        }
    }

    lifecycle.sweep_on_start();
    // Resolve the live perch once — the same path the start-scan resolved it by
    // (resolve_perch_path/Infer), so the harness-reachable stamp lands on the
    // exact info.json `spt endpoint list` / `whoami` reads.
    let perch = perch::resolve_perch_path(id, ParentHint::Infer);
    match lifecycle.spawn_psyche(Some(&session_id), None) {
        Ok(h) => {
            eprintln!("LIVEHOST_PSYCHE:{id} pid={}", h.pid);
            // Recovered: CLEAR any prior psyche-host-failure stamp so a now-hosted
            // live agent shows clean. Best-effort — status is untouched.
            // [impl->REQ-HAZARD-LIVEHOST-BOOT-RACE]
            let _ = spt_store::info::set_psyche_host_error(&perch, None);
        }
        Err(e) => {
            // Keep the (invisible) brain-stderr line AND stamp the failure onto the
            // perch's info.json so it is HARNESS-REACHABLE — a harness/human reads
            // perch state, never the brain's stderr. Current-state stamp: overwrite
            // reason+ts, increment attempts (the helper reads the prior count inside
            // its locked RMW). `status` is NOT touched — liveness stays authoritative.
            // host_one returns without tabling, so the 5s reconcile retries and each
            // retry re-stamps; an eventual success clears it above.
            // [impl->REQ-HAZARD-LIVEHOST-BOOT-RACE]
            eprintln!("LIVEHOST_PSYCHE_FAIL:{id}: {e}");
            let _ = spt_store::info::set_psyche_host_error(&perch, Some(&e.to_string()));
            return; // no Psyche → no driver; retried next reconcile
        }
    }


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


Let me check the `spawn_psyche` error type to confirm `e.to_string()` works.


crates\spt-daemon\src\lifecycle.rs:178:    pub fn spawn_psyche(
C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\src\lifecycle.rs-179-        &self,
crates\spt-daemon\src\lifecycle.rs-180-        session_id: Option<&str>,
crates\spt-daemon\src\lifecycle.rs-181-        event: Option<&str>,


178	    pub fn spawn_psyche(
179	        &self,
180	        session_id: Option<&str>,
181	        event: Option<&str>,
182	    ) -> Result<PsycheHandle, String> {
183	        let handle = spawn_psyche(
184	            &self.runtime,
185	            &self.id,
186	            &self.base_keys(session_id),
187	            event,
188	            &now_stamp(),
189	        )
190	        .map_err(|e| e.to_string())?;
191	        spt_store::info::set_status(&handle.perch_path, spt_store::liveness::STATUS_ONLINE)
192	            .map_err(|e| format!("mark psyche online: {e}"))?;


The error is already a `String`, so `e.to_string()` works (and `&e` would too, but `e.to_string()` is fine for `String`). Good.

Now refinement 3: surface it in `render_self_pin` / the SELF pin. The SELF pin already reads info.json for `resources`. I'll read `psyche_host_error` there too and thread it through `render_self_pin`. Let me update both the call site and the render function.


        let desc = spt_store::info::read_info(&perch::resolve_perch_path(&self_id, ParentHint::Infer))
            .and_then(|rec| rec.resources);
        println!("{}", render_self_pin(&self_id, &state, desc.as_deref()));

        let info = spt_store::info::read_info(&perch::resolve_perch_path(&self_id, ParentHint::Infer));
        let desc = info.as_ref().and_then(|rec| rec.resources.clone());
        // The harness-reachable psyche-host-failure annotation (v0.8.1,
        // REQ-HAZARD-LIVEHOST-BOOT-RACE): an online live agent whose Psyche failed
        // to host shows status authoritatively AND this extra line, surfacing the
        // boot-race that was otherwise silent on the brain's stderr.
        // [impl->REQ-HAZARD-LIVEHOST-BOOT-RACE]
        let phe = info.as_ref().and_then(|rec| rec.psyche_host_error.clone());
        println!("{}", render_self_pin(&self_id, &state, desc.as_deref(), phe.as_ref()));


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


/// Render the SELF-pin block for `endpoint list` / `whoami`: the session
/// endpoint's id + liveness state, plus its authored description inline when one
/// is present (an empty/whitespace blurb is treated as absent). Pure over its
/// inputs so the rendered shape is unit-tested; the caller supplies the live
/// state + blurb from the roster + info store.
/// [impl->REQ-WHOAMI-1]
fn render_self_pin(self_id: &str, state: &str, description: Option<&str>) -> String {
    match description.map(str::trim).filter(|d| !d.is_empty()) {
        Some(d) => format!("SELF: {self_id}  {state}  — {d}"),
        None => format!("SELF: {self_id}  {state}"),
    }
}

/// Render the SELF-pin block for `endpoint list` / `whoami`: the session
/// endpoint's id + liveness state, plus its authored description inline when one
/// is present (an empty/whitespace blurb is treated as absent). When the perch
/// carries a `psyche_host_error` (v0.8.1, REQ-HAZARD-LIVEHOST-BOOT-RACE) — an
/// online live agent whose daemon-hosted Psyche failed to spawn — an additional
/// `psyche-host: FAILED (...)` line follows the pin so the boot-race is
/// harness-/human-visible (status still renders authoritatively above; this is
/// an annotation, not a replacement). Pure over its inputs so the rendered shape
/// is unit-tested; the caller supplies the live state + blurb + error from the
/// roster + info store.
/// [impl->REQ-WHOAMI-1]
// [impl->REQ-HAZARD-LIVEHOST-BOOT-RACE]
fn render_self_pin(
    self_id: &str,
    state: &str,
    description: Option<&str>,
    psyche_host_error: Option<&spt_store::info::PsycheHostError>,
) -> String {
    let pin = match description.map(str::trim).filter(|d| !d.is_empty()) {
        Some(d) => format!("SELF: {self_id}  {state}  — {d}"),
        None => format!("SELF: {self_id}  {state}"),
    };
    match psyche_host_error {
        Some(e) => format!(
            "{pin}\n  psyche-host: FAILED ({}; {} attempt{}; {})",
            e.reason,
            e.attempts,
            if e.attempts == 1 { "" } else { "s" },
            e.ts,
        ),
        None => pin,
    }
}


Now check for existing unit tests of `render_self_pin` that need updating for the new arg.