1	//! `info.json` — the per-perch identity/status record at `<perch_path>/info.json`.
2	//!
3	//! Adapted from sister `types.rs` (`InfoJson` / `PidValue`) per ADR-0001:
4	//! - [`PidValue`] (untagged numeric-or-`"BUSY"`) is copied verbatim — the
5	//!   `"BUSY"` sentinel is load-bearing (a busy poll-listener writes it; numeric
6	//!   readers must treat it as "no usable pid").
7	//! - `state` is the spt-core [`EndpointType`](spt_proto::endpoint::EndpointType)
8	//!   tag string rather than the sister's `PerchState` enum (whose Spine/Touch
9	//!   kinds the spt-core taxonomy drops — see T4).
10	//!
11	//! ## Torn-read tolerance (KNOWN-HAZARDS 1.2, REQ-HAZARD-INFO-JSON-TORN-READ)
12	//! A reader may hit the file mid-write. Two layers guarantee it never observes a
13	//! truncated record:
14	//! 1. **Writers go through [`write_info`]** → [`crate::atomic::atomic_write_string`]
15	//!    (tmp + rename): a same-volume rename is atomic, so a concurrent reader
16	//!    sees the *complete* old file or the *complete* new file, never a torn one.
17	//! 2. **Readers tolerate failure**: [`read_info`] / [`read_pid`] parse via a
18	//!    bounded retry and return `None` on persistent failure — never panic.
19
20	use serde::{Deserialize, Serialize};
21	use std::path::Path;
22
23	use crate::atomic::atomic_write_string;
24
25	/// Pid field that is either a real numeric pid or the `"BUSY"` sentinel a
26	/// listener writes while it holds the perch. Untagged: serializes as a bare
27	/// integer or a bare string. Copied verbatim (sister `PidValue`).
28	#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29	#[serde(untagged)]
30	pub enum PidValue {
31	    Numeric(u32),
32	    Busy(String),
33	}
34
35	/// On-disk shape of a perch's `info.json`. Field order is locked by declaration
36	/// (serde_json `preserve_order`). Optional fields are omitted when `None`.
37	#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
38	pub struct InfoJson {
39	    /// The perch / agent id.
40	    pub id: String,
41	    /// Creation timestamp (opaque string; producer's format).
42	    pub started: String,
43	    /// Owning process pid, or the `"BUSY"` sentinel.
44	    pub pid: PidValue,
45	    /// Harness session id bound to this perch.
46	    pub session_id: String,
47	    /// Endpoint type tag (`spt_proto::endpoint::EndpointType::as_tag`).
48	    pub state: String,
49	    #[serde(skip_serializing_if = "Option::is_none")]
50	    pub parent_pid: Option<u32>,
51	    /// Working directory the perch was created in (forward-slash normalized).
52	    #[serde(skip_serializing_if = "Option::is_none")]
53	    pub cwd: Option<String>,
54	    /// Daemon-managed liveness status (`online`/`offline`) for **daemon-hosted**
55	    /// perches (Psyche, spt-hosted Self). Present ⇒ the daemon owns this perch's
56	    /// liveness and it is read authoritatively, **never** `is_process_alive(pid)`
57	    /// (KNOWN-HAZARDS 2.5). Absent ⇒ an external/interim perch whose liveness is
58	    /// the per-pid probe. The single liveness resolver
59	    /// ([`crate::liveness::is_perch_alive`]) is the only consumer.
60	    #[serde(skip_serializing_if = "Option::is_none")]
61	    pub status: Option<String>,
62	    /// Last-active wall-clock stamp (epoch ms), refreshed by the daemon on this
63	    /// endpoint's activity (pulse tick / input / bind). The recency signal the
64	    /// consent gate resolves "most-recently-active live session" against
65	    /// (REQ-UPD-4) — a v1 precursor to PresenceChannel dispatch. Absent ⇒ never
66	    /// stamped (an interim perch, or one with no activity yet); a resolver treats
67	    /// that as least-recent.
68	    #[serde(skip_serializing_if = "Option::is_none")]
69	    pub last_active_ms: Option<u64>,
70	    /// The node currently remote-driving this endpoint's session (hex node id),
71	    /// stamped by the daemon when it serves a net-attached surface and cleared
72	    /// when the viewport ends (REQ-REACH-1: detection is a daemon fact, not an
73	    /// agent guess). Absent ⇒ locally driven (or never net-attached). `default`
74	    /// so a pre-D5c record (no field) still parses.
75	    #[serde(default, skip_serializing_if = "Option::is_none")]
76	    pub driven_by: Option<String>,
77	}
78
79	impl InfoJson {
80	    /// Construct a numeric-pid record.
81	    pub fn new(id: &str, started: &str, pid: u32, session_id: &str, state: &str) -> InfoJson {
82	        InfoJson {
83	            id: id.to_string(),
84	            started: started.to_string(),
85	            pid: PidValue::Numeric(pid),
86	            session_id: session_id.to_string(),
87	            state: state.to_string(),
88	            parent_pid: None,
89	            cwd: None,
90	            status: None,
91	            last_active_ms: None,
92	            driven_by: None,
93	        }
94	    }
95	}
96
97	/// Set the daemon-managed `status` on an existing perch's `info.json` (the 2.5
98	/// daemon-authoritative liveness marker). Read-modify-write through the atomic
99	/// path, so a concurrent reader still sees a complete old-or-new record. Errors
100	/// if the record is absent/unreadable — the daemon only marks perches it bound.
101	// [impl->REQ-HAZARD-DAEMON-HOSTED-LIVENESS]
102	pub fn set_status(perch_path: &Path, status: &str) -> std::io::Result<()> {
103	    let mut rec = read_info(perch_path).ok_or_else(|| {
104	        std::io::Error::new(std::io::ErrorKind::NotFound, "info.json absent or unreadable")
105	    })?;
106	    rec.status = Some(status.to_string());
107	    write_info(perch_path, &rec)
108	}
109
110	/// Stamp the `last_active_ms` recency marker on an existing perch's `info.json`
111	/// (REQ-UPD-4). Read-modify-write through the atomic path (a concurrent reader
112	/// still sees a complete old-or-new record). Errors if the record is
113	/// absent/unreadable — the daemon only stamps perches it hosts.
114	// [impl->REQ-UPD-4]
115	pub fn set_last_active(perch_path: &Path, now_ms: u64) -> std::io::Result<()> {
116	    let mut rec = read_info(perch_path).ok_or_else(|| {
117	        std::io::Error::new(std::io::ErrorKind::NotFound, "info.json absent or unreadable")
118	    })?;
119	    rec.last_active_ms = Some(now_ms);
120	    write_info(perch_path, &rec)
121	}
122
123	/// Stamp (or clear, with `None`) the `driven_by` remote-drive marker on an
124	/// existing perch's `info.json` (REQ-REACH-1): the daemon records which node's
125	/// operator is driving this endpoint's session while it serves a net-attached
126	/// surface. Read-modify-write through the atomic path (a concurrent reader
127	/// still sees a complete old-or-new record). Errors if the record is
128	/// absent/unreadable — the daemon only stamps perches that exist.
129	// [impl->REQ-REACH-1]
130	pub fn set_driven_by(perch_path: &Path, node: Option<&str>) -> std::io::Result<()> {
131	    let mut rec = read_info(perch_path).ok_or_else(|| {
132	        std::io::Error::new(std::io::ErrorKind::NotFound, "info.json absent or unreadable")
133	    })?;
134	    rec.driven_by = node.map(str::to_string);
135	    write_info(perch_path, &rec)
136	}
137
138	/// Number of read attempts before giving up (torn-read retry).
139	const READ_ATTEMPTS: u32 = 5;
140
141	/// Atomically write `info` to `<perch_path>/info.json` (tmp + rename, EBUSY
142	/// retry via the atomic layer). The perch directory must already exist.
143	// [impl->REQ-HAZARD-INFO-JSON-TORN-READ]
144	pub fn write_info(perch_path: &Path, info: &InfoJson) -> std::io::Result<()> {
145	    let json = serde_json::to_string(info)
146	        .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
147	    atomic_write_string(&perch_path.join("info.json"), &json)
148	}
149
150	/// Read and parse `<perch_path>/info.json`. Tolerant: a transient read/parse
151	/// failure (e.g. catching the file mid-replace on a filesystem where that is
152	/// observable) is retried up to [`READ_ATTEMPTS`] times; returns `None` on
153	/// persistent absence/corruption — never panics.
154	// [impl->REQ-HAZARD-INFO-JSON-TORN-READ]
155	pub fn read_info(perch_path: &Path) -> Option<InfoJson> {
156	    let path = perch_path.join("info.json");
157	    for _ in 0..READ_ATTEMPTS {
158	        if let Ok(content) = std::fs::read_to_string(&path) {
159	            if let Ok(info) = serde_json::from_str::<InfoJson>(&content) {
160	                return Some(info);
161	            }
162	        }
163	    }
164	    None
165	}
166
167	/// Read just the numeric pid, tolerantly. Routed through `serde_json::Value` so
168	/// a schema-minimal `{"pid": N}` (as wrapper-owned listeners write) parses
169	/// without the full `InfoJson` shape. Returns `None` for a missing file, the
170	/// `"BUSY"` sentinel, or any non-numeric pid. Copied semantics from sister
171	/// `get_pid_from_info`.
172	// [impl->REQ-HAZARD-INFO-JSON-TORN-READ]
173	pub fn read_pid(perch_path: &Path) -> Option<u32> {
174	    let path = perch_path.join("info.json");
175	    for _ in 0..READ_ATTEMPTS {
176	        if let Ok(content) = std::fs::read_to_string(&path) {
177	            if let Ok(val) = serde_json::from_str::<serde_json::Value>(&content) {
178	                // `as_u64` is None for the "BUSY" string sentinel → None pid.
179	                return val.get("pid").and_then(|v| v.as_u64()).map(|n| n as u32);
180	            }
181	        }
182	    }
183	    None
184	}
185
186	#[cfg(test)]
187	mod tests {
188	    use super::*;
189	    use std::sync::atomic::{AtomicBool, Ordering};
190	    use std::sync::Arc;
191
192	    fn perch() -> tempfile::TempDir {
193	        tempfile::tempdir().unwrap()
194	    }
195
196	    // [unit->REQ-HAZARD-INFO-JSON-TORN-READ] write → read round-trip; optional
197	    // fields omitted when None.
198	    #[test]
199	    fn write_read_round_trip() {
200	        let d = perch();
201	        let mut info = InfoJson::new("doyle", "2026-05-31T00:00:00Z", 4242, "sid-1", "live_agent");
202	        info.cwd = Some("C:/repo/spt-core".to_string());
203	        write_info(d.path(), &info).unwrap();
204
205	        let got = read_info(d.path()).expect("reads back");
206	        assert_eq!(got, info);
207
208	        // parent_pid omitted (None) → absent from JSON.
209	        let raw = std::fs::read_to_string(d.path().join("info.json")).unwrap();
210	        assert!(!raw.contains("parent_pid"), "None field must be omitted: {raw}");
211	        assert!(raw.contains("\"state\":\"live_agent\""));
212	    }
213
214	    // [unit->REQ-HAZARD-INFO-JSON-TORN-READ] BUSY sentinel: full parse keeps it;
215	    // read_pid yields None (no usable pid).
216	    #[test]
217	    fn busy_sentinel_round_trips_and_read_pid_is_none() {
218	        let d = perch();
219	        let info = InfoJson {
220	            id: "doyle".to_string(),
221	            started: "t".to_string(),
222	            pid: PidValue::Busy("BUSY".to_string()),
223	            session_id: "sid".to_string(),
224	            state: "ready_agent".to_string(),
225	            parent_pid: None,
226	            cwd: None,
227	            status: None,
228	            last_active_ms: None,
229	            driven_by: None,
230	        };
231	        write_info(d.path(), &info).unwrap();
232	        assert_eq!(read_info(d.path()).unwrap().pid, PidValue::Busy("BUSY".to_string()));
233	        assert_eq!(read_pid(d.path()), None, "BUSY must yield no numeric pid");
234	    }
235
236	    // [unit->REQ-HAZARD-DAEMON-HOSTED-LIVENESS] set_status stamps the daemon
237	    // liveness marker onto an existing record; absent by default (interim
238	    // perches carry no status); set_status on an absent record errors.
239	    #[test]
240	    fn set_status_marks_existing_record() {
241	        let d = perch();
242	        write_info(d.path(), &InfoJson::new("doyle", "t", 4242, "sid", "live_agent")).unwrap();
243	        // Interim record: no status field serialized.
244	        let raw = std::fs::read_to_string(d.path().join("info.json")).unwrap();
245	        assert!(!raw.contains("status"), "interim perch carries no status: {raw}");
246
247	        set_status(d.path(), "online").unwrap();
248	        assert_eq!(read_info(d.path()).unwrap().status.as_deref(), Some("online"));
249	        // Re-mark offline.
250	        set_status(d.path(), "offline").unwrap();
251	        assert_eq!(read_info(d.path()).unwrap().status.as_deref(), Some("offline"));
252
253	        // No record to mark → error, never a panic / silent write.
254	        let empty = perch();
255	        assert!(set_status(empty.path(), "online").is_err());
256	    }
257
258	    // [unit->REQ-UPD-4] set_last_active stamps the recency marker on an existing
259	    // record (absent by default), and errors on a missing record.
260	    #[test]
261	    fn set_last_active_stamps_recency() {
262	        let d = perch();
263	        write_info(d.path(), &InfoJson::new("doyle", "t", 4242, "sid", "live_agent")).unwrap();
264	        assert_eq!(read_info(d.path()).unwrap().last_active_ms, None, "absent by default");
265
266	        set_last_active(d.path(), 1234).unwrap();
267	        assert_eq!(read_info(d.path()).unwrap().last_active_ms, Some(1234));
268	        // A later stamp overwrites.
269	        set_last_active(d.path(), 5678).unwrap();
270	        assert_eq!(read_info(d.path()).unwrap().last_active_ms, Some(5678));
271
272	        // No record → error, never a silent write.
273	        let empty = perch();
274	        assert!(set_last_active(empty.path(), 1).is_err());
275	    }
276
277	    // [unit->REQ-REACH-1] set_driven_by stamps the remote-drive marker onto an
278	    // existing record (absent by default = locally driven), clears it with
279	    // None, errors on a missing record — and a pre-D5c record (no field at
280	    // all) still parses with driven_by == None.
281	    #[test]
282	    fn set_driven_by_stamps_and_clears() {
283	        let d = perch();
284	        write_info(d.path(), &InfoJson::new("ling", "t", 4242, "sid", "live_agent")).unwrap();
285	        let raw = std::fs::read_to_string(d.path().join("info.json")).unwrap();
286	        assert!(!raw.contains("driven_by"), "locally driven perch carries no marker: {raw}");
287
288	        set_driven_by(d.path(), Some("cafe1234")).unwrap();
289	        assert_eq!(read_info(d.path()).unwrap().driven_by.as_deref(), Some("cafe1234"));
290
291	        set_driven_by(d.path(), None).unwrap();
292	        assert_eq!(read_info(d.path()).unwrap().driven_by, None, "viewport over → cleared");
293
294	        // No record → error, never a silent write.
295	        let empty = perch();
296	        assert!(set_driven_by(empty.path(), Some("cafe1234")).is_err());
297
298	        // Forward tolerance: a pre-D5c record without the field parses.
299	        let old = perch();
300	        atomic_write_string(
301	            &old.path().join("info.json"),
302	            r#"{"id":"ling","started":"t","pid":1,"session_id":"sid","state":"live_agent"}"#,
303	        )
304	        .unwrap();
305	        assert_eq!(read_info(old.path()).unwrap().driven_by, None);
306	    }
307
308	    // [unit->REQ-HAZARD-INFO-JSON-TORN-READ] read_pid tolerates a schema-minimal
309	    // {"pid": N} record (wrapper-owned listener shape).
310	    #[test]
311	    fn read_pid_tolerates_schema_minimal() {
312	        let d = perch();
313	        atomic_write_string(&d.path().join("info.json"), r#"{"pid": 1234}"#).unwrap();
314	        assert_eq!(read_pid(d.path()), Some(1234));
315	        // But the full InfoJson parse fails on the minimal shape → None, no panic.
316	        assert_eq!(read_info(d.path()), None);
317	    }
318
319	    // [unit->REQ-HAZARD-INFO-JSON-TORN-READ] corrupt / truncated content is
320	    // tolerated: None, never a panic.
321	    #[test]
322	    fn corrupt_content_returns_none() {
323	        let d = perch();
324	        std::fs::write(d.path().join("info.json"), "{ \"id\": \"doyle\", \"pi").unwrap();
325	        assert_eq!(read_info(d.path()), None);
326	        assert_eq!(read_pid(d.path()), None);
327	        // Missing file too.
328	        let empty = perch();
329	        assert_eq!(read_info(empty.path()), None);
330	    }
331
332	    // [unit->REQ-HAZARD-INFO-JSON-TORN-READ] under a concurrent writer
333	    // continuously rewriting info.json, every successful read is a COMPLETE,
334	    // valid record — never a torn parse. Atomic rename guarantees old-or-new.
335	    #[test]
336	    fn concurrent_read_during_writes_never_torn() {
337	        let d = perch();
338	        let path = Arc::new(d.path().to_path_buf());
339	        // Seed a valid file so early readers have something.
340	        write_info(&path, &InfoJson::new("doyle", "t", 1, "sid", "live_agent")).unwrap();
341
342	        let stop = Arc::new(AtomicBool::new(false));
343	        let writer_path = Arc::clone(&path);
344	        let writer_stop = Arc::clone(&stop);
345	        let writer = std::thread::spawn(move || {
346	            let mut n = 0u32;
347	            while !writer_stop.load(Ordering::Relaxed) {
348	                n = n.wrapping_add(1);
349	                // Vary length (long cwd) so a torn read would be detectable.
350	                let mut info = InfoJson::new("doyle", "t", n, "sid", "live_agent");
351	                info.cwd = Some("x".repeat((n % 500) as usize));
352	                let _ = write_info(&writer_path, &info);
353	            }
354	        });
355
356	        // Hammer reads; each must be None or a valid record with id == "doyle".
357	        let mut valid_reads = 0;
358	        for _ in 0..3000 {
359	            if let Some(info) = read_info(&path) {
360	                assert_eq!(info.id, "doyle", "a read must never be torn/garbage");
361	                valid_reads += 1;
362	            }
363	        }
364	        stop.store(true, Ordering::Relaxed);
365	        writer.join().unwrap();
366	        assert!(valid_reads > 0, "should have observed some complete reads");
367	    }
368	}
369

//! `info.json` — the per-perch identity/status record at `<perch_path>/info.json`.
//!
//! Adapted from sister `types.rs` (`InfoJson` / `PidValue`) per ADR-0001:
//! - [`PidValue`] (untagged numeric-or-`"BUSY"`) is copied verbatim — the
//!   `"BUSY"` sentinel is load-bearing (a busy poll-listener writes it; numeric
//!   readers must treat it as "no usable pid").
//! - `state` is the spt-core [`EndpointType`](spt_proto::endpoint::EndpointType)
//!   tag string rather than the sister's `PerchState` enum (whose Spine/Touch
//!   kinds the spt-core taxonomy drops — see T4).
//!
//! ## Torn-read tolerance (KNOWN-HAZARDS 1.2, REQ-HAZARD-INFO-JSON-TORN-READ)
//! A reader may hit the file mid-write. Two layers guarantee it never observes a
//! truncated record:
//! 1. **Writers go through [`write_info`]** → [`crate::atomic::atomic_write_string`]
//!    (tmp + rename): a same-volume rename is atomic, so a concurrent reader
//!    sees the *complete* old file or the *complete* new file, never a torn one.
//! 2. **Readers tolerate failure**: [`read_info`] / [`read_pid`] parse via a
//!    bounded retry and return `None` on persistent failure — never panic.

use serde::{Deserialize, Serialize};
use std::path::Path;

use crate::atomic::atomic_write_string;

/// Pid field that is either a real numeric pid or the `"BUSY"` sentinel a
/// listener writes while it holds the perch. Untagged: serializes as a bare
/// integer or a bare string. Copied verbatim (sister `PidValue`).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum PidValue {
    Numeric(u32),
    Busy(String),
}

/// On-disk shape of a perch's `info.json`. Field order is locked by declaration
/// (serde_json `preserve_order`). Optional fields are omitted when `None`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct InfoJson {
    /// The perch / agent id.
    pub id: String,
    /// Creation timestamp (opaque string; producer's format).
    pub started: String,
    /// Owning process pid, or the `"BUSY"` sentinel.
    pub pid: PidValue,
    /// Harness session id bound to this perch.
    pub session_id: String,
    /// Endpoint type tag (`spt_proto::endpoint::EndpointType::as_tag`).
    pub state: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub parent_pid: Option<u32>,
    /// Working directory the perch was created in (forward-slash normalized).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub cwd: Option<String>,
    /// Daemon-managed liveness status (`online`/`offline`) for **daemon-hosted**
    /// perches (Psyche, spt-hosted Self). Present ⇒ the daemon owns this perch's
    /// liveness and it is read authoritatively, **never** `is_process_alive(pid)`
    /// (KNOWN-HAZARDS 2.5). Absent ⇒ an external/interim perch whose liveness is
    /// the per-pid probe. The single liveness resolver
    /// ([`crate::liveness::is_perch_alive`]) is the only consumer.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub status: Option<String>,
    /// Last-active wall-clock stamp (epoch ms), refreshed by the daemon on this
    /// endpoint's activity (pulse tick / input / bind). The recency signal the
    /// consent gate resolves "most-recently-active live session" against
    /// (REQ-UPD-4) — a v1 precursor to PresenceChannel dispatch. Absent ⇒ never
    /// stamped (an interim perch, or one with no activity yet); a resolver treats
    /// that as least-recent.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub last_active_ms: Option<u64>,
    /// The node currently remote-driving this endpoint's session (hex node id),
    /// stamped by the daemon when it serves a net-attached surface and cleared
    /// when the viewport ends (REQ-REACH-1: detection is a daemon fact, not an
    /// agent guess). Absent ⇒ locally driven (or never net-attached). `default`
    /// so a pre-D5c record (no field) still parses.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub driven_by: Option<String>,
}

impl InfoJson {
    /// Construct a numeric-pid record.
    pub fn new(id: &str, started: &str, pid: u32, session_id: &str, state: &str) -> InfoJson {
        InfoJson {
            id: id.to_string(),
            started: started.to_string(),
            pid: PidValue::Numeric(pid),
            session_id: session_id.to_string(),
            state: state.to_string(),
            parent_pid: None,
            cwd: None,
            status: None,
            last_active_ms: None,
            driven_by: None,
        }
    }
}

/// Set the daemon-managed `status` on an existing perch's `info.json` (the 2.5
/// daemon-authoritative liveness marker). Read-modify-write through the atomic
/// path, so a concurrent reader still sees a complete old-or-new record. Errors
/// if the record is absent/unreadable — the daemon only marks perches it bound.
// [impl->REQ-HAZARD-DAEMON-HOSTED-LIVENESS]
pub fn set_status(perch_path: &Path, status: &str) -> std::io::Result<()> {
    let mut rec = read_info(perch_path).ok_or_else(|| {
        std::io::Error::new(std::io::ErrorKind::NotFound, "info.json absent or unreadable")
    })?;
    rec.status = Some(status.to_string());
    write_info(perch_path, &rec)
}

/// Stamp the `last_active_ms` recency marker on an existing perch's `info.json`
/// (REQ-UPD-4). Read-modify-write through the atomic path (a concurrent reader
/// still sees a complete old-or-new record). Errors if the record is
/// absent/unreadable — the daemon only stamps perches it hosts.
// [impl->REQ-UPD-4]
pub fn set_last_active(perch_path: &Path, now_ms: u64) -> std::io::Result<()> {
    let mut rec = read_info(perch_path).ok_or_else(|| {
        std::io::Error::new(std::io::ErrorKind::NotFound, "info.json absent or unreadable")
    })?;
    rec.last_active_ms = Some(now_ms);
    write_info(perch_path, &rec)
}

/// Stamp (or clear, with `None`) the `driven_by` remote-drive marker on an
/// existing perch's `info.json` (REQ-REACH-1): the daemon records which node's
/// operator is driving this endpoint's session while it serves a net-attached
/// surface. Read-modify-write through the atomic path (a concurrent reader
/// still sees a complete old-or-new record). Errors if the record is
/// absent/unreadable — the daemon only stamps perches that exist.
// [impl->REQ-REACH-1]
pub fn set_driven_by(perch_path: &Path, node: Option<&str>) -> std::io::Result<()> {
    let mut rec = read_info(perch_path).ok_or_else(|| {
        std::io::Error::new(std::io::ErrorKind::NotFound, "info.json absent or unreadable")
    })?;
    rec.driven_by = node.map(str::to_string);
    write_info(perch_path, &rec)
}

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

/// Atomically write `info` to `<perch_path>/info.json` (tmp + rename, EBUSY
/// retry via the atomic layer). The perch directory must already exist.
// [impl->REQ-HAZARD-INFO-JSON-TORN-READ]
pub fn write_info(perch_path: &Path, info: &InfoJson) -> std::io::Result<()> {
    let json = serde_json::to_string(info)
        .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
    atomic_write_string(&perch_path.join("info.json"), &json)
}

/// Read and parse `<perch_path>/info.json`. Tolerant: a transient read/parse
/// failure (e.g. catching the file mid-replace on a filesystem where that is
/// observable) is retried up to [`READ_ATTEMPTS`] times; returns `None` on
/// persistent absence/corruption — never panics.
// [impl->REQ-HAZARD-INFO-JSON-TORN-READ]
pub fn read_info(perch_path: &Path) -> Option<InfoJson> {
    let path = perch_path.join("info.json");
    for _ in 0..READ_ATTEMPTS {
        if let Ok(content) = std::fs::read_to_string(&path) {
            if let Ok(info) = serde_json::from_str::<InfoJson>(&content) {
                return Some(info);
            }
        }
    }
    None
}

/// Read just the numeric pid, tolerantly. Routed through `serde_json::Value` so
/// a schema-minimal `{"pid": N}` (as wrapper-owned listeners write) parses
/// without the full `InfoJson` shape. Returns `None` for a missing file, the
/// `"BUSY"` sentinel, or any non-numeric pid. Copied semantics from sister
/// `get_pid_from_info`.
// [impl->REQ-HAZARD-INFO-JSON-TORN-READ]
pub fn read_pid(perch_path: &Path) -> Option<u32> {
    let path = perch_path.join("info.json");
    for _ in 0..READ_ATTEMPTS {
        if let Ok(content) = std::fs::read_to_string(&path) {
            if let Ok(val) = serde_json::from_str::<serde_json::Value>(&content) {
                // `as_u64` is None for the "BUSY" string sentinel → None pid.
                return val.get("pid").and_then(|v| v.as_u64()).map(|n| n as u32);
            }
        }
    }
    None
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::sync::atomic::{AtomicBool, Ordering};
    use std::sync::Arc;

    fn perch() -> tempfile::TempDir {
        tempfile::tempdir().unwrap()
    }

    // [unit->REQ-HAZARD-INFO-JSON-TORN-READ] write → read round-trip; optional
    // fields omitted when None.
    #[test]
    fn write_read_round_trip() {
        let d = perch();
        let mut info = InfoJson::new("doyle", "2026-05-31T00:00:00Z", 4242, "sid-1", "live_agent");
        info.cwd = Some("C:/repo/spt-core".to_string());
        write_info(d.path(), &info).unwrap();

        let got = read_info(d.path()).expect("reads back");
        assert_eq!(got, info);

        // parent_pid omitted (None) → absent from JSON.
        let raw = std::fs::read_to_string(d.path().join("info.json")).unwrap();
        assert!(!raw.contains("parent_pid"), "None field must be omitted: {raw}");
        assert!(raw.contains("\"state\":\"live_agent\""));
    }

    // [unit->REQ-HAZARD-INFO-JSON-TORN-READ] BUSY sentinel: full parse keeps it;
    // read_pid yields None (no usable pid).
    #[test]
    fn busy_sentinel_round_trips_and_read_pid_is_none() {
        let d = perch();
        let info = InfoJson {
            id: "doyle".to_string(),
            started: "t".to_string(),
            pid: PidValue::Busy("BUSY".to_string()),
            session_id: "sid".to_string(),
            state: "ready_agent".to_string(),
            parent_pid: None,
            cwd: None,
            status: None,
            last_active_ms: None,
            driven_by: None,
        };
        write_info(d.path(), &info).unwrap();
        assert_eq!(read_info(d.path()).unwrap().pid, PidValue::Busy("BUSY".to_string()));
        assert_eq!(read_pid(d.path()), None, "BUSY must yield no numeric pid");
    }

    // [unit->REQ-HAZARD-DAEMON-HOSTED-LIVENESS] set_status stamps the daemon
    // liveness marker onto an existing record; absent by default (interim
    // perches carry no status); set_status on an absent record errors.
    #[test]
    fn set_status_marks_existing_record() {
        let d = perch();
        write_info(d.path(), &InfoJson::new("doyle", "t", 4242, "sid", "live_agent")).unwrap();
        // Interim record: no status field serialized.
        let raw = std::fs::read_to_string(d.path().join("info.json")).unwrap();
        assert!(!raw.contains("status"), "interim perch carries no status: {raw}");

        set_status(d.path(), "online").unwrap();
        assert_eq!(read_info(d.path()).unwrap().status.as_deref(), Some("online"));
        // Re-mark offline.
        set_status(d.path(), "offline").unwrap();
        assert_eq!(read_info(d.path()).unwrap().status.as_deref(), Some("offline"));

        // No record to mark → error, never a panic / silent write.
        let empty = perch();
        assert!(set_status(empty.path(), "online").is_err());
    }

    // [unit->REQ-UPD-4] set_last_active stamps the recency marker on an existing
    // record (absent by default), and errors on a missing record.
    #[test]
    fn set_last_active_stamps_recency() {
        let d = perch();
        write_info(d.path(), &InfoJson::new("doyle", "t", 4242, "sid", "live_agent")).unwrap();
        assert_eq!(read_info(d.path()).unwrap().last_active_ms, None, "absent by default");

        set_last_active(d.path(), 1234).unwrap();
        assert_eq!(read_info(d.path()).unwrap().last_active_ms, Some(1234));
        // A later stamp overwrites.
        set_last_active(d.path(), 5678).unwrap();
        assert_eq!(read_info(d.path()).unwrap().last_active_ms, Some(5678));

        // No record → error, never a silent write.
        let empty = perch();
        assert!(set_last_active(empty.path(), 1).is_err());
    }

    // [unit->REQ-REACH-1] set_driven_by stamps the remote-drive marker onto an
    // existing record (absent by default = locally driven), clears it with
    // None, errors on a missing record — and a pre-D5c record (no field at
    // all) still parses with driven_by == None.
    #[test]
    fn set_driven_by_stamps_and_clears() {
        let d = perch();
        write_info(d.path(), &InfoJson::new("ling", "t", 4242, "sid", "live_agent")).unwrap();
        let raw = std::fs::read_to_string(d.path().join("info.json")).unwrap();
        assert!(!raw.contains("driven_by"), "locally driven perch carries no marker: {raw}");

        set_driven_by(d.path(), Some("cafe1234")).unwrap();
        assert_eq!(read_info(d.path()).unwrap().driven_by.as_deref(), Some("cafe1234"));

        set_driven_by(d.path(), None).unwrap();
        assert_eq!(read_info(d.path()).unwrap().driven_by, None, "viewport over → cleared");

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

        // Forward tolerance: a pre-D5c record without the field parses.
        let old = perch();
        atomic_write_string(
            &old.path().join("info.json"),
            r#"{"id":"ling","started":"t","pid":1,"session_id":"sid","state":"live_agent"}"#,
        )
        .unwrap();
        assert_eq!(read_info(old.path()).unwrap().driven_by, None);
    }

    // [unit->REQ-HAZARD-INFO-JSON-TORN-READ] read_pid tolerates a schema-minimal
    // {"pid": N} record (wrapper-owned listener shape).
    #[test]
    fn read_pid_tolerates_schema_minimal() {
        let d = perch();
        atomic_write_string(&d.path().join("info.json"), r#"{"pid": 1234}"#).unwrap();
        assert_eq!(read_pid(d.path()), Some(1234));
        // But the full InfoJson parse fails on the minimal shape → None, no panic.
        assert_eq!(read_info(d.path()), None);
    }

    // [unit->REQ-HAZARD-INFO-JSON-TORN-READ] corrupt / truncated content is
    // tolerated: None, never a panic.
    #[test]
    fn corrupt_content_returns_none() {
        let d = perch();
        std::fs::write(d.path().join("info.json"), "{ \"id\": \"doyle\", \"pi").unwrap();
        assert_eq!(read_info(d.path()), None);
        assert_eq!(read_pid(d.path()), None);
        // Missing file too.
        let empty = perch();
        assert_eq!(read_info(empty.path()), None);
    }

    // [unit->REQ-HAZARD-INFO-JSON-TORN-READ] under a concurrent writer
    // continuously rewriting info.json, every successful read is a COMPLETE,
    // valid record — never a torn parse. Atomic rename guarantees old-or-new.
    #[test]
    fn concurrent_read_during_writes_never_torn() {
        let d = perch();
        let path = Arc::new(d.path().to_path_buf());
        // Seed a valid file so early readers have something.
        write_info(&path, &InfoJson::new("doyle", "t", 1, "sid", "live_agent")).unwrap();

        let stop = Arc::new(AtomicBool::new(false));
        let writer_path = Arc::clone(&path);
        let writer_stop = Arc::clone(&stop);
        let writer = std::thread::spawn(move || {
            let mut n = 0u32;
            while !writer_stop.load(Ordering::Relaxed) {
                n = n.wrapping_add(1);
                // Vary length (long cwd) so a torn read would be detectable.
                let mut info = InfoJson::new("doyle", "t", n, "sid", "live_agent");
                info.cwd = Some("x".repeat((n % 500) as usize));
                let _ = write_info(&writer_path, &info);
            }
        });

        // Hammer reads; each must be None or a valid record with id == "doyle".
        let mut valid_reads = 0;
        for _ in 0..3000 {
            if let Some(info) = read_info(&path) {
                assert_eq!(info.id, "doyle", "a read must never be torn/garbage");
                valid_reads += 1;
            }
        }
        stop.store(true, Ordering::Relaxed);
        writer.join().unwrap();
        assert!(valid_reads > 0, "should have observed some complete reads");
    }
}


More recon: resurface, config, peerloop seam, traceable-reqs entries.


<owl_messages>
<owl_message from="doyle-psyche" priority="normal"><EVENT type="echo_commune" from="doyle-psyche" timestamp="2026-06-04T04:53:44-07:00" machine="HFENDULEAM" project="spt-core" branch="dev-freeform" head_sha="cb81e831eabe86dd7da4c64658eb2caf1ca3c99e" head_subject="docs(plan): mark M4-D9-1 complete (five CI-green sub-slices)" note="Echo commune brief — auto-fired">&lt;live-context&gt;
Generation 15. D9-1 COMPLETE — 5 sub-slices CI-green @ cb81e83. D9-2 next.

## D9-1 COMPLETE (production trigger loops — D5–D8 deferral closed)
- ea73c9a D9-1-1: dispatch.rs — inbound dispatcher. `initiated_locally` on stream table (gotcha #9 first-class). First-record SHAPE demux (kind collides across families; field discriminants). Worker per stream = own Brain::cold_start (peek + serve SEPARATE). Arms: serve_sync, serve_update, notif apply, wan funnel. tests/dispatch.rs: all converge undriven, gates load canonical SPT_HOME.
- 822d3ce D9-1-2: registryhost.rs (REQ-INST-7 production) + peerloop.rs. Registry: member subnet ∧ trusted-in-subnet gate; OWN ROWS ONLY (no transitive gossip); atomic snapshots identity/registry/&lt;subnet&gt;.json. Pump: per-trusted-peer-per-subnet registry push + notif feed + anti-entropy sync (want-refs = select_refs ∪ registry-derived a-&lt;id&gt; bootstrap refs); pump-ops.json = DEDICATED EpochSource (two cursors on one file = double-mint). tests/peerloop.rs: registry + notif + bootstrap mind converge undriven.
- b15cf2c D9-1-3: VerifyPolicy::production/load_from — identity/release-keys.json; missing/malformed = EMPTY fail-closed; bad hex skipped loud. current_version floor = staged_version (rollback gate). Pump update: Updated→stage+produce_consent_notif; Rejected→loud.
- af7f394 D9-1-4: wansend.rs — spt send WAN leg: registry snapshot → resolve_across_visible → dial-by-id → one WanMessage; CLI ops from identity/cli-ops.json. Deferred sends stay local-only (documented seam).
- 0dd85d5 D9-1-5: SpawnReq.endpoint label + broker session table + sessions IPC. Attach gate resolves subject from TABLE (never wire); unlabeled/unknown refuses fail-closed. XferRecord grew endpoint field (serde default); origin stays handshake-proven.

Carry-forward seams:
- In-pump Psyche reconcile: no daemon-hosted per-agent runtime yet (KH 7.4); conflicts loud via PumpHooks::on_pull, artifacts persist (KH 6.6).
- Binary APPLY after staged+consented update = M3c seam (apply_brain_only has no production caller — D9-7 audit note).

## Current Focus: D9-2 — resting-state model (REQ-INST-3/4)
State machine: active→dormant (detach/attention-shift via recency) → suspended (spt suspend / auto-suspend-after knob, default OFF) → wake. Status::Suspended registry variant (additive serde). Transition echo commune at active→(dormant|suspended) edge (KH 3.3). Wake edge fires resurface_at_boundary(Wake) + freshness pull trigger (D9-1 pump seam). REQ-INST-6 stays inactive (seam doc only).

## Remaining D9 Slices
- D9-3: dormancy budget measurement (SPT_BUDGET=1, both rig hosts → DORMANCY-BUDGET.md + ADR-0003 #9 closed).
- D9-4: REQ-INST-14 resources Option&lt;String&gt; on Instance + spt resources set/show/list.
- D9-5: REQ-INST-15 home-at-creation + sync_subnets=[home] seed + spt fork.
- D9-6: two-host ladder (SPT_TWO_HOST=1) + runbook + RUN ON RIG + gh-repo interim retires.
- D9-7: required_stages sweep, ROADMAP M4-delivered, CONTEXT amend, plan-status, CI FINAL green.

## Gotchas (carry — all 10)
1. Journaled net_stream_send (Some(op_id)) blocks ack + DISCARDS interleaved events. Pump loops use None.
2. Echo children repeat typed input; compare against local OutputLog replay.
3. CI Linux clippy rustc 1.96 deny-defaults newer than local.
4. &quot;Stop once buffer contains needle&quot; overshoots; compare prefix-through-first-needle.
5. Brain::snapshot()/handoff() panics without PTY session — net-only restart tests use fresh cold_start.
6. QUIC bidi stream invisible until first bytes flow — send before wait_for_stream.
7. `gh run watch` exit code LIES — verify FINAL via `gh run list --json conclusion`.
8. Manifest test commands in TOML '…' literals — nested single quotes break parse; Windows masks it.
9. Broker stream table holds LOCALLY-OPENED outbound streams too — snapshot before requester opens.
10. Scripted `&lt;EVENT&gt;` envelopes: raw `\n` splits line — use `&lt;br&gt;` per 4.1 grammar.

## Flake Ledger
1. brain_restart_survives_gaplessly — hung once hosted Windows; timeout bounds it.
2. spt-term::digest_parses_a_real_pty_stream — failed once on gravity; rerun passed.
3. sync.rs torn_pull + concurrent_writes — HARDENED: wait_for_stream_except 2s→10s.

## CI Topology (stable)
- hfenduleam (Windows): C:\actions-runner detached (NOT service — re-launch: `Start-Process C:\actions-runner\run.cmd`). Queue can sit minutes — poll, don't panic.
- gravity (Linux): systemd service, auto-start.

## Workflow Constraints (carried)
- NO cargo fmt
- Tag [impl/unit-&gt;REQ-…] on real evidence only
- traceable-reqs check from REPO ROOT (exit 0 before push)
- Linux CI clippy -D warnings is real gate
- Verify FINAL conclusion via `gh run list --json conclusion` (gotcha #7)
- Push each slice → watch green before next
- Commit trailer: `Co-Authored-By: Claude Opus 4.8 (1M context) &lt;noreply@anthropic.com&gt;`

## No Blockers
&lt;/live-context&gt;
&lt;project-context&gt;
name=spt-core

M4-D9 in flight: D9-1 COMPLETE cb81e83 (production trigger loops: inbound dispatch all 7 wire families, peer pump registry/notif/sync/update, production VerifyPolicy fail-closed, spt send WAN leg, attach/xfer arms with session endpoint labels). Plan = M4-D9-PLAN.md (7 slices; user-locked forks: env-gated two-host + manual runbook, INST-14/15 both build in D9, PAIR-7 defers to GUI milestone). Remaining: D9-2 resting states, D9-3 dormancy budget, D9-4 resource advertisement, D9-5 home subnet + fork, D9-6 two-host rig proof + gh-interim retirement, D9-7 sweep. Conventions hold: no cargo fmt; evidence-only tags; traceable-reqs check repo root; clippy -D warnings; push slice → watch FINAL via gh run list (gotcha #7); commit trailer Claude Opus 4.8 (1M context).
&lt;/project-context&gt;</EVENT></owl_message>
</owl_messages>


crates\spt-daemon\src\lib.rs:166:    resurface_at_boundary, FirstFireOutcome, NotifSurfacePolicy, ResurfaceOutcome,
crates\spt-daemon\src\notif.rs:288:pub fn resurface_at_boundary(
crates\spt-daemon\src\notif.rs:471:            // Boundary at doyle PAST the window: the live row surfaces there;
crates\spt-daemon\src\notif.rs:474:            let out = resurface_at_boundary(
crates\spt-daemon\src\notif.rs:489:            let again = resurface_at_boundary(
crates\spt-daemon\src\notif.rs:521:            let inside = resurface_at_boundary(
crates\spt-daemon\src\notif.rs:531:            let past = resurface_at_boundary(
crates\spt-daemon\src\notif.rs:646:            let out = resurface_at_boundary(


170:id = "REQ-INST-3"
171-title = "Dormant (warm) / suspended (cold) resting states"
172-required_stages = []
173-[[requirements]]
174:id = "REQ-INST-4"
175-title = "active to dormant/suspended fires a transition echo commune"
176-required_stages = []
177-[[requirements]]
178-id = "REQ-INST-5"
179-title = "Two-tier context sync (live to all, project to same-project)"
180-[Omitted long context line]
181-[[requirements]]
182:id = "REQ-INST-6"
183-title = "Deferred messages not delivered to dormant/suspended instances"
184-required_stages = []
185-[[requirements]]
186-id = "REQ-INST-7"
187-title = "Subnet registry + bare-id resolution policy"
188-[Omitted long context line]
189-[[requirements]]
190-id = "REQ-INST-8"
--
562:id = "REQ-HAZARD-DAEMON-SCHED-NONBLOCKING"
563-title = "Per-agent pulse\psyche\echo-commune scheduling must not serialize across agents: each agent's bounded LLM call (echo-commune summarizer, Psyche turn) runs off the shared scheduler so one slow/hung call cannot stall another agent's tick (7.4)"
564-required_stages = []  # rule 5: activate when the daemon multi-agent pulse fan-out is wired. Today run_pulse_loop is def+test only and each agent drives its own pulse from its own process, so blocking is naturally isolated; the invariant binds once the daemon hosts N per-agent loops
565-# --- Added 2026-06-03 at M4-D2 start (ADR-0005 Stage-A red-team #10\#11\#12); rule 3: register before satisfying, rule 5: activate per sub-task commit ---
566-[[requirements]]
567-id = "REQ-HAZARD-PAIR-TRANSCRIPT-BIND"
568-title = "Pairing transcript binds roles, both node pubkeys, subnet ID, seed epoch, TOTP time-step, and confirmation MACs — or unknown-key-share/reflection/wrong-subnet/replay pairing remain possible (ADR-0005 #12)"
569-required_stages = ["impl", "unit"]  # activated M4-D2b: pairing::transcript HMAC-SHA256 confirmation MAC over the canonical transcript (domain/role/both-msgs/subnet/epoch/step/both-pubkeys, length-prefixed) + ct_eq verify, fed into the SPAKE2 KDF identity too. Negative tests in pairing::spake cover wrong-code, wrong-subnet, stale-step, stale-epoch, unknown-key-share, reflection (PAKE BadSide), tampered-tag. int = the on-wire MITM/replay E2E at D9
570-[[requirements]]


Psyche echo noted (FYI). Continue recon — re-issue greps:


Grep

id = "REQ-INST-3"|id = "REQ-INST-4"|id = "REQ-INST-6"|REQ-HAZARD-DAEMON-SCHED-NONBLOCKING


170:id = "REQ-INST-3"
171-title = "Dormant (warm) / suspended (cold) resting states"
172-required_stages = []
173-[[requirements]]
174:id = "REQ-INST-4"
175-title = "active to dormant/suspended fires a transition echo commune"
176-required_stages = []
177-[[requirements]]
178-id = "REQ-INST-5"
179-title = "Two-tier context sync (live to all, project to same-project)"
180-[Omitted long context line]
--
182:id = "REQ-INST-6"
183-title = "Deferred messages not delivered to dormant/suspended instances"
184-required_stages = []
185-[[requirements]]
186-id = "REQ-INST-7"
187-title = "Subnet registry + bare-id resolution policy"
188-[Omitted long context line]
--
562:id = "REQ-HAZARD-DAEMON-SCHED-NONBLOCKING"
563-title = "Per-agent pulse\psyche\echo-commune scheduling must not serialize across agents: each agent's bounded LLM call (echo-commune summarizer, Psyche turn) runs off the shared scheduler so one slow/hung call cannot stall another agent's tick (7.4)"
564-required_stages = []  # rule 5: activate when the daemon multi-agent pulse fan-out is wired. Today run_pulse_loop is def+test only and each agent drives its own pulse from its own process, so blocking is naturally isolated; the invariant binds once the daemon hosts N per-agent loops
565-# --- Added 2026-06-03 at M4-D2 start (ADR-0005 Stage-A red-team #10\#11\#12); rule 3: register before satisfying, rule 5: activate per sub-task commit ---
566-[[requirements]]
567-id = "REQ-HAZARD-PAIR-TRANSCRIPT-BIND"
568-title = "Pairing transcript binds roles, both node pubkeys, subnet ID, seed epoch, TOTP time-step, and confirmation MACs — or unknown-key-share/reflection/wrong-subnet/replay pairing remain possible (ADR-0005 #12)"


230	/// The update-consent producer (D8c, ADR-0007: the consent escalation is a
231	/// notif producer, not its own delivery path). Wraps the pure gate's verdict:
232	/// [`ConsentDecision::AutoApproved`] produces nothing (the seamless path has
233	/// no prompt); [`ConsentDecision::NeedsConsent`] produces a `consent`-kind
234	/// notif and first-fires it. The decision's embedded target is advisory here
235	/// — the engine re-resolves at fire time with the subnet scope (same
236	/// resolver, fresher state); a target-less `NeedsConsent` simply rides the
237	/// boundary-resurface path ([`FirstFireOutcome::NoTarget`] — the gate still
238	/// never proceeds unprompted, REQ-UPD-4).
239	// [impl->REQ-NOTIF-1]
240	#[allow(clippy::too_many_arguments)]
241	pub fn produce_consent_notif(
242	    decision: &crate::consent::ConsentDecision,
243	    version: &str,
244	    store: &NotifStore,
245	    node_hex: &str,
246	    epochs: &mut EpochSource,
247	    subnet: &str,
248	    policy: &NotifSurfacePolicy,
249	    owlery: &Path,
250	    now_ms: u64,
251	) -> Result<Option<(NotifRow, FirstFireOutcome)>, String> {
252	    match decision {
253	        crate::consent::ConsentDecision::AutoApproved => Ok(None),
254	        crate::consent::ConsentDecision::NeedsConsent(_) => {
255	            // Real newline — the 4.1 codec turns it into `<br>` at compose.
256	            let body =
257	                format!("update-available {version}\nconfirm to apply (spt update apply) or dismiss");
258	            produce_and_first_fire(
259	                store, node_hex, epochs, subnet, NOTIF_KIND_CONSENT, "spt-update", &body,
260	                policy, owlery, now_ms,
261	            )
262	            .map(Some)
263	        }
264	    }
265	}
266
267	/// What resurface did with one undismissed row at one boundary.
268	#[derive(Debug, Clone, PartialEq, Eq)]
269	pub enum ResurfaceOutcome {
270	    /// Delivered to this endpoint; marked seen + surfaced.
271	    Surfaced { notif_id: String, delivery: SendOutcome },
272	    /// This endpoint already saw it — dismissal is the only further ack.
273	    SuppressedSeen { notif_id: String },
274	    /// Surfaced somewhere in the subnet within the suppression window.
275	    SuppressedWindow { notif_id: String },
276	    /// The endpoint is not visible in the row's subnet — not a surface there.
277	    HiddenInSubnet { notif_id: String },
278	}
279
280	/// Resurface undismissed notifs to `endpoint` at a reported boundary
281	/// (`api boundary clear|compact` / new-session-start; wake = D9). Walks every
282	/// member subnet's undismissed rows and applies the gates in module-doc
283	/// order: visibility → seen-per-endpoint → suppression window. Suppression
284	/// compares against the max-merged `last_surfaced_ms` with
285	/// `suppression_ms` = [`SUPPRESSION_WINDOW_MS`] in production (parameterized
286	/// for tests).
287	// [impl->REQ-NOTIF-1]
288	pub fn resurface_at_boundary(
289	    store: &NotifStore,
290	    endpoint: &str,
291	    policy: &NotifSurfacePolicy,
292	    owlery: &Path,
293	    now_ms: u64,
294	    suppression_ms: u64,
295	) -> Result<Vec<ResurfaceOutcome>, String> {
296	    let mut out = Vec::new();
297	    for (subnet, _) in &policy.subnets {
298	        let rows = store.undismissed(subnet).map_err(|e| format!("undismissed: {e}"))?;
299	        if rows.is_empty() {
300	            continue;
301	        }
302	        let visible = policy.visible(endpoint, subnet);
303	        for row in rows {
304	            if !visible {
305	                out.push(ResurfaceOutcome::HiddenInSubnet { notif_id: row.notif_id });
306	                continue;
307	            }
308	            if row.seen.contains(endpoint) {
309	                out.push(ResurfaceOutcome::SuppressedSeen { notif_id: row.notif_id });
310	                continue;
311	            }
312	            if now_ms.saturating_sub(row.last_surfaced_ms) < suppression_ms {
313	                out.push(ResurfaceOutcome::SuppressedWindow { notif_id: row.notif_id });
314	                continue;
315	            }
316	            let delivery = send(endpoint, &row.from_id, &notify_envelope(&row), owlery);
317	            store.mark_seen(&row.notif_id, endpoint).map_err(|e| format!("mark seen: {e}"))?;
318	            store
319	                .touch_surfaced(&row.notif_id, now_ms)
320	                .map_err(|e| format!("touch surfaced: {e}"))?;
321	            out.push(ResurfaceOutcome::Surfaced { notif_id: row.notif_id, delivery });
322	        }
323	    }
324	    Ok(out)
325	}
326
327	#[cfg(test)]
328	mod tests {
329	    use super::*;
330	    use crate::test_home::with_home;
331	    use spt_store::info::{set_last_active, set_status, write_info, InfoJson};
332	    use spt_store::liveness::{STATUS_OFFLINE, STATUS_ONLINE};
333	    use spt_store::spool;
334	    use std::path::PathBuf;
335
336	    /// Seed a daemon-hosted Self perch with a status + recency stamp (the
337	    /// consent.rs test shape).
338	    fn seed_perch(owlery: &Path, id: &str, status: &str, last_active: Option<u64>) -> PathBuf {
339	        let p = perch::resolve_perch_path_in(owlery, id, ParentHint::Infer);
340	        std::fs::create_dir_all(&p).unwrap();
341	        write_info(&p, &InfoJson::new(id, "t", 4242, "sid", "live_agent")).unwrap();
342	        set_status(&p, status).unwrap();
343	        if let Some(ms) = last_active {
344	            set_last_active(&p, ms).unwrap();
345	        }
346	        p
347	    }
348
349	    /// A policy member of `home` (no hide defaults) with the given store.
350	    fn home_policy() -> NotifSurfacePolicy {
351	        NotifSurfacePolicy { subnets: vec![("home".into(), false)], vis: VisibilityStore::default() }
352	    }
353
354	    fn store(dir: &Path) -> NotifStore {
355	        NotifStore::open_at(&dir.join("notifs.db")).unwrap()
356	    }
357
358	    fn epochs(dir: &Path) -> EpochSource {
359	        EpochSource::load_from(&dir.join("epoch"))
360	    }
361
362	    // [unit->REQ-NOTIF-1] first-fire lands at the most-recently-active LIVE
363	    // endpoint visible in the subnet, as the daemon-authored typed notify
364	    // envelope; the row comes back marked seen-by-target + surfaced.
365	    #[test]
366	    fn first_fire_reaches_most_recent_visible_endpoint() {
367	        with_home(|home| {
368	            let owlery = perch::owlery_dir();
369	            seed_perch(&owlery, "doyle", STATUS_ONLINE, Some(100));

/// The update-consent producer (D8c, ADR-0007: the consent escalation is a
/// notif producer, not its own delivery path). Wraps the pure gate's verdict:
/// [`ConsentDecision::AutoApproved`] produces nothing (the seamless path has
/// no prompt); [`ConsentDecision::NeedsConsent`] produces a `consent`-kind
/// notif and first-fires it. The decision's embedded target is advisory here
/// — the engine re-resolves at fire time with the subnet scope (same
/// resolver, fresher state); a target-less `NeedsConsent` simply rides the
/// boundary-resurface path ([`FirstFireOutcome::NoTarget`] — the gate still
/// never proceeds unprompted, REQ-UPD-4).
// [impl->REQ-NOTIF-1]
#[allow(clippy::too_many_arguments)]
pub fn produce_consent_notif(
    decision: &crate::consent::ConsentDecision,
    version: &str,
    store: &NotifStore,
    node_hex: &str,
    epochs: &mut EpochSource,
    subnet: &str,
    policy: &NotifSurfacePolicy,
    owlery: &Path,
    now_ms: u64,
) -> Result<Option<(NotifRow, FirstFireOutcome)>, String> {
    match decision {
        crate::consent::ConsentDecision::AutoApproved => Ok(None),
        crate::consent::ConsentDecision::NeedsConsent(_) => {
            // Real newline — the 4.1 codec turns it into `<br>` at compose.
            let body =
                format!("update-available {version}\nconfirm to apply (spt update apply) or dismiss");
            produce_and_first_fire(
                store, node_hex, epochs, subnet, NOTIF_KIND_CONSENT, "spt-update", &body,
                policy, owlery, now_ms,
            )
            .map(Some)
        }
    }
}

/// What resurface did with one undismissed row at one boundary.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ResurfaceOutcome {
    /// Delivered to this endpoint; marked seen + surfaced.
    Surfaced { notif_id: String, delivery: SendOutcome },
    /// This endpoint already saw it — dismissal is the only further ack.
    SuppressedSeen { notif_id: String },
    /// Surfaced somewhere in the subnet within the suppression window.
    SuppressedWindow { notif_id: String },
    /// The endpoint is not visible in the row's subnet — not a surface there.
    HiddenInSubnet { notif_id: String },
}

/// Resurface undismissed notifs to `endpoint` at a reported boundary
/// (`api boundary clear|compact` / new-session-start; wake = D9). Walks every
/// member subnet's undismissed rows and applies the gates in module-doc
/// order: visibility → seen-per-endpoint → suppression window. Suppression
/// compares against the max-merged `last_surfaced_ms` with
/// `suppression_ms` = [`SUPPRESSION_WINDOW_MS`] in production (parameterized
/// for tests).
// [impl->REQ-NOTIF-1]
pub fn resurface_at_boundary(
    store: &NotifStore,
    endpoint: &str,
    policy: &NotifSurfacePolicy,
    owlery: &Path,
    now_ms: u64,
    suppression_ms: u64,
) -> Result<Vec<ResurfaceOutcome>, String> {
    let mut out = Vec::new();
    for (subnet, _) in &policy.subnets {
        let rows = store.undismissed(subnet).map_err(|e| format!("undismissed: {e}"))?;
        if rows.is_empty() {
            continue;
        }
        let visible = policy.visible(endpoint, subnet);
        for row in rows {
            if !visible {
                out.push(ResurfaceOutcome::HiddenInSubnet { notif_id: row.notif_id });
                continue;
            }
            if row.seen.contains(endpoint) {
                out.push(ResurfaceOutcome::SuppressedSeen { notif_id: row.notif_id });
                continue;
            }
            if now_ms.saturating_sub(row.last_surfaced_ms) < suppression_ms {
                out.push(ResurfaceOutcome::SuppressedWindow { notif_id: row.notif_id });
                continue;
            }
            let delivery = send(endpoint, &row.from_id, &notify_envelope(&row), owlery);
            store.mark_seen(&row.notif_id, endpoint).map_err(|e| format!("mark seen: {e}"))?;
            store
                .touch_surfaced(&row.notif_id, now_ms)
                .map_err(|e| format!("touch surfaced: {e}"))?;
            out.push(ResurfaceOutcome::Surfaced { notif_id: row.notif_id, delivery });
        }
    }
    Ok(out)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::test_home::with_home;
    use spt_store::info::{set_last_active, set_status, write_info, InfoJson};
    use spt_store::liveness::{STATUS_OFFLINE, STATUS_ONLINE};
    use spt_store::spool;
    use std::path::PathBuf;

    /// Seed a daemon-hosted Self perch with a status + recency stamp (the
    /// consent.rs test shape).
    fn seed_perch(owlery: &Path, id: &str, status: &str, last_active: Option<u64>) -> PathBuf {
        let p = perch::resolve_perch_path_in(owlery, id, ParentHint::Infer);
        std::fs::create_dir_all(&p).unwrap();
        write_info(&p, &InfoJson::new(id, "t", 4242, "sid", "live_agent")).unwrap();
        set_status(&p, status).unwrap();
        if let Some(ms) = last_active {
            set_last_active(&p, ms).unwrap();
        }
        p
    }

    /// A policy member of `home` (no hide defaults) with the given store.
    fn home_policy() -> NotifSurfacePolicy {
        NotifSurfacePolicy { subnets: vec![("home".into(), false)], vis: VisibilityStore::default() }
    }

    fn store(dir: &Path) -> NotifStore {
        NotifStore::open_at(&dir.join("notifs.db")).unwrap()
    }

    fn epochs(dir: &Path) -> EpochSource {
        EpochSource::load_from(&dir.join("epoch"))
    }

    // [unit->REQ-NOTIF-1] first-fire lands at the most-recently-active LIVE
    // endpoint visible in the subnet, as the daemon-authored typed notify
    // envelope; the row comes back marked seen-by-target + surfaced.
    #[test]
    fn first_fire_reaches_most_recent_visible_endpoint() {
        with_home(|home| {
            let owlery = perch::owlery_dir();
            seed_perch(&owlery, "doyle", STATUS_ONLINE, Some(100));


crates\spt-daemon\src\frame.rs
crates\spt-daemon\src\codec.rs
crates\spt-daemon\src\transport.rs
crates\spt-daemon\src\relay.rs
crates\spt-daemon\src\test_home.rs
crates\spt-daemon\src\seedmap.rs
crates\spt-daemon\src\endpoint.rs
crates\spt-daemon\src\digest.rs
crates\spt-daemon\src\digesthub.rs
crates\spt-daemon\src\update.rs
crates\spt-daemon\src\adapter_update.rs
crates\spt-daemon\src\effect.rs
crates\spt-daemon\src\attach.rs
crates\spt-daemon\src\access.rs
crates\spt-daemon\src\wan.rs
crates\spt-daemon\src\lifecycle.rs
crates\spt-daemon\src\reconcile.rs
crates\spt-daemon\src\sync.rs
crates\spt-daemon\src\propagate.rs
crates\spt-daemon\src\notifsync.rs
crates\spt-daemon\src\consent.rs
crates\spt-daemon\src\psyrelay.rs
crates\spt-daemon\src\notif.rs
crates\spt-daemon\src\nethost.rs
crates\spt-daemon\src\registryhost.rs
crates\spt-daemon\src\config.rs
crates\spt-daemon\src\daemon.rs
crates\spt-daemon\src\lib.rs
crates\spt-daemon\src\relcache.rs
crates\spt-daemon\src\release.rs
crates\spt-daemon\src\peerloop.rs
crates\spt-daemon\src\msg.rs
crates\spt-daemon\src\broker.rs
crates\spt-daemon\src\brain.rs
crates\spt-daemon\src\xfer.rs
crates\spt-daemon\src\dispatch.rs


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	}
84
85	/// Resolved daemon configuration. The brain's scheduled loops read their cadence
86	/// from `pulse_period`; the drop-ingest guard reads `protection_window_ms`.
87	#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88	pub struct DaemonConfig {
89	    /// The pulse loop period (config-driven; defaults to [`DEFAULT_PULSE_PERIOD`]).
90	    pub pulse_period: Duration,
91	    /// The drop-file precedence protection window in epoch-ms (6.5).
92	    pub protection_window_ms: u64,
93	    /// The orphan-watch grace window (B7): the recovery wait before a
94	    /// supervised-crash teardown rechecks liveness and signs off (1.1).
95	    pub crash_grace: Duration,
96	    /// Whether self-update is full-auto (C2, REQ-UPD-4). **Default `false`** — an
97	    /// update is gated on user confirmation delivered to the most-recently-active
98	    /// live session. Setting this to `true` is the explicit opt-in to the
99	    /// seamless, prompt-free path.
100	    pub full_auto_update: bool,
101	    /// The broker net endpoint's relay stance (D4a, REQ-NET-2). Default
102	    /// [`RelayChoice::N0`] — the ADR-0002 zero-config path. An unrecognized
103	    /// string degrades to the default (forward-compat: a future value must not
104	    /// wedge an older daemon).
105	    pub relay: RelayChoice,
106	    /// Whether the peer pump runs at all (D9-1). Default `true` — the subnet
107	    /// self-drives; `false` is the kill switch for debugging/hermetic setups.
108	    pub peer_pump: bool,
109	    /// Registry feed push cadence (D9-1).
110	    pub registry_pump_period: Duration,
111	    /// Notif feed push cadence (D9-1).
112	    pub notif_pump_period: Duration,
113	    /// Context sync pull cadence — the anti-entropy loop (D9-1).
114	    pub sync_pull_period: Duration,
115	    /// Update check cadence (D9-1; the pull rides the same trusted conns).
116	    pub update_check_period: Duration,
117	}
118
119	impl Default for DaemonConfig {
120	    fn default() -> Self {
121	        DaemonConfig {
122	            pulse_period: DEFAULT_PULSE_PERIOD,
123	            protection_window_ms: DEFAULT_PROTECTION_WINDOW_MS,
124	            crash_grace: DEFAULT_CRASH_GRACE,
125	            full_auto_update: false, // gated by default (REQ-UPD-4)
126	            relay: RelayChoice::N0,  // zero-config default (ADR-0002)
127	            peer_pump: true,         // the subnet self-drives (D9-1)
128	            registry_pump_period: DEFAULT_REGISTRY_PUMP_PERIOD,
129	            notif_pump_period: DEFAULT_NOTIF_PUMP_PERIOD,
130	            sync_pull_period: DEFAULT_SYNC_PULL_PERIOD,
131	            update_check_period: DEFAULT_UPDATE_CHECK_PERIOD,
132	        }
133	    }
134	}
135
136	impl DaemonConfig {
137	    /// The canonical config path: `<spt_home>/daemon.json`.
138	    pub fn default_path() -> PathBuf {
139	        spt_store::perch::spt_home().join("daemon.json")
140	    }
141
142	    /// Load the daemon config from the canonical path, falling back to the
143	    /// default if the file is absent or unreadable.
144	    pub fn load() -> Self {
145	        Self::load_from(&Self::default_path())
146	    }
147
148	    /// Load from an explicit path. A missing file → default; a present-but-partial
149	    /// file fills only the named fields and defaults the rest; a malformed file
150	    /// degrades to the default (a broken config must never wedge the daemon).
151	    pub fn load_from(path: &Path) -> Self {
152	        let Ok(raw) = std::fs::read_to_string(path) else {
153	            return Self::default();
154	        };
155	        let file: DaemonConfigFile = match serde_json::from_str(&raw) {
156	            Ok(f) => f,
157	            Err(_) => return Self::default(),
158	        };
159	        Self::from_file(file)
160	    }
161
162	    fn from_file(file: DaemonConfigFile) -> Self {
163	        let d = DaemonConfig::default();
164	        DaemonConfig {
165	            pulse_period: file
166	                .pulse_period_ms
167	                .map(Duration::from_millis)
168	                .unwrap_or(d.pulse_period),
169	            protection_window_ms: file.protection_window_ms.unwrap_or(d.protection_window_ms),
170	            crash_grace: file
171	                .crash_grace_ms
172	                .map(Duration::from_millis)
173	                .unwrap_or(d.crash_grace),
174	            full_auto_update: file.full_auto_update.unwrap_or(d.full_auto_update),
175	            relay: match file.relay.as_deref() {
176	                Some("disabled") => RelayChoice::Disabled,
177	                // "n0", absent, or an unrecognized future value → the default.
178	                _ => d.relay,
179	            },
180	            peer_pump: file.peer_pump.unwrap_or(d.peer_pump),
181	            registry_pump_period: file
182	                .registry_pump_period_ms
183	                .map(Duration::from_millis)
184	                .unwrap_or(d.registry_pump_period),
185	            notif_pump_period: file
186	                .notif_pump_period_ms
187	                .map(Duration::from_millis)
188	                .unwrap_or(d.notif_pump_period),
189	            sync_pull_period: file
190	                .sync_pull_period_ms
191	                .map(Duration::from_millis)
192	                .unwrap_or(d.sync_pull_period),
193	            update_check_period: file
194	                .update_check_period_ms
195	                .map(Duration::from_millis)
196	                .unwrap_or(d.update_check_period),
197	        }
198	    }
199
200	    /// Persist this config to an explicit path (for `spt` to write the knob).
201	    pub fn save_to(&self, path: &Path) -> std::io::Result<()> {
202	        let file = DaemonConfigFile {
203	            pulse_period_ms: Some(self.pulse_period.as_millis() as u64),
204	            protection_window_ms: Some(self.protection_window_ms),
205	            crash_grace_ms: Some(self.crash_grace.as_millis() as u64),
206	            full_auto_update: Some(self.full_auto_update),
207	            relay: Some(
208	                match self.relay {
209	                    RelayChoice::N0 => "n0",
210	                    RelayChoice::Disabled => "disabled",
211	                }
212	                .to_string(),
213	            ),
214	            peer_pump: Some(self.peer_pump),
215	            registry_pump_period_ms: Some(self.registry_pump_period.as_millis() as u64),
216	            notif_pump_period_ms: Some(self.notif_pump_period.as_millis() as u64),
217	            sync_pull_period_ms: Some(self.sync_pull_period.as_millis() as u64),
218	            update_check_period_ms: Some(self.update_check_period.as_millis() as u64),
219	        };
220	        let json = serde_json::to_string_pretty(&file)
221	            .map_err(|e| std::io::Error::other(e.to_string()))?;
222	        std::fs::write(path, json)
223	    }
224	}
225
226	#[cfg(test)]
227	mod tests {
228	    use super::*;
229
230	    #[test] // [unit->REQ-DAEMON-1] an absent config file yields the default period.
231	    fn missing_file_is_default() {
232	        let dir = tempfile::tempdir().unwrap();
233	        let cfg = DaemonConfig::load_from(&dir.path().join("nope.json"));
234	        assert_eq!(cfg, DaemonConfig::default());
235	        assert_eq!(cfg.pulse_period, DEFAULT_PULSE_PERIOD);
236	    }
237
238	    #[test] // [unit->REQ-DAEMON-1] the pulse period is config-driven, not a constant.
239	    fn pulse_period_is_config_driven() {
240	        let dir = tempfile::tempdir().unwrap();
241	        let path = dir.path().join("daemon.json");
242	        std::fs::write(&path, r#"{"pulse_period_ms": 250}"#).unwrap();
243
244	        let cfg = DaemonConfig::load_from(&path);
245	        assert_eq!(cfg.pulse_period, Duration::from_millis(250));
246	        assert_ne!(cfg.pulse_period, DEFAULT_PULSE_PERIOD, "must override the default");
247	        // The unspecified field keeps its default.
248	        assert_eq!(cfg.protection_window_ms, DEFAULT_PROTECTION_WINDOW_MS);
249	    }
250
251	    #[test] // [unit->REQ-DAEMON-1] a malformed config degrades to default, never wedges.
252	    fn malformed_file_degrades_to_default() {
253	        let dir = tempfile::tempdir().unwrap();
254	        let path = dir.path().join("daemon.json");
255	        std::fs::write(&path, "not json {{{").unwrap();
256	        assert_eq!(DaemonConfig::load_from(&path), DaemonConfig::default());
257	    }
258
259	    #[test] // [unit->REQ-DAEMON-1] save then load round-trips the configured period.
260	    fn save_load_roundtrip() {
261	        let dir = tempfile::tempdir().unwrap();
262	        let path = dir.path().join("daemon.json");
263	        let cfg = DaemonConfig {
264	            pulse_period: Duration::from_millis(750),
265	            protection_window_ms: 99,
266	            crash_grace: Duration::from_millis(1234),
267	            full_auto_update: true,
268	            relay: RelayChoice::Disabled,
269	            peer_pump: false,
270	            registry_pump_period: Duration::from_millis(11_000),
271	            notif_pump_period: Duration::from_millis(12_000),
272	            sync_pull_period: Duration::from_millis(13_000),
273	            update_check_period: Duration::from_millis(14_000),
274	        };
275	        cfg.save_to(&path).unwrap();
276	        assert_eq!(DaemonConfig::load_from(&path), cfg);
277	    }
278
279	    #[test] // [unit->REQ-UPD-4] full-auto update is OFF by default and config-driven.
280	    fn full_auto_update_is_gated_by_default() {
281	        assert!(!DaemonConfig::default().full_auto_update, "gated by default (consent required)");
282
283	        let dir = tempfile::tempdir().unwrap();
284	        let path = dir.path().join("daemon.json");
285	        std::fs::write(&path, r#"{"full_auto_update": true}"#).unwrap();
286	        assert!(DaemonConfig::load_from(&path).full_auto_update, "explicit opt-in honored");
287	        // An absent file keeps the gated default.
288	        assert!(!DaemonConfig::load_from(&dir.path().join("nope.json")).full_auto_update);
289	    }
290
291	    #[test] // [unit->REQ-NET-2] the relay stance is config-driven: "disabled" opts out,
292	            // absent/unknown values keep the zero-config n0 default, and it round-trips.
293	    fn relay_choice_is_config_driven() {
294	        assert_eq!(DaemonConfig::default().relay, RelayChoice::N0, "zero-config default");
295
296	        let dir = tempfile::tempdir().unwrap();
297	        let path = dir.path().join("daemon.json");
298	        std::fs::write(&path, r#"{"relay": "disabled"}"#).unwrap();
299	        assert_eq!(DaemonConfig::load_from(&path).relay, RelayChoice::Disabled);
300
301	        // An unrecognized future value degrades to the default, never wedges.
302	        std::fs::write(&path, r#"{"relay": "quantum-mesh"}"#).unwrap();
303	        assert_eq!(DaemonConfig::load_from(&path).relay, RelayChoice::N0);
304
305	        let cfg = DaemonConfig { relay: RelayChoice::Disabled, ..DaemonConfig::default() };
306	        cfg.save_to(&path).unwrap();
307	        assert_eq!(DaemonConfig::load_from(&path).relay, RelayChoice::Disabled);
308	    }
309
310	    #[test] // [unit->REQ-DAEMON-4] the orphan-watch grace is config-driven, default otherwise.
311	    fn crash_grace_is_config_driven() {
312	        let dir = tempfile::tempdir().unwrap();
313	        let path = dir.path().join("daemon.json");
314	        std::fs::write(&path, r#"{"crash_grace_ms": 500}"#).unwrap();
315
316	        let cfg = DaemonConfig::load_from(&path);
317	        assert_eq!(cfg.crash_grace, Duration::from_millis(500));
318	        assert_ne!(cfg.crash_grace, DEFAULT_CRASH_GRACE, "must override the default");
319	        // Unspecified fields keep their defaults.
320	        assert_eq!(cfg.pulse_period, DEFAULT_PULSE_PERIOD);
321	        // An absent file yields the default grace.
322	        assert_eq!(
323	            DaemonConfig::load_from(&dir.path().join("nope.json")).crash_grace,
324	            DEFAULT_CRASH_GRACE
325	        );
326	    }
327	}
328

//! 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>,
}

/// Resolved daemon configuration. The brain's scheduled loops read their cadence
/// from `pulse_period`; the drop-ingest guard reads `protection_window_ms`.
#[derive(Debug, Clone, Copy, 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,
}

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,
        }
    }
}

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),
        }
    }

    /// 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),
        };
        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),
        };
        cfg.save_to(&path).unwrap();
        assert_eq!(DaemonConfig::load_from(&path), cfg);
    }

    #[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
        );
    }
}