1	//! `spt send`'s WAN leg (D9-1-4) — the D5 deferral: "`spt send` grows the
2	//! off-node leg transparently."
3	//!
4	//! When local delivery finds no perch, the CLI consults the daemon's
5	//! registry **snapshots** (`identity/registry/<subnet>.json`, the
6	//! [`spt_daemon::registryhost::RegistryHost`] mirror — stale-tolerant by
7	//! contract, KNOWN-HAZARDS 4.3), resolves the target under the D3c policy
8	//! ([`resolve_across_visible`]: refuse-and-qualify on ambiguity, never a
9	//! guess), and ships one [`WanMessage`] over the broker's QUIC endpoint to
10	//! the resolved node. The receiving daemon's dispatcher funnels it through
11	//! the access gate + op-id dedup into the target's local delivery
12	//! (`spt-daemon::wan::receive_wan`).
13	//!
14	//! Deferred sends stay local-only in v1: the wire record carries no
15	//! deferred class, and inventing one is a protocol change — the seam is
16	//! documented, not faked.
17	// [impl->REQ-NET-1]
18	// [impl->REQ-INST-10]
19
20	use std::collections::BTreeMap;
21	use std::path::Path;
22
23	use spt_daemon::brain::Brain;
24	use spt_daemon::endpoint::broker_socket_name;
25	use spt_net::net::endpoint::addr_for_node_hex;
26	use spt_net::net::ndjson;
27	use spt_net::net::registry::{resolve_across_visible, Resolution, SubnetRegistry};
28	use spt_net::net::wanmsg::WanMessage;
29	use spt_proto::addr::Address;
30	use spt_store::epoch::EpochSource;
31	use spt_store::perch;
32	use spt_store::subnet::SubnetStore;
33	use spt_store::visibility::VisibilityStore;
34
35	/// What the WAN leg did — the CLI's exit-path discriminant.
36	#[derive(Debug)]
37	pub enum WanSendOutcome {
38	    /// Shipped to the resolved node (hex). Delivery/spool happens there.
39	    Sent { node: String },
40	    /// Resolution refused — the message says which qualifier disambiguates.
41	    Ambiguous(String),
42	    /// No routable remote instance in any visible subnet.
43	    NotFound,
44	    /// Resolution succeeded but the wire leg failed (daemon down, dial
45	    /// failed, peer unreachable).
46	    Failed(String),
47	}
48
49	/// Production entry: resolve `target` against the canonical snapshots and
50	/// ship over the canonical broker. `target` accepts `[subnet:]id[@node]`.
51	pub fn wan_send(target: &str, from: &str, body: &str) -> WanSendOutcome {
52	    let own_hex = match spt_store::nodeid::load_or_create() {
53	        Ok(id) => id.public_key().to_hex(),
54	        Err(e) => return WanSendOutcome::Failed(format!("node identity: {e}")),
55	    };
56	    let regs = load_snapshots(&perch::identity_dir().join("registry"));
57	    if spt_daemon::daemon::ensure_running().is_err() {
58	        return WanSendOutcome::Failed("daemon did not come up".to_string());
59	    }
60	    let mut brain = match Brain::cold_start(&broker_socket_name(), now_ms()) {
61	        Ok(b) => b,
62	        Err(e) => return WanSendOutcome::Failed(format!("broker connect: {e}")),
63	    };
64	    let mut ops = EpochSource::load_from(&perch::identity_dir().join("cli-ops.json"));
65	    let op_id = match ops.next_epoch() {
66	        Ok(e) => format!("{own_hex}:cli:{e}"),
67	        Err(e) => return WanSendOutcome::Failed(format!("op counter: {e}")),
68	    };
69	    wan_send_with(&mut brain, &regs, &own_hex, target, from, body, &op_id, |hex| {
70	        addr_for_node_hex(hex).and_then(|a| serde_json::to_value(a).ok())
71	    })
72	}
73
74	/// The testable core: resolve against `regs`, dial via `resolver`, write one
75	/// `WanMessage` line, finish the stream.
76	#[allow(clippy::too_many_arguments)]
77	pub fn wan_send_with(
78	    brain: &mut Brain,
79	    regs: &BTreeMap<String, SubnetRegistry>,
80	    own_hex: &str,
81	    target: &str,
82	    from: &str,
83	    body: &str,
84	    op_id: &str,
85	    resolver: impl Fn(&str) -> Option<serde_json::Value>,
86	) -> WanSendOutcome {
87	    let address = match Address::parse(target) {
88	        Ok(a) => a,
89	        Err(e) => return WanSendOutcome::Failed(format!("bad address: {e:?}")),
90	    };
91
92	    // The same exclusion rule the registry's advertisement side enforces
93	    // (ADR-0006 §6): hidden = not routable, even via an explicit pin.
94	    let subnets = SubnetStore::load();
95	    let vis = VisibilityStore::load();
96	    let hide_new: BTreeMap<String, bool> =
97	        subnets.subnets.iter().map(|s| (s.name.clone(), s.hide_new_endpoints)).collect();
98	    let excluded =
99	        |subnet: &str, id: &str| vis.hidden(id, subnet, *hide_new.get(subnet).unwrap_or(&false));
100
101	    let instance = match resolve_across_visible(regs, &address, own_hex, excluded) {
102	        Resolution::Resolved { instance, .. } => instance,
103	        Resolution::Ambiguous(a) => return WanSendOutcome::Ambiguous(format!("{a:?}")),
104	        Resolution::NotFound => return WanSendOutcome::NotFound,
105	    };
106	    if instance.node == own_hex {
107	        // A local instance resolves here only when the local perch scan
108	        // missed it (registry staleness) — WAN-dialing ourselves helps
109	        // nobody; let the caller report the local outcome.
110	        return WanSendOutcome::NotFound;
111	    }
112
113	    let Some(addr) = resolver(&instance.node) else {
114	        return WanSendOutcome::Failed(format!("no dialable address for {}", instance.node));
115	    };
116	    let msg = WanMessage {
117	        target: address.id.clone(),
118	        from: from.to_string(),
119	        body: body.to_string(),
120	        op_id: op_id.to_string(),
121	    };
122	    let send = || -> std::io::Result<()> {
123	        let conn = brain.net_dial(addr, None)?;
124	        let opened = brain.net_open_stream(conn.conn_id, None)?;
125	        brain.net_stream_send(opened.stream_id, &ndjson::encode_line(&msg), None, true)?;
126	        Ok(())
127	    };
128	    match send() {
129	        Ok(()) => WanSendOutcome::Sent { node: instance.node },
130	        Err(e) => WanSendOutcome::Failed(e.to_string()),
131	    }
132	}
133
134	/// What the remote rest leg did — `spt suspend/wake <id@node>`'s exit-path
135	/// discriminant (M5-D5b, the lifted `CROSS_NODE_M5` refusal).
136	#[derive(Debug)]
137	pub enum WanRestOutcome {
138	    /// The target node applied a real edge (`From->To` as it rendered it).
139	    Edge { node: String, detail: String },
140	    /// The target reports no edge — already in the target state (the local
141	    /// `NO_EDGE` sibling; idempotent UX).
142	    NoEdge { node: String },
143	    /// The stream finished with no reply — the target's access gate refused
144	    /// this origin (or dropped the request). Existence is not advertised.
145	    NoReply { node: String },
146	    /// The target's transition host failed; its error text.
147	    RemoteFail { node: String, detail: String },
148	    /// Resolution refused — the message says which qualifier disambiguates.
149	    Ambiguous(String),
150	    /// No routable remote instance in any visible subnet.
151	    NotFound,
152	    /// Resolution succeeded but the wire leg failed (daemon down, dial
153	    /// failed, peer unreachable).
154	    Failed(String),
155	}
156
157	/// Production entry for the remote rest op: resolve `target`
158	/// (`[subnet:]id[@node]`) against the canonical snapshots, dial, send one
159	/// rest request, block for the reply. `event_tag` is the wire verb
160	/// ([`spt_net::net::rest::REST_EVENT_SUSPEND`] / [`REST_EVENT_WAKE`]).
161	// [impl->REQ-INST-6]
162	pub fn wan_rest(target: &str, event_tag: &str) -> WanRestOutcome {
163	    let own_hex = match spt_store::nodeid::load_or_create() {
164	        Ok(id) => id.public_key().to_hex(),
165	        Err(e) => return WanRestOutcome::Failed(format!("node identity: {e}")),
166	    };
167	    let regs = load_snapshots(&perch::identity_dir().join("registry"));
168	    if spt_daemon::daemon::ensure_running().is_err() {
169	        return WanRestOutcome::Failed("daemon did not come up".to_string());
170	    }
171	    let mut brain = match Brain::cold_start(&broker_socket_name(), now_ms()) {
172	        Ok(b) => b,
173	        Err(e) => return WanRestOutcome::Failed(format!("broker connect: {e}")),
174	    };
175	    let mut ops = EpochSource::load_from(&perch::identity_dir().join("cli-ops.json"));
176	    let epoch = match ops.next_epoch() {
177	        Ok(e) => e,
178	        Err(e) => return WanRestOutcome::Failed(format!("op counter: {e}")),
179	    };
180	    let op_id = format!("{own_hex}:cli:{epoch}");
181	    wan_rest_with(&mut brain, &regs, &own_hex, target, event_tag, &op_id, epoch, |hex| {
182	        addr_for_node_hex(hex).and_then(|a| serde_json::to_value(a).ok())
183	    })
184	}
185
186	/// The testable core of [`wan_rest`]: the same resolve-and-dial discipline as
187	/// [`wan_send_with`] (hidden = not routable; ambiguity refuses; an own-node
188	/// hit degrades to NotFound — the local path's report stands), then one
189	/// [`spt_daemon::request_rest`] round trip.
190	// [impl->REQ-INST-6]
191	#[allow(clippy::too_many_arguments)]
192	pub fn wan_rest_with(
193	    brain: &mut Brain,
194	    regs: &BTreeMap<String, SubnetRegistry>,
195	    own_hex: &str,
196	    target: &str,
197	    event_tag: &str,
198	    op_id: &str,
199	    open_op: u64,
200	    resolver: impl Fn(&str) -> Option<serde_json::Value>,
201	) -> WanRestOutcome {
202	    let address = match Address::parse(target) {
203	        Ok(a) => a,
204	        Err(e) => return WanRestOutcome::Failed(format!("bad address: {e:?}")),
205	    };
206	    let subnets = SubnetStore::load();
207	    let vis = VisibilityStore::load();
208	    let hide_new: BTreeMap<String, bool> =
209	        subnets.subnets.iter().map(|s| (s.name.clone(), s.hide_new_endpoints)).collect();
210	    let excluded =
211	        |subnet: &str, id: &str| vis.hidden(id, subnet, *hide_new.get(subnet).unwrap_or(&false));
212
213	    let instance = match resolve_across_visible(regs, &address, own_hex, excluded) {
214	        Resolution::Resolved { instance, .. } => instance,
215	        Resolution::Ambiguous(a) => return WanRestOutcome::Ambiguous(format!("{a:?}")),
216	        Resolution::NotFound => return WanRestOutcome::NotFound,
217	    };
218	    if instance.node == own_hex {
219	        return WanRestOutcome::NotFound;
220	    }
221	    let Some(addr) = resolver(&instance.node) else {
222	        return WanRestOutcome::Failed(format!("no dialable address for {}", instance.node));
223	    };
224	    let node = instance.node.clone();
225	    let round_trip = || -> std::io::Result<spt_daemon::RestRequestOutcome> {
226	        let conn = brain.net_dial(addr, None)?;
227	        spt_daemon::request_rest(brain, conn.conn_id, &address.id, event_tag, op_id, open_op)
228	    };
229	    match round_trip() {
230	        Ok(spt_daemon::RestRequestOutcome::Edge(detail)) => WanRestOutcome::Edge { node, detail },
231	        Ok(spt_daemon::RestRequestOutcome::NoEdge) => WanRestOutcome::NoEdge { node },
232	        Ok(spt_daemon::RestRequestOutcome::NoReply) => WanRestOutcome::NoReply { node },
233	        Ok(spt_daemon::RestRequestOutcome::Failed(detail)) => {
234	            WanRestOutcome::RemoteFail { node, detail }
235	        }
236	        Err(e) => WanRestOutcome::Failed(e.to_string()),
237	    }
238	}
239
240	/// What a remote shell-link op did — the CLI's exit-path discriminant
241	/// (M5-D8c, REQ-SHELL-2).
242	#[derive(Debug)]
243	pub enum WanShellLinkOutcome {
244	    /// The target node ran the action; its report.
245	    Ok { node: String, detail: String },
246	    /// The target found no such instance for that owner (also the
247	    /// foreign-owner shape — owner exclusivity is resolution scope).
248	    NoShell { node: String },
249	    /// The stream finished with no reply — the target's access gate refused
250	    /// this origin. Existence is not advertised.
251	    NoReply { node: String },
252	    /// The target's action failed; its error text.
253	    RemoteFail { node: String, detail: String },
254	    /// Resolution refused — the message says which qualifier disambiguates.
255	    Ambiguous(String),
256	    /// No routable remote instance of the OWNER in any visible subnet.
257	    NotFound,
258	    /// Resolution succeeded but the wire leg failed.
259	    Failed(String),
260	}
261
262	/// Production entry for the cross-node shell link (M5-D8c): `owner_target`
263	/// is the owner's qualified address (`[subnet:]owner[@node]` — the @node
264	/// names the SHELL's node, where the owner's original instance is
265	/// registered); resolve it, dial, and run `action` against `shell_ref` in
266	/// that node's owlery. The same resolve-and-dial discipline as
267	/// [`wan_rest`].
268	// [impl->REQ-SHELL-2]
269	pub fn wan_shell_link(
270	    owner_target: &str,
271	    shell_ref: &str,
272	    action: &str,
273	    args: &[String],
274	) -> WanShellLinkOutcome {
275	    let own_hex = match spt_store::nodeid::load_or_create() {
276	        Ok(id) => id.public_key().to_hex(),
277	        Err(e) => return WanShellLinkOutcome::Failed(format!("node identity: {e}")),
278	    };
279	    let regs = load_snapshots(&perch::identity_dir().join("registry"));
280	    if spt_daemon::daemon::ensure_running().is_err() {
281	        return WanShellLinkOutcome::Failed("daemon did not come up".to_string());
282	    }
283	    let mut brain = match Brain::cold_start(&broker_socket_name(), now_ms()) {
284	        Ok(b) => b,
285	        Err(e) => return WanShellLinkOutcome::Failed(format!("broker connect: {e}")),
286	    };
287	    let mut ops = EpochSource::load_from(&perch::identity_dir().join("cli-ops.json"));
288	    let epoch = match ops.next_epoch() {
289	        Ok(e) => e,
290	        Err(e) => return WanShellLinkOutcome::Failed(format!("op counter: {e}")),
291	    };
292	    let op_id = format!("{own_hex}:cli:{epoch}");
293	    wan_shell_link_with(
294	        &mut brain, &regs, &own_hex, owner_target, shell_ref, action, args, &op_id, epoch,
295	        |hex| addr_for_node_hex(hex).and_then(|a| serde_json::to_value(a).ok()),
296	    )
297	}
298
299	/// The testable core of [`wan_shell_link`] — resolve, dial, one
300	/// [`spt_daemon::request_shell_link`] round trip.
301	// [impl->REQ-SHELL-2]
302	#[allow(clippy::too_many_arguments)]
303	pub fn wan_shell_link_with(
304	    brain: &mut Brain,
305	    regs: &BTreeMap<String, SubnetRegistry>,
306	    own_hex: &str,
307	    owner_target: &str,
308	    shell_ref: &str,
309	    action: &str,
310	    args: &[String],
311	    op_id: &str,
312	    open_op: u64,
313	    resolver: impl Fn(&str) -> Option<serde_json::Value>,
314	) -> WanShellLinkOutcome {
315	    let address = match Address::parse(owner_target) {
316	        Ok(a) => a,
317	        Err(e) => return WanShellLinkOutcome::Failed(format!("bad address: {e:?}")),
318	    };
319	    let subnets = SubnetStore::load();
320	    let vis = VisibilityStore::load();
321	    let hide_new: BTreeMap<String, bool> =
322	        subnets.subnets.iter().map(|s| (s.name.clone(), s.hide_new_endpoints)).collect();
323	    let excluded =
324	        |subnet: &str, id: &str| vis.hidden(id, subnet, *hide_new.get(subnet).unwrap_or(&false));
325
326	    let instance = match resolve_across_visible(regs, &address, own_hex, excluded) {
327	        Resolution::Resolved { instance, .. } => instance,
328	        Resolution::Ambiguous(a) => return WanShellLinkOutcome::Ambiguous(format!("{a:?}")),
329	        Resolution::NotFound => return WanShellLinkOutcome::NotFound,
330	    };
331	    if instance.node == own_hex {
332	        return WanShellLinkOutcome::NotFound;
333	    }
334	    let Some(addr) = resolver(&instance.node) else {
335	        return WanShellLinkOutcome::Failed(format!("no dialable address for {}", instance.node));
336	    };
337	    let node = instance.node.clone();
338	    let round_trip = || -> std::io::Result<spt_daemon::ShellLinkRequestOutcome> {
339	        let conn = brain.net_dial(addr, None)?;
340	        spt_daemon::request_shell_link(
341	            brain, conn.conn_id, &address.id, shell_ref, action, args, op_id, open_op,
342	        )
343	    };
344	    match round_trip() {
345	        Ok(spt_daemon::ShellLinkRequestOutcome::Ok(detail)) => {
346	            WanShellLinkOutcome::Ok { node, detail }
347	        }
348	        Ok(spt_daemon::ShellLinkRequestOutcome::NoShell) => WanShellLinkOutcome::NoShell { node },
349	        Ok(spt_daemon::ShellLinkRequestOutcome::NoReply) => WanShellLinkOutcome::NoReply { node },
350	        Ok(spt_daemon::ShellLinkRequestOutcome::Failed(detail)) => {
351	            WanShellLinkOutcome::RemoteFail { node, detail }
352	        }
353	        Err(e) => WanShellLinkOutcome::Failed(e.to_string()),
354	    }
355	}
356
357	/// Dial-probe a node (M7 D2, REQ-SUBNET-1 — the hybrid liveness's expensive
358	/// half, spent only on rows gossip-recency could not settle): true iff the
359	/// broker's QUIC endpoint can connect to it right now. Every failure mode
360	/// (daemon down, no dialable address, refused/timed-out dial) reads `false` —
361	/// the caller renders offline, never errors a status view.
362	// [impl->REQ-SUBNET-1]
363	pub fn probe_node(node_hex: &str) -> bool {
364	    if spt_daemon::daemon::ensure_running().is_err() {
365	        return false;
366	    }
367	    let Ok(mut brain) = Brain::cold_start(&broker_socket_name(), now_ms()) else {
368	        return false;
369	    };
370	    let Some(addr) = addr_for_node_hex(node_hex).and_then(|a| serde_json::to_value(a).ok())
371	    else {
372	        return false;
373	    };
374	    brain.net_dial(addr, None).is_ok()
375	}
376
377	/// Load every per-subnet registry snapshot under `dir`. Missing dir or an
378	/// unparseable snapshot degrade to absent (the reader's stale-tolerant
379	/// contract) — resolution then simply finds nothing there.
380	pub fn load_snapshots(dir: &Path) -> BTreeMap<String, SubnetRegistry> {
381	    let mut out = BTreeMap::new();
382	    let Ok(entries) = std::fs::read_dir(dir) else {
383	        return out;
384	    };
385	    for entry in entries.flatten() {
386	        let path = entry.path();
387	        if path.extension().and_then(|e| e.to_str()) != Some("json") {
388	            continue;
389	        }
390	        let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else { continue };
391	        let Ok(raw) = std::fs::read_to_string(&path) else { continue };
392	        let Ok(reg) = serde_json::from_str::<SubnetRegistry>(&raw) else { continue };
393	        out.insert(stem.to_string(), reg);
394	    }
395	    out
396	}
397
398	fn now_ms() -> u64 {
399	    std::time::SystemTime::now()
400	        .duration_since(std::time::UNIX_EPOCH)
401	        .map(|d| d.as_millis() as u64)
402	        .unwrap_or(0)
403	}
404
405	#[cfg(test)]
406	mod tests {
407	    use super::*;
408	    use crate::testutil::isolated_home;
409	    use spt_daemon::broker::Broker;
410	    use spt_daemon::dispatch::{run_dispatch_loop, DispatchPaths};
411	    use spt_daemon::nethost::{NetConfig, NetHost};
412	    use spt_daemon::registryhost::RegistryHost;
413	    use spt_net::net::endpoint::{BindScope, LocalDiscovery, RelayPolicy};
414	    use spt_net::net::registry::{Instance, Status};
415	    use spt_proto::identity::Identity;
416	    use spt_store::info::{self, InfoJson};
417	    use spt_store::perch::ParentHint;
418	    use spt_store::spool;
419	    use std::sync::atomic::{AtomicBool, Ordering};
420	    use std::sync::Arc;
421	    use std::time::Duration;
422
423	    fn net_broker(name: &str, dir: &Path) -> Arc<Broker> {
424	        let host = NetHost::start(NetConfig {
425	            identity: Identity::generate(),
426	            relay: RelayPolicy::Disabled,
427	            discovery: LocalDiscovery::Off,
428	            scope: BindScope::Loopback,
429	        })
430	        .expect("net host");
431	        let broker = Broker::bind_in_with_net(name, dir.join("effects.log"), Some(host))
432	            .expect("bind broker");
433	        let serve = Arc::clone(&broker);
434	        std::thread::spawn(move || {
435	            let _ = serve.serve();
436	        });
437	        broker
438	    }
439
440	    fn connect_retry(name: &str) -> Brain {
441	        for _ in 0..300 {
442	            if let Ok(b) = Brain::cold_start(name, 1) {
443	                return b;
444	            }
445	            std::thread::sleep(Duration::from_millis(10));
446	        }
447	        panic!("brain could not connect");
448	    }
449
450	    fn reg_with(subnet: &str, id: &str, node: &str) -> BTreeMap<String, SubnetRegistry> {
451	        let mut reg = SubnetRegistry::new();
452	        reg.merge_instance(
453	            id,
454	            Instance { node: node.to_string(), status: Status::Active, epoch: 1, resources: None, last_active_ms: None, shell_adapters: Vec::new(), node_label: None },
455	        );
456	        let mut map = BTreeMap::new();
457	        map.insert(subnet.to_string(), reg);
458	        map
459	    }
460
461	    // [unit->REQ-NET-1] the WAN leg end to end: resolve from a registry map,
462	    // dial, ship one WanMessage; B's production dispatcher funnels it into
463	    // the target perch's spool. Ambiguity refuses (REQ-INST-10); an
464	    // own-node hit degrades to NotFound (the local path's report stands).
465	    #[test]
466	    fn wan_send_ships_to_the_resolved_node() {
467	        let _home = isolated_home();
468	        let dir = tempfile::tempdir().unwrap();
469	        let (a_name, b_name) = (
470	            format!("spt-wansend-a-{}.sock", std::process::id()),
471	            format!("spt-wansend-b-{}.sock", std::process::id()),
472	        );
473	        let _a = net_broker(&a_name, dir.path());
474	        let _b = net_broker(&b_name, dir.path());
475
476	        // B runs the production dispatcher against the canonical home.
477	        let stop = Arc::new(AtomicBool::new(false));
478	        {
479	            let name = b_name.clone();
480	            let flag = Arc::clone(&stop);
481	            let paths = DispatchPaths {
482	                owlery: perch::owlery_dir(),
483	                releases: perch::spt_home().join("releases"),
484	                scratch: dir.path().join("b-scratch"),
485	            };
486	            let registry = Arc::new(RegistryHost::new_at(
487	                "b-wansend",
488	                EpochSource::load_from(&dir.path().join("b-epoch.json")),
489	                dir.path().join("b-registry"),
490	            ));
491	            std::thread::spawn(move || {
492	                let _ = run_dispatch_loop(&name, &paths, registry, &flag, Duration::from_millis(25));
493	            });
494	        }
495	        // An offline perch at B so the funnel spools.
496	        let target = "ling-wan";
497	        let perch_path = perch::resolve_perch_path(target, ParentHint::Infer);
498	        std::fs::create_dir_all(&perch_path).unwrap();
499	        let rec = InfoJson::new(target, "now", std::process::id(), "s", "ready_agent");
500	        info::write_info(&perch_path, &rec).unwrap();
501
502	        let mut a = connect_retry(&a_name);
503	        let mut b_probe = connect_retry(&b_name);
504	        let a_hex = a.net_status().expect("status").node_id_hex.expect("net");
505	        let b_hex = b_probe.net_status().expect("status").node_id_hex.expect("net");
506	        let b_addr = b_probe.net_status().expect("status").addr;
507
508	        // Ambiguity refuses before any wire work (two subnets hold the id).
509	        let mut ambiguous = reg_with("s1", target, &b_hex);
510	        ambiguous.extend(reg_with("s2", target, &b_hex));
511	        let out = wan_send_with(
512	            &mut a, &ambiguous, &a_hex, target, "doyle", "x", "op-amb",
513	            |_| None,
514	        );
515	        assert!(matches!(out, WanSendOutcome::Ambiguous(_)), "got {out:?}");
516
517	        // An own-node hit is NotFound (the local report stands; no self-dial).
518	        let own = reg_with("s1", target, &a_hex);
519	        let out =
520	            wan_send_with(&mut a, &own, &a_hex, target, "doyle", "x", "op-own", |_| None);
521	        assert!(matches!(out, WanSendOutcome::NotFound), "got {out:?}");
522
523	        // The real ship: resolve → dial → one record → B spools it.
524	        let regs = reg_with("s1", target, &b_hex);
525	        let resolver = |hex: &str| {
526	            if hex == b_hex {
527	                Some(b_addr.clone())
528	            } else {
529	                None
530	            }
531	        };
532	        let out = wan_send_with(
533	            &mut a, &regs, &a_hex, target, "doyle", "over the wan", "op-ship", resolver,
534	        );
535	        assert!(matches!(out, WanSendOutcome::Sent { .. }), "got {out:?}");
536	        let mut spooled = false;
537	        for _ in 0..400 {
538	            let pending = spool::peek_all_at(&perch_path).expect("peek");
539	            if pending.iter().any(|(_, _, body, _)| body == "over the wan") {
540	                spooled = true;
541	                break;
542	            }
543	            std::thread::sleep(Duration::from_millis(25));
544	        }
545	        assert!(spooled, "B's dispatcher funneled the WAN message into the spool");
546	        stop.store(true, Ordering::Relaxed);
547	    }
548
549	    // [int->REQ-INST-6] the remote rest arm end to end over the loopback
550	    // wire, production dispatcher serving (the attach/xfer test pattern):
551	    // suspend cross-"node" lands the edge on the target's transition host;
552	    // the deferred-message gate HOLDS while suspended (the D5a gate keyed on
553	    // the now-remotely-flipped record); a replayed suspend is a NO_EDGE
554	    // (idempotence over the wire — report, don't dedup); wake cross-"node"
555	    // releases the held row through the unchanged hook drain, exactly once,
556	    // and fires the wake effects; and the access gate refuses an unlisted
557	    // origin when a whitelist exists — stream finished, no reply, no state
558	    // change (REQ-HAZARD-WAN-ORIGIN-AUTH: the subject was the handshake
559	    // origin, not wire bytes).
560	    #[test]
561	    fn remote_rest_suspends_and_wakes_with_the_deferred_gate() {
562	        use spt_daemon::resting::{read_rest, RestState};
563	        use spt_net::net::rest::{REST_EVENT_SUSPEND, REST_EVENT_WAKE};
564
565	        let _home = isolated_home();
566	        let dir = tempfile::tempdir().unwrap();
567	        let (a_name, b_name) = (
568	            format!("spt-wanrest-a-{}.sock", std::process::id()),
569	            format!("spt-wanrest-b-{}.sock", std::process::id()),
570	        );
571	        let _a = net_broker(&a_name, dir.path());
572	        let _b = net_broker(&b_name, dir.path());
573
574	        // B runs the production dispatcher against the canonical home.
575	        let stop = Arc::new(AtomicBool::new(false));
576	        {
577	            let name = b_name.clone();
578	            let flag = Arc::clone(&stop);
579	            let paths = DispatchPaths {
580	                owlery: perch::owlery_dir(),
581	                releases: perch::spt_home().join("releases"),
582	                scratch: dir.path().join("b-scratch"),
583	            };
584	            let registry = Arc::new(RegistryHost::new_at(
585	                "b-wanrest",
586	                EpochSource::load_from(&dir.path().join("b-epoch.json")),
587	                dir.path().join("b-registry"),
588	            ));
589	            std::thread::spawn(move || {
590	                let _ = run_dispatch_loop(&name, &paths, registry, &flag, Duration::from_millis(25));
591	            });
592	        }
593
594	        // The target instance "lives at B": a hosted live perch with one
595	        // deferred (spool-only) row and one normal row pending.
596	        let target = "ling-rest";
597	        let perch_path = perch::resolve_perch_path(target, ParentHint::Infer);
598	        std::fs::create_dir_all(&perch_path).unwrap();
599	        let rec = InfoJson::new(target, "now", std::process::id(), "s", "live_agent");
600	        info::write_info(&perch_path, &rec).unwrap();
601	        spool::spool_message_deferred_at(&perch_path, "psyche", "deferred brief").unwrap();
602	        spool::spool_message_at(&perch_path, "bob", "live one").unwrap();
603
604	        let mut a = connect_retry(&a_name);
605	        let mut b_probe = connect_retry(&b_name);
606	        let a_hex = a.net_status().expect("status").node_id_hex.expect("net");
607	        let b_hex = b_probe.net_status().expect("status").node_id_hex.expect("net");
608	        let b_addr = b_probe.net_status().expect("status").addr;
609	        let regs = reg_with("s1", target, &b_hex);
610	        let resolver = |hex: &str| {
611	            if hex == b_hex {
612	                Some(b_addr.clone())
613	            } else {
614	                None
615	            }
616	        };
617
618	        // Suspend from the other node: the TARGET's transition host runs it.
619	        let out = wan_rest_with(
620	            &mut a, &regs, &a_hex, target, REST_EVENT_SUSPEND, "op-s1", 11, resolver,
621	        );
622	        assert!(
623	            matches!(&out, WanRestOutcome::Edge { detail, .. } if detail == "Active->Suspended"),
624	            "got {out:?}"
625	        );
626	        assert_eq!(read_rest(&perch_path).unwrap().state, RestState::Suspended);
627
628	        // The deferred gate holds at the hook drain; non-deferred untouched.
629	        let got = crate::api::delivery::poll_drain(target, true);
630	        assert_eq!(got.len(), 1, "deferred held while suspended: {got:?}");
631	        assert!(got[0].contains("live one"));
632	        assert_eq!(spool::pending_count_at(&perch_path).unwrap(), 1, "held row survives");
633
634	        // Idempotence over the wire: a redelivered suspend is a NO_EDGE.
635	        let out = wan_rest_with(
636	            &mut a, &regs, &a_hex, target, REST_EVENT_SUSPEND, "op-s1", 12, resolver,
637	        );
638	        assert!(matches!(out, WanRestOutcome::NoEdge { .. }), "got {out:?}");
639
640	        // Wake from the other node: the edge releases the held row through
641	        // the SAME drain, exactly once, and the wake effects fire.
642	        let out = wan_rest_with(
643	            &mut a, &regs, &a_hex, target, REST_EVENT_WAKE, "op-w1", 13, resolver,
644	        );
645	        assert!(
646	            matches!(&out, WanRestOutcome::Edge { detail, .. } if detail == "Suspended->Active"),
647	            "got {out:?}"
648	        );
649	        assert_eq!(read_rest(&perch_path).unwrap().state, RestState::Active);
650	        let got = crate::api::delivery::poll_drain(target, true);
651	        assert_eq!(got.len(), 1, "released exactly at the wake edge: {got:?}");
652	        assert!(got[0].contains("deferred brief"));
653	        assert!(crate::api::delivery::poll_drain(target, true).is_empty(), "exactly once");
654	        assert!(
655	            perch::identity_dir().join(spt_daemon::PULL_MARKER_FILE).exists(),
656	            "wake effects fired on the target node (freshness pull requested)"
657	        );
658
659	        // Access-gate negative: a whitelist naming someone else refuses this
660	        // origin — no reply, no edge.
661	        let mut acl = spt_store::access::AccessStore::load();
662	        acl.allow(target, "someone-else");
663	        acl.save().unwrap();
664	        let out = wan_rest_with(
665	            &mut a, &regs, &a_hex, target, REST_EVENT_SUSPEND, "op-s2", 14, resolver,
666	        );
667	        assert!(matches!(out, WanRestOutcome::NoReply { .. }), "got {out:?}");
668	        assert_eq!(
669	            read_rest(&perch_path).unwrap().state,
670	            RestState::Active,
671	            "a refused rest op moves nothing"
672	        );
673
674	        stop.store(true, Ordering::Relaxed);
675	    }
676
677	    // [unit->REQ-NET-1] snapshot loading degrades: missing dir, junk files,
678	    // and non-json entries all yield absence, never an error.
679	    #[test]
680	    fn load_snapshots_degrades_on_junk() {
681	        let dir = tempfile::tempdir().unwrap();
682	        assert!(load_snapshots(&dir.path().join("nope")).is_empty());
683	        let d = dir.path().join("registry");
684	        std::fs::create_dir_all(&d).unwrap();
685	        std::fs::write(d.join("good.json"), serde_json::to_string(&SubnetRegistry::new()).unwrap())
686	            .unwrap();
687	        std::fs::write(d.join("bad.json"), "{{{").unwrap();
688	        std::fs::write(d.join("ignored.txt"), "x").unwrap();
689	        let map = load_snapshots(&d);
690	        assert_eq!(map.len(), 1);
691	        assert!(map.contains_key("good"));
692	    }
693	}
694

//! `spt send`'s WAN leg (D9-1-4) — the D5 deferral: "`spt send` grows the
//! off-node leg transparently."
//!
//! When local delivery finds no perch, the CLI consults the daemon's
//! registry **snapshots** (`identity/registry/<subnet>.json`, the
//! [`spt_daemon::registryhost::RegistryHost`] mirror — stale-tolerant by
//! contract, KNOWN-HAZARDS 4.3), resolves the target under the D3c policy
//! ([`resolve_across_visible`]: refuse-and-qualify on ambiguity, never a
//! guess), and ships one [`WanMessage`] over the broker's QUIC endpoint to
//! the resolved node. The receiving daemon's dispatcher funnels it through
//! the access gate + op-id dedup into the target's local delivery
//! (`spt-daemon::wan::receive_wan`).
//!
//! Deferred sends stay local-only in v1: the wire record carries no
//! deferred class, and inventing one is a protocol change — the seam is
//! documented, not faked.
// [impl->REQ-NET-1]
// [impl->REQ-INST-10]

use std::collections::BTreeMap;
use std::path::Path;

use spt_daemon::brain::Brain;
use spt_daemon::endpoint::broker_socket_name;
use spt_net::net::endpoint::addr_for_node_hex;
use spt_net::net::ndjson;
use spt_net::net::registry::{resolve_across_visible, Resolution, SubnetRegistry};
use spt_net::net::wanmsg::WanMessage;
use spt_proto::addr::Address;
use spt_store::epoch::EpochSource;
use spt_store::perch;
use spt_store::subnet::SubnetStore;
use spt_store::visibility::VisibilityStore;

/// What the WAN leg did — the CLI's exit-path discriminant.
#[derive(Debug)]
pub enum WanSendOutcome {
    /// Shipped to the resolved node (hex). Delivery/spool happens there.
    Sent { node: String },
    /// Resolution refused — the message says which qualifier disambiguates.
    Ambiguous(String),
    /// No routable remote instance in any visible subnet.
    NotFound,
    /// Resolution succeeded but the wire leg failed (daemon down, dial
    /// failed, peer unreachable).
    Failed(String),
}

/// Production entry: resolve `target` against the canonical snapshots and
/// ship over the canonical broker. `target` accepts `[subnet:]id[@node]`.
pub fn wan_send(target: &str, from: &str, body: &str) -> WanSendOutcome {
    let own_hex = match spt_store::nodeid::load_or_create() {
        Ok(id) => id.public_key().to_hex(),
        Err(e) => return WanSendOutcome::Failed(format!("node identity: {e}")),
    };
    let regs = load_snapshots(&perch::identity_dir().join("registry"));
    if spt_daemon::daemon::ensure_running().is_err() {
        return WanSendOutcome::Failed("daemon did not come up".to_string());
    }
    let mut brain = match Brain::cold_start(&broker_socket_name(), now_ms()) {
        Ok(b) => b,
        Err(e) => return WanSendOutcome::Failed(format!("broker connect: {e}")),
    };
    let mut ops = EpochSource::load_from(&perch::identity_dir().join("cli-ops.json"));
    let op_id = match ops.next_epoch() {
        Ok(e) => format!("{own_hex}:cli:{e}"),
        Err(e) => return WanSendOutcome::Failed(format!("op counter: {e}")),
    };
    wan_send_with(&mut brain, &regs, &own_hex, target, from, body, &op_id, |hex| {
        addr_for_node_hex(hex).and_then(|a| serde_json::to_value(a).ok())
    })
}

/// The testable core: resolve against `regs`, dial via `resolver`, write one
/// `WanMessage` line, finish the stream.
#[allow(clippy::too_many_arguments)]
pub fn wan_send_with(
    brain: &mut Brain,
    regs: &BTreeMap<String, SubnetRegistry>,
    own_hex: &str,
    target: &str,
    from: &str,
    body: &str,
    op_id: &str,
    resolver: impl Fn(&str) -> Option<serde_json::Value>,
) -> WanSendOutcome {
    let address = match Address::parse(target) {
        Ok(a) => a,
        Err(e) => return WanSendOutcome::Failed(format!("bad address: {e:?}")),
    };

    // The same exclusion rule the registry's advertisement side enforces
    // (ADR-0006 §6): hidden = not routable, even via an explicit pin.
    let subnets = SubnetStore::load();
    let vis = VisibilityStore::load();
    let hide_new: BTreeMap<String, bool> =
        subnets.subnets.iter().map(|s| (s.name.clone(), s.hide_new_endpoints)).collect();
    let excluded =
        |subnet: &str, id: &str| vis.hidden(id, subnet, *hide_new.get(subnet).unwrap_or(&false));

    let instance = match resolve_across_visible(regs, &address, own_hex, excluded) {
        Resolution::Resolved { instance, .. } => instance,
        Resolution::Ambiguous(a) => return WanSendOutcome::Ambiguous(format!("{a:?}")),
        Resolution::NotFound => return WanSendOutcome::NotFound,
    };
    if instance.node == own_hex {
        // A local instance resolves here only when the local perch scan
        // missed it (registry staleness) — WAN-dialing ourselves helps
        // nobody; let the caller report the local outcome.
        return WanSendOutcome::NotFound;
    }

    let Some(addr) = resolver(&instance.node) else {
        return WanSendOutcome::Failed(format!("no dialable address for {}", instance.node));
    };
    let msg = WanMessage {
        target: address.id.clone(),
        from: from.to_string(),
        body: body.to_string(),
        op_id: op_id.to_string(),
    };
    let send = || -> std::io::Result<()> {
        let conn = brain.net_dial(addr, None)?;
        let opened = brain.net_open_stream(conn.conn_id, None)?;
        brain.net_stream_send(opened.stream_id, &ndjson::encode_line(&msg), None, true)?;
        Ok(())
    };
    match send() {
        Ok(()) => WanSendOutcome::Sent { node: instance.node },
        Err(e) => WanSendOutcome::Failed(e.to_string()),
    }
}

/// What the remote rest leg did — `spt suspend/wake <id@node>`'s exit-path
/// discriminant (M5-D5b, the lifted `CROSS_NODE_M5` refusal).
#[derive(Debug)]
pub enum WanRestOutcome {
    /// The target node applied a real edge (`From->To` as it rendered it).
    Edge { node: String, detail: String },
    /// The target reports no edge — already in the target state (the local
    /// `NO_EDGE` sibling; idempotent UX).
    NoEdge { node: String },
    /// The stream finished with no reply — the target's access gate refused
    /// this origin (or dropped the request). Existence is not advertised.
    NoReply { node: String },
    /// The target's transition host failed; its error text.
    RemoteFail { node: String, detail: String },
    /// Resolution refused — the message says which qualifier disambiguates.
    Ambiguous(String),
    /// No routable remote instance in any visible subnet.
    NotFound,
    /// Resolution succeeded but the wire leg failed (daemon down, dial
    /// failed, peer unreachable).
    Failed(String),
}

/// Production entry for the remote rest op: resolve `target`
/// (`[subnet:]id[@node]`) against the canonical snapshots, dial, send one
/// rest request, block for the reply. `event_tag` is the wire verb
/// ([`spt_net::net::rest::REST_EVENT_SUSPEND`] / [`REST_EVENT_WAKE`]).
// [impl->REQ-INST-6]
pub fn wan_rest(target: &str, event_tag: &str) -> WanRestOutcome {
    let own_hex = match spt_store::nodeid::load_or_create() {
        Ok(id) => id.public_key().to_hex(),
        Err(e) => return WanRestOutcome::Failed(format!("node identity: {e}")),
    };
    let regs = load_snapshots(&perch::identity_dir().join("registry"));
    if spt_daemon::daemon::ensure_running().is_err() {
        return WanRestOutcome::Failed("daemon did not come up".to_string());
    }
    let mut brain = match Brain::cold_start(&broker_socket_name(), now_ms()) {
        Ok(b) => b,
        Err(e) => return WanRestOutcome::Failed(format!("broker connect: {e}")),
    };
    let mut ops = EpochSource::load_from(&perch::identity_dir().join("cli-ops.json"));
    let epoch = match ops.next_epoch() {
        Ok(e) => e,
        Err(e) => return WanRestOutcome::Failed(format!("op counter: {e}")),
    };
    let op_id = format!("{own_hex}:cli:{epoch}");
    wan_rest_with(&mut brain, &regs, &own_hex, target, event_tag, &op_id, epoch, |hex| {
        addr_for_node_hex(hex).and_then(|a| serde_json::to_value(a).ok())
    })
}

/// The testable core of [`wan_rest`]: the same resolve-and-dial discipline as
/// [`wan_send_with`] (hidden = not routable; ambiguity refuses; an own-node
/// hit degrades to NotFound — the local path's report stands), then one
/// [`spt_daemon::request_rest`] round trip.
// [impl->REQ-INST-6]
#[allow(clippy::too_many_arguments)]
pub fn wan_rest_with(
    brain: &mut Brain,
    regs: &BTreeMap<String, SubnetRegistry>,
    own_hex: &str,
    target: &str,
    event_tag: &str,
    op_id: &str,
    open_op: u64,
    resolver: impl Fn(&str) -> Option<serde_json::Value>,
) -> WanRestOutcome {
    let address = match Address::parse(target) {
        Ok(a) => a,
        Err(e) => return WanRestOutcome::Failed(format!("bad address: {e:?}")),
    };
    let subnets = SubnetStore::load();
    let vis = VisibilityStore::load();
    let hide_new: BTreeMap<String, bool> =
        subnets.subnets.iter().map(|s| (s.name.clone(), s.hide_new_endpoints)).collect();
    let excluded =
        |subnet: &str, id: &str| vis.hidden(id, subnet, *hide_new.get(subnet).unwrap_or(&false));

    let instance = match resolve_across_visible(regs, &address, own_hex, excluded) {
        Resolution::Resolved { instance, .. } => instance,
        Resolution::Ambiguous(a) => return WanRestOutcome::Ambiguous(format!("{a:?}")),
        Resolution::NotFound => return WanRestOutcome::NotFound,
    };
    if instance.node == own_hex {
        return WanRestOutcome::NotFound;
    }
    let Some(addr) = resolver(&instance.node) else {
        return WanRestOutcome::Failed(format!("no dialable address for {}", instance.node));
    };
    let node = instance.node.clone();
    let round_trip = || -> std::io::Result<spt_daemon::RestRequestOutcome> {
        let conn = brain.net_dial(addr, None)?;
        spt_daemon::request_rest(brain, conn.conn_id, &address.id, event_tag, op_id, open_op)
    };
    match round_trip() {
        Ok(spt_daemon::RestRequestOutcome::Edge(detail)) => WanRestOutcome::Edge { node, detail },
        Ok(spt_daemon::RestRequestOutcome::NoEdge) => WanRestOutcome::NoEdge { node },
        Ok(spt_daemon::RestRequestOutcome::NoReply) => WanRestOutcome::NoReply { node },
        Ok(spt_daemon::RestRequestOutcome::Failed(detail)) => {
            WanRestOutcome::RemoteFail { node, detail }
        }
        Err(e) => WanRestOutcome::Failed(e.to_string()),
    }
}

/// What a remote shell-link op did — the CLI's exit-path discriminant
/// (M5-D8c, REQ-SHELL-2).
#[derive(Debug)]
pub enum WanShellLinkOutcome {
    /// The target node ran the action; its report.
    Ok { node: String, detail: String },
    /// The target found no such instance for that owner (also the
    /// foreign-owner shape — owner exclusivity is resolution scope).
    NoShell { node: String },
    /// The stream finished with no reply — the target's access gate refused
    /// this origin. Existence is not advertised.
    NoReply { node: String },
    /// The target's action failed; its error text.
    RemoteFail { node: String, detail: String },
    /// Resolution refused — the message says which qualifier disambiguates.
    Ambiguous(String),
    /// No routable remote instance of the OWNER in any visible subnet.
    NotFound,
    /// Resolution succeeded but the wire leg failed.
    Failed(String),
}

/// Production entry for the cross-node shell link (M5-D8c): `owner_target`
/// is the owner's qualified address (`[subnet:]owner[@node]` — the @node
/// names the SHELL's node, where the owner's original instance is
/// registered); resolve it, dial, and run `action` against `shell_ref` in
/// that node's owlery. The same resolve-and-dial discipline as
/// [`wan_rest`].
// [impl->REQ-SHELL-2]
pub fn wan_shell_link(
    owner_target: &str,
    shell_ref: &str,
    action: &str,
    args: &[String],
) -> WanShellLinkOutcome {
    let own_hex = match spt_store::nodeid::load_or_create() {
        Ok(id) => id.public_key().to_hex(),
        Err(e) => return WanShellLinkOutcome::Failed(format!("node identity: {e}")),
    };
    let regs = load_snapshots(&perch::identity_dir().join("registry"));
    if spt_daemon::daemon::ensure_running().is_err() {
        return WanShellLinkOutcome::Failed("daemon did not come up".to_string());
    }
    let mut brain = match Brain::cold_start(&broker_socket_name(), now_ms()) {
        Ok(b) => b,
        Err(e) => return WanShellLinkOutcome::Failed(format!("broker connect: {e}")),
    };
    let mut ops = EpochSource::load_from(&perch::identity_dir().join("cli-ops.json"));
    let epoch = match ops.next_epoch() {
        Ok(e) => e,
        Err(e) => return WanShellLinkOutcome::Failed(format!("op counter: {e}")),
    };
    let op_id = format!("{own_hex}:cli:{epoch}");
    wan_shell_link_with(
        &mut brain, &regs, &own_hex, owner_target, shell_ref, action, args, &op_id, epoch,
        |hex| addr_for_node_hex(hex).and_then(|a| serde_json::to_value(a).ok()),
    )
}

/// The testable core of [`wan_shell_link`] — resolve, dial, one
/// [`spt_daemon::request_shell_link`] round trip.
// [impl->REQ-SHELL-2]
#[allow(clippy::too_many_arguments)]
pub fn wan_shell_link_with(
    brain: &mut Brain,
    regs: &BTreeMap<String, SubnetRegistry>,
    own_hex: &str,
    owner_target: &str,
    shell_ref: &str,
    action: &str,
    args: &[String],
    op_id: &str,
    open_op: u64,
    resolver: impl Fn(&str) -> Option<serde_json::Value>,
) -> WanShellLinkOutcome {
    let address = match Address::parse(owner_target) {
        Ok(a) => a,
        Err(e) => return WanShellLinkOutcome::Failed(format!("bad address: {e:?}")),
    };
    let subnets = SubnetStore::load();
    let vis = VisibilityStore::load();
    let hide_new: BTreeMap<String, bool> =
        subnets.subnets.iter().map(|s| (s.name.clone(), s.hide_new_endpoints)).collect();
    let excluded =
        |subnet: &str, id: &str| vis.hidden(id, subnet, *hide_new.get(subnet).unwrap_or(&false));

    let instance = match resolve_across_visible(regs, &address, own_hex, excluded) {
        Resolution::Resolved { instance, .. } => instance,
        Resolution::Ambiguous(a) => return WanShellLinkOutcome::Ambiguous(format!("{a:?}")),
        Resolution::NotFound => return WanShellLinkOutcome::NotFound,
    };
    if instance.node == own_hex {
        return WanShellLinkOutcome::NotFound;
    }
    let Some(addr) = resolver(&instance.node) else {
        return WanShellLinkOutcome::Failed(format!("no dialable address for {}", instance.node));
    };
    let node = instance.node.clone();
    let round_trip = || -> std::io::Result<spt_daemon::ShellLinkRequestOutcome> {
        let conn = brain.net_dial(addr, None)?;
        spt_daemon::request_shell_link(
            brain, conn.conn_id, &address.id, shell_ref, action, args, op_id, open_op,
        )
    };
    match round_trip() {
        Ok(spt_daemon::ShellLinkRequestOutcome::Ok(detail)) => {
            WanShellLinkOutcome::Ok { node, detail }
        }
        Ok(spt_daemon::ShellLinkRequestOutcome::NoShell) => WanShellLinkOutcome::NoShell { node },
        Ok(spt_daemon::ShellLinkRequestOutcome::NoReply) => WanShellLinkOutcome::NoReply { node },
        Ok(spt_daemon::ShellLinkRequestOutcome::Failed(detail)) => {
            WanShellLinkOutcome::RemoteFail { node, detail }
        }
        Err(e) => WanShellLinkOutcome::Failed(e.to_string()),
    }
}

/// Dial-probe a node (M7 D2, REQ-SUBNET-1 — the hybrid liveness's expensive
/// half, spent only on rows gossip-recency could not settle): true iff the
/// broker's QUIC endpoint can connect to it right now. Every failure mode
/// (daemon down, no dialable address, refused/timed-out dial) reads `false` —
/// the caller renders offline, never errors a status view.
// [impl->REQ-SUBNET-1]
pub fn probe_node(node_hex: &str) -> bool {
    if spt_daemon::daemon::ensure_running().is_err() {
        return false;
    }
    let Ok(mut brain) = Brain::cold_start(&broker_socket_name(), now_ms()) else {
        return false;
    };
    let Some(addr) = addr_for_node_hex(node_hex).and_then(|a| serde_json::to_value(a).ok())
    else {
        return false;
    };
    brain.net_dial(addr, None).is_ok()
}

/// Load every per-subnet registry snapshot under `dir`. Missing dir or an
/// unparseable snapshot degrade to absent (the reader's stale-tolerant
/// contract) — resolution then simply finds nothing there.
pub fn load_snapshots(dir: &Path) -> BTreeMap<String, SubnetRegistry> {
    let mut out = BTreeMap::new();
    let Ok(entries) = std::fs::read_dir(dir) else {
        return out;
    };
    for entry in entries.flatten() {
        let path = entry.path();
        if path.extension().and_then(|e| e.to_str()) != Some("json") {
            continue;
        }
        let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else { continue };
        let Ok(raw) = std::fs::read_to_string(&path) else { continue };
        let Ok(reg) = serde_json::from_str::<SubnetRegistry>(&raw) else { continue };
        out.insert(stem.to_string(), reg);
    }
    out
}

fn now_ms() -> u64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_millis() as u64)
        .unwrap_or(0)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::testutil::isolated_home;
    use spt_daemon::broker::Broker;
    use spt_daemon::dispatch::{run_dispatch_loop, DispatchPaths};
    use spt_daemon::nethost::{NetConfig, NetHost};
    use spt_daemon::registryhost::RegistryHost;
    use spt_net::net::endpoint::{BindScope, LocalDiscovery, RelayPolicy};
    use spt_net::net::registry::{Instance, Status};
    use spt_proto::identity::Identity;
    use spt_store::info::{self, InfoJson};
    use spt_store::perch::ParentHint;
    use spt_store::spool;
    use std::sync::atomic::{AtomicBool, Ordering};
    use std::sync::Arc;
    use std::time::Duration;

    fn net_broker(name: &str, dir: &Path) -> Arc<Broker> {
        let host = NetHost::start(NetConfig {
            identity: Identity::generate(),
            relay: RelayPolicy::Disabled,
            discovery: LocalDiscovery::Off,
            scope: BindScope::Loopback,
        })
        .expect("net host");
        let broker = Broker::bind_in_with_net(name, dir.join("effects.log"), Some(host))
            .expect("bind broker");
        let serve = Arc::clone(&broker);
        std::thread::spawn(move || {
            let _ = serve.serve();
        });
        broker
    }

    fn connect_retry(name: &str) -> Brain {
        for _ in 0..300 {
            if let Ok(b) = Brain::cold_start(name, 1) {
                return b;
            }
            std::thread::sleep(Duration::from_millis(10));
        }
        panic!("brain could not connect");
    }

    fn reg_with(subnet: &str, id: &str, node: &str) -> BTreeMap<String, SubnetRegistry> {
        let mut reg = SubnetRegistry::new();
        reg.merge_instance(
            id,
            Instance { node: node.to_string(), status: Status::Active, epoch: 1, resources: None, last_active_ms: None, shell_adapters: Vec::new(), node_label: None },
        );
        let mut map = BTreeMap::new();
        map.insert(subnet.to_string(), reg);
        map
    }

    // [unit->REQ-NET-1] the WAN leg end to end: resolve from a registry map,
    // dial, ship one WanMessage; B's production dispatcher funnels it into
    // the target perch's spool. Ambiguity refuses (REQ-INST-10); an
    // own-node hit degrades to NotFound (the local path's report stands).
    #[test]
    fn wan_send_ships_to_the_resolved_node() {
        let _home = isolated_home();
        let dir = tempfile::tempdir().unwrap();
        let (a_name, b_name) = (
            format!("spt-wansend-a-{}.sock", std::process::id()),
            format!("spt-wansend-b-{}.sock", std::process::id()),
        );
        let _a = net_broker(&a_name, dir.path());
        let _b = net_broker(&b_name, dir.path());

        // B runs the production dispatcher against the canonical home.
        let stop = Arc::new(AtomicBool::new(false));
        {
            let name = b_name.clone();
            let flag = Arc::clone(&stop);
            let paths = DispatchPaths {
                owlery: perch::owlery_dir(),
                releases: perch::spt_home().join("releases"),
                scratch: dir.path().join("b-scratch"),
            };
            let registry = Arc::new(RegistryHost::new_at(
                "b-wansend",
                EpochSource::load_from(&dir.path().join("b-epoch.json")),
                dir.path().join("b-registry"),
            ));
            std::thread::spawn(move || {
                let _ = run_dispatch_loop(&name, &paths, registry, &flag, Duration::from_millis(25));
            });
        }
        // An offline perch at B so the funnel spools.
        let target = "ling-wan";
        let perch_path = perch::resolve_perch_path(target, ParentHint::Infer);
        std::fs::create_dir_all(&perch_path).unwrap();
        let rec = InfoJson::new(target, "now", std::process::id(), "s", "ready_agent");
        info::write_info(&perch_path, &rec).unwrap();

        let mut a = connect_retry(&a_name);
        let mut b_probe = connect_retry(&b_name);
        let a_hex = a.net_status().expect("status").node_id_hex.expect("net");
        let b_hex = b_probe.net_status().expect("status").node_id_hex.expect("net");
        let b_addr = b_probe.net_status().expect("status").addr;

        // Ambiguity refuses before any wire work (two subnets hold the id).
        let mut ambiguous = reg_with("s1", target, &b_hex);
        ambiguous.extend(reg_with("s2", target, &b_hex));
        let out = wan_send_with(
            &mut a, &ambiguous, &a_hex, target, "doyle", "x", "op-amb",
            |_| None,
        );
        assert!(matches!(out, WanSendOutcome::Ambiguous(_)), "got {out:?}");

        // An own-node hit is NotFound (the local report stands; no self-dial).
        let own = reg_with("s1", target, &a_hex);
        let out =
            wan_send_with(&mut a, &own, &a_hex, target, "doyle", "x", "op-own", |_| None);
        assert!(matches!(out, WanSendOutcome::NotFound), "got {out:?}");

        // The real ship: resolve → dial → one record → B spools it.
        let regs = reg_with("s1", target, &b_hex);
        let resolver = |hex: &str| {
            if hex == b_hex {
                Some(b_addr.clone())
            } else {
                None
            }
        };
        let out = wan_send_with(
            &mut a, &regs, &a_hex, target, "doyle", "over the wan", "op-ship", resolver,
        );
        assert!(matches!(out, WanSendOutcome::Sent { .. }), "got {out:?}");
        let mut spooled = false;
        for _ in 0..400 {
            let pending = spool::peek_all_at(&perch_path).expect("peek");
            if pending.iter().any(|(_, _, body, _)| body == "over the wan") {
                spooled = true;
                break;
            }
            std::thread::sleep(Duration::from_millis(25));
        }
        assert!(spooled, "B's dispatcher funneled the WAN message into the spool");
        stop.store(true, Ordering::Relaxed);
    }

    // [int->REQ-INST-6] the remote rest arm end to end over the loopback
    // wire, production dispatcher serving (the attach/xfer test pattern):
    // suspend cross-"node" lands the edge on the target's transition host;
    // the deferred-message gate HOLDS while suspended (the D5a gate keyed on
    // the now-remotely-flipped record); a replayed suspend is a NO_EDGE
    // (idempotence over the wire — report, don't dedup); wake cross-"node"
    // releases the held row through the unchanged hook drain, exactly once,
    // and fires the wake effects; and the access gate refuses an unlisted
    // origin when a whitelist exists — stream finished, no reply, no state
    // change (REQ-HAZARD-WAN-ORIGIN-AUTH: the subject was the handshake
    // origin, not wire bytes).
    #[test]
    fn remote_rest_suspends_and_wakes_with_the_deferred_gate() {
        use spt_daemon::resting::{read_rest, RestState};
        use spt_net::net::rest::{REST_EVENT_SUSPEND, REST_EVENT_WAKE};

        let _home = isolated_home();
        let dir = tempfile::tempdir().unwrap();
        let (a_name, b_name) = (
            format!("spt-wanrest-a-{}.sock", std::process::id()),
            format!("spt-wanrest-b-{}.sock", std::process::id()),
        );
        let _a = net_broker(&a_name, dir.path());
        let _b = net_broker(&b_name, dir.path());

        // B runs the production dispatcher against the canonical home.
        let stop = Arc::new(AtomicBool::new(false));
        {
            let name = b_name.clone();
            let flag = Arc::clone(&stop);
            let paths = DispatchPaths {
                owlery: perch::owlery_dir(),
                releases: perch::spt_home().join("releases"),
                scratch: dir.path().join("b-scratch"),
            };
            let registry = Arc::new(RegistryHost::new_at(
                "b-wanrest",
                EpochSource::load_from(&dir.path().join("b-epoch.json")),
                dir.path().join("b-registry"),
            ));
            std::thread::spawn(move || {
                let _ = run_dispatch_loop(&name, &paths, registry, &flag, Duration::from_millis(25));
            });
        }

        // The target instance "lives at B": a hosted live perch with one
        // deferred (spool-only) row and one normal row pending.
        let target = "ling-rest";
        let perch_path = perch::resolve_perch_path(target, ParentHint::Infer);
        std::fs::create_dir_all(&perch_path).unwrap();
        let rec = InfoJson::new(target, "now", std::process::id(), "s", "live_agent");
        info::write_info(&perch_path, &rec).unwrap();
        spool::spool_message_deferred_at(&perch_path, "psyche", "deferred brief").unwrap();
        spool::spool_message_at(&perch_path, "bob", "live one").unwrap();

        let mut a = connect_retry(&a_name);
        let mut b_probe = connect_retry(&b_name);
        let a_hex = a.net_status().expect("status").node_id_hex.expect("net");
        let b_hex = b_probe.net_status().expect("status").node_id_hex.expect("net");
        let b_addr = b_probe.net_status().expect("status").addr;
        let regs = reg_with("s1", target, &b_hex);
        let resolver = |hex: &str| {
            if hex == b_hex {
                Some(b_addr.clone())
            } else {
                None
            }
        };

        // Suspend from the other node: the TARGET's transition host runs it.
        let out = wan_rest_with(
            &mut a, &regs, &a_hex, target, REST_EVENT_SUSPEND, "op-s1", 11, resolver,
        );
        assert!(
            matches!(&out, WanRestOutcome::Edge { detail, .. } if detail == "Active->Suspended"),
            "got {out:?}"
        );
        assert_eq!(read_rest(&perch_path).unwrap().state, RestState::Suspended);

        // The deferred gate holds at the hook drain; non-deferred untouched.
        let got = crate::api::delivery::poll_drain(target, true);
        assert_eq!(got.len(), 1, "deferred held while suspended: {got:?}");
        assert!(got[0].contains("live one"));
        assert_eq!(spool::pending_count_at(&perch_path).unwrap(), 1, "held row survives");

        // Idempotence over the wire: a redelivered suspend is a NO_EDGE.
        let out = wan_rest_with(
            &mut a, &regs, &a_hex, target, REST_EVENT_SUSPEND, "op-s1", 12, resolver,
        );
        assert!(matches!(out, WanRestOutcome::NoEdge { .. }), "got {out:?}");

        // Wake from the other node: the edge releases the held row through
        // the SAME drain, exactly once, and the wake effects fire.
        let out = wan_rest_with(
            &mut a, &regs, &a_hex, target, REST_EVENT_WAKE, "op-w1", 13, resolver,
        );
        assert!(
            matches!(&out, WanRestOutcome::Edge { detail, .. } if detail == "Suspended->Active"),
            "got {out:?}"
        );
        assert_eq!(read_rest(&perch_path).unwrap().state, RestState::Active);
        let got = crate::api::delivery::poll_drain(target, true);
        assert_eq!(got.len(), 1, "released exactly at the wake edge: {got:?}");
        assert!(got[0].contains("deferred brief"));
        assert!(crate::api::delivery::poll_drain(target, true).is_empty(), "exactly once");
        assert!(
            perch::identity_dir().join(spt_daemon::PULL_MARKER_FILE).exists(),
            "wake effects fired on the target node (freshness pull requested)"
        );

        // Access-gate negative: a whitelist naming someone else refuses this
        // origin — no reply, no edge.
        let mut acl = spt_store::access::AccessStore::load();
        acl.allow(target, "someone-else");
        acl.save().unwrap();
        let out = wan_rest_with(
            &mut a, &regs, &a_hex, target, REST_EVENT_SUSPEND, "op-s2", 14, resolver,
        );
        assert!(matches!(out, WanRestOutcome::NoReply { .. }), "got {out:?}");
        assert_eq!(
            read_rest(&perch_path).unwrap().state,
            RestState::Active,
            "a refused rest op moves nothing"
        );

        stop.store(true, Ordering::Relaxed);
    }

    // [unit->REQ-NET-1] snapshot loading degrades: missing dir, junk files,
    // and non-json entries all yield absence, never an error.
    #[test]
    fn load_snapshots_degrades_on_junk() {
        let dir = tempfile::tempdir().unwrap();
        assert!(load_snapshots(&dir.path().join("nope")).is_empty());
        let d = dir.path().join("registry");
        std::fs::create_dir_all(&d).unwrap();
        std::fs::write(d.join("good.json"), serde_json::to_string(&SubnetRegistry::new()).unwrap())
            .unwrap();
        std::fs::write(d.join("bad.json"), "{{{").unwrap();
        std::fs::write(d.join("ignored.txt"), "x").unwrap();
        let map = load_snapshots(&d);
        assert_eq!(map.len(), 1);
        assert!(map.contains_key("good"));
    }
}


Proceed with bug root cause analysis and fixes


Full picture forming. Now config defaults + traceable-reqs format + hazards doc.


1	//! Durable daemon configuration (B3) — the configurable pulse period that
2	//! replaces the interim 5s constant.
3	//!
4	//! In the no-daemon interim model the pulse cadence was a hard-coded
5	//! `spt_live::pulse::DEFAULT_PULSE_PERIOD` (5s). Consolidation (REQ-DAEMON-1)
6	//! folds the pulse loop into the brain as a *scheduled* in-process loop whose
7	//! period is **durable and configurable**, sourced from a small JSON file under
8	//! the spt home (`<spt_home>/daemon.json`) and falling back to the default when
9	//! absent. Kept deliberately tiny — one struct, JSON via the crate's existing
10	//! `serde_json` dep (no new TOML edge) — so the brain's loop reads its cadence
11	//! from config, never a constant.
12	// [impl->REQ-DAEMON-1]
13
14	use std::path::{Path, PathBuf};
15	use std::time::Duration;
16
17	use serde::{Deserialize, Serialize};
18
19	/// The interim default pulse period carried forward as the config default. The
20	/// daemon now treats this as a *fallback*, not a constant: `daemon.json` may
21	/// override it per machine.
22	pub const DEFAULT_PULSE_PERIOD: Duration = Duration::from_secs(5);
23
24	/// Default drop-file precedence protection window (epoch-ms), mirroring the
25	/// interim `spt_live` guard. Surfaced here so the one config source owns it.
26	pub const DEFAULT_PROTECTION_WINDOW_MS: u64 = 2_000;
27
28	/// Default orphan-watch grace window (B7): how long the supervised-crash teardown
29	/// waits after observing a hosted Self's harness owner gone before it rechecks
30	/// liveness and signs off (KNOWN-HAZARDS 1.1). Sized to cover a transient recovery
31	/// — a binary handoff relaunch or a brief stale poll — without delaying a real
32	/// teardown for long. Configurable per machine via `crash_grace_ms`.
33	pub const DEFAULT_CRASH_GRACE: Duration = Duration::from_secs(2);
34
35	/// Default peer-pump cadences (D9-1): how often the daemon pushes its
36	/// registry/notif feeds to trusted peers and pulls context syncs from them.
37	/// Conservative defaults — every loop is a thin call over an idempotent
38	/// driver, so a missed tick costs only latency, never correctness.
39	pub const DEFAULT_REGISTRY_PUMP_PERIOD: Duration = Duration::from_secs(30);
40	pub const DEFAULT_NOTIF_PUMP_PERIOD: Duration = Duration::from_secs(15);
41	pub const DEFAULT_SYNC_PULL_PERIOD: Duration = Duration::from_secs(60);
42	pub const DEFAULT_UPDATE_CHECK_PERIOD: Duration = Duration::from_secs(3_600);
43
44	/// Which relay stance the broker's net endpoint runs (ADR-0002 / REQ-NET-2) —
45	/// the `"relay"` knob in `daemon.json`. Maps onto `spt-net`'s `RelayPolicy` in
46	/// `Daemon::run`; kept as a local enum so the config layer stays transport-free.
47	/// The self-hosted variant (`RelayPolicy::SelfHosted`) needs a relay-map URL
48	/// list and gets its config plumbing when a self-host consumer exists.
49	// [impl->REQ-NET-2]
50	#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51	pub enum RelayChoice {
52	    /// n0's public relays — the zero-config default (`"relay": "n0"`).
53	    N0,
54	    /// No relays: direct/LAN only (`"relay": "disabled"`).
55	    Disabled,
56	}
57
58	/// The on-disk shape (`<spt_home>/daemon.json`). Every field is optional so a
59	/// partial file still loads (forward/back-compat) — a missing field takes the
60	/// default. Stored as plain integers (ms) to keep the file human-editable.
61	#[derive(Debug, Clone, Default, Serialize, Deserialize)]
62	struct DaemonConfigFile {
63	    #[serde(default, skip_serializing_if = "Option::is_none")]
64	    pulse_period_ms: Option<u64>,
65	    #[serde(default, skip_serializing_if = "Option::is_none")]
66	    protection_window_ms: Option<u64>,
67	    #[serde(default, skip_serializing_if = "Option::is_none")]
68	    crash_grace_ms: Option<u64>,
69	    #[serde(default, skip_serializing_if = "Option::is_none")]
70	    full_auto_update: Option<bool>,
71	    #[serde(default, skip_serializing_if = "Option::is_none")]
72	    relay: Option<String>,
73	    #[serde(default, skip_serializing_if = "Option::is_none")]
74	    peer_pump: Option<bool>,
75	    #[serde(default, skip_serializing_if = "Option::is_none")]
76	    registry_pump_period_ms: Option<u64>,
77	    #[serde(default, skip_serializing_if = "Option::is_none")]
78	    notif_pump_period_ms: Option<u64>,
79	    #[serde(default, skip_serializing_if = "Option::is_none")]
80	    sync_pull_period_ms: Option<u64>,
81	    #[serde(default, skip_serializing_if = "Option::is_none")]
82	    update_check_period_ms: Option<u64>,
83	    #[serde(default, skip_serializing_if = "Option::is_none")]
84	    auto_suspend_after_ms: Option<u64>,
85	    #[serde(default, skip_serializing_if = "Option::is_none")]
86	    resources_blurb: Option<String>,
87	}
88
89	/// Resolved daemon configuration. The brain's scheduled loops read their cadence
90	/// from `pulse_period`; the drop-ingest guard reads `protection_window_ms`.
91	/// (No longer `Copy` — `resources_blurb` carries a `String`; callers clone.)
92	#[derive(Debug, Clone, PartialEq, Eq)]
93	pub struct DaemonConfig {
94	    /// The pulse loop period (config-driven; defaults to [`DEFAULT_PULSE_PERIOD`]).
95	    pub pulse_period: Duration,
96	    /// The drop-file precedence protection window in epoch-ms (6.5).
97	    pub protection_window_ms: u64,
98	    /// The orphan-watch grace window (B7): the recovery wait before a
99	    /// supervised-crash teardown rechecks liveness and signs off (1.1).
100	    pub crash_grace: Duration,
101	    /// Whether self-update is full-auto (C2, REQ-UPD-4). **Default `false`** — an
102	    /// update is gated on user confirmation delivered to the most-recently-active
103	    /// live session. Setting this to `true` is the explicit opt-in to the
104	    /// seamless, prompt-free path.
105	    pub full_auto_update: bool,
106	    /// The broker net endpoint's relay stance (D4a, REQ-NET-2). Default
107	    /// [`RelayChoice::N0`] — the ADR-0002 zero-config path. An unrecognized
108	    /// string degrades to the default (forward-compat: a future value must not
109	    /// wedge an older daemon).
110	    pub relay: RelayChoice,
111	    /// Whether the peer pump runs at all (D9-1). Default `true` — the subnet
112	    /// self-drives; `false` is the kill switch for debugging/hermetic setups.
113	    pub peer_pump: bool,
114	    /// Registry feed push cadence (D9-1).
115	    pub registry_pump_period: Duration,
116	    /// Notif feed push cadence (D9-1).
117	    pub notif_pump_period: Duration,
118	    /// Context sync pull cadence — the anti-entropy loop (D9-1).
119	    pub sync_pull_period: Duration,
120	    /// Update check cadence (D9-1; the pull rides the same trusted conns).
121	    pub update_check_period: Duration,
122	    /// The NODE leg of the auto-suspend knob chain (D9-2, REQ-INST-3):
123	    /// dormant → suspended after this many ms counted from dormancy onset.
124	    /// **`None` = the global default, OFF** — auto-suspend is opt-in
125	    /// (CONTEXT §dormant/suspended); a constrained node sets it here, and a
126	    /// per-endpoint `info.json` override wins over this value
127	    /// ([`crate::resting::effective_auto_suspend`]).
128	    pub auto_suspend_after_ms: Option<u64>,
129	    /// The node-level resource-advertisement seed (D9-4, REQ-INST-14): the
130	    /// default blurb advertised for an endpoint that has not authored its
131	    /// own (`info.json` `resources` wins when present — config seeds, the
132	    /// agent refines; CONTEXT §resource advertisement). `None` = endpoints
133	    /// without their own blurb advertise none.
134	    pub resources_blurb: Option<String>,
135	}
136
137	impl Default for DaemonConfig {
138	    fn default() -> Self {
139	        DaemonConfig {
140	            pulse_period: DEFAULT_PULSE_PERIOD,
141	            protection_window_ms: DEFAULT_PROTECTION_WINDOW_MS,
142	            crash_grace: DEFAULT_CRASH_GRACE,
143	            full_auto_update: false, // gated by default (REQ-UPD-4)
144	            relay: RelayChoice::N0,  // zero-config default (ADR-0002)
145	            peer_pump: true,         // the subnet self-drives (D9-1)
146	            registry_pump_period: DEFAULT_REGISTRY_PUMP_PERIOD,
147	            notif_pump_period: DEFAULT_NOTIF_PUMP_PERIOD,
148	            sync_pull_period: DEFAULT_SYNC_PULL_PERIOD,
149	            update_check_period: DEFAULT_UPDATE_CHECK_PERIOD,
150	            auto_suspend_after_ms: None, // opt-in, default OFF (REQ-INST-3)
151	            resources_blurb: None,       // endpoints author their own (REQ-INST-14)
152	        }
153	    }
154	}
155
156	impl DaemonConfig {
157	    /// The canonical config path: `<spt_home>/daemon.json`.
158	    pub fn default_path() -> PathBuf {
159	        spt_store::perch::spt_home().join("daemon.json")
160	    }
161
162	    /// Load the daemon config from the canonical path, falling back to the
163	    /// default if the file is absent or unreadable.
164	    pub fn load() -> Self {
165	        Self::load_from(&Self::default_path())
166	    }
167
168	    /// Load from an explicit path. A missing file → default; a present-but-partial
169	    /// file fills only the named fields and defaults the rest; a malformed file
170	    /// degrades to the default (a broken config must never wedge the daemon).
171	    pub fn load_from(path: &Path) -> Self {
172	        let Ok(raw) = std::fs::read_to_string(path) else {
173	            return Self::default();
174	        };
175	        let file: DaemonConfigFile = match serde_json::from_str(&raw) {
176	            Ok(f) => f,
177	            Err(_) => return Self::default(),
178	        };
179	        Self::from_file(file)
180	    }
181
182	    fn from_file(file: DaemonConfigFile) -> Self {
183	        let d = DaemonConfig::default();
184	        DaemonConfig {
185	            pulse_period: file
186	                .pulse_period_ms
187	                .map(Duration::from_millis)
188	                .unwrap_or(d.pulse_period),
189	            protection_window_ms: file.protection_window_ms.unwrap_or(d.protection_window_ms),
190	            crash_grace: file
191	                .crash_grace_ms
192	                .map(Duration::from_millis)
193	                .unwrap_or(d.crash_grace),
194	            full_auto_update: file.full_auto_update.unwrap_or(d.full_auto_update),
195	            relay: match file.relay.as_deref() {
196	                Some("disabled") => RelayChoice::Disabled,
197	                // "n0", absent, or an unrecognized future value → the default.
198	                _ => d.relay,
199	            },
200	            peer_pump: file.peer_pump.unwrap_or(d.peer_pump),
201	            registry_pump_period: file
202	                .registry_pump_period_ms
203	                .map(Duration::from_millis)
204	                .unwrap_or(d.registry_pump_period),
205	            notif_pump_period: file
206	                .notif_pump_period_ms
207	                .map(Duration::from_millis)
208	                .unwrap_or(d.notif_pump_period),
209	            sync_pull_period: file
210	                .sync_pull_period_ms
211	                .map(Duration::from_millis)
212	                .unwrap_or(d.sync_pull_period),
213	            update_check_period: file
214	                .update_check_period_ms
215	                .map(Duration::from_millis)
216	                .unwrap_or(d.update_check_period),
217	            auto_suspend_after_ms: file.auto_suspend_after_ms,
218	            resources_blurb: file.resources_blurb,
219	        }
220	    }
221
222	    /// Persist this config to an explicit path (for `spt` to write the knob).
223	    pub fn save_to(&self, path: &Path) -> std::io::Result<()> {
224	        let file = DaemonConfigFile {
225	            pulse_period_ms: Some(self.pulse_period.as_millis() as u64),
226	            protection_window_ms: Some(self.protection_window_ms),
227	            crash_grace_ms: Some(self.crash_grace.as_millis() as u64),
228	            full_auto_update: Some(self.full_auto_update),
229	            relay: Some(
230	                match self.relay {
231	                    RelayChoice::N0 => "n0",
232	                    RelayChoice::Disabled => "disabled",
233	                }
234	                .to_string(),
235	            ),
236	            peer_pump: Some(self.peer_pump),
237	            registry_pump_period_ms: Some(self.registry_pump_period.as_millis() as u64),
238	            notif_pump_period_ms: Some(self.notif_pump_period.as_millis() as u64),
239	            sync_pull_period_ms: Some(self.sync_pull_period.as_millis() as u64),
240	            update_check_period_ms: Some(self.update_check_period.as_millis() as u64),
241	            auto_suspend_after_ms: self.auto_suspend_after_ms,
242	            resources_blurb: self.resources_blurb.clone(),
243	        };
244	        let json = serde_json::to_string_pretty(&file)
245	            .map_err(|e| std::io::Error::other(e.to_string()))?;
246	        std::fs::write(path, json)
247	    }
248	}
249
250	#[cfg(test)]
251	mod tests {
252	    use super::*;
253
254	    #[test] // [unit->REQ-DAEMON-1] an absent config file yields the default period.
255	    fn missing_file_is_default() {
256	        let dir = tempfile::tempdir().unwrap();
257	        let cfg = DaemonConfig::load_from(&dir.path().join("nope.json"));
258	        assert_eq!(cfg, DaemonConfig::default());
259	        assert_eq!(cfg.pulse_period, DEFAULT_PULSE_PERIOD);
260	    }
261
262	    #[test] // [unit->REQ-DAEMON-1] the pulse period is config-driven, not a constant.
263	    fn pulse_period_is_config_driven() {
264	        let dir = tempfile::tempdir().unwrap();
265	        let path = dir.path().join("daemon.json");
266	        std::fs::write(&path, r#"{"pulse_period_ms": 250}"#).unwrap();
267
268	        let cfg = DaemonConfig::load_from(&path);
269	        assert_eq!(cfg.pulse_period, Duration::from_millis(250));
270	        assert_ne!(cfg.pulse_period, DEFAULT_PULSE_PERIOD, "must override the default");
271	        // The unspecified field keeps its default.
272	        assert_eq!(cfg.protection_window_ms, DEFAULT_PROTECTION_WINDOW_MS);
273	    }
274
275	    #[test] // [unit->REQ-DAEMON-1] a malformed config degrades to default, never wedges.
276	    fn malformed_file_degrades_to_default() {
277	        let dir = tempfile::tempdir().unwrap();
278	        let path = dir.path().join("daemon.json");
279	        std::fs::write(&path, "not json {{{").unwrap();
280	        assert_eq!(DaemonConfig::load_from(&path), DaemonConfig::default());
281	    }
282
283	    #[test] // [unit->REQ-DAEMON-1] save then load round-trips the configured period.
284	    fn save_load_roundtrip() {
285	        let dir = tempfile::tempdir().unwrap();
286	        let path = dir.path().join("daemon.json");
287	        let cfg = DaemonConfig {
288	            pulse_period: Duration::from_millis(750),
289	            protection_window_ms: 99,
290	            crash_grace: Duration::from_millis(1234),
291	            full_auto_update: true,
292	            relay: RelayChoice::Disabled,
293	            peer_pump: false,
294	            registry_pump_period: Duration::from_millis(11_000),
295	            notif_pump_period: Duration::from_millis(12_000),
296	            sync_pull_period: Duration::from_millis(13_000),
297	            update_check_period: Duration::from_millis(14_000),
298	            auto_suspend_after_ms: Some(15_000),
299	            resources_blurb: Some("node default services".to_string()),
300	        };
301	        cfg.save_to(&path).unwrap();
302	        assert_eq!(DaemonConfig::load_from(&path), cfg);
303	    }
304
305	    // [unit->REQ-INST-3] the node auto-suspend knob is opt-in: absent ⇒ OFF
306	    // (the global default), present ⇒ the node leg of the knob chain.
307	    #[test]
308	    fn auto_suspend_knob_is_opt_in_default_off() {
309	        assert_eq!(DaemonConfig::default().auto_suspend_after_ms, None, "default OFF");
310
311	        let dir = tempfile::tempdir().unwrap();
312	        let path = dir.path().join("daemon.json");
313	        std::fs::write(&path, r#"{"auto_suspend_after_ms": 300000}"#).unwrap();
314	        assert_eq!(DaemonConfig::load_from(&path).auto_suspend_after_ms, Some(300_000));
315	        // An absent file keeps the OFF default.
316	        assert_eq!(
317	            DaemonConfig::load_from(&dir.path().join("nope.json")).auto_suspend_after_ms,
318	            None
319	        );
320	    }
321
322	    #[test] // [unit->REQ-UPD-4] full-auto update is OFF by default and config-driven.
323	    fn full_auto_update_is_gated_by_default() {
324	        assert!(!DaemonConfig::default().full_auto_update, "gated by default (consent required)");
325
326	        let dir = tempfile::tempdir().unwrap();
327	        let path = dir.path().join("daemon.json");
328	        std::fs::write(&path, r#"{"full_auto_update": true}"#).unwrap();
329	        assert!(DaemonConfig::load_from(&path).full_auto_update, "explicit opt-in honored");
330	        // An absent file keeps the gated default.
331	        assert!(!DaemonConfig::load_from(&dir.path().join("nope.json")).full_auto_update);
332	    }
333
334	    #[test] // [unit->REQ-NET-2] the relay stance is config-driven: "disabled" opts out,
335	            // absent/unknown values keep the zero-config n0 default, and it round-trips.
336	    fn relay_choice_is_config_driven() {
337	        assert_eq!(DaemonConfig::default().relay, RelayChoice::N0, "zero-config default");
338
339	        let dir = tempfile::tempdir().unwrap();
340	        let path = dir.path().join("daemon.json");
341	        std::fs::write(&path, r#"{"relay": "disabled"}"#).unwrap();
342	        assert_eq!(DaemonConfig::load_from(&path).relay, RelayChoice::Disabled);
343
344	        // An unrecognized future value degrades to the default, never wedges.
345	        std::fs::write(&path, r#"{"relay": "quantum-mesh"}"#).unwrap();
346	        assert_eq!(DaemonConfig::load_from(&path).relay, RelayChoice::N0);
347
348	        let cfg = DaemonConfig { relay: RelayChoice::Disabled, ..DaemonConfig::default() };
349	        cfg.save_to(&path).unwrap();
350	        assert_eq!(DaemonConfig::load_from(&path).relay, RelayChoice::Disabled);
351	    }
352
353	    #[test] // [unit->REQ-DAEMON-4] the orphan-watch grace is config-driven, default otherwise.
354	    fn crash_grace_is_config_driven() {
355	        let dir = tempfile::tempdir().unwrap();
356	        let path = dir.path().join("daemon.json");
357	        std::fs::write(&path, r#"{"crash_grace_ms": 500}"#).unwrap();
358
359	        let cfg = DaemonConfig::load_from(&path);
360	        assert_eq!(cfg.crash_grace, Duration::from_millis(500));
361	        assert_ne!(cfg.crash_grace, DEFAULT_CRASH_GRACE, "must override the default");
362	        // Unspecified fields keep their defaults.
363	        assert_eq!(cfg.pulse_period, DEFAULT_PULSE_PERIOD);
364	        // An absent file yields the default grace.
365	        assert_eq!(
366	            DaemonConfig::load_from(&dir.path().join("nope.json")).crash_grace,
367	            DEFAULT_CRASH_GRACE
368	        );
369	    }
370	}
371

//! Durable daemon configuration (B3) — the configurable pulse period that
//! replaces the interim 5s constant.
//!
//! In the no-daemon interim model the pulse cadence was a hard-coded
//! `spt_live::pulse::DEFAULT_PULSE_PERIOD` (5s). Consolidation (REQ-DAEMON-1)
//! folds the pulse loop into the brain as a *scheduled* in-process loop whose
//! period is **durable and configurable**, sourced from a small JSON file under
//! the spt home (`<spt_home>/daemon.json`) and falling back to the default when
//! absent. Kept deliberately tiny — one struct, JSON via the crate's existing
//! `serde_json` dep (no new TOML edge) — so the brain's loop reads its cadence
//! from config, never a constant.
// [impl->REQ-DAEMON-1]

use std::path::{Path, PathBuf};
use std::time::Duration;

use serde::{Deserialize, Serialize};

/// The interim default pulse period carried forward as the config default. The
/// daemon now treats this as a *fallback*, not a constant: `daemon.json` may
/// override it per machine.
pub const DEFAULT_PULSE_PERIOD: Duration = Duration::from_secs(5);

/// Default drop-file precedence protection window (epoch-ms), mirroring the
/// interim `spt_live` guard. Surfaced here so the one config source owns it.
pub const DEFAULT_PROTECTION_WINDOW_MS: u64 = 2_000;

/// Default orphan-watch grace window (B7): how long the supervised-crash teardown
/// waits after observing a hosted Self's harness owner gone before it rechecks
/// liveness and signs off (KNOWN-HAZARDS 1.1). Sized to cover a transient recovery
/// — a binary handoff relaunch or a brief stale poll — without delaying a real
/// teardown for long. Configurable per machine via `crash_grace_ms`.
pub const DEFAULT_CRASH_GRACE: Duration = Duration::from_secs(2);

/// Default peer-pump cadences (D9-1): how often the daemon pushes its
/// registry/notif feeds to trusted peers and pulls context syncs from them.
/// Conservative defaults — every loop is a thin call over an idempotent
/// driver, so a missed tick costs only latency, never correctness.
pub const DEFAULT_REGISTRY_PUMP_PERIOD: Duration = Duration::from_secs(30);
pub const DEFAULT_NOTIF_PUMP_PERIOD: Duration = Duration::from_secs(15);
pub const DEFAULT_SYNC_PULL_PERIOD: Duration = Duration::from_secs(60);
pub const DEFAULT_UPDATE_CHECK_PERIOD: Duration = Duration::from_secs(3_600);

/// Which relay stance the broker's net endpoint runs (ADR-0002 / REQ-NET-2) —
/// the `"relay"` knob in `daemon.json`. Maps onto `spt-net`'s `RelayPolicy` in
/// `Daemon::run`; kept as a local enum so the config layer stays transport-free.
/// The self-hosted variant (`RelayPolicy::SelfHosted`) needs a relay-map URL
/// list and gets its config plumbing when a self-host consumer exists.
// [impl->REQ-NET-2]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RelayChoice {
    /// n0's public relays — the zero-config default (`"relay": "n0"`).
    N0,
    /// No relays: direct/LAN only (`"relay": "disabled"`).
    Disabled,
}

/// The on-disk shape (`<spt_home>/daemon.json`). Every field is optional so a
/// partial file still loads (forward/back-compat) — a missing field takes the
/// default. Stored as plain integers (ms) to keep the file human-editable.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct DaemonConfigFile {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pulse_period_ms: Option<u64>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    protection_window_ms: Option<u64>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    crash_grace_ms: Option<u64>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    full_auto_update: Option<bool>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    relay: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    peer_pump: Option<bool>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    registry_pump_period_ms: Option<u64>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    notif_pump_period_ms: Option<u64>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    sync_pull_period_ms: Option<u64>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    update_check_period_ms: Option<u64>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    auto_suspend_after_ms: Option<u64>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    resources_blurb: Option<String>,
}

/// Resolved daemon configuration. The brain's scheduled loops read their cadence
/// from `pulse_period`; the drop-ingest guard reads `protection_window_ms`.
/// (No longer `Copy` — `resources_blurb` carries a `String`; callers clone.)
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DaemonConfig {
    /// The pulse loop period (config-driven; defaults to [`DEFAULT_PULSE_PERIOD`]).
    pub pulse_period: Duration,
    /// The drop-file precedence protection window in epoch-ms (6.5).
    pub protection_window_ms: u64,
    /// The orphan-watch grace window (B7): the recovery wait before a
    /// supervised-crash teardown rechecks liveness and signs off (1.1).
    pub crash_grace: Duration,
    /// Whether self-update is full-auto (C2, REQ-UPD-4). **Default `false`** — an
    /// update is gated on user confirmation delivered to the most-recently-active
    /// live session. Setting this to `true` is the explicit opt-in to the
    /// seamless, prompt-free path.
    pub full_auto_update: bool,
    /// The broker net endpoint's relay stance (D4a, REQ-NET-2). Default
    /// [`RelayChoice::N0`] — the ADR-0002 zero-config path. An unrecognized
    /// string degrades to the default (forward-compat: a future value must not
    /// wedge an older daemon).
    pub relay: RelayChoice,
    /// Whether the peer pump runs at all (D9-1). Default `true` — the subnet
    /// self-drives; `false` is the kill switch for debugging/hermetic setups.
    pub peer_pump: bool,
    /// Registry feed push cadence (D9-1).
    pub registry_pump_period: Duration,
    /// Notif feed push cadence (D9-1).
    pub notif_pump_period: Duration,
    /// Context sync pull cadence — the anti-entropy loop (D9-1).
    pub sync_pull_period: Duration,
    /// Update check cadence (D9-1; the pull rides the same trusted conns).
    pub update_check_period: Duration,
    /// The NODE leg of the auto-suspend knob chain (D9-2, REQ-INST-3):
    /// dormant → suspended after this many ms counted from dormancy onset.
    /// **`None` = the global default, OFF** — auto-suspend is opt-in
    /// (CONTEXT §dormant/suspended); a constrained node sets it here, and a
    /// per-endpoint `info.json` override wins over this value
    /// ([`crate::resting::effective_auto_suspend`]).
    pub auto_suspend_after_ms: Option<u64>,
    /// The node-level resource-advertisement seed (D9-4, REQ-INST-14): the
    /// default blurb advertised for an endpoint that has not authored its
    /// own (`info.json` `resources` wins when present — config seeds, the
    /// agent refines; CONTEXT §resource advertisement). `None` = endpoints
    /// without their own blurb advertise none.
    pub resources_blurb: Option<String>,
}

impl Default for DaemonConfig {
    fn default() -> Self {
        DaemonConfig {
            pulse_period: DEFAULT_PULSE_PERIOD,
            protection_window_ms: DEFAULT_PROTECTION_WINDOW_MS,
            crash_grace: DEFAULT_CRASH_GRACE,
            full_auto_update: false, // gated by default (REQ-UPD-4)
            relay: RelayChoice::N0,  // zero-config default (ADR-0002)
            peer_pump: true,         // the subnet self-drives (D9-1)
            registry_pump_period: DEFAULT_REGISTRY_PUMP_PERIOD,
            notif_pump_period: DEFAULT_NOTIF_PUMP_PERIOD,
            sync_pull_period: DEFAULT_SYNC_PULL_PERIOD,
            update_check_period: DEFAULT_UPDATE_CHECK_PERIOD,
            auto_suspend_after_ms: None, // opt-in, default OFF (REQ-INST-3)
            resources_blurb: None,       // endpoints author their own (REQ-INST-14)
        }
    }
}

impl DaemonConfig {
    /// The canonical config path: `<spt_home>/daemon.json`.
    pub fn default_path() -> PathBuf {
        spt_store::perch::spt_home().join("daemon.json")
    }

    /// Load the daemon config from the canonical path, falling back to the
    /// default if the file is absent or unreadable.
    pub fn load() -> Self {
        Self::load_from(&Self::default_path())
    }

    /// Load from an explicit path. A missing file → default; a present-but-partial
    /// file fills only the named fields and defaults the rest; a malformed file
    /// degrades to the default (a broken config must never wedge the daemon).
    pub fn load_from(path: &Path) -> Self {
        let Ok(raw) = std::fs::read_to_string(path) else {
            return Self::default();
        };
        let file: DaemonConfigFile = match serde_json::from_str(&raw) {
            Ok(f) => f,
            Err(_) => return Self::default(),
        };
        Self::from_file(file)
    }

    fn from_file(file: DaemonConfigFile) -> Self {
        let d = DaemonConfig::default();
        DaemonConfig {
            pulse_period: file
                .pulse_period_ms
                .map(Duration::from_millis)
                .unwrap_or(d.pulse_period),
            protection_window_ms: file.protection_window_ms.unwrap_or(d.protection_window_ms),
            crash_grace: file
                .crash_grace_ms
                .map(Duration::from_millis)
                .unwrap_or(d.crash_grace),
            full_auto_update: file.full_auto_update.unwrap_or(d.full_auto_update),
            relay: match file.relay.as_deref() {
                Some("disabled") => RelayChoice::Disabled,
                // "n0", absent, or an unrecognized future value → the default.
                _ => d.relay,
            },
            peer_pump: file.peer_pump.unwrap_or(d.peer_pump),
            registry_pump_period: file
                .registry_pump_period_ms
                .map(Duration::from_millis)
                .unwrap_or(d.registry_pump_period),
            notif_pump_period: file
                .notif_pump_period_ms
                .map(Duration::from_millis)
                .unwrap_or(d.notif_pump_period),
            sync_pull_period: file
                .sync_pull_period_ms
                .map(Duration::from_millis)
                .unwrap_or(d.sync_pull_period),
            update_check_period: file
                .update_check_period_ms
                .map(Duration::from_millis)
                .unwrap_or(d.update_check_period),
            auto_suspend_after_ms: file.auto_suspend_after_ms,
            resources_blurb: file.resources_blurb,
        }
    }

    /// Persist this config to an explicit path (for `spt` to write the knob).
    pub fn save_to(&self, path: &Path) -> std::io::Result<()> {
        let file = DaemonConfigFile {
            pulse_period_ms: Some(self.pulse_period.as_millis() as u64),
            protection_window_ms: Some(self.protection_window_ms),
            crash_grace_ms: Some(self.crash_grace.as_millis() as u64),
            full_auto_update: Some(self.full_auto_update),
            relay: Some(
                match self.relay {
                    RelayChoice::N0 => "n0",
                    RelayChoice::Disabled => "disabled",
                }
                .to_string(),
            ),
            peer_pump: Some(self.peer_pump),
            registry_pump_period_ms: Some(self.registry_pump_period.as_millis() as u64),
            notif_pump_period_ms: Some(self.notif_pump_period.as_millis() as u64),
            sync_pull_period_ms: Some(self.sync_pull_period.as_millis() as u64),
            update_check_period_ms: Some(self.update_check_period.as_millis() as u64),
            auto_suspend_after_ms: self.auto_suspend_after_ms,
            resources_blurb: self.resources_blurb.clone(),
        };
        let json = serde_json::to_string_pretty(&file)
            .map_err(|e| std::io::Error::other(e.to_string()))?;
        std::fs::write(path, json)
    }
}

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

    #[test] // [unit->REQ-DAEMON-1] an absent config file yields the default period.
    fn missing_file_is_default() {
        let dir = tempfile::tempdir().unwrap();
        let cfg = DaemonConfig::load_from(&dir.path().join("nope.json"));
        assert_eq!(cfg, DaemonConfig::default());
        assert_eq!(cfg.pulse_period, DEFAULT_PULSE_PERIOD);
    }

    #[test] // [unit->REQ-DAEMON-1] the pulse period is config-driven, not a constant.
    fn pulse_period_is_config_driven() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("daemon.json");
        std::fs::write(&path, r#"{"pulse_period_ms": 250}"#).unwrap();

        let cfg = DaemonConfig::load_from(&path);
        assert_eq!(cfg.pulse_period, Duration::from_millis(250));
        assert_ne!(cfg.pulse_period, DEFAULT_PULSE_PERIOD, "must override the default");
        // The unspecified field keeps its default.
        assert_eq!(cfg.protection_window_ms, DEFAULT_PROTECTION_WINDOW_MS);
    }

    #[test] // [unit->REQ-DAEMON-1] a malformed config degrades to default, never wedges.
    fn malformed_file_degrades_to_default() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("daemon.json");
        std::fs::write(&path, "not json {{{").unwrap();
        assert_eq!(DaemonConfig::load_from(&path), DaemonConfig::default());
    }

    #[test] // [unit->REQ-DAEMON-1] save then load round-trips the configured period.
    fn save_load_roundtrip() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("daemon.json");
        let cfg = DaemonConfig {
            pulse_period: Duration::from_millis(750),
            protection_window_ms: 99,
            crash_grace: Duration::from_millis(1234),
            full_auto_update: true,
            relay: RelayChoice::Disabled,
            peer_pump: false,
            registry_pump_period: Duration::from_millis(11_000),
            notif_pump_period: Duration::from_millis(12_000),
            sync_pull_period: Duration::from_millis(13_000),
            update_check_period: Duration::from_millis(14_000),
            auto_suspend_after_ms: Some(15_000),
            resources_blurb: Some("node default services".to_string()),
        };
        cfg.save_to(&path).unwrap();
        assert_eq!(DaemonConfig::load_from(&path), cfg);
    }

    // [unit->REQ-INST-3] the node auto-suspend knob is opt-in: absent ⇒ OFF
    // (the global default), present ⇒ the node leg of the knob chain.
    #[test]
    fn auto_suspend_knob_is_opt_in_default_off() {
        assert_eq!(DaemonConfig::default().auto_suspend_after_ms, None, "default OFF");

        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("daemon.json");
        std::fs::write(&path, r#"{"auto_suspend_after_ms": 300000}"#).unwrap();
        assert_eq!(DaemonConfig::load_from(&path).auto_suspend_after_ms, Some(300_000));
        // An absent file keeps the OFF default.
        assert_eq!(
            DaemonConfig::load_from(&dir.path().join("nope.json")).auto_suspend_after_ms,
            None
        );
    }

    #[test] // [unit->REQ-UPD-4] full-auto update is OFF by default and config-driven.
    fn full_auto_update_is_gated_by_default() {
        assert!(!DaemonConfig::default().full_auto_update, "gated by default (consent required)");

        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("daemon.json");
        std::fs::write(&path, r#"{"full_auto_update": true}"#).unwrap();
        assert!(DaemonConfig::load_from(&path).full_auto_update, "explicit opt-in honored");
        // An absent file keeps the gated default.
        assert!(!DaemonConfig::load_from(&dir.path().join("nope.json")).full_auto_update);
    }

    #[test] // [unit->REQ-NET-2] the relay stance is config-driven: "disabled" opts out,
            // absent/unknown values keep the zero-config n0 default, and it round-trips.
    fn relay_choice_is_config_driven() {
        assert_eq!(DaemonConfig::default().relay, RelayChoice::N0, "zero-config default");

        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("daemon.json");
        std::fs::write(&path, r#"{"relay": "disabled"}"#).unwrap();
        assert_eq!(DaemonConfig::load_from(&path).relay, RelayChoice::Disabled);

        // An unrecognized future value degrades to the default, never wedges.
        std::fs::write(&path, r#"{"relay": "quantum-mesh"}"#).unwrap();
        assert_eq!(DaemonConfig::load_from(&path).relay, RelayChoice::N0);

        let cfg = DaemonConfig { relay: RelayChoice::Disabled, ..DaemonConfig::default() };
        cfg.save_to(&path).unwrap();
        assert_eq!(DaemonConfig::load_from(&path).relay, RelayChoice::Disabled);
    }

    #[test] // [unit->REQ-DAEMON-4] the orphan-watch grace is config-driven, default otherwise.
    fn crash_grace_is_config_driven() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("daemon.json");
        std::fs::write(&path, r#"{"crash_grace_ms": 500}"#).unwrap();

        let cfg = DaemonConfig::load_from(&path);
        assert_eq!(cfg.crash_grace, Duration::from_millis(500));
        assert_ne!(cfg.crash_grace, DEFAULT_CRASH_GRACE, "must override the default");
        // Unspecified fields keep their defaults.
        assert_eq!(cfg.pulse_period, DEFAULT_PULSE_PERIOD);
        // An absent file yields the default grace.
        assert_eq!(
            DaemonConfig::load_from(&dir.path().join("nope.json")).crash_grace,
            DEFAULT_CRASH_GRACE
        );
    }
}


Bash

tail -100 C:/Users/decid/Documents/projects/spt-core/traceable-reqs.toml && grep -n "REQ-HAZARD" C:/Users/decid/Documents/projects/spt-core/traceable-reqs.toml | tail -30

Show traceable-reqs tail + hazard REQ ids