1	//! Ready-agent lifecycle: register a perch, listen, drain backlog, tear down.
2	//!
3	//! Clean-room re-expression of the sister `owl/poll.rs` startup + `setup.rs`
4	//! against the spt-store API, scoped to the M1 loopback model. The harness
5	//! coupling that bloats the sister `poll.rs` (stream EVENT emission, EVENT-PART
6	//! chunking, alarms, handoff, Psyche) is **not** M1 — it lands in M2/M3. M1's
7	//! ready agent is the minimal participant: a perch + a TCP listener + a spool
8	//! drain.
9	//!
10	//! Startup ([`ReadyAgent::start`]) is the REQ-MSG-3 contract:
11	//! 1. Create the perch dir; write `info.json` (`ready_agent` endpoint) + the
12	//!    `ready` marker.
13	//! 2. Bind a loopback listener and register its address (via [`PollListener`]).
14	//! 3. Drain the **non-deferred** spool backlog so messages that arrived while
15	//!    the agent was offline are delivered on startup. Deferred rows are left
16	//!    for the hook channel (REQ-HAZARD-DEFERRED-SURVIVE-DRAIN, covered M0).
17	//!
18	//! Teardown ([`ReadyAgent::soft_cleanup`]) honors REQ-HAZARD-SOFT-CLEANUP (6.2):
19	//! it removes **only** the `ready` marker and unregisters the address. The
20	//! spool and `info.json` are preserved so a restart resumes its backlog.
21	// [impl->REQ-MSG-3]
22
23	use spt_store::info::{self, InfoJson};
24	use spt_store::perch::{self, ParentHint};
25	use spt_store::spool;
26	use std::fs;
27	use std::path::PathBuf;
28	use std::time::Duration;
29
30	use crate::listener::PollListener;
31
32	/// Default poll window for [`ReadyAgent::recv`]; bounds each blocking accept so
33	/// the caller stays responsive (and can re-check spool / shut down between calls).
34	const RECV_POLL: Duration = Duration::from_millis(200);
35
36	/// A running ready agent: an owned perch + a registered loopback listener.
37	pub struct ReadyAgent {
38	    id: String,
39	    listener: PollListener,
40	    perch_path: PathBuf,
41	    closed: bool,
42	}
43
44	impl ReadyAgent {
45	    /// Register the perch (info.json + ready marker + listener address) and
46	    /// drain the offline backlog. Returns the agent and any backlog messages
47	    /// (oldest first) that were spooled while it was down.
48	    // [impl->REQ-MSG-3]
49	    pub fn start(id: &str) -> Result<(ReadyAgent, Vec<String>), String> {
50	        Self::start_homed(id, None)
51	    }
52
53	    /// [`start`](Self::start) with an explicit home-subnet pick for a NEW
54	    /// endpoint (D9-5, REQ-INST-15): home is assigned at creation through the
55	    /// shared seam ([`spt_store::home::stamp_creation_fields`]) — auto on a
56	    /// sole-subnet node, `subnet` required on a multi-subnet one
57	    /// (refuse-and-qualify), local-only when unpaired. A restart carries the
58	    /// durable creation-time fields forward (home is immutable; the resource
59	    /// blurb and knobs are user state a re-register must not wipe).
60	    // [impl->REQ-INST-15]
61	    pub fn start_homed(
62	        id: &str,
63	        subnet: Option<&str>,
64	    ) -> Result<(ReadyAgent, Vec<String>), String> {
65	        // [impl->REQ-HAZARD-ID-CHARSET] guard the id charset at the creation seam.
66	        spt_proto::id::validate_endpoint_id(id).map_err(|e| e.to_string())?;
67	        let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);
68	        fs::create_dir_all(&perch_path)
69	            .map_err(|e| format!("Failed to create perch dir: {}", e))?;
70
71	        // info.json first, so the record exists by the time the address is live.
72	        let prior = info::read_info(&perch_path);
73	        let mut rec = InfoJson::new(
74	            id,
75	            &now_stamp(),
76	            std::process::id(),
77	            &session_id(),
78	            "ready_agent",
79	        );
80	        let subnets = spt_store::subnet::SubnetStore::load();
81	        let mut vis = spt_store::visibility::VisibilityStore::load();
82	        let dirty = spt_store::home::stamp_creation_fields(
83	            &mut rec,
84	            prior.as_ref(),
85	            &subnets,
86	            &mut vis,
87	            subnet,
88	            None,
89	        )
90	        .map_err(|e| format!("HOME: {e}"))?;
91	        if dirty {
92	            let _ = vis.save();
93	        }
94	        info::write_info(&perch_path, &rec)
95	            .map_err(|e| format!("Failed to write info.json: {}", e))?;
96
97	        // ready marker — the liveness signal senders check.
98	        fs::write(perch::resolve_ready_file(id, ParentHint::Infer), "")
99	            .map_err(|e| format!("Failed to write ready marker: {}", e))?;
100
101	        // Bind + register the listener address. Blocking with a short poll so
102	        // recv() stays responsive.
103	        let listener = PollListener::bind_blocking(id, RECV_POLL)?;
104
105	        // Drain the offline backlog (non-deferred only; deferred rows survive
106	        // for the hook channel).
107	        let backlog = spool::drain_non_deferred_at(&perch_path)
108	            .map_err(|e| format!("Failed to drain spool backlog: {}", e))?;
109
110	        Ok((
111	            ReadyAgent {
112	                id: id.to_string(),
113	                listener,
114	                perch_path,
115	                closed: false,
116	            },
117	            backlog,
118	        ))
119	    }
120
121	    /// The agent's id.
122	    pub fn id(&self) -> &str {
123	        &self.id
124	    }
125
126	    /// Wait up to one poll window for a live TCP message. Returns `None` on
127	    /// timeout (call again to keep listening).
128	    pub fn recv(&self) -> Option<String> {
129	        self.listener.check_message_blocking()
130	    }
131
132	    /// Drain any non-deferred spool rows that accrued since startup (e.g. a
133	    /// sender that spooled because it raced the listener bind, or a hook-channel
134	    /// arrival). Oldest first.
135	    pub fn drain_spool(&self) -> Vec<String> {
136	        spool::drain_non_deferred_at(&self.perch_path).unwrap_or_default()
137	    }
138
139	    /// Soft teardown (6.2): remove the `ready` marker and unregister the
140	    /// address. The spool and `info.json` are **preserved** so a later restart
141	    /// resumes its backlog. Idempotent.
142	    // [impl->REQ-MSG-3]
143	    // [impl->REQ-HAZARD-SOFT-CLEANUP]
144	    pub fn soft_cleanup(&mut self) {
145	        let _ = fs::remove_file(perch::resolve_ready_file(&self.id, ParentHint::Infer));
146	        self.listener.close();
147	        self.closed = true;
148	    }
149	}
150
151	impl Drop for ReadyAgent {
152	    fn drop(&mut self) {
153	        if !self.closed {
154	            self.soft_cleanup();
155	        }
156	    }
157	}
158
159	/// Coarse creation timestamp (epoch seconds as a string). Opaque to readers —
160	/// `info.json` treats `started` as a producer-format string.
161	fn now_stamp() -> String {
162	    std::time::SystemTime::now()
163	        .duration_since(std::time::UNIX_EPOCH)
164	        .map(|d| d.as_secs().to_string())
165	        .unwrap_or_else(|_| "0".to_string())
166	}
167
168	/// Harness session id: honor `OWL_SESSION_ID` if set, else synthesize a
169	/// process-unique fallback. (M2 wires the real harness session id.)
170	fn session_id() -> String {
171	    if let Ok(s) = std::env::var("OWL_SESSION_ID") {
172	        if !s.is_empty() {
173	            return s;
174	        }
175	    }
176	    let nanos = std::time::SystemTime::now()
177	        .duration_since(std::time::UNIX_EPOCH)
178	        .map(|d| d.as_nanos())
179	        .unwrap_or(0);
180	    format!("sess-{}-{}", std::process::id(), nanos)
181	}
182
183	#[cfg(test)]
184	mod tests {
185	    use super::*;
186	    use crate::deliver;
187
188	    fn unique_id(prefix: &str) -> String {
189	        let nanos = std::time::SystemTime::now()
190	            .duration_since(std::time::UNIX_EPOCH)
191	            .map(|d| d.as_nanos())
192	            .unwrap_or(0);
193	        format!("{}-{}-{}", prefix, std::process::id(), nanos)
194	    }
195
196	    // [unit->REQ-MSG-3] start registers a reachable, resolvable perch.
197	    #[test]
198	    fn start_registers_resolvable_perch() {
199	        crate::test_home::isolate();
200	        let id = unique_id("test-ready-start");
201	        let (agent, backlog) = ReadyAgent::start(&id).unwrap();
202	        assert!(backlog.is_empty());
203
204	        // Resolvable + online from a sender's point of view.
205	        assert!(deliver::is_online(&id));
206	        assert!(deliver::perch_exists(&id));
207
208	        // info.json + ready marker on disk.
209	        let perch_path = perch::resolve_perch_path(&id, ParentHint::Infer);
210	        assert!(info::read_info(&perch_path).is_some());
211	        assert!(perch::resolve_ready_file(&id, ParentHint::Infer).exists());
212	        drop(agent);
213	    }
214
215	    // [unit->REQ-MSG-3] backlog spooled while offline drains on startup.
216	    #[test]
217	    fn backlog_drains_on_start() {
218	        crate::test_home::isolate();
219	        let owlery = perch::owlery_dir();
220	        let id = unique_id("test-ready-backlog");
221	        // Pre-create an offline perch and spool a message to it.
222	        let perch_path = perch::resolve_perch_path(&id, ParentHint::Infer);
223	        fs::create_dir_all(&perch_path).unwrap();
224	        let rec = InfoJson::new(&id, "now", std::process::id(), "sess", "ready_agent");
225	        info::write_info(&perch_path, &rec).unwrap();
226	        assert_eq!(
227	            deliver::deliver(&id, "sender", "while you slept", &owlery),
228	            deliver::DeliveryPath::Spool
229	        );
230
231	        // Starting drains it.
232	        let (_agent, backlog) = ReadyAgent::start(&id).unwrap();
233	        assert_eq!(backlog.len(), 1);
234	        assert!(backlog[0].contains("while you slept"));
235	    }
236
237	    // [unit->REQ-MSG-3] a live TCP message is received via recv().
238	    #[test]
239	    fn recv_gets_live_tcp_message() {
240	        crate::test_home::isolate();
241	        let owlery = perch::owlery_dir();
242	        let id = unique_id("test-ready-recv");
243	        let (agent, _) = ReadyAgent::start(&id).unwrap();
244
245	        let owlery2 = owlery.clone();
246	        let target = id.clone();
247	        let h = std::thread::spawn(move || {
248	            std::thread::sleep(Duration::from_millis(30));
249	            deliver::deliver(&target, "peer", "hello live", &owlery2)
250	        });
251
252	        let mut got = None;
253	        for _ in 0..25 {
254	            if let Some(m) = agent.recv() {
255	                got = Some(m);
256	                break;
257	            }
258	        }
259	        assert_eq!(h.join().unwrap(), deliver::DeliveryPath::Tcp);
260	        assert_eq!(got.as_deref(), Some("__REPLY_TO__:peer\nhello live\n"));
261	        drop(agent);
262	    }
263
264	    // [unit->REQ-HAZARD-SOFT-CLEANUP] soft_cleanup removes only the ready marker;
265	    // spool (deferred row) and info.json are preserved.
266	    #[test]
267	    fn soft_cleanup_preserves_spool_and_info() {
268	        crate::test_home::isolate();
269	        let id = unique_id("test-ready-soft");
270	        let (mut agent, _) = ReadyAgent::start(&id).unwrap();
271	        let perch_path = perch::resolve_perch_path(&id, ParentHint::Infer);
272
273	        // A deferred row survives the startup drain — use it to prove the spool
274	        // is preserved across cleanup.
275	        spool::spool_message_deferred_at(&perch_path, "hooker", "deferred body").unwrap();
276	        assert_eq!(spool::pending_count_at(&perch_path).unwrap(), 1);
277
278	        agent.soft_cleanup();
279
280	        // ready marker gone...
281	        assert!(!perch::resolve_ready_file(&id, ParentHint::Infer).exists());
282	        // ...but spool row + info.json preserved.
283	        assert_eq!(spool::pending_count_at(&perch_path).unwrap(), 1);
284	        assert!(info::read_info(&perch_path).is_some());
285	    }
286	}
287

//! Ready-agent lifecycle: register a perch, listen, drain backlog, tear down.
//!
//! Clean-room re-expression of the sister `owl/poll.rs` startup + `setup.rs`
//! against the spt-store API, scoped to the M1 loopback model. The harness
//! coupling that bloats the sister `poll.rs` (stream EVENT emission, EVENT-PART
//! chunking, alarms, handoff, Psyche) is **not** M1 — it lands in M2/M3. M1's
//! ready agent is the minimal participant: a perch + a TCP listener + a spool
//! drain.
//!
//! Startup ([`ReadyAgent::start`]) is the REQ-MSG-3 contract:
//! 1. Create the perch dir; write `info.json` (`ready_agent` endpoint) + the
//!    `ready` marker.
//! 2. Bind a loopback listener and register its address (via [`PollListener`]).
//! 3. Drain the **non-deferred** spool backlog so messages that arrived while
//!    the agent was offline are delivered on startup. Deferred rows are left
//!    for the hook channel (REQ-HAZARD-DEFERRED-SURVIVE-DRAIN, covered M0).
//!
//! Teardown ([`ReadyAgent::soft_cleanup`]) honors REQ-HAZARD-SOFT-CLEANUP (6.2):
//! it removes **only** the `ready` marker and unregisters the address. The
//! spool and `info.json` are preserved so a restart resumes its backlog.
// [impl->REQ-MSG-3]

use spt_store::info::{self, InfoJson};
use spt_store::perch::{self, ParentHint};
use spt_store::spool;
use std::fs;
use std::path::PathBuf;
use std::time::Duration;

use crate::listener::PollListener;

/// Default poll window for [`ReadyAgent::recv`]; bounds each blocking accept so
/// the caller stays responsive (and can re-check spool / shut down between calls).
const RECV_POLL: Duration = Duration::from_millis(200);

/// A running ready agent: an owned perch + a registered loopback listener.
pub struct ReadyAgent {
    id: String,
    listener: PollListener,
    perch_path: PathBuf,
    closed: bool,
}

impl ReadyAgent {
    /// Register the perch (info.json + ready marker + listener address) and
    /// drain the offline backlog. Returns the agent and any backlog messages
    /// (oldest first) that were spooled while it was down.
    // [impl->REQ-MSG-3]
    pub fn start(id: &str) -> Result<(ReadyAgent, Vec<String>), String> {
        Self::start_homed(id, None)
    }

    /// [`start`](Self::start) with an explicit home-subnet pick for a NEW
    /// endpoint (D9-5, REQ-INST-15): home is assigned at creation through the
    /// shared seam ([`spt_store::home::stamp_creation_fields`]) — auto on a
    /// sole-subnet node, `subnet` required on a multi-subnet one
    /// (refuse-and-qualify), local-only when unpaired. A restart carries the
    /// durable creation-time fields forward (home is immutable; the resource
    /// blurb and knobs are user state a re-register must not wipe).
    // [impl->REQ-INST-15]
    pub fn start_homed(
        id: &str,
        subnet: Option<&str>,
    ) -> Result<(ReadyAgent, Vec<String>), String> {
        // [impl->REQ-HAZARD-ID-CHARSET] guard the id charset at the creation seam.
        spt_proto::id::validate_endpoint_id(id).map_err(|e| e.to_string())?;
        let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);
        fs::create_dir_all(&perch_path)
            .map_err(|e| format!("Failed to create perch dir: {}", e))?;

        // info.json first, so the record exists by the time the address is live.
        let prior = info::read_info(&perch_path);
        let mut rec = InfoJson::new(
            id,
            &now_stamp(),
            std::process::id(),
            &session_id(),
            "ready_agent",
        );
        let subnets = spt_store::subnet::SubnetStore::load();
        let mut vis = spt_store::visibility::VisibilityStore::load();
        let dirty = spt_store::home::stamp_creation_fields(
            &mut rec,
            prior.as_ref(),
            &subnets,
            &mut vis,
            subnet,
            None,
        )
        .map_err(|e| format!("HOME: {e}"))?;
        if dirty {
            let _ = vis.save();
        }
        info::write_info(&perch_path, &rec)
            .map_err(|e| format!("Failed to write info.json: {}", e))?;

        // ready marker — the liveness signal senders check.
        fs::write(perch::resolve_ready_file(id, ParentHint::Infer), "")
            .map_err(|e| format!("Failed to write ready marker: {}", e))?;

        // Bind + register the listener address. Blocking with a short poll so
        // recv() stays responsive.
        let listener = PollListener::bind_blocking(id, RECV_POLL)?;

        // Drain the offline backlog (non-deferred only; deferred rows survive
        // for the hook channel).
        let backlog = spool::drain_non_deferred_at(&perch_path)
            .map_err(|e| format!("Failed to drain spool backlog: {}", e))?;

        Ok((
            ReadyAgent {
                id: id.to_string(),
                listener,
                perch_path,
                closed: false,
            },
            backlog,
        ))
    }

    /// The agent's id.
    pub fn id(&self) -> &str {
        &self.id
    }

    /// Wait up to one poll window for a live TCP message. Returns `None` on
    /// timeout (call again to keep listening).
    pub fn recv(&self) -> Option<String> {
        self.listener.check_message_blocking()
    }

    /// Drain any non-deferred spool rows that accrued since startup (e.g. a
    /// sender that spooled because it raced the listener bind, or a hook-channel
    /// arrival). Oldest first.
    pub fn drain_spool(&self) -> Vec<String> {
        spool::drain_non_deferred_at(&self.perch_path).unwrap_or_default()
    }

    /// Soft teardown (6.2): remove the `ready` marker and unregister the
    /// address. The spool and `info.json` are **preserved** so a later restart
    /// resumes its backlog. Idempotent.
    // [impl->REQ-MSG-3]
    // [impl->REQ-HAZARD-SOFT-CLEANUP]
    pub fn soft_cleanup(&mut self) {
        let _ = fs::remove_file(perch::resolve_ready_file(&self.id, ParentHint::Infer));
        self.listener.close();
        self.closed = true;
    }
}

impl Drop for ReadyAgent {
    fn drop(&mut self) {
        if !self.closed {
            self.soft_cleanup();
        }
    }
}

/// Coarse creation timestamp (epoch seconds as a string). Opaque to readers —
/// `info.json` treats `started` as a producer-format string.
fn now_stamp() -> String {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_secs().to_string())
        .unwrap_or_else(|_| "0".to_string())
}

/// Harness session id: honor `OWL_SESSION_ID` if set, else synthesize a
/// process-unique fallback. (M2 wires the real harness session id.)
fn session_id() -> String {
    if let Ok(s) = std::env::var("OWL_SESSION_ID") {
        if !s.is_empty() {
            return s;
        }
    }
    let nanos = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_nanos())
        .unwrap_or(0);
    format!("sess-{}-{}", std::process::id(), nanos)
}

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

    fn unique_id(prefix: &str) -> String {
        let nanos = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .map(|d| d.as_nanos())
            .unwrap_or(0);
        format!("{}-{}-{}", prefix, std::process::id(), nanos)
    }

    // [unit->REQ-MSG-3] start registers a reachable, resolvable perch.
    #[test]
    fn start_registers_resolvable_perch() {
        crate::test_home::isolate();
        let id = unique_id("test-ready-start");
        let (agent, backlog) = ReadyAgent::start(&id).unwrap();
        assert!(backlog.is_empty());

        // Resolvable + online from a sender's point of view.
        assert!(deliver::is_online(&id));
        assert!(deliver::perch_exists(&id));

        // info.json + ready marker on disk.
        let perch_path = perch::resolve_perch_path(&id, ParentHint::Infer);
        assert!(info::read_info(&perch_path).is_some());
        assert!(perch::resolve_ready_file(&id, ParentHint::Infer).exists());
        drop(agent);
    }

    // [unit->REQ-MSG-3] backlog spooled while offline drains on startup.
    #[test]
    fn backlog_drains_on_start() {
        crate::test_home::isolate();
        let owlery = perch::owlery_dir();
        let id = unique_id("test-ready-backlog");
        // Pre-create an offline perch and spool a message to it.
        let perch_path = perch::resolve_perch_path(&id, ParentHint::Infer);
        fs::create_dir_all(&perch_path).unwrap();
        let rec = InfoJson::new(&id, "now", std::process::id(), "sess", "ready_agent");
        info::write_info(&perch_path, &rec).unwrap();
        assert_eq!(
            deliver::deliver(&id, "sender", "while you slept", &owlery),
            deliver::DeliveryPath::Spool
        );

        // Starting drains it.
        let (_agent, backlog) = ReadyAgent::start(&id).unwrap();
        assert_eq!(backlog.len(), 1);
        assert!(backlog[0].contains("while you slept"));
    }

    // [unit->REQ-MSG-3] a live TCP message is received via recv().
    #[test]
    fn recv_gets_live_tcp_message() {
        crate::test_home::isolate();
        let owlery = perch::owlery_dir();
        let id = unique_id("test-ready-recv");
        let (agent, _) = ReadyAgent::start(&id).unwrap();

        let owlery2 = owlery.clone();
        let target = id.clone();
        let h = std::thread::spawn(move || {
            std::thread::sleep(Duration::from_millis(30));
            deliver::deliver(&target, "peer", "hello live", &owlery2)
        });

        let mut got = None;
        for _ in 0..25 {
            if let Some(m) = agent.recv() {
                got = Some(m);
                break;
            }
        }
        assert_eq!(h.join().unwrap(), deliver::DeliveryPath::Tcp);
        assert_eq!(got.as_deref(), Some("__REPLY_TO__:peer\nhello live\n"));
        drop(agent);
    }

    // [unit->REQ-HAZARD-SOFT-CLEANUP] soft_cleanup removes only the ready marker;
    // spool (deferred row) and info.json are preserved.
    #[test]
    fn soft_cleanup_preserves_spool_and_info() {
        crate::test_home::isolate();
        let id = unique_id("test-ready-soft");
        let (mut agent, _) = ReadyAgent::start(&id).unwrap();
        let perch_path = perch::resolve_perch_path(&id, ParentHint::Infer);

        // A deferred row survives the startup drain — use it to prove the spool
        // is preserved across cleanup.
        spool::spool_message_deferred_at(&perch_path, "hooker", "deferred body").unwrap();
        assert_eq!(spool::pending_count_at(&perch_path).unwrap(), 1);

        agent.soft_cleanup();

        // ready marker gone...
        assert!(!perch::resolve_ready_file(&id, ParentHint::Infer).exists());
        // ...but spool row + info.json preserved.
        assert_eq!(spool::pending_count_at(&perch_path).unwrap(), 1);
        assert!(info::read_info(&perch_path).is_some());
    }
}


ready.rs backlog/recv/drain_spool change shape. To keep spt-msg's surface uniform, I'll convert to `(from,body)` tuples there. Read the consumers — ring.rs, deliver.rs, relay.rs, startup.rs, worker.rs, cli.rs render sites.


1	//! `ring` — blocking send-and-wait-for-reply.
2	//!
3	//! Clean-room re-expression of the sister `owl/ring.rs` against the spt-store
4	//! API. Delivers a message, then blocks until the target replies (or a timeout
5	//! elapses). If the ringer has no live perch of its own, a **ephemeral** reply
6	//! perch is created so the reply can route back over TCP — and that ephemeral
7	//! perch must be cleaned up on **every** exit path.
8	//!
9	//! REQ-HAZARD-EPHEMERAL-CLEANUP (3.1) is the classic ring bug this guards: the
10	//! ephemeral perch leaks if cleanup only runs on the happy path. Here a single
11	//! [`cleanup_ephemeral`] is called before returning on success, on timeout, on
12	//! an offline/queued target, and on any error — verified by tests on each path.
13	// [impl->REQ-MSG-1]
14	// [impl->REQ-HAZARD-EPHEMERAL-CLEANUP]
15
16	use spt_store::info::{self, InfoJson};
17	use spt_store::perch::{self, ParentHint};
18	use spt_store::spool;
19	use std::fs;
20	use std::path::Path;
21	use std::time::{Duration, Instant};
22
23	use crate::deliver;
24	use crate::listener::PollListener;
25
26	/// How often the reply wait polls its listener / spool.
27	const REPLY_POLL: Duration = Duration::from_millis(50);
28
29	/// Outcome of a [`ring`].
30	#[derive(Debug, Clone, PartialEq, Eq)]
31	pub enum RingOutcome {
32	    /// Target replied within the timeout; carries the framed reply.
33	    Replied(String),
34	    /// Delivered live but no reply arrived before the timeout.
35	    TimedOut,
36	    /// Target offline — message spooled, not awaited.
37	    Queued,
38	    /// Delivered to the ringer's *existing* live listener; the reply will
39	    /// surface there, so this call does not block-wait (avoids racing it).
40	    Delivered,
41	    /// No perch for the target at all.
42	    NoPerch,
43	    /// Refused: empty body.
44	    Empty,
45	}
46
47	/// True if `from` already has its own live listener (so we must not create an
48	/// ephemeral perch or block-wait — the reply will land on that listener). Routes
49	/// through the single liveness resolver (2.5): daemon-authoritative for hosted
50	/// perches, per-pid for external/interim ones — never an inline pid check.
51	// [impl->REQ-HAZARD-DAEMON-HOSTED-LIVENESS]
52	fn from_has_live_perch(from: &str) -> bool {
53	    if !perch::resolve_ready_file(from, ParentHint::Infer).exists() {
54	        return false;
55	    }
56	    let perch_path = perch::resolve_perch_path(from, ParentHint::Infer);
57	    spt_store::liveness::is_perch_alive(&perch_path)
58	}
59
60	/// Send `body` to `target` (as `from`) and block up to `timeout` for a reply.
61	// [impl->REQ-MSG-1]
62	// [impl->REQ-HAZARD-EPHEMERAL-CLEANUP]
63	pub fn ring(target: &str, from: &str, body: &str, timeout: Duration, owlery: &Path) -> RingOutcome {
64	    let trimmed = body.trim();
65	    if trimmed.is_empty() {
66	        return RingOutcome::Empty; // no perch created yet — nothing to clean.
67	    }
68	    if !deliver::is_online(target) && !deliver::perch_exists(target) {
69	        return RingOutcome::NoPerch;
70	    }
71
72	    // If the ringer already has a live listener, deliver and let the reply land
73	    // there — blocking here would race that listener for the inbox.
74	    if from_has_live_perch(from) {
75	        deliver::deliver(target, from, trimmed, owlery);
76	        return RingOutcome::Delivered;
77	    }
78
79	    // --- Ephemeral reply perch. From here, EVERY return path cleans up. ---
80	    let reply_listener = match setup_ephemeral(from) {
81	        Ok(l) => l,
82	        Err(_) => {
83	            cleanup_ephemeral(from); // partial setup may have created the dir
84	            return RingOutcome::TimedOut; // treat setup failure as no-reply
85	        }
86	    };
87
88	    // Deliver. If the target turned out offline, the message spooled — don't
89	    // wait; clean up and report Queued.
90	    let path = deliver::deliver(target, from, trimmed, owlery);
91	    if path == deliver::DeliveryPath::Spool {
92	        drop(reply_listener);
93	        cleanup_ephemeral(from);
94	        return RingOutcome::Queued;
95	    }
96
97	    // Block for the reply: poll the listener and the spool until the deadline.
98	    let perch_path = perch::resolve_perch_path(from, ParentHint::Infer);
99	    let deadline = Instant::now() + timeout;
100	    let outcome = loop {
101	        if let Some(msg) = reply_listener.check_message_blocking() {
102	            break RingOutcome::Replied(msg);
103	        }
104	        if let Ok(Some(msg)) = spool::drain_one_at(&perch_path) {
105	            break RingOutcome::Replied(msg);
106	        }
107	        if Instant::now() >= deadline {
108	            break RingOutcome::TimedOut;
109	        }
110	        std::thread::sleep(REPLY_POLL);
111	    };
112
113	    drop(reply_listener); // unregisters the address before we delete the perch
114	    cleanup_ephemeral(from);
115	    outcome
116	}
117
118	/// Create an ephemeral reply perch for `from` (dir + info.json + ready marker)
119	/// and bind its listener. The perch looks online so the target's reply routes
120	/// back over TCP.
121	fn setup_ephemeral(from: &str) -> Result<PollListener, String> {
122	    let perch_path = perch::resolve_perch_path(from, ParentHint::Infer);
123	    fs::create_dir_all(&perch_path).map_err(|e| format!("create ephemeral perch: {}", e))?;
124	    let rec = InfoJson::new(from, "once", std::process::id(), "ring", "ready_agent");
125	    info::write_info(&perch_path, &rec).map_err(|e| format!("write info.json: {}", e))?;
126	    fs::write(perch::resolve_ready_file(from, ParentHint::Infer), "")
127	        .map_err(|e| format!("write ready marker: {}", e))?;
128	    PollListener::bind_blocking(from, REPLY_POLL)
129	}
130
131	/// Remove an ephemeral perch entirely: ready marker, info.json, spool db, then
132	/// the perch dir. Best-effort + idempotent (every leftover is `let _ =`).
133	fn cleanup_ephemeral(from: &str) {
134	    let perch_path = perch::resolve_perch_path(from, ParentHint::Infer);
135	    let _ = fs::remove_file(perch::resolve_ready_file(from, ParentHint::Infer));
136	    let _ = fs::remove_file(perch::resolve_info_file(from, ParentHint::Infer));
137	    let _ = fs::remove_file(perch::resolve_spool_db(from, ParentHint::Infer));
138	    let _ = fs::remove_dir(&perch_path);
139	}
140
141	#[cfg(test)]
142	mod tests {
143	    use super::*;
144	    use crate::ready::ReadyAgent;
145
146	    fn unique_id(prefix: &str) -> String {
147	        let nanos = std::time::SystemTime::now()
148	            .duration_since(std::time::UNIX_EPOCH)
149	            .map(|d| d.as_nanos())
150	            .unwrap_or(0);
151	        format!("{}-{}-{}", prefix, std::process::id(), nanos)
152	    }
153
154	    fn perch_gone(id: &str) -> bool {
155	        !perch::resolve_perch_path(id, ParentHint::Infer).exists()
156	    }
157
158	    // [unit->REQ-MSG-1] ring delivers and returns the reply; ephemeral perch is
159	    // gone afterward (3.1 success path).
160	    // [unit->REQ-HAZARD-EPHEMERAL-CLEANUP]
161	    #[test]
162	    fn ring_gets_reply_and_cleans_up() {
163	        crate::test_home::isolate();
164	        let owlery = perch::owlery_dir();
165	        let target = unique_id("test-ring-target");
166	        let from = unique_id("test-ring-from");
167
168	        // Target: a ready agent that replies to whoever rings it.
169	        let (agent, _) = ReadyAgent::start(&target).unwrap();
170	        let owlery2 = owlery.clone();
171	        let h = std::thread::spawn(move || {
172	            // Wait for the incoming ring, parse __REPLY_TO__, reply to sender.
173	            loop {
174	                if let Some(msg) = agent.recv() {
175	                    let replier = agent.id().to_string();
176	                    let sender = msg
177	                        .lines()
178	                        .next()
179	                        .and_then(|l| l.strip_prefix("__REPLY_TO__:"))
180	                        .unwrap_or("")
181	                        .to_string();
182	                    deliver::deliver(&sender, &replier, "pong", &owlery2);
183	                    break;
184	                }
185	            }
186	        });
187
188	        let outcome = ring(&target, &from, "ping", Duration::from_secs(5), &owlery);
189	        h.join().unwrap();
190	        match outcome {
191	            RingOutcome::Replied(reply) => assert!(reply.contains("pong")),
192	            other => panic!("expected Replied, got {:?}", other),
193	        }
194	        assert!(perch_gone(&from), "ephemeral perch leaked on success path");
195	    }
196
197	    // [unit->REQ-HAZARD-EPHEMERAL-CLEANUP] timeout path also cleans up.
198	    #[test]
199	    fn ring_timeout_cleans_up() {
200	        crate::test_home::isolate();
201	        let owlery = perch::owlery_dir();
202	        let target = unique_id("test-ring-silent");
203	        let from = unique_id("test-ring-from2");
204
205	        // Silent target: online, never replies.
206	        let (_agent, _) = ReadyAgent::start(&target).unwrap();
207
208	        let outcome = ring(
209	            &target,
210	            &from,
211	            "anyone?",
212	            Duration::from_millis(200),
213	            &owlery,
214	        );
215	        assert_eq!(outcome, RingOutcome::TimedOut);
216	        assert!(perch_gone(&from), "ephemeral perch leaked on timeout path");
217	    }
218
219	    // [unit->REQ-HAZARD-EPHEMERAL-CLEANUP] offline target => Queued, no wait,
220	    // ephemeral perch cleaned.
221	    #[test]
222	    fn ring_offline_target_queues_and_cleans_up() {
223	        crate::test_home::isolate();
224	        let owlery = perch::owlery_dir();
225	        let target = unique_id("test-ring-offline");
226	        let from = unique_id("test-ring-from3");
227
228	        // Offline perch (exists, no listener).
229	        let perch_path = perch::resolve_perch_path(&target, ParentHint::Infer);
230	        fs::create_dir_all(&perch_path).unwrap();
231	        let rec = InfoJson::new(&target, "now", std::process::id(), "sess", "ready_agent");
232	        info::write_info(&perch_path, &rec).unwrap();
233
234	        let outcome = ring(&target, &from, "you up?", Duration::from_secs(5), &owlery);
235	        assert_eq!(outcome, RingOutcome::Queued);
236	        assert!(perch_gone(&from), "ephemeral perch leaked on queued path");
237	        // The message did spool to the target.
238	        assert_eq!(spool::pending_count_at(&perch_path).unwrap(), 1);
239	    }
240
241	    // [unit->REQ-MSG-1] no target perch => NoPerch, nothing created.
242	    #[test]
243	    fn ring_no_perch() {
244	        crate::test_home::isolate();
245	        let owlery = perch::owlery_dir();
246	        let target = unique_id("test-ring-absent");
247	        let from = unique_id("test-ring-from4");
248	        assert_eq!(
249	            ring(&target, &from, "hi", Duration::from_secs(1), &owlery),
250	            RingOutcome::NoPerch
251	        );
252	        assert!(perch_gone(&from));
253	    }
254	}
255

//! `ring` — blocking send-and-wait-for-reply.
//!
//! Clean-room re-expression of the sister `owl/ring.rs` against the spt-store
//! API. Delivers a message, then blocks until the target replies (or a timeout
//! elapses). If the ringer has no live perch of its own, a **ephemeral** reply
//! perch is created so the reply can route back over TCP — and that ephemeral
//! perch must be cleaned up on **every** exit path.
//!
//! REQ-HAZARD-EPHEMERAL-CLEANUP (3.1) is the classic ring bug this guards: the
//! ephemeral perch leaks if cleanup only runs on the happy path. Here a single
//! [`cleanup_ephemeral`] is called before returning on success, on timeout, on
//! an offline/queued target, and on any error — verified by tests on each path.
// [impl->REQ-MSG-1]
// [impl->REQ-HAZARD-EPHEMERAL-CLEANUP]

use spt_store::info::{self, InfoJson};
use spt_store::perch::{self, ParentHint};
use spt_store::spool;
use std::fs;
use std::path::Path;
use std::time::{Duration, Instant};

use crate::deliver;
use crate::listener::PollListener;

/// How often the reply wait polls its listener / spool.
const REPLY_POLL: Duration = Duration::from_millis(50);

/// Outcome of a [`ring`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RingOutcome {
    /// Target replied within the timeout; carries the framed reply.
    Replied(String),
    /// Delivered live but no reply arrived before the timeout.
    TimedOut,
    /// Target offline — message spooled, not awaited.
    Queued,
    /// Delivered to the ringer's *existing* live listener; the reply will
    /// surface there, so this call does not block-wait (avoids racing it).
    Delivered,
    /// No perch for the target at all.
    NoPerch,
    /// Refused: empty body.
    Empty,
}

/// True if `from` already has its own live listener (so we must not create an
/// ephemeral perch or block-wait — the reply will land on that listener). Routes
/// through the single liveness resolver (2.5): daemon-authoritative for hosted
/// perches, per-pid for external/interim ones — never an inline pid check.
// [impl->REQ-HAZARD-DAEMON-HOSTED-LIVENESS]
fn from_has_live_perch(from: &str) -> bool {
    if !perch::resolve_ready_file(from, ParentHint::Infer).exists() {
        return false;
    }
    let perch_path = perch::resolve_perch_path(from, ParentHint::Infer);
    spt_store::liveness::is_perch_alive(&perch_path)
}

/// Send `body` to `target` (as `from`) and block up to `timeout` for a reply.
// [impl->REQ-MSG-1]
// [impl->REQ-HAZARD-EPHEMERAL-CLEANUP]
pub fn ring(target: &str, from: &str, body: &str, timeout: Duration, owlery: &Path) -> RingOutcome {
    let trimmed = body.trim();
    if trimmed.is_empty() {
        return RingOutcome::Empty; // no perch created yet — nothing to clean.
    }
    if !deliver::is_online(target) && !deliver::perch_exists(target) {
        return RingOutcome::NoPerch;
    }

    // If the ringer already has a live listener, deliver and let the reply land
    // there — blocking here would race that listener for the inbox.
    if from_has_live_perch(from) {
        deliver::deliver(target, from, trimmed, owlery);
        return RingOutcome::Delivered;
    }

    // --- Ephemeral reply perch. From here, EVERY return path cleans up. ---
    let reply_listener = match setup_ephemeral(from) {
        Ok(l) => l,
        Err(_) => {
            cleanup_ephemeral(from); // partial setup may have created the dir
            return RingOutcome::TimedOut; // treat setup failure as no-reply
        }
    };

    // Deliver. If the target turned out offline, the message spooled — don't
    // wait; clean up and report Queued.
    let path = deliver::deliver(target, from, trimmed, owlery);
    if path == deliver::DeliveryPath::Spool {
        drop(reply_listener);
        cleanup_ephemeral(from);
        return RingOutcome::Queued;
    }

    // Block for the reply: poll the listener and the spool until the deadline.
    let perch_path = perch::resolve_perch_path(from, ParentHint::Infer);
    let deadline = Instant::now() + timeout;
    let outcome = loop {
        if let Some(msg) = reply_listener.check_message_blocking() {
            break RingOutcome::Replied(msg);
        }
        if let Ok(Some(msg)) = spool::drain_one_at(&perch_path) {
            break RingOutcome::Replied(msg);
        }
        if Instant::now() >= deadline {
            break RingOutcome::TimedOut;
        }
        std::thread::sleep(REPLY_POLL);
    };

    drop(reply_listener); // unregisters the address before we delete the perch
    cleanup_ephemeral(from);
    outcome
}

/// Create an ephemeral reply perch for `from` (dir + info.json + ready marker)
/// and bind its listener. The perch looks online so the target's reply routes
/// back over TCP.
fn setup_ephemeral(from: &str) -> Result<PollListener, String> {
    let perch_path = perch::resolve_perch_path(from, ParentHint::Infer);
    fs::create_dir_all(&perch_path).map_err(|e| format!("create ephemeral perch: {}", e))?;
    let rec = InfoJson::new(from, "once", std::process::id(), "ring", "ready_agent");
    info::write_info(&perch_path, &rec).map_err(|e| format!("write info.json: {}", e))?;
    fs::write(perch::resolve_ready_file(from, ParentHint::Infer), "")
        .map_err(|e| format!("write ready marker: {}", e))?;
    PollListener::bind_blocking(from, REPLY_POLL)
}

/// Remove an ephemeral perch entirely: ready marker, info.json, spool db, then
/// the perch dir. Best-effort + idempotent (every leftover is `let _ =`).
fn cleanup_ephemeral(from: &str) {
    let perch_path = perch::resolve_perch_path(from, ParentHint::Infer);
    let _ = fs::remove_file(perch::resolve_ready_file(from, ParentHint::Infer));
    let _ = fs::remove_file(perch::resolve_info_file(from, ParentHint::Infer));
    let _ = fs::remove_file(perch::resolve_spool_db(from, ParentHint::Infer));
    let _ = fs::remove_dir(&perch_path);
}

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

    fn unique_id(prefix: &str) -> String {
        let nanos = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .map(|d| d.as_nanos())
            .unwrap_or(0);
        format!("{}-{}-{}", prefix, std::process::id(), nanos)
    }

    fn perch_gone(id: &str) -> bool {
        !perch::resolve_perch_path(id, ParentHint::Infer).exists()
    }

    // [unit->REQ-MSG-1] ring delivers and returns the reply; ephemeral perch is
    // gone afterward (3.1 success path).
    // [unit->REQ-HAZARD-EPHEMERAL-CLEANUP]
    #[test]
    fn ring_gets_reply_and_cleans_up() {
        crate::test_home::isolate();
        let owlery = perch::owlery_dir();
        let target = unique_id("test-ring-target");
        let from = unique_id("test-ring-from");

        // Target: a ready agent that replies to whoever rings it.
        let (agent, _) = ReadyAgent::start(&target).unwrap();
        let owlery2 = owlery.clone();
        let h = std::thread::spawn(move || {
            // Wait for the incoming ring, parse __REPLY_TO__, reply to sender.
            loop {
                if let Some(msg) = agent.recv() {
                    let replier = agent.id().to_string();
                    let sender = msg
                        .lines()
                        .next()
                        .and_then(|l| l.strip_prefix("__REPLY_TO__:"))
                        .unwrap_or("")
                        .to_string();
                    deliver::deliver(&sender, &replier, "pong", &owlery2);
                    break;
                }
            }
        });

        let outcome = ring(&target, &from, "ping", Duration::from_secs(5), &owlery);
        h.join().unwrap();
        match outcome {
            RingOutcome::Replied(reply) => assert!(reply.contains("pong")),
            other => panic!("expected Replied, got {:?}", other),
        }
        assert!(perch_gone(&from), "ephemeral perch leaked on success path");
    }

    // [unit->REQ-HAZARD-EPHEMERAL-CLEANUP] timeout path also cleans up.
    #[test]
    fn ring_timeout_cleans_up() {
        crate::test_home::isolate();
        let owlery = perch::owlery_dir();
        let target = unique_id("test-ring-silent");
        let from = unique_id("test-ring-from2");

        // Silent target: online, never replies.
        let (_agent, _) = ReadyAgent::start(&target).unwrap();

        let outcome = ring(
            &target,
            &from,
            "anyone?",
            Duration::from_millis(200),
            &owlery,
        );
        assert_eq!(outcome, RingOutcome::TimedOut);
        assert!(perch_gone(&from), "ephemeral perch leaked on timeout path");
    }

    // [unit->REQ-HAZARD-EPHEMERAL-CLEANUP] offline target => Queued, no wait,
    // ephemeral perch cleaned.
    #[test]
    fn ring_offline_target_queues_and_cleans_up() {
        crate::test_home::isolate();
        let owlery = perch::owlery_dir();
        let target = unique_id("test-ring-offline");
        let from = unique_id("test-ring-from3");

        // Offline perch (exists, no listener).
        let perch_path = perch::resolve_perch_path(&target, ParentHint::Infer);
        fs::create_dir_all(&perch_path).unwrap();
        let rec = InfoJson::new(&target, "now", std::process::id(), "sess", "ready_agent");
        info::write_info(&perch_path, &rec).unwrap();

        let outcome = ring(&target, &from, "you up?", Duration::from_secs(5), &owlery);
        assert_eq!(outcome, RingOutcome::Queued);
        assert!(perch_gone(&from), "ephemeral perch leaked on queued path");
        // The message did spool to the target.
        assert_eq!(spool::pending_count_at(&perch_path).unwrap(), 1);
    }

    // [unit->REQ-MSG-1] no target perch => NoPerch, nothing created.
    #[test]
    fn ring_no_perch() {
        crate::test_home::isolate();
        let owlery = perch::owlery_dir();
        let target = unique_id("test-ring-absent");
        let from = unique_id("test-ring-from4");
        assert_eq!(
            ring(&target, &from, "hi", Duration::from_secs(1), &owlery),
            RingOutcome::NoPerch
        );
        assert!(perch_gone(&from));
    }
}


80	pub fn is_online(target: &str) -> bool {
81	    if !perch::resolve_ready_file(target, ParentHint::Infer).exists() {
82	        return false;
83	    }
84	    let perch_path = perch::resolve_perch_path(target, ParentHint::Infer);
85	    spt_store::liveness::is_perch_alive(&perch_path)
86	}
87
88	/// The live-TCP leg of [`deliver`], alone: resolve the address (stale-clean
89	/// first) and attempt one framed TCP write. `true` only on a completed write —
90	/// **TCP success is the only thing that counts as a live delivery.** No spool
91	/// side effect on failure; callers that need their own fallback discipline
92	/// (the WAN receive funnel's atomic op-id+spool insert, M4-D5a) branch on the
93	/// return instead of re-implementing the resolve/connect/recycle-guard steps.
94	// [impl->REQ-MSG-1]
95	// [impl->REQ-HAZARD-WINDOWS-PID-RECYCLE]
96	pub fn deliver_tcp(target: &str, from: &str, body: &str, owlery: &Path) -> bool {
97	    if let Some(addr_str) = registry::resolve_address(target, owlery) {
98	        if let Ok(addr) = addr_str.parse::<SocketAddr>() {
99	            match TcpStream::connect_timeout(&addr, CONNECT_TIMEOUT) {
100	                Ok(mut stream) => {
101	                    if wire::write_message(&mut stream, from, body).is_ok() {
102	                        return true;
103	                    }
104	                    // Write failed mid-stream — the caller falls back.
105	                }
106	                Err(_) => {
107	                    // Connect refused/timed out. Recycled-pid guard (5.1): if the
108	                    // owning pid is confirmed dead, drop the stale row. If the pid
109	                    // is "alive" (possibly recycled to an unrelated process), the
110	                    // refused connect already prevented misdelivery — the caller
111	                    // falls back.
112	                    if let Some(pid) = registry::lookup_pid(target, owlery) {
113	                        if !proc::is_process_alive(pid) {
114	                            let _ = registry::unregister_address(target, owlery);
115	                        }
116	                    }
117	                }
118	            }
119	        }
120	    }
121	    false
122	}
123
124	/// Core delivery primitive: TCP-first, spool-fallback. Returns which path the
125	/// message took. `from` is written into the `__REPLY_TO__` line (empty =
126	/// anonymous). Used by `send`, `ring`, and reply routing alike.
127	// [impl->REQ-MSG-1]
128	// [impl->REQ-HAZARD-INBOX-NO-DOUBLE]
129	pub fn deliver(target: &str, from: &str, body: &str, owlery: &Path) -> DeliveryPath {
130	    // 1. Try a live TCP delivery. On success, return WITHOUT spooling (4.5: no
131	    //    double-delivery).
132	    if deliver_tcp(target, from, body, owlery) {
133	        return DeliveryPath::Tcp;
134	    }
135
136	    // 2. Spool fallback — the poll drain delivers it when the target starts.
137	    let perch_path = perch::resolve_perch_path(target, ParentHint::Infer);
138	    let _ = spool::spool_message_at(&perch_path, from, body);
139	    DeliveryPath::Spool
140	}
141
142	/// High-level fire-and-forget send. Validates the perch exists and the body is
143	/// non-empty, delivers, and maps the delivery path to a [`SendOutcome`]. No
144	/// I/O, no exit — the caller renders the outcome.
145	///
146	/// Reply routing is *this same call* with `from` set to the replier's id: the
147	/// `__REPLY_TO__` line the original message carried names the target, and the
148	/// reply's own `from` lets the original sender reply back in turn.
149	// [impl->REQ-MSG-1]
150	pub fn send(target: &str, from: &str, body: &str, owlery: &Path) -> SendOutcome {
151	    let trimmed = body.trim();
152	    if trimmed.is_empty() {
153	        return SendOutcome::Empty;
154	    }
155	    // An offline-but-existing perch is still a valid spool target; only a
156	    // wholly absent perch is NoPerch.
157	    if !is_online(target) && !perch_exists(target) {
158	        return SendOutcome::NoPerch;
159	    }
160	    match deliver(target, from, trimmed, owlery) {
161	        DeliveryPath::Tcp => SendOutcome::Sent,
162	        DeliveryPath::Spool => SendOutcome::Queued,
163	    }
164	}
165
166	/// Deferred send (the hook channel): spool-only, **no TCP, no listener wake**.
167	/// The row is tagged `deferred=1` so the live event-stream drain
168	/// (`drain_non_deferred_at`) skips it — it surfaces only when a hook consumer
169	/// drains everything (`drain_all_at` / `peek_all_at`) on its natural cadence.
170	/// This is how a sender hands a message to an agent it must not interrupt.
171	///
172	/// Always `Queued` on a valid non-empty body to an existing perch — deferred
173	/// delivery never reports `Sent` (there is no live handshake).
174	// [impl->REQ-MSG-1]
175	pub fn send_deferred(target: &str, from: &str, body: &str) -> SendOutcome {
176	    let trimmed = body.trim();
177	    if trimmed.is_empty() {
178	        return SendOutcome::Empty;
179	    }
180	    if !perch_exists(target) {
181	        return SendOutcome::NoPerch;
182	    }
183	    let perch_path = perch::resolve_perch_path(target, ParentHint::Infer);
184	    let _ = spool::spool_message_deferred_at(&perch_path, from, trimmed);
185	    SendOutcome::Queued
186	}
187
188	#[cfg(test)]
189	mod tests {
190	    use super::*;
191	    use crate::listener::PollListener;
192	    use spt_store::info;
193	    use std::net::SocketAddr;
194
195	    fn unique_id(prefix: &str) -> String {
196	        let nanos = std::time::SystemTime::now()
197	            .duration_since(std::time::UNIX_EPOCH)
198	            .map(|d| d.as_nanos())
199	            .unwrap_or(0);
200	        format!("{}-{}-{}", prefix, std::process::id(), nanos)
201	    }
202
203	    // [unit->REQ-MSG-1] online target receives via TCP; no spool row is written
204	    // (4.5: TCP xor spool — never double-delivered).
205	    // [unit->REQ-HAZARD-INBOX-NO-DOUBLE]
206	    #[test]
207	    fn online_delivers_via_tcp_without_spooling() {
208	        crate::test_home::isolate();
209	        let owlery = perch::owlery_dir();
210	        let id = unique_id("test-online");
211	        let listener = PollListener::bind(&id).unwrap();
212
213	        let path = deliver(&id, "sender", "live msg", &owlery);
214	        assert_eq!(path, DeliveryPath::Tcp);
215
216	        // Receiver got it over the wire.
217	        let mut got = None;
218	        for _ in 0..200 {
219	            if let Some(m) = listener.check_message() {

pub fn is_online(target: &str) -> bool {
    if !perch::resolve_ready_file(target, ParentHint::Infer).exists() {
        return false;
    }
    let perch_path = perch::resolve_perch_path(target, ParentHint::Infer);
    spt_store::liveness::is_perch_alive(&perch_path)
}

/// The live-TCP leg of [`deliver`], alone: resolve the address (stale-clean
/// first) and attempt one framed TCP write. `true` only on a completed write —
/// **TCP success is the only thing that counts as a live delivery.** No spool
/// side effect on failure; callers that need their own fallback discipline
/// (the WAN receive funnel's atomic op-id+spool insert, M4-D5a) branch on the
/// return instead of re-implementing the resolve/connect/recycle-guard steps.
// [impl->REQ-MSG-1]
// [impl->REQ-HAZARD-WINDOWS-PID-RECYCLE]
pub fn deliver_tcp(target: &str, from: &str, body: &str, owlery: &Path) -> bool {
    if let Some(addr_str) = registry::resolve_address(target, owlery) {
        if let Ok(addr) = addr_str.parse::<SocketAddr>() {
            match TcpStream::connect_timeout(&addr, CONNECT_TIMEOUT) {
                Ok(mut stream) => {
                    if wire::write_message(&mut stream, from, body).is_ok() {
                        return true;
                    }
                    // Write failed mid-stream — the caller falls back.
                }
                Err(_) => {
                    // Connect refused/timed out. Recycled-pid guard (5.1): if the
                    // owning pid is confirmed dead, drop the stale row. If the pid
                    // is "alive" (possibly recycled to an unrelated process), the
                    // refused connect already prevented misdelivery — the caller
                    // falls back.
                    if let Some(pid) = registry::lookup_pid(target, owlery) {
                        if !proc::is_process_alive(pid) {
                            let _ = registry::unregister_address(target, owlery);
                        }
                    }
                }
            }
        }
    }
    false
}

/// Core delivery primitive: TCP-first, spool-fallback. Returns which path the
/// message took. `from` is written into the `__REPLY_TO__` line (empty =
/// anonymous). Used by `send`, `ring`, and reply routing alike.
// [impl->REQ-MSG-1]
// [impl->REQ-HAZARD-INBOX-NO-DOUBLE]
pub fn deliver(target: &str, from: &str, body: &str, owlery: &Path) -> DeliveryPath {
    // 1. Try a live TCP delivery. On success, return WITHOUT spooling (4.5: no
    //    double-delivery).
    if deliver_tcp(target, from, body, owlery) {
        return DeliveryPath::Tcp;
    }

    // 2. Spool fallback — the poll drain delivers it when the target starts.
    let perch_path = perch::resolve_perch_path(target, ParentHint::Infer);
    let _ = spool::spool_message_at(&perch_path, from, body);
    DeliveryPath::Spool
}

/// High-level fire-and-forget send. Validates the perch exists and the body is
/// non-empty, delivers, and maps the delivery path to a [`SendOutcome`]. No
/// I/O, no exit — the caller renders the outcome.
///
/// Reply routing is *this same call* with `from` set to the replier's id: the
/// `__REPLY_TO__` line the original message carried names the target, and the
/// reply's own `from` lets the original sender reply back in turn.
// [impl->REQ-MSG-1]
pub fn send(target: &str, from: &str, body: &str, owlery: &Path) -> SendOutcome {
    let trimmed = body.trim();
    if trimmed.is_empty() {
        return SendOutcome::Empty;
    }
    // An offline-but-existing perch is still a valid spool target; only a
    // wholly absent perch is NoPerch.
    if !is_online(target) && !perch_exists(target) {
        return SendOutcome::NoPerch;
    }
    match deliver(target, from, trimmed, owlery) {
        DeliveryPath::Tcp => SendOutcome::Sent,
        DeliveryPath::Spool => SendOutcome::Queued,
    }
}

/// Deferred send (the hook channel): spool-only, **no TCP, no listener wake**.
/// The row is tagged `deferred=1` so the live event-stream drain
/// (`drain_non_deferred_at`) skips it — it surfaces only when a hook consumer
/// drains everything (`drain_all_at` / `peek_all_at`) on its natural cadence.
/// This is how a sender hands a message to an agent it must not interrupt.
///
/// Always `Queued` on a valid non-empty body to an existing perch — deferred
/// delivery never reports `Sent` (there is no live handshake).
// [impl->REQ-MSG-1]
pub fn send_deferred(target: &str, from: &str, body: &str) -> SendOutcome {
    let trimmed = body.trim();
    if trimmed.is_empty() {
        return SendOutcome::Empty;
    }
    if !perch_exists(target) {
        return SendOutcome::NoPerch;
    }
    let perch_path = perch::resolve_perch_path(target, ParentHint::Infer);
    let _ = spool::spool_message_deferred_at(&perch_path, from, trimmed);
    SendOutcome::Queued
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::listener::PollListener;
    use spt_store::info;
    use std::net::SocketAddr;

    fn unique_id(prefix: &str) -> String {
        let nanos = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .map(|d| d.as_nanos())
            .unwrap_or(0);
        format!("{}-{}-{}", prefix, std::process::id(), nanos)
    }

    // [unit->REQ-MSG-1] online target receives via TCP; no spool row is written
    // (4.5: TCP xor spool — never double-delivered).
    // [unit->REQ-HAZARD-INBOX-NO-DOUBLE]
    #[test]
    fn online_delivers_via_tcp_without_spooling() {
        crate::test_home::isolate();
        let owlery = perch::owlery_dir();
        let id = unique_id("test-online");
        let listener = PollListener::bind(&id).unwrap();

        let path = deliver(&id, "sender", "live msg", &owlery);
        assert_eq!(path, DeliveryPath::Tcp);

        // Receiver got it over the wire.
        let mut got = None;
        for _ in 0..200 {
            if let Some(m) = listener.check_message() {


ring.rs `RingOutcome::Replied(String)` and the recv-based reply parsing need the structural shape. Find downstream usage of Replied + the cli render/check sites + ReadyAgent backlog consumers.


crates\spt\tests\contract_e2e.rs:220:    // binds the perch, drains the backlog, and exits (--once).
crates\spt\tests\contract_e2e.rs:483:    // Listen TAKEs the seed from the daemon, binds, drains the backlog, exits.
crates\spt\src\cli.rs:95:    /// Drains the spooled backlog first; each received message prints to
crates\spt\src\cli.rs:2623:        RingOutcome::Replied(reply) => {
crates\spt\src\cli.rs:2664:    let (agent, backlog) = match ReadyAgent::start_homed(id, subnet) {
crates\spt\src\cli.rs:2677:    for msg in backlog {
crates\spt\src\cli.rs:2682:        if let Some(msg) = agent.recv() {
crates\spt\src\cli.rs:2685:        for msg in agent.drain_spool() {
crates\spt\src\cli.rs:2697:        if let Some(msg) = agent.recv() {
crates\spt\src\cli.rs:2700:        for msg in agent.drain_spool() {
crates\spt\src\cli.rs:2711:    for line in spt_msg::emit::render_event_lines(msg) {
crates\spt\src\cli.rs:4624:- The spooled backlog drains first; then each delivery prints to stdout as it
crates\spt\src\cli.rs:4636:- Drains the backlog, waits for one live delivery, then exits.
crates\spt\tests\quickstart_e2e.rs:7://! the backlog. If the documented flow stops working, this fails — the doc
crates\spt\tests\quickstart_e2e.rs:120:    // ---- sergey comes back: the backlog drains (`ready --once`) ----
crates\spt\tests\quickstart_e2e.rs:127:    // The backlog path renders the same envelope shape as the live path.
crates\spt\tests\quickstart_e2e.rs:131:        "backlog must drain enveloped on return, got {stdout:?}"
crates\spt\src\api\mod.rs:66:        /// Drain backlog + one receive cycle, then exit (testability).
crates\spt\src\api\startup.rs:298:    // undismissed notifs surface into the spool before the backlog drain, so
crates\spt\src\api\startup.rs:312:    // Drain the offline backlog first (non-deferred only; deferred rows wait
crates\spt\src\api\startup.rs:408:    for line in spt_msg::emit::render_event_lines(msg) {


Read cli.rs run_listen + ring render sections.


2610
2611	fn cmd_ring(target: String, from: Option<String>, timeout: u64) -> i32 {
2612	    let from = resolve_from(from);
2613	    if from.is_empty() {
2614	        eprintln!("NO_SELF: ring needs a sender id (--from <id> or $OWL_SESSION_ID)");
2615	        return 1;
2616	    }
2617	    // Trim stdin (REQ-HAZARD-ENVELOPE-CR-LINESAFE parity with cmd_notify/send).
2618	    let body = read_stdin();
2619	    let body = body.trim();
2620	    let owlery = perch::owlery_dir();
2621
2622	    match ring::ring(&target, &from, body, Duration::from_secs(timeout), &owlery) {
2623	        RingOutcome::Replied(reply) => {
2624	            print!("{}", reply);
2625	            let _ = std::io::stdout().flush();
2626	            0
2627	        }
2628	        RingOutcome::TimedOut => {
2629	            eprintln!("TIMEOUT:{} did not reply within {}s", target, timeout);
2630	            0
2631	        }
2632	        RingOutcome::Queued => {
2633	            ok_line(format!("QUEUED:{}", target));
2634	            0
2635	        }
2636	        RingOutcome::Delivered => {
2637	            ok_line(format!(
2638	                "SENT:{} (reply will arrive on your listener)",
2639	                target
2640	            ));
2641	            0
2642	        }
2643	        RingOutcome::NoPerch => {
2644	            eprintln!("NO_PERCH:{} is not listening", target);
2645	            1
2646	        }
2647	        RingOutcome::Empty => {
2648	            eprintln!(
2649	                "EMPTY_MSG: refusing to ring {} with an empty message",
2650	                target
2651	            );
2652	            1
2653	        }
2654	    }
2655	}
2656
2657	/// Run the listener loop (REQ-MSG-3 lifecycle, driven from the CLI). Blocks
2658	/// until the process is killed unless `once` is set. Each received message is
2659	/// printed framed to stdout; status goes to stderr.
2660	///
2661	/// Interim M1 model: the perch is reachable only while this process runs. M3's
2662	/// spt-daemon decouples registration from the live process.
2663	fn run_listen(id: &str, once: bool, subnet: Option<&str>) -> i32 {
2664	    let (agent, backlog) = match ReadyAgent::start_homed(id, subnet) {
2665	        Ok(pair) => pair,
2666	        Err(e) => {
2667	            eprintln!("READY_FAIL:{}: {}", id, e);
2668	            return 1;
2669	        }
2670	    };
2671	    // Event-driven advertisement (M8 decision 15, REQ-CONV-2): coming
2672	    // online wakes the pump's registry leg NOW — a peer sees this endpoint
2673	    // ready within seconds, not a cadence.
2674	    // [impl->REQ-CONV-2]
2675	    spt_daemon::registryhost::request_advertise_now();
2676	    ok_line(format!("READY:{}", id));
2677	    for msg in backlog {
2678	        emit(&msg);
2679	    }
2680
2681	    if once {
2682	        if let Some(msg) = agent.recv() {
2683	            emit(&msg);
2684	        }
2685	        for msg in agent.drain_spool() {
2686	            emit(&msg);
2687	        }
2688	        // Going offline (the --once listener ends here): drop the perch's
2689	        // ready state, then wake the pump so peers see the flip promptly.
2690	        // [impl->REQ-CONV-2]
2691	        drop(agent);
2692	        spt_daemon::registryhost::request_advertise_now();
2693	        return 0;
2694	    }
2695
2696	    loop {
2697	        if let Some(msg) = agent.recv() {
2698	            emit(&msg);
2699	        }
2700	        for msg in agent.drain_spool() {
2701	            emit(&msg);
2702	        }
2703	    }
2704	}
2705
2706	/// Emit one delivery to the listener stream: render the raw frame into its
2707	/// `<EVENT …>` envelope line(s) (typed envelopes pass through; oversized
2708	/// lines chunk into EVENT-PART) and print + flush. One delivery per emit.
2709	// [impl->REQ-MSG-4]
2710	fn emit(msg: &str) {
2711	    for line in spt_msg::emit::render_event_lines(msg) {
2712	        println!("{}", line);
2713	    }
2714	    let _ = std::io::stdout().flush();
2715	}
2716
2717	/// `spt endpoint list --local` — this node's perch roster (the pre-M8 bare
2718	/// `spt list` view, moved under the endpoint noun: M8 decision 2).
2719	// [impl->REQ-CLI-1]
2720	fn cmd_list_local() -> i32 {
2721	    let perches = roster::enumerate();
2722	    if perches.is_empty() {
2723	        eprintln!("(no perches)");
2724	        return 0;
2725	    }
2726	    for p in perches {
2727	        println!(
2728	            "{:<28} {:<14} {:<22} ready={} alive={}",
2729	            p.id,

fn cmd_ring(target: String, from: Option<String>, timeout: u64) -> i32 {
    let from = resolve_from(from);
    if from.is_empty() {
        eprintln!("NO_SELF: ring needs a sender id (--from <id> or $OWL_SESSION_ID)");
        return 1;
    }
    // Trim stdin (REQ-HAZARD-ENVELOPE-CR-LINESAFE parity with cmd_notify/send).
    let body = read_stdin();
    let body = body.trim();
    let owlery = perch::owlery_dir();

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

/// Run the listener loop (REQ-MSG-3 lifecycle, driven from the CLI). Blocks
/// until the process is killed unless `once` is set. Each received message is
/// printed framed to stdout; status goes to stderr.
///
/// Interim M1 model: the perch is reachable only while this process runs. M3's
/// spt-daemon decouples registration from the live process.
fn run_listen(id: &str, once: bool, subnet: Option<&str>) -> i32 {
    let (agent, backlog) = match ReadyAgent::start_homed(id, subnet) {
        Ok(pair) => pair,
        Err(e) => {
            eprintln!("READY_FAIL:{}: {}", id, e);
            return 1;
        }
    };
    // Event-driven advertisement (M8 decision 15, REQ-CONV-2): coming
    // online wakes the pump's registry leg NOW — a peer sees this endpoint
    // ready within seconds, not a cadence.
    // [impl->REQ-CONV-2]
    spt_daemon::registryhost::request_advertise_now();
    ok_line(format!("READY:{}", id));
    for msg in backlog {
        emit(&msg);
    }

    if once {
        if let Some(msg) = agent.recv() {
            emit(&msg);
        }
        for msg in agent.drain_spool() {
            emit(&msg);
        }
        // Going offline (the --once listener ends here): drop the perch's
        // ready state, then wake the pump so peers see the flip promptly.
        // [impl->REQ-CONV-2]
        drop(agent);
        spt_daemon::registryhost::request_advertise_now();
        return 0;
    }

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

/// Emit one delivery to the listener stream: render the raw frame into its
/// `<EVENT …>` envelope line(s) (typed envelopes pass through; oversized
/// lines chunk into EVENT-PART) and print + flush. One delivery per emit.
// [impl->REQ-MSG-4]
fn emit(msg: &str) {
    for line in spt_msg::emit::render_event_lines(msg) {
        println!("{}", line);
    }
    let _ = std::io::stdout().flush();
}

/// `spt endpoint list --local` — this node's perch roster (the pre-M8 bare
/// `spt list` view, moved under the endpoint noun: M8 decision 2).
// [impl->REQ-CLI-1]
fn cmd_list_local() -> i32 {
    let perches = roster::enumerate();
    if perches.is_empty() {
        eprintln!("(no perches)");
        return 0;
    }
    for p in perches {
        println!(
            "{:<28} {:<14} {:<22} ready={} alive={}",
            p.id,


Now I have the full picture. Editing ring.rs: `Replied` carries structural `(from,body)`.


Edit

pub enum RingOutcome {
    /// Target replied within the timeout; carries the framed reply.
    Replied(String),

pub enum RingOutcome {
    /// Target replied within the timeout; carries the structural `(from, body)`
    /// reply (the caller composes the `<EVENT>` envelope at its edge).
    Replied { from: String, body: String },


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

pub enum RingOutcome {
    /// Target replied within the timeout; carries the framed reply.
    Replied(String),

pub enum RingOutcome {
    /// Target replied within the timeout; carries the structural `(from, body)`
    /// reply (the caller composes the `<EVENT>` envelope at its edge).
    Replied { from: String, body: String },

-    /// Target replied within the timeout; carries the framed reply.

+    /// Target replied within the timeout; carries the structural `(from, body)`

+    /// reply (the caller composes the `<EVENT>` envelope at its edge).

+    Replied { from: String, body: String },

/// Delivered live but no reply arrived before the timeout.

/// Target offline — message spooled, not awaited.


    let outcome = loop {
        if let Some(msg) = reply_listener.check_message_blocking() {
            break RingOutcome::Replied(msg);
        }
        if let Ok(Some(msg)) = spool::drain_one_at(&perch_path) {
            break RingOutcome::Replied(msg);
        }

    let outcome = loop {
        if let Some((from, body)) = reply_listener.check_message_blocking() {
            break RingOutcome::Replied { from, body };
        }
        if let Ok(Some(msg)) = spool::drain_one_at(&perch_path) {
            break RingOutcome::Replied {
                from: msg.from,
                body: msg.body,
            };
        }


    let outcome = loop {
        if let Some(msg) = reply_listener.check_message_blocking() {
            break RingOutcome::Replied(msg);
        }
        if let Ok(Some(msg)) = spool::drain_one_at(&perch_path) {
            break RingOutcome::Replied(msg);
        }

    let outcome = loop {
        if let Some((from, body)) = reply_listener.check_message_blocking() {
            break RingOutcome::Replied { from, body };
        }
        if let Ok(Some(msg)) = spool::drain_one_at(&perch_path) {
            break RingOutcome::Replied {
                from: msg.from,
                body: msg.body,
            };
        }

let perch_path = perch::resolve_perch_path(from, ParentHint::Infer);

let deadline = Instant::now() + timeout;

-        if let Some(msg) = reply_listener.check_message_blocking() {

-            break RingOutcome::Replied(msg);

+        if let Some((from, body)) = reply_listener.check_message_blocking() {

+            break RingOutcome::Replied { from, body };

+            break RingOutcome::Replied {


Now ring.rs tests — handler uses `agent.recv()` structurally, and the assertion.


        let h = std::thread::spawn(move || {
            // Wait for the incoming ring, parse __REPLY_TO__, reply to sender.
            loop {
                if let Some(msg) = agent.recv() {
                    let replier = agent.id().to_string();
                    let sender = msg
                        .lines()
                        .next()
                        .and_then(|l| l.strip_prefix("__REPLY_TO__:"))
                        .unwrap_or("")
                        .to_string();
                    deliver::deliver(&sender, &replier, "pong", &owlery2);
                    break;
                }
            }
        });

        let h = std::thread::spawn(move || {
            // Wait for the incoming ring; the sender is the structural `from`.
            loop {
                if let Some((sender, _body)) = agent.recv() {
                    let replier = agent.id().to_string();
                    deliver::deliver(&sender, &replier, "pong", &owlery2);
                    break;
                }
            }
        });


        let h = std::thread::spawn(move || {
            // Wait for the incoming ring, parse __REPLY_TO__, reply to sender.
            loop {
                if let Some(msg) = agent.recv() {
                    let replier = agent.id().to_string();
                    let sender = msg
                        .lines()
                        .next()
                        .and_then(|l| l.strip_prefix("__REPLY_TO__:"))
                        .unwrap_or("")
                        .to_string();
                    deliver::deliver(&sender, &replier, "pong", &owlery2);
                    break;
                }
            }
        });

        let h = std::thread::spawn(move || {
            // Wait for the incoming ring; the sender is the structural `from`.
            loop {
                if let Some((sender, _body)) = agent.recv() {
                    let replier = agent.id().to_string();
                    deliver::deliver(&sender, &replier, "pong", &owlery2);
                    break;
                }
            }
        });

let (agent, _) = ReadyAgent::start(&target).unwrap();

-            // Wait for the incoming ring, parse __REPLY_TO__, reply to sender.

+            // Wait for the incoming ring; the sender is the structural `from`.

-                if let Some(msg) = agent.recv() {

+                if let Some((sender, _body)) = agent.recv() {

-                        .and_then(|l| l.strip_prefix("__REPLY_TO__:"))


        match outcome {
            RingOutcome::Replied(reply) => assert!(reply.contains("pong")),
            other => panic!("expected Replied, got {:?}", other),
        }

        match outcome {
            RingOutcome::Replied { from, body } => {
                assert_eq!(from, target, "reply carries the replier's structural from");
                assert!(body.contains("pong"));
            }
            other => panic!("expected Replied, got {:?}", other),
        }


        match outcome {
            RingOutcome::Replied(reply) => assert!(reply.contains("pong")),
            other => panic!("expected Replied, got {:?}", other),
        }

        match outcome {
            RingOutcome::Replied { from, body } => {
                assert_eq!(from, target, "reply carries the replier's structural from");
                assert!(body.contains("pong"));
            }
            other => panic!("expected Replied, got {:?}", other),
        }

let outcome = ring(&target, &from, "ping", Duration::from_secs(5), &owlery);

-            RingOutcome::Replied(reply) => assert!(reply.contains("pong")),

+            RingOutcome::Replied { from, body } => {

+                assert_eq!(from, target, "reply carries the replier's structural from");

+                assert!(body.contains("pong"));

assert!(perch_gone(&from), "ephemeral perch leaked on success path");


Now ready.rs: convert backlog/recv/drain_spool to `(String,String)` tuples.


    pub fn start(id: &str) -> Result<(ReadyAgent, Vec<String>), String> {
        Self::start_homed(id, None)
    }

    pub fn start(id: &str) -> Result<(ReadyAgent, Vec<(String, String)>), String> {
        Self::start_homed(id, None)
    }


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

    pub fn start(id: &str) -> Result<(ReadyAgent, Vec<String>), String> {
        Self::start_homed(id, None)
    }

    pub fn start(id: &str) -> Result<(ReadyAgent, Vec<(String, String)>), String> {
        Self::start_homed(id, None)
    }

/// drain the offline backlog. Returns the agent and any backlog messages

/// (oldest first) that were spooled while it was down.

-    pub fn start(id: &str) -> Result<(ReadyAgent, Vec<String>), String> {

+    pub fn start(id: &str) -> Result<(ReadyAgent, Vec<(String, String)>), String> {


    pub fn start_homed(
        id: &str,
        subnet: Option<&str>,
    ) -> Result<(ReadyAgent, Vec<String>), String> {

    pub fn start_homed(
        id: &str,
        subnet: Option<&str>,
    ) -> Result<(ReadyAgent, Vec<(String, String)>), String> {


    pub fn start_homed(
        id: &str,
        subnet: Option<&str>,
    ) -> Result<(ReadyAgent, Vec<String>), String> {

    pub fn start_homed(
        id: &str,
        subnet: Option<&str>,
    ) -> Result<(ReadyAgent, Vec<(String, String)>), String> {

-    ) -> Result<(ReadyAgent, Vec<String>), String> {

+    ) -> Result<(ReadyAgent, Vec<(String, String)>), String> {

// [impl->REQ-HAZARD-ID-CHARSET] guard the id charset at the creation seam.

let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);


        // Drain the offline backlog (non-deferred only; deferred rows survive
        // for the hook channel).
        let backlog = spool::drain_non_deferred_at(&perch_path)
            .map_err(|e| format!("Failed to drain spool backlog: {}", e))?;

        // Drain the offline backlog (non-deferred only; deferred rows survive
        // for the hook channel). Carried structurally as (from, body) — the
        // delivery edge composes the <EVENT> (ADR-0020).
        let backlog: Vec<(String, String)> = spool::drain_non_deferred_at(&perch_path)
            .map_err(|e| format!("Failed to drain spool backlog: {}", e))?
            .into_iter()
            .map(|m| (m.from, m.body))
            .collect();


        // Drain the offline backlog (non-deferred only; deferred rows survive
        // for the hook channel).
        let backlog = spool::drain_non_deferred_at(&perch_path)
            .map_err(|e| format!("Failed to drain spool backlog: {}", e))?;

        // Drain the offline backlog (non-deferred only; deferred rows survive
        // for the hook channel). Carried structurally as (from, body) — the
        // delivery edge composes the <EVENT> (ADR-0020).
        let backlog: Vec<(String, String)> = spool::drain_non_deferred_at(&perch_path)
            .map_err(|e| format!("Failed to drain spool backlog: {}", e))?
            .into_iter()
            .map(|m| (m.from, m.body))
            .collect();

let listener = PollListener::bind_blocking(id, RECV_POLL)?;

-        let backlog = spool::drain_non_deferred_at(&perch_path)

-            .map_err(|e| format!("Failed to drain spool backlog: {}", e))?;

+        // for the hook channel). Carried structurally as (from, body) — the

+        // delivery edge composes the <EVENT> (ADR-0020).

+        let backlog: Vec<(String, String)> = spool::drain_non_deferred_at(&perch_path)

+            .map_err(|e| format!("Failed to drain spool backlog: {}", e))?


    /// Wait up to one poll window for a live TCP message. Returns `None` on
    /// timeout (call again to keep listening).
    pub fn recv(&self) -> Option<String> {
        self.listener.check_message_blocking()
    }

    /// Drain any non-deferred spool rows that accrued since startup (e.g. a
    /// sender that spooled because it raced the listener bind, or a hook-channel
    /// arrival). Oldest first.
    pub fn drain_spool(&self) -> Vec<String> {
        spool::drain_non_deferred_at(&self.perch_path).unwrap_or_default()
    }

    /// Wait up to one poll window for a live TCP message, returning the
    /// structural `(from, body)`. Returns `None` on timeout (call again to keep
    /// listening).
    pub fn recv(&self) -> Option<(String, String)> {
        self.listener.check_message_blocking()
    }

    /// Drain any non-deferred spool rows that accrued since startup (e.g. a
    /// sender that spooled because it raced the listener bind, or a hook-channel
    /// arrival), as `(from, body)`. Oldest first.
    pub fn drain_spool(&self) -> Vec<(String, String)> {
        spool::drain_non_deferred_at(&self.perch_path)
            .unwrap_or_default()
            .into_iter()
            .map(|m| (m.from, m.body))
            .collect()
    }


    /// Wait up to one poll window for a live TCP message. Returns `None` on
    /// timeout (call again to keep listening).
    pub fn recv(&self) -> Option<String> {
        self.listener.check_message_blocking()
    }

    /// Drain any non-deferred spool rows that accrued since startup (e.g. a
    /// sender that spooled because it raced the listener bind, or a hook-channel
    /// arrival). Oldest first.
    pub fn drain_spool(&self) -> Vec<String> {
        spool::drain_non_deferred_at(&self.perch_path).unwrap_or_default()
    }

    /// Wait up to one poll window for a live TCP message, returning the
    /// structural `(from, body)`. Returns `None` on timeout (call again to keep
    /// listening).
    pub fn recv(&self) -> Option<(String, String)> {
        self.listener.check_message_blocking()
    }

    /// Drain any non-deferred spool rows that accrued since startup (e.g. a
    /// sender that spooled because it raced the listener bind, or a hook-channel
    /// arrival), as `(from, body)`. Oldest first.
    pub fn drain_spool(&self) -> Vec<(String, String)> {
        spool::drain_non_deferred_at(&self.perch_path)
            .unwrap_or_default()
            .into_iter()
            .map(|m| (m.from, m.body))
            .collect()
    }

-    /// Wait up to one poll window for a live TCP message. Returns `None` on

-    /// timeout (call again to keep listening).

-    pub fn recv(&self) -> Option<String> {

+    /// Wait up to one poll window for a live TCP message, returning the

+    /// structural `(from, body)`. Returns `None` on timeout (call again to keep

+    pub fn recv(&self) -> Option<(String, String)> {

-    pub fn drain_spool(&self) -> Vec<String> {

-        spool::drain_non_deferred_at(&self.perch_path).unwrap_or_default()

+    /// arrival), as `(from, body)`. Oldest first.

+    pub fn drain_spool(&self) -> Vec<(String, String)> {

+        spool::drain_non_deferred_at(&self.perch_path)

/// Soft teardown (6.2): remove the `ready` marker and unregister the


Now ready.rs tests (backlog assertion + recv assertion).


        let (_agent, backlog) = ReadyAgent::start(&id).unwrap();
        assert_eq!(backlog.len(), 1);
        assert!(backlog[0].contains("while you slept"));
    }

        let (_agent, backlog) = ReadyAgent::start(&id).unwrap();
        assert_eq!(backlog.len(), 1);
        assert_eq!(backlog[0].0, "sender");
        assert!(backlog[0].1.contains("while you slept"));
    }


        let (_agent, backlog) = ReadyAgent::start(&id).unwrap();
        assert_eq!(backlog.len(), 1);
        assert!(backlog[0].contains("while you slept"));
    }

        let (_agent, backlog) = ReadyAgent::start(&id).unwrap();
        assert_eq!(backlog.len(), 1);
        assert_eq!(backlog[0].0, "sender");
        assert!(backlog[0].1.contains("while you slept"));
    }

-        assert!(backlog[0].contains("while you slept"));

+        assert_eq!(backlog[0].0, "sender");

+        assert!(backlog[0].1.contains("while you slept"));

// [unit->REQ-MSG-3] a live TCP message is received via recv().


        assert_eq!(h.join().unwrap(), deliver::DeliveryPath::Tcp);
        assert_eq!(got.as_deref(), Some("__REPLY_TO__:peer\nhello live\n"));
        drop(agent);

        assert_eq!(h.join().unwrap(), deliver::DeliveryPath::Tcp);
        assert_eq!(got, Some(("peer".to_string(), "hello live".to_string())));
        drop(agent);


        assert_eq!(h.join().unwrap(), deliver::DeliveryPath::Tcp);
        assert_eq!(got.as_deref(), Some("__REPLY_TO__:peer\nhello live\n"));
        drop(agent);

        assert_eq!(h.join().unwrap(), deliver::DeliveryPath::Tcp);
        assert_eq!(got, Some(("peer".to_string(), "hello live".to_string())));
        drop(agent);

-        assert_eq!(got.as_deref(), Some("__REPLY_TO__:peer\nhello live\n"));

+        assert_eq!(got, Some(("peer".to_string(), "hello live".to_string())));


Now deliver.rs comments + tests. Read 216-330.


216	        // Receiver got it over the wire.
217	        let mut got = None;
218	        for _ in 0..200 {
219	            if let Some(m) = listener.check_message() {
220	                got = Some(m);
221	                break;
222	            }
223	            std::thread::sleep(Duration::from_millis(5));
224	        }
225	        assert_eq!(got.as_deref(), Some("__REPLY_TO__:sender\nlive msg\n"));
226
227	        // No spooled copy accrued for an online delivery.
228	        let perch_path = perch::resolve_perch_path(&id, ParentHint::Infer);
229	        assert_eq!(spool::pending_count_at(&perch_path).unwrap(), 0);
230	    }
231
232	    // [unit->REQ-MSG-1] offline target (perch exists, no listener) spools.
233	    #[test]
234	    fn offline_existing_perch_spools() {
235	        crate::test_home::isolate();
236	        let owlery = perch::owlery_dir();
237	        let id = unique_id("test-offline");
238	        // Create the perch dir + a minimal info.json so perch_exists is true,
239	        // but bind no listener (offline).
240	        let perch_path = perch::resolve_perch_path(&id, ParentHint::Infer);
241	        std::fs::create_dir_all(&perch_path).unwrap();
242	        let rec = info::InfoJson::new(&id, "now", std::process::id(), "sess", "ready_agent");
243	        info::write_info(&perch_path, &rec).unwrap();
244
245	        let path = deliver(&id, "sender", "queued msg", &owlery);
246	        assert_eq!(path, DeliveryPath::Spool);
247	        assert_eq!(spool::pending_count_at(&perch_path).unwrap(), 1);
248	    }
249
250	    // [unit->REQ-HAZARD-WINDOWS-PID-RECYCLE] a registry row whose pid is alive
251	    // (e.g. recycled to this test process) but whose address has no listener
252	    // must NOT misdeliver or hang — connect refuses and the message spools.
253	    #[test]
254	    fn recycled_pid_address_does_not_misdeliver() {
255	        crate::test_home::isolate();
256	        let owlery = perch::owlery_dir();
257	        let id = unique_id("test-recycle");
258	        let perch_path = perch::resolve_perch_path(&id, ParentHint::Infer);
259	        std::fs::create_dir_all(&perch_path).unwrap();
260	        let rec = info::InfoJson::new(&id, "now", std::process::id(), "sess", "ready_agent");
261	        info::write_info(&perch_path, &rec).unwrap();
262
263	        // Grab a port, then drop the listener so nothing is listening — but
264	        // register that dead address under THIS process's (alive) pid, mimicking
265	        // a recycled pid whose stale row survives the stale-clean sweep.
266	        let dead_addr: SocketAddr = {
267	            let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
268	            l.local_addr().unwrap()
269	        };
270	        registry::register_address(&id, &dead_addr, &owlery).unwrap();
271
272	        let path = deliver(&id, "sender", "must spool", &owlery);
273	        assert_eq!(path, DeliveryPath::Spool);
274	        assert_eq!(spool::pending_count_at(&perch_path).unwrap(), 1);
275	    }
276
277	    // [unit->REQ-MSG-1] send() maps NoPerch / Empty / Sent / Queued correctly.
278	    #[test]
279	    fn send_outcome_mapping() {
280	        crate::test_home::isolate();
281	        let owlery = perch::owlery_dir();
282	        let absent = unique_id("test-absent");
283	        assert_eq!(send(&absent, "me", "hi", &owlery), SendOutcome::NoPerch);
284
285	        let id = unique_id("test-send-online");
286	        let _listener = PollListener::bind(&id).unwrap();
287	        // Need a perch dir for perch_exists; bind doesn't create info.json, but
288	        // is_online is true (ready... actually bind writes no ready marker).
289	        // Create ready + info so the online path is exercised deterministically.
290	        let perch_path = perch::resolve_perch_path(&id, ParentHint::Infer);
291	        std::fs::create_dir_all(&perch_path).unwrap();
292	        std::fs::write(perch::resolve_ready_file(&id, ParentHint::Infer), "").unwrap();
293	        let rec = info::InfoJson::new(&id, "now", std::process::id(), "sess", "ready_agent");
294	        info::write_info(&perch_path, &rec).unwrap();
295
296	        assert_eq!(send(&id, "me", "   ", &owlery), SendOutcome::Empty);
297	        assert_eq!(send(&id, "me", "real", &owlery), SendOutcome::Sent);
298	    }
299
300	    // [unit->REQ-MSG-1] deferred send writes a deferred row: the live-stream
301	    // drain skips it, but a hook-style drain-all delivers it.
302	    #[test]
303	    fn deferred_send_skips_stream_drain_survives_for_hook() {
304	        crate::test_home::isolate();
305	        let id = unique_id("test-deferred");
306	        let perch_path = perch::resolve_perch_path(&id, ParentHint::Infer);
307	        std::fs::create_dir_all(&perch_path).unwrap();
308	        let rec = info::InfoJson::new(&id, "now", std::process::id(), "sess", "ready_agent");
309	        info::write_info(&perch_path, &rec).unwrap();
310
311	        assert_eq!(send_deferred(&id, "hooker", "later"), SendOutcome::Queued);
312
313	        // Live event-stream drain must NOT pick it up...
314	        assert!(spool::drain_non_deferred_at(&perch_path)
315	            .unwrap()
316	            .is_empty());
317	        // ...but the row is still pending for a hook drain.
318	        assert_eq!(spool::pending_count_at(&perch_path).unwrap(), 1);
319	        let all = spool::drain_all_at(&perch_path).unwrap();
320	        assert_eq!(all.len(), 1);
321	        assert!(all[0].contains("later"));
322	    }
323
324	    // [unit->REQ-MSG-1] deferred send to an absent perch is NoPerch; empty is Empty.
325	    #[test]
326	    fn deferred_send_validates() {
327	        crate::test_home::isolate();
328	        let absent = unique_id("test-deferred-absent");
329	        assert_eq!(send_deferred(&absent, "me", "x"), SendOutcome::NoPerch);
330	    }
331	}
332

        // Receiver got it over the wire.
        let mut got = None;
        for _ in 0..200 {
            if let Some(m) = listener.check_message() {
                got = Some(m);
                break;
            }
            std::thread::sleep(Duration::from_millis(5));
        }
        assert_eq!(got.as_deref(), Some("__REPLY_TO__:sender\nlive msg\n"));

        // No spooled copy accrued for an online delivery.
        let perch_path = perch::resolve_perch_path(&id, ParentHint::Infer);
        assert_eq!(spool::pending_count_at(&perch_path).unwrap(), 0);
    }

    // [unit->REQ-MSG-1] offline target (perch exists, no listener) spools.
    #[test]
    fn offline_existing_perch_spools() {
        crate::test_home::isolate();
        let owlery = perch::owlery_dir();
        let id = unique_id("test-offline");
        // Create the perch dir + a minimal info.json so perch_exists is true,
        // but bind no listener (offline).
        let perch_path = perch::resolve_perch_path(&id, ParentHint::Infer);
        std::fs::create_dir_all(&perch_path).unwrap();
        let rec = info::InfoJson::new(&id, "now", std::process::id(), "sess", "ready_agent");
        info::write_info(&perch_path, &rec).unwrap();

        let path = deliver(&id, "sender", "queued msg", &owlery);
        assert_eq!(path, DeliveryPath::Spool);
        assert_eq!(spool::pending_count_at(&perch_path).unwrap(), 1);
    }

    // [unit->REQ-HAZARD-WINDOWS-PID-RECYCLE] a registry row whose pid is alive
    // (e.g. recycled to this test process) but whose address has no listener
    // must NOT misdeliver or hang — connect refuses and the message spools.
    #[test]
    fn recycled_pid_address_does_not_misdeliver() {
        crate::test_home::isolate();
        let owlery = perch::owlery_dir();
        let id = unique_id("test-recycle");
        let perch_path = perch::resolve_perch_path(&id, ParentHint::Infer);
        std::fs::create_dir_all(&perch_path).unwrap();
        let rec = info::InfoJson::new(&id, "now", std::process::id(), "sess", "ready_agent");
        info::write_info(&perch_path, &rec).unwrap();

        // Grab a port, then drop the listener so nothing is listening — but
        // register that dead address under THIS process's (alive) pid, mimicking
        // a recycled pid whose stale row survives the stale-clean sweep.
        let dead_addr: SocketAddr = {
            let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
            l.local_addr().unwrap()
        };
        registry::register_address(&id, &dead_addr, &owlery).unwrap();

        let path = deliver(&id, "sender", "must spool", &owlery);
        assert_eq!(path, DeliveryPath::Spool);
        assert_eq!(spool::pending_count_at(&perch_path).unwrap(), 1);
    }

    // [unit->REQ-MSG-1] send() maps NoPerch / Empty / Sent / Queued correctly.
    #[test]
    fn send_outcome_mapping() {
        crate::test_home::isolate();
        let owlery = perch::owlery_dir();
        let absent = unique_id("test-absent");
        assert_eq!(send(&absent, "me", "hi", &owlery), SendOutcome::NoPerch);

        let id = unique_id("test-send-online");
        let _listener = PollListener::bind(&id).unwrap();
        // Need a perch dir for perch_exists; bind doesn't create info.json, but
        // is_online is true (ready... actually bind writes no ready marker).
        // Create ready + info so the online path is exercised deterministically.
        let perch_path = perch::resolve_perch_path(&id, ParentHint::Infer);
        std::fs::create_dir_all(&perch_path).unwrap();
        std::fs::write(perch::resolve_ready_file(&id, ParentHint::Infer), "").unwrap();
        let rec = info::InfoJson::new(&id, "now", std::process::id(), "sess", "ready_agent");
        info::write_info(&perch_path, &rec).unwrap();

        assert_eq!(send(&id, "me", "   ", &owlery), SendOutcome::Empty);
        assert_eq!(send(&id, "me", "real", &owlery), SendOutcome::Sent);
    }

    // [unit->REQ-MSG-1] deferred send writes a deferred row: the live-stream
    // drain skips it, but a hook-style drain-all delivers it.
    #[test]
    fn deferred_send_skips_stream_drain_survives_for_hook() {
        crate::test_home::isolate();
        let id = unique_id("test-deferred");
        let perch_path = perch::resolve_perch_path(&id, ParentHint::Infer);
        std::fs::create_dir_all(&perch_path).unwrap();
        let rec = info::InfoJson::new(&id, "now", std::process::id(), "sess", "ready_agent");
        info::write_info(&perch_path, &rec).unwrap();

        assert_eq!(send_deferred(&id, "hooker", "later"), SendOutcome::Queued);

        // Live event-stream drain must NOT pick it up...
        assert!(spool::drain_non_deferred_at(&perch_path)
            .unwrap()
            .is_empty());
        // ...but the row is still pending for a hook drain.
        assert_eq!(spool::pending_count_at(&perch_path).unwrap(), 1);
        let all = spool::drain_all_at(&perch_path).unwrap();
        assert_eq!(all.len(), 1);
        assert!(all[0].contains("later"));
    }

    // [unit->REQ-MSG-1] deferred send to an absent perch is NoPerch; empty is Empty.
    #[test]
    fn deferred_send_validates() {
        crate::test_home::isolate();
        let absent = unique_id("test-deferred-absent");
        assert_eq!(send_deferred(&absent, "me", "x"), SendOutcome::NoPerch);
    }
}


        assert_eq!(got.as_deref(), Some("__REPLY_TO__:sender\nlive msg\n"));

        // No spooled copy accrued for an online delivery.

        assert_eq!(got, Some(("sender".to_string(), "live msg".to_string())));

        // No spooled copy accrued for an online delivery.


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

        assert_eq!(got.as_deref(), Some("__REPLY_TO__:sender\nlive msg\n"));

        // No spooled copy accrued for an online delivery.

        assert_eq!(got, Some(("sender".to_string(), "live msg".to_string())));

        // No spooled copy accrued for an online delivery.

-        assert_eq!(got.as_deref(), Some("__REPLY_TO__:sender\nlive msg\n"));

+        assert_eq!(got, Some(("sender".to_string(), "live msg".to_string())));

let perch_path = perch::resolve_perch_path(&id, ParentHint::Infer);


        let all = spool::drain_all_at(&perch_path).unwrap();
        assert_eq!(all.len(), 1);
        assert!(all[0].contains("later"));
    }

        let all = spool::drain_all_at(&perch_path).unwrap();
        assert_eq!(all.len(), 1);
        assert_eq!(all[0].from, "hooker");
        assert!(all[0].body.contains("later"));
    }


        let all = spool::drain_all_at(&perch_path).unwrap();
        assert_eq!(all.len(), 1);
        assert!(all[0].contains("later"));
    }

        let all = spool::drain_all_at(&perch_path).unwrap();
        assert_eq!(all.len(), 1);
        assert_eq!(all[0].from, "hooker");
        assert!(all[0].body.contains("later"));
    }

-        assert!(all[0].contains("later"));

+        assert_eq!(all[0].from, "hooker");

+        assert!(all[0].body.contains("later"));

// [unit->REQ-MSG-1] deferred send to an absent perch is NoPerch; empty is Empty.


/// Core delivery primitive: TCP-first, spool-fallback. Returns which path the
/// message took. `from` is written into the `__REPLY_TO__` line (empty =
/// anonymous). Used by `send`, `ring`, and reply routing alike.

/// Core delivery primitive: TCP-first, spool-fallback. Returns which path the
/// message took. `from` is carried structurally on the wire/spool (empty =
/// anonymous). Used by `send`, `ring`, and reply routing alike.


/// Core delivery primitive: TCP-first, spool-fallback. Returns which path the
/// message took. `from` is written into the `__REPLY_TO__` line (empty =
/// anonymous). Used by `send`, `ring`, and reply routing alike.

/// Core delivery primitive: TCP-first, spool-fallback. Returns which path the
/// message took. `from` is carried structurally on the wire/spool (empty =
/// anonymous). Used by `send`, `ring`, and reply routing alike.

-/// message took. `from` is written into the `__REPLY_TO__` line (empty =

+/// message took. `from` is carried structurally on the wire/spool (empty =


/// Reply routing is *this same call* with `from` set to the replier's id: the
/// `__REPLY_TO__` line the original message carried names the target, and the
/// reply's own `from` lets the original sender reply back in turn.

/// Reply routing is *this same call* with `from` set to the replier's id: the
/// structural `from` the original message carried names the target, and the
/// reply's own `from` lets the original sender reply back in turn.


/// Reply routing is *this same call* with `from` set to the replier's id: the
/// `__REPLY_TO__` line the original message carried names the target, and the
/// reply's own `from` lets the original sender reply back in turn.

/// Reply routing is *this same call* with `from` set to the replier's id: the
/// structural `from` the original message carried names the target, and the
/// reply's own `from` lets the original sender reply back in turn.

/// I/O, no exit — the caller renders the outcome.

-/// `__REPLY_TO__` line the original message carried names the target, and the

+/// structural `from` the original message carried names the target, and the

pub fn send(target: &str, from: &str, body: &str, owlery: &Path) -> SendOutcome {


spt-msg lib.rs — correct the ADR-0001 framing note.