1	//! Per-perch SQLite spool for offline message queuing.
2	//!
3	//! Schema and drain semantics copied verbatim from the sister project
4	//! (`claude_skill_owl` `src/common/spool.rs`) per ADR-0001 — `spool.db` is a
5	//! "perch property": one database at `<perch_path>/spool.db`. This module ports
6	//! the **path-aware (`_at`) surface**: every call takes the resolved perch path
7	//! directly. The legacy id-based wrappers depend on the perch-path resolver
8	//! (T10) and arrive with it; nothing here needs id→path resolution.
9	//!
10	//! ## Deferred rows — two coupled invariants
11	//! A row carries a `deferred` flag. The flag splits two delivery paths:
12	//! - **REQ-HAZARD-DEFERRED-DRAIN (1.4):** the event-stream drain
13	//!   ([`drain_non_deferred_at`]) MUST exclude deferred rows — they must not
14	//!   wake a poll listener / surface on the Monitor stream.
15	//! - **REQ-HAZARD-DEFERRED-SURVIVE-DRAIN (4.4):** a deferred row MUST survive
16	//!   an event-stream drain undelivered, so the hook channel can still deliver
17	//!   it later. [`drain_all_at`] (the hook path) remains unfiltered.
18	//!
19	//! A drained row is marked `delivered = 1`, never deleted (audit + idempotency).
20
21	use rusqlite::{params, Connection};
22	use std::path::Path;
23
24	/// Default message TTL: 0 = infinite (no expiry). Persistent perches retain
25	/// messages indefinitely.
26	const DEFAULT_TTL_SECONDS: i64 = 0;
27
28	/// Current epoch seconds.
29	fn now_epoch() -> i64 {
30	    std::time::SystemTime::now()
31	        .duration_since(std::time::UNIX_EPOCH)
32	        .expect("system clock before UNIX epoch")
33	        .as_secs() as i64
34	}
35
36	/// Open (creating if needed) the spool database at `<perch_path>/spool.db`,
37	/// creating the perch directory if absent. WAL + busy_timeout for concurrent
38	/// readers/writers. The `ALTER TABLE` migrations are idempotent — a second
39	/// open silently no-ops them (SQLite errors on duplicate column; caught).
40	// [impl->REQ-HAZARD-DEFERRED-DRAIN]
41	pub fn open_spool_at(perch_path: &Path) -> rusqlite::Result<Connection> {
42	    if !perch_path.exists() {
43	        std::fs::create_dir_all(perch_path)
44	            .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?;
45	    }
46	    let db_path = perch_path.join("spool.db");
47	    let conn = Connection::open(&db_path)?;
48	    // [impl->REQ-HAZARD-REGISTRY-CONCURRENT] concurrency-safe pragmas — see
49	    // crate::db::tune_connection (busy_timeout first, WAL-with-retry).
50	    crate::db::tune_connection(&conn)?;
51	    conn.execute_batch(
52	        "CREATE TABLE IF NOT EXISTS messages (
53	            id INTEGER PRIMARY KEY AUTOINCREMENT,
54	            from_id TEXT NOT NULL,
55	            body TEXT NOT NULL,
56	            created_at INTEGER NOT NULL,
57	            ttl_seconds INTEGER NOT NULL DEFAULT 0,
58	            delivered INTEGER NOT NULL DEFAULT 0,
59	            deferred INTEGER NOT NULL DEFAULT 0
60	        )",
61	    )?;
62	    // Idempotent migrations for pre-existing databases (duplicate-column error
63	    // is expected and swallowed).
64	    let _ =
65	        conn.execute_batch("ALTER TABLE messages ADD COLUMN delivered INTEGER NOT NULL DEFAULT 0");
66	    let _ =
67	        conn.execute_batch("ALTER TABLE messages ADD COLUMN deferred INTEGER NOT NULL DEFAULT 0");
68	    // WAN-inbound idempotency keys (M4-D5a): op_ids this perch has already
69	    // accepted, so a replayed wire feed can't double-deliver. Same database as
70	    // the messages table so the seen-mark and the spool row commit in ONE
71	    // transaction (REQ-HAZARD-RESTART-IDEMPOTENT at the spool boundary).
72	    conn.execute_batch(
73	        "CREATE TABLE IF NOT EXISTS wan_seen (
74	            op_id TEXT PRIMARY KEY,
75	            at_ms INTEGER NOT NULL
76	        )",
77	    )?;
78	    Ok(conn)
79	}
80
81	/// Touch the `.has-messages` sentinel beside the spool so a listener knows to
82	/// drain without opening the DB. Best-effort (a failure here must not fail the
83	/// spool write).
84	fn touch_has_messages(perch_path: &Path) {
85	    let _ = std::fs::File::create(perch_path.join(".has-messages"));
86	}
87
88	/// Format a drained row for delivery, preserving the sister's `__REPLY_TO__`
89	/// convention: anonymous senders (`from_id == ""`) yield the bare body;
90	/// named senders prepend a reply-routing header.
91	fn format_row(from_id: &str, body: &str) -> String {
92	    if from_id.is_empty() {
93	        body.to_string()
94	    } else {
95	        format!("__REPLY_TO__:{}\n{}", from_id, body)
96	    }
97	}
98
99	/// Insert a normal (non-deferred) message and touch `.has-messages`.
100	// [impl->REQ-HAZARD-DEFERRED-DRAIN]
101	pub fn spool_message_at(perch_path: &Path, from_id: &str, body: &str) -> rusqlite::Result<()> {
102	    let conn = open_spool_at(perch_path)?;
103	    conn.execute(
104	        "INSERT INTO messages (from_id, body, created_at, ttl_seconds, deferred) VALUES (?1, ?2, ?3, ?4, 0)",
105	        params![from_id, body, now_epoch(), DEFAULT_TTL_SECONDS],
106	    )?;
107	    touch_has_messages(perch_path);
108	    Ok(())
109	}
110
111	/// Insert a deferred message (hook-channel only; excluded from the event-stream
112	/// drain) and touch `.has-messages`.
113	// [impl->REQ-HAZARD-DEFERRED-SURVIVE-DRAIN]
114	pub fn spool_message_deferred_at(
115	    perch_path: &Path,
116	    from_id: &str,
117	    body: &str,
118	) -> rusqlite::Result<()> {
119	    let conn = open_spool_at(perch_path)?;
120	    conn.execute(
121	        "INSERT INTO messages (from_id, body, created_at, ttl_seconds, deferred) VALUES (?1, ?2, ?3, ?4, 1)",
122	        params![from_id, body, now_epoch(), DEFAULT_TTL_SECONDS],
123	    )?;
124	    touch_has_messages(perch_path);
125	    Ok(())
126	}
127
128	/// Insert a WAN-inbound message **iff its `op_id` is new**, atomically: the
129	/// `wan_seen` claim and the spool row commit in one transaction, so a crash
130	/// can never leave the op claimed-but-unspooled (drop) or spooled-but-unclaimed
131	/// (dup on replay). Returns whether the row was inserted (`false` = duplicate
132	/// replay, nothing written). This is the wire feed's exactly-once point for
133	/// offline targets (REQ-HAZARD-RESTART-IDEMPOTENT, the spool boundary).
134	// [impl->REQ-HAZARD-RESTART-IDEMPOTENT]
135	// [impl->REQ-NET-1]
136	pub fn spool_wan_message_at(
137	    perch_path: &Path,
138	    from_id: &str,
139	    body: &str,
140	    op_id: &str,
141	) -> rusqlite::Result<bool> {
142	    let now_ms = std::time::SystemTime::now()
143	        .duration_since(std::time::UNIX_EPOCH)
144	        .expect("system clock before UNIX epoch")
145	        .as_millis() as i64;
146	    let conn = open_spool_at(perch_path)?;
147	    conn.execute_batch("BEGIN IMMEDIATE")?;
148	    let claimed = conn.execute(
149	        "INSERT OR IGNORE INTO wan_seen (op_id, at_ms) VALUES (?1, ?2)",
150	        params![op_id, now_ms],
151	    )?;
152	    if claimed == 0 {
153	        // Already seen — a replay. Nothing to write.
154	        conn.execute_batch("COMMIT")?;
155	        return Ok(false);
156	    }
157	    conn.execute(
158	        "INSERT INTO messages (from_id, body, created_at, ttl_seconds, deferred) VALUES (?1, ?2, ?3, ?4, 0)",
159	        params![from_id, body, now_epoch(), DEFAULT_TTL_SECONDS],
160	    )?;
161	    conn.execute_batch("COMMIT")?;
162	    touch_has_messages(perch_path);
163	    Ok(true)
164	}
165
166	/// True if this perch has already accepted WAN op `op_id`.
167	pub fn wan_seen_at(perch_path: &Path, op_id: &str) -> rusqlite::Result<bool> {
168	    let conn = open_spool_at(perch_path)?;
169	    let n: i64 = conn.query_row(
170	        "SELECT COUNT(*) FROM wan_seen WHERE op_id = ?1",
171	        params![op_id],
172	        |r| r.get(0),
173	    )?;
174	    Ok(n > 0)
175	}
176
177	/// Mark WAN op `op_id` seen without writing a spool row (the live-TCP delivery
178	/// path marks after a successful live handoff). Returns whether the mark was
179	/// new (`false` = already seen).
180	// [impl->REQ-HAZARD-RESTART-IDEMPOTENT]
181	pub fn wan_mark_seen_at(perch_path: &Path, op_id: &str) -> rusqlite::Result<bool> {
182	    let now_ms = std::time::SystemTime::now()
183	        .duration_since(std::time::UNIX_EPOCH)
184	        .expect("system clock before UNIX epoch")
185	        .as_millis() as i64;
186	    let conn = open_spool_at(perch_path)?;
187	    let n = conn.execute(
188	        "INSERT OR IGNORE INTO wan_seen (op_id, at_ms) VALUES (?1, ?2)",
189	        params![op_id, now_ms],
190	    )?;
191	    Ok(n > 0)
192	}
193
194	/// Purge expired rows (finite TTL only).
195	fn purge_expired(conn: &Connection) -> rusqlite::Result<()> {
196	    let now = now_epoch();
197	    conn.execute(
198	        "DELETE FROM messages WHERE ttl_seconds > 0 AND created_at + ttl_seconds < ?1",
199	        params![now],
200	    )?;
201	    Ok(())
202	}
203
204	/// Drain (mark delivered + return) every undelivered row, ordered by id.
205	/// **Unfiltered** — includes deferred rows (the hook delivery path). This is
206	/// what lets a deferred row reach the user even though the event stream skips
207	/// it.
208	// [impl->REQ-HAZARD-DEFERRED-SURVIVE-DRAIN]
209	pub fn drain_all_at(perch_path: &Path) -> rusqlite::Result<Vec<String>> {
210	    drain_where(perch_path, false)
211	}
212
213	/// Drain only undelivered **non-deferred** rows (the event-stream path).
214	/// Deferred rows are left untouched and undelivered.
215	// [impl->REQ-HAZARD-DEFERRED-DRAIN]
216	pub fn drain_non_deferred_at(perch_path: &Path) -> rusqlite::Result<Vec<String>> {
217	    drain_where(perch_path, true)
218	}
219
220	/// Shared drain body. `non_deferred_only` adds the `deferred = 0` predicate.
221	fn drain_where(perch_path: &Path, non_deferred_only: bool) -> rusqlite::Result<Vec<String>> {
222	    let conn = open_spool_at(perch_path)?;
223	    purge_expired(&conn)?;
224	    let sql = if non_deferred_only {
225	        "SELECT id, from_id, body FROM messages WHERE delivered = 0 AND deferred = 0 ORDER BY id ASC"
226	    } else {
227	        "SELECT id, from_id, body FROM messages WHERE delivered = 0 ORDER BY id ASC"
228	    };
229	    let mut stmt = conn.prepare(sql)?;
230	    let rows: Vec<(i64, String, String)> = stmt
231	        .query_map([], |row| {
232	            Ok((
233	                row.get::<_, i64>(0)?,
234	                row.get::<_, String>(1)?,
235	                row.get::<_, String>(2)?,
236	            ))
237	        })?
238	        .filter_map(|r| r.ok())
239	        .collect();
240
241	    let mut out = Vec::with_capacity(rows.len());
242	    for (id, from_id, body) in &rows {
243	        conn.execute(
244	            "UPDATE messages SET delivered = 1 WHERE id = ?1",
245	            params![id],
246	        )?;
247	        out.push(format_row(from_id, body));
248	    }
249	    Ok(out)
250	}
251
252	/// Drain the single oldest undelivered row (any deferred status), or `None`.
253	pub fn drain_one_at(perch_path: &Path) -> rusqlite::Result<Option<String>> {
254	    let conn = open_spool_at(perch_path)?;
255	    purge_expired(&conn)?;
256	    let row = conn.query_row(
257	        "SELECT id, from_id, body FROM messages WHERE delivered = 0 ORDER BY id ASC LIMIT 1",
258	        [],
259	        |row| {
260	            Ok((
261	                row.get::<_, i64>(0)?,
262	                row.get::<_, String>(1)?,
263	                row.get::<_, String>(2)?,
264	            ))
265	        },
266	    );
267	    match row {
268	        Ok((id, from_id, body)) => {
269	            conn.execute(
270	                "UPDATE messages SET delivered = 1 WHERE id = ?1",
271	                params![id],
272	            )?;
273	            Ok(Some(format_row(&from_id, &body)))
274	        }
275	        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
276	        Err(e) => Err(e),
277	    }
278	}
279
280	/// Non-destructive read of all undelivered rows: `(id, from_id, body, created_at)`.
281	pub fn peek_all_at(perch_path: &Path) -> rusqlite::Result<Vec<(i64, String, String, i64)>> {
282	    let conn = open_spool_at(perch_path)?;
283	    purge_expired(&conn)?;
284	    let mut stmt = conn.prepare(
285	        "SELECT id, from_id, body, created_at FROM messages WHERE delivered = 0 ORDER BY id ASC",
286	    )?;
287	    let rows = stmt
288	        .query_map([], |row| {
289	            Ok((
290	                row.get::<_, i64>(0)?,
291	                row.get::<_, String>(1)?,
292	                row.get::<_, String>(2)?,
293	                row.get::<_, i64>(3)?,
294	            ))
295	        })?
296	        .filter_map(|r| r.ok())
297	        .collect();
298	    Ok(rows)
299	}
300
301	/// Count of undelivered, non-expired rows (all deferred statuses).
302	pub fn pending_count_at(perch_path: &Path) -> rusqlite::Result<usize> {
303	    let conn = open_spool_at(perch_path)?;
304	    let now = now_epoch();
305	    let n: i64 = conn.query_row(
306	        "SELECT COUNT(*) FROM messages WHERE delivered = 0 AND (ttl_seconds <= 0 OR created_at + ttl_seconds >= ?1)",
307	        params![now],
308	        |r| r.get(0),
309	    )?;
310	    Ok(n as usize)
311	}
312
313	/// Count of undelivered, non-expired, **non-deferred** rows (event-stream view).
314	// [impl->REQ-HAZARD-DEFERRED-DRAIN]
315	pub fn pending_count_non_deferred_at(perch_path: &Path) -> rusqlite::Result<usize> {
316	    let conn = open_spool_at(perch_path)?;
317	    let now = now_epoch();
318	    let n: i64 = conn.query_row(
319	        "SELECT COUNT(*) FROM messages WHERE delivered = 0 AND deferred = 0 AND (ttl_seconds <= 0 OR created_at + ttl_seconds >= ?1)",
320	        params![now],
321	        |r| r.get(0),
322	    )?;
323	    Ok(n as usize)
324	}
325
326	/// Mark a set of message ids delivered, in one transaction. Empty input is a
327	/// no-op.
328	pub fn mark_delivered_at(perch_path: &Path, msg_ids: &[i64]) -> rusqlite::Result<()> {
329	    if msg_ids.is_empty() {
330	        return Ok(());
331	    }
332	    let conn = open_spool_at(perch_path)?;
333	    conn.execute_batch("BEGIN IMMEDIATE")?;
334	    for id in msg_ids {
335	        conn.execute(
336	            "UPDATE messages SET delivered = 1 WHERE id = ?1",
337	            params![id],
338	        )?;
339	    }
340	    conn.execute_batch("COMMIT")?;
341	    Ok(())
342	}
343
344	#[cfg(test)]
345	mod tests {
346	    use super::*;
347
348	    fn perch() -> tempfile::TempDir {
349	        tempfile::tempdir().unwrap()
350	    }
351
352	    // [unit->REQ-HAZARD-DEFERRED-DRAIN] the event-stream drain skips deferred
353	    // rows; non-deferred drains and ordering is preserved.
354	    #[test]
355	    fn non_deferred_drain_skips_deferred_and_preserves_order() {
356	        let d = perch();
357	        let p = d.path();
358	        spool_message_at(p, "alice", "first").unwrap();
359	        spool_message_deferred_at(p, "bob", "deferred-noise").unwrap();
360	        spool_message_at(p, "carol", "second").unwrap();
361
362	        let drained = drain_non_deferred_at(p).unwrap();
363	        assert_eq!(
364	            drained,
365	            vec![
366	                "__REPLY_TO__:alice\nfirst".to_string(),
367	                "__REPLY_TO__:carol\nsecond".to_string()
368	            ],
369	            "only non-deferred rows, in id order"
370	        );
371	    }
372
373	    // [unit->REQ-HAZARD-DEFERRED-SURVIVE-DRAIN] a deferred row survives the
374	    // event-stream drain undelivered, then still drains via the unfiltered
375	    // hook path.
376	    #[test]
377	    fn deferred_row_survives_event_drain_then_hook_delivers() {
378	        let d = perch();
379	        let p = d.path();
380	        spool_message_at(p, "alice", "normal").unwrap();
381	        spool_message_deferred_at(p, "psyche", "brief").unwrap();
382
383	        // Event-stream drain takes only the normal row.
384	        assert_eq!(
385	            drain_non_deferred_at(p).unwrap(),
386	            vec!["__REPLY_TO__:alice\nnormal"]
387	        );
388	        // Deferred row is still pending (survived).
389	        let peeked = peek_all_at(p).unwrap();
390	        assert_eq!(peeked.len(), 1);
391	        assert_eq!(peeked[0].2, "brief");
392	        assert_eq!(
393	            pending_count_non_deferred_at(p).unwrap(),
394	            0,
395	            "no non-deferred left"
396	        );
397	        assert_eq!(
398	            pending_count_at(p).unwrap(),
399	            1,
400	            "deferred still counted overall"
401	        );
402
403	        // Hook path (unfiltered) delivers it.
404	        assert_eq!(drain_all_at(p).unwrap(), vec!["__REPLY_TO__:psyche\nbrief"]);
405	        // Now nothing remains.
406	        assert!(peek_all_at(p).unwrap().is_empty());
407	    }
408
409	    // [unit->REQ-HAZARD-DEFERRED-DRAIN] drain marks delivered (not delete) and
410	    // is idempotent: a second drain returns nothing.
411	    #[test]
412	    fn drain_marks_delivered_and_is_idempotent() {
413	        let d = perch();
414	        let p = d.path();
415	        spool_message_at(p, "alice", "x").unwrap();
416	        spool_message_at(p, "", "anon").unwrap(); // anonymous → bare body
417
418	        let first = drain_all_at(p).unwrap();
419	        assert_eq!(
420	            first,
421	            vec!["__REPLY_TO__:alice\nx".to_string(), "anon".to_string()]
422	        );
423	        assert!(
424	            drain_all_at(p).unwrap().is_empty(),
425	            "delivered rows are skipped"
426	        );
427
428	        // Rows retained, marked delivered (audit).
429	        let conn = open_spool_at(p).unwrap();
430	        let total: i64 = conn
431	            .query_row("SELECT COUNT(*) FROM messages", [], |r| r.get(0))
432	            .unwrap();
433	        assert_eq!(total, 2, "rows retained, not deleted");
434	    }
435
436	    // [unit->REQ-HAZARD-DEFERRED-SURVIVE-DRAIN] an EVENT-PART line persists and
437	    // drains byte-exact — ties spool storage to the spt-proto chunk format.
438	    #[test]
439	    fn event_part_body_persists_byte_exact() {
440	        let d = perch();
441	        let p = d.path();
442	        // A long EVENT line chunked by spt-proto; persist each part as a body.
443	        let line = spt_proto::event::compose_msg_event("doyle", &"z".repeat(1200));
444	        let parts = spt_proto::chunk::chunk_if_oversized(&line, 200);
445	        assert!(parts.len() > 1);
446	        for part in &parts {
447	            spool_message_at(p, "", part).unwrap(); // anon → bare body, no header
448	        }
449	        let drained = drain_all_at(p).unwrap();
450	        assert_eq!(
451	            drained, parts,
452	            "EVENT-PART bodies persist and drain verbatim, in order"
453	        );
454	        // And they reassemble back to the original line.
455	        assert_eq!(spt_proto::chunk::reassemble_group(&drained).unwrap(), line);
456	    }
457
458	    // [unit->REQ-HAZARD-DEFERRED-DRAIN] schema migrations are idempotent across
459	    // reopens.
460	    #[test]
461	    fn reopen_is_idempotent() {
462	        let d = perch();
463	        let p = d.path();
464	        drop(open_spool_at(p).unwrap());
465	        let conn = open_spool_at(p).unwrap();
466	        conn.execute(
467	            "INSERT INTO messages (from_id, body, created_at, ttl_seconds, delivered, deferred) VALUES (?1,?2,?3,?4,?5,?6)",
468	            params!["t", "ok", now_epoch(), 0, 0, 1],
469	        )
470	        .unwrap();
471	        let deferred: i64 = conn
472	            .query_row("SELECT deferred FROM messages WHERE body='ok'", [], |r| {
473	                r.get(0)
474	            })
475	            .unwrap();
476	        assert_eq!(deferred, 1);
477	    }
478
479	    // [unit->REQ-HAZARD-DEFERRED-DRAIN] .has-messages sentinel is touched on write.
480	    #[test]
481	    fn spool_touches_has_messages_sentinel() {
482	        let d = perch();
483	        let p = d.path();
484	        spool_message_at(p, "a", "b").unwrap();
485	        assert!(p.join(".has-messages").exists());
486	    }
487
488	    // [unit->REQ-HAZARD-RESTART-IDEMPOTENT] the WAN insert is exactly-once on
489	    // op_id: a replay (same op_id) writes nothing — one spool row no matter how
490	    // often the wire feed re-presents the record — and the claim survives a
491	    // reopen (durable across a daemon restart).
492	    // [unit->REQ-NET-1]
493	    #[test]
494	    fn wan_spool_dedups_on_op_id_across_reopen() {
495	        let d = perch();
496	        let p = d.path();
497
498	        assert!(
499	            spool_wan_message_at(p, "doyle", "hi", "cafe:7").unwrap(),
500	            "first insert lands"
501	        );
502	        assert!(
503	            !spool_wan_message_at(p, "doyle", "hi", "cafe:7").unwrap(),
504	            "replay is a no-op"
505	        );
506	        assert_eq!(pending_count_at(p).unwrap(), 1, "one row, not two");
507
508	        // The claim is durable: a fresh open (daemon restart) still dedups.
509	        assert!(wan_seen_at(p, "cafe:7").unwrap());
510	        assert!(!spool_wan_message_at(p, "doyle", "hi", "cafe:7").unwrap());
511	        assert_eq!(pending_count_at(p).unwrap(), 1);
512
513	        // A different op_id is a different logical send.
514	        assert!(spool_wan_message_at(p, "doyle", "again", "cafe:8").unwrap());
515	        assert_eq!(pending_count_at(p).unwrap(), 2);
516	    }
517
518	    // [unit->REQ-HAZARD-RESTART-IDEMPOTENT] the live-TCP path's mark-after-
519	    // delivery claim: first mark is new, the replay sees it (and the spool
520	    // fallback respects a TCP-path claim — one op never lands via both paths).
521	    #[test]
522	    fn wan_mark_seen_claims_once_and_blocks_spool_path() {
523	        let d = perch();
524	        let p = d.path();
525
526	        assert!(wan_mark_seen_at(p, "cafe:9").unwrap(), "first claim is new");
527	        assert!(
528	            !wan_mark_seen_at(p, "cafe:9").unwrap(),
529	            "second claim is a replay"
530	        );
531	        // A replayed record falling back to the spool path after a TCP-path
532	        // claim must not double-deliver.
533	        assert!(!spool_wan_message_at(p, "doyle", "x", "cafe:9").unwrap());
534	        assert_eq!(
535	            pending_count_at(p).unwrap(),
536	            0,
537	            "no spool row for a TCP-claimed op"
538	        );
539	    }
540	}
541

//! Per-perch SQLite spool for offline message queuing.
//!
//! Schema and drain semantics copied verbatim from the sister project
//! (`claude_skill_owl` `src/common/spool.rs`) per ADR-0001 — `spool.db` is a
//! "perch property": one database at `<perch_path>/spool.db`. This module ports
//! the **path-aware (`_at`) surface**: every call takes the resolved perch path
//! directly. The legacy id-based wrappers depend on the perch-path resolver
//! (T10) and arrive with it; nothing here needs id→path resolution.
//!
//! ## Deferred rows — two coupled invariants
//! A row carries a `deferred` flag. The flag splits two delivery paths:
//! - **REQ-HAZARD-DEFERRED-DRAIN (1.4):** the event-stream drain
//!   ([`drain_non_deferred_at`]) MUST exclude deferred rows — they must not
//!   wake a poll listener / surface on the Monitor stream.
//! - **REQ-HAZARD-DEFERRED-SURVIVE-DRAIN (4.4):** a deferred row MUST survive
//!   an event-stream drain undelivered, so the hook channel can still deliver
//!   it later. [`drain_all_at`] (the hook path) remains unfiltered.
//!
//! A drained row is marked `delivered = 1`, never deleted (audit + idempotency).

use rusqlite::{params, Connection};
use std::path::Path;

/// Default message TTL: 0 = infinite (no expiry). Persistent perches retain
/// messages indefinitely.
const DEFAULT_TTL_SECONDS: i64 = 0;

/// Current epoch seconds.
fn now_epoch() -> i64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .expect("system clock before UNIX epoch")
        .as_secs() as i64
}

/// Open (creating if needed) the spool database at `<perch_path>/spool.db`,
/// creating the perch directory if absent. WAL + busy_timeout for concurrent
/// readers/writers. The `ALTER TABLE` migrations are idempotent — a second
/// open silently no-ops them (SQLite errors on duplicate column; caught).
// [impl->REQ-HAZARD-DEFERRED-DRAIN]
pub fn open_spool_at(perch_path: &Path) -> rusqlite::Result<Connection> {
    if !perch_path.exists() {
        std::fs::create_dir_all(perch_path)
            .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?;
    }
    let db_path = perch_path.join("spool.db");
    let conn = Connection::open(&db_path)?;
    // [impl->REQ-HAZARD-REGISTRY-CONCURRENT] concurrency-safe pragmas — see
    // crate::db::tune_connection (busy_timeout first, WAL-with-retry).
    crate::db::tune_connection(&conn)?;
    conn.execute_batch(
        "CREATE TABLE IF NOT EXISTS messages (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            from_id TEXT NOT NULL,
            body TEXT NOT NULL,
            created_at INTEGER NOT NULL,
            ttl_seconds INTEGER NOT NULL DEFAULT 0,
            delivered INTEGER NOT NULL DEFAULT 0,
            deferred INTEGER NOT NULL DEFAULT 0
        )",
    )?;
    // Idempotent migrations for pre-existing databases (duplicate-column error
    // is expected and swallowed).
    let _ =
        conn.execute_batch("ALTER TABLE messages ADD COLUMN delivered INTEGER NOT NULL DEFAULT 0");
    let _ =
        conn.execute_batch("ALTER TABLE messages ADD COLUMN deferred INTEGER NOT NULL DEFAULT 0");
    // WAN-inbound idempotency keys (M4-D5a): op_ids this perch has already
    // accepted, so a replayed wire feed can't double-deliver. Same database as
    // the messages table so the seen-mark and the spool row commit in ONE
    // transaction (REQ-HAZARD-RESTART-IDEMPOTENT at the spool boundary).
    conn.execute_batch(
        "CREATE TABLE IF NOT EXISTS wan_seen (
            op_id TEXT PRIMARY KEY,
            at_ms INTEGER NOT NULL
        )",
    )?;
    Ok(conn)
}

/// Touch the `.has-messages` sentinel beside the spool so a listener knows to
/// drain without opening the DB. Best-effort (a failure here must not fail the
/// spool write).
fn touch_has_messages(perch_path: &Path) {
    let _ = std::fs::File::create(perch_path.join(".has-messages"));
}

/// Format a drained row for delivery, preserving the sister's `__REPLY_TO__`
/// convention: anonymous senders (`from_id == ""`) yield the bare body;
/// named senders prepend a reply-routing header.
fn format_row(from_id: &str, body: &str) -> String {
    if from_id.is_empty() {
        body.to_string()
    } else {
        format!("__REPLY_TO__:{}\n{}", from_id, body)
    }
}

/// Insert a normal (non-deferred) message and touch `.has-messages`.
// [impl->REQ-HAZARD-DEFERRED-DRAIN]
pub fn spool_message_at(perch_path: &Path, from_id: &str, body: &str) -> rusqlite::Result<()> {
    let conn = open_spool_at(perch_path)?;
    conn.execute(
        "INSERT INTO messages (from_id, body, created_at, ttl_seconds, deferred) VALUES (?1, ?2, ?3, ?4, 0)",
        params![from_id, body, now_epoch(), DEFAULT_TTL_SECONDS],
    )?;
    touch_has_messages(perch_path);
    Ok(())
}

/// Insert a deferred message (hook-channel only; excluded from the event-stream
/// drain) and touch `.has-messages`.
// [impl->REQ-HAZARD-DEFERRED-SURVIVE-DRAIN]
pub fn spool_message_deferred_at(
    perch_path: &Path,
    from_id: &str,
    body: &str,
) -> rusqlite::Result<()> {
    let conn = open_spool_at(perch_path)?;
    conn.execute(
        "INSERT INTO messages (from_id, body, created_at, ttl_seconds, deferred) VALUES (?1, ?2, ?3, ?4, 1)",
        params![from_id, body, now_epoch(), DEFAULT_TTL_SECONDS],
    )?;
    touch_has_messages(perch_path);
    Ok(())
}

/// Insert a WAN-inbound message **iff its `op_id` is new**, atomically: the
/// `wan_seen` claim and the spool row commit in one transaction, so a crash
/// can never leave the op claimed-but-unspooled (drop) or spooled-but-unclaimed
/// (dup on replay). Returns whether the row was inserted (`false` = duplicate
/// replay, nothing written). This is the wire feed's exactly-once point for
/// offline targets (REQ-HAZARD-RESTART-IDEMPOTENT, the spool boundary).
// [impl->REQ-HAZARD-RESTART-IDEMPOTENT]
// [impl->REQ-NET-1]
pub fn spool_wan_message_at(
    perch_path: &Path,
    from_id: &str,
    body: &str,
    op_id: &str,
) -> rusqlite::Result<bool> {
    let now_ms = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .expect("system clock before UNIX epoch")
        .as_millis() as i64;
    let conn = open_spool_at(perch_path)?;
    conn.execute_batch("BEGIN IMMEDIATE")?;
    let claimed = conn.execute(
        "INSERT OR IGNORE INTO wan_seen (op_id, at_ms) VALUES (?1, ?2)",
        params![op_id, now_ms],
    )?;
    if claimed == 0 {
        // Already seen — a replay. Nothing to write.
        conn.execute_batch("COMMIT")?;
        return Ok(false);
    }
    conn.execute(
        "INSERT INTO messages (from_id, body, created_at, ttl_seconds, deferred) VALUES (?1, ?2, ?3, ?4, 0)",
        params![from_id, body, now_epoch(), DEFAULT_TTL_SECONDS],
    )?;
    conn.execute_batch("COMMIT")?;
    touch_has_messages(perch_path);
    Ok(true)
}

/// True if this perch has already accepted WAN op `op_id`.
pub fn wan_seen_at(perch_path: &Path, op_id: &str) -> rusqlite::Result<bool> {
    let conn = open_spool_at(perch_path)?;
    let n: i64 = conn.query_row(
        "SELECT COUNT(*) FROM wan_seen WHERE op_id = ?1",
        params![op_id],
        |r| r.get(0),
    )?;
    Ok(n > 0)
}

/// Mark WAN op `op_id` seen without writing a spool row (the live-TCP delivery
/// path marks after a successful live handoff). Returns whether the mark was
/// new (`false` = already seen).
// [impl->REQ-HAZARD-RESTART-IDEMPOTENT]
pub fn wan_mark_seen_at(perch_path: &Path, op_id: &str) -> rusqlite::Result<bool> {
    let now_ms = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .expect("system clock before UNIX epoch")
        .as_millis() as i64;
    let conn = open_spool_at(perch_path)?;
    let n = conn.execute(
        "INSERT OR IGNORE INTO wan_seen (op_id, at_ms) VALUES (?1, ?2)",
        params![op_id, now_ms],
    )?;
    Ok(n > 0)
}

/// Purge expired rows (finite TTL only).
fn purge_expired(conn: &Connection) -> rusqlite::Result<()> {
    let now = now_epoch();
    conn.execute(
        "DELETE FROM messages WHERE ttl_seconds > 0 AND created_at + ttl_seconds < ?1",
        params![now],
    )?;
    Ok(())
}

/// Drain (mark delivered + return) every undelivered row, ordered by id.
/// **Unfiltered** — includes deferred rows (the hook delivery path). This is
/// what lets a deferred row reach the user even though the event stream skips
/// it.
// [impl->REQ-HAZARD-DEFERRED-SURVIVE-DRAIN]
pub fn drain_all_at(perch_path: &Path) -> rusqlite::Result<Vec<String>> {
    drain_where(perch_path, false)
}

/// Drain only undelivered **non-deferred** rows (the event-stream path).
/// Deferred rows are left untouched and undelivered.
// [impl->REQ-HAZARD-DEFERRED-DRAIN]
pub fn drain_non_deferred_at(perch_path: &Path) -> rusqlite::Result<Vec<String>> {
    drain_where(perch_path, true)
}

/// Shared drain body. `non_deferred_only` adds the `deferred = 0` predicate.
fn drain_where(perch_path: &Path, non_deferred_only: bool) -> rusqlite::Result<Vec<String>> {
    let conn = open_spool_at(perch_path)?;
    purge_expired(&conn)?;
    let sql = if non_deferred_only {
        "SELECT id, from_id, body FROM messages WHERE delivered = 0 AND deferred = 0 ORDER BY id ASC"
    } else {
        "SELECT id, from_id, body FROM messages WHERE delivered = 0 ORDER BY id ASC"
    };
    let mut stmt = conn.prepare(sql)?;
    let rows: Vec<(i64, String, String)> = stmt
        .query_map([], |row| {
            Ok((
                row.get::<_, i64>(0)?,
                row.get::<_, String>(1)?,
                row.get::<_, String>(2)?,
            ))
        })?
        .filter_map(|r| r.ok())
        .collect();

    let mut out = Vec::with_capacity(rows.len());
    for (id, from_id, body) in &rows {
        conn.execute(
            "UPDATE messages SET delivered = 1 WHERE id = ?1",
            params![id],
        )?;
        out.push(format_row(from_id, body));
    }
    Ok(out)
}

/// Drain the single oldest undelivered row (any deferred status), or `None`.
pub fn drain_one_at(perch_path: &Path) -> rusqlite::Result<Option<String>> {
    let conn = open_spool_at(perch_path)?;
    purge_expired(&conn)?;
    let row = conn.query_row(
        "SELECT id, from_id, body FROM messages WHERE delivered = 0 ORDER BY id ASC LIMIT 1",
        [],
        |row| {
            Ok((
                row.get::<_, i64>(0)?,
                row.get::<_, String>(1)?,
                row.get::<_, String>(2)?,
            ))
        },
    );
    match row {
        Ok((id, from_id, body)) => {
            conn.execute(
                "UPDATE messages SET delivered = 1 WHERE id = ?1",
                params![id],
            )?;
            Ok(Some(format_row(&from_id, &body)))
        }
        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
        Err(e) => Err(e),
    }
}

/// Non-destructive read of all undelivered rows: `(id, from_id, body, created_at)`.
pub fn peek_all_at(perch_path: &Path) -> rusqlite::Result<Vec<(i64, String, String, i64)>> {
    let conn = open_spool_at(perch_path)?;
    purge_expired(&conn)?;
    let mut stmt = conn.prepare(
        "SELECT id, from_id, body, created_at FROM messages WHERE delivered = 0 ORDER BY id ASC",
    )?;
    let rows = stmt
        .query_map([], |row| {
            Ok((
                row.get::<_, i64>(0)?,
                row.get::<_, String>(1)?,
                row.get::<_, String>(2)?,
                row.get::<_, i64>(3)?,
            ))
        })?
        .filter_map(|r| r.ok())
        .collect();
    Ok(rows)
}

/// Count of undelivered, non-expired rows (all deferred statuses).
pub fn pending_count_at(perch_path: &Path) -> rusqlite::Result<usize> {
    let conn = open_spool_at(perch_path)?;
    let now = now_epoch();
    let n: i64 = conn.query_row(
        "SELECT COUNT(*) FROM messages WHERE delivered = 0 AND (ttl_seconds <= 0 OR created_at + ttl_seconds >= ?1)",
        params![now],
        |r| r.get(0),
    )?;
    Ok(n as usize)
}

/// Count of undelivered, non-expired, **non-deferred** rows (event-stream view).
// [impl->REQ-HAZARD-DEFERRED-DRAIN]
pub fn pending_count_non_deferred_at(perch_path: &Path) -> rusqlite::Result<usize> {
    let conn = open_spool_at(perch_path)?;
    let now = now_epoch();
    let n: i64 = conn.query_row(
        "SELECT COUNT(*) FROM messages WHERE delivered = 0 AND deferred = 0 AND (ttl_seconds <= 0 OR created_at + ttl_seconds >= ?1)",
        params![now],
        |r| r.get(0),
    )?;
    Ok(n as usize)
}

/// Mark a set of message ids delivered, in one transaction. Empty input is a
/// no-op.
pub fn mark_delivered_at(perch_path: &Path, msg_ids: &[i64]) -> rusqlite::Result<()> {
    if msg_ids.is_empty() {
        return Ok(());
    }
    let conn = open_spool_at(perch_path)?;
    conn.execute_batch("BEGIN IMMEDIATE")?;
    for id in msg_ids {
        conn.execute(
            "UPDATE messages SET delivered = 1 WHERE id = ?1",
            params![id],
        )?;
    }
    conn.execute_batch("COMMIT")?;
    Ok(())
}

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

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

    // [unit->REQ-HAZARD-DEFERRED-DRAIN] the event-stream drain skips deferred
    // rows; non-deferred drains and ordering is preserved.
    #[test]
    fn non_deferred_drain_skips_deferred_and_preserves_order() {
        let d = perch();
        let p = d.path();
        spool_message_at(p, "alice", "first").unwrap();
        spool_message_deferred_at(p, "bob", "deferred-noise").unwrap();
        spool_message_at(p, "carol", "second").unwrap();

        let drained = drain_non_deferred_at(p).unwrap();
        assert_eq!(
            drained,
            vec![
                "__REPLY_TO__:alice\nfirst".to_string(),
                "__REPLY_TO__:carol\nsecond".to_string()
            ],
            "only non-deferred rows, in id order"
        );
    }

    // [unit->REQ-HAZARD-DEFERRED-SURVIVE-DRAIN] a deferred row survives the
    // event-stream drain undelivered, then still drains via the unfiltered
    // hook path.
    #[test]
    fn deferred_row_survives_event_drain_then_hook_delivers() {
        let d = perch();
        let p = d.path();
        spool_message_at(p, "alice", "normal").unwrap();
        spool_message_deferred_at(p, "psyche", "brief").unwrap();

        // Event-stream drain takes only the normal row.
        assert_eq!(
            drain_non_deferred_at(p).unwrap(),
            vec!["__REPLY_TO__:alice\nnormal"]
        );
        // Deferred row is still pending (survived).
        let peeked = peek_all_at(p).unwrap();
        assert_eq!(peeked.len(), 1);
        assert_eq!(peeked[0].2, "brief");
        assert_eq!(
            pending_count_non_deferred_at(p).unwrap(),
            0,
            "no non-deferred left"
        );
        assert_eq!(
            pending_count_at(p).unwrap(),
            1,
            "deferred still counted overall"
        );

        // Hook path (unfiltered) delivers it.
        assert_eq!(drain_all_at(p).unwrap(), vec!["__REPLY_TO__:psyche\nbrief"]);
        // Now nothing remains.
        assert!(peek_all_at(p).unwrap().is_empty());
    }

    // [unit->REQ-HAZARD-DEFERRED-DRAIN] drain marks delivered (not delete) and
    // is idempotent: a second drain returns nothing.
    #[test]
    fn drain_marks_delivered_and_is_idempotent() {
        let d = perch();
        let p = d.path();
        spool_message_at(p, "alice", "x").unwrap();
        spool_message_at(p, "", "anon").unwrap(); // anonymous → bare body

        let first = drain_all_at(p).unwrap();
        assert_eq!(
            first,
            vec!["__REPLY_TO__:alice\nx".to_string(), "anon".to_string()]
        );
        assert!(
            drain_all_at(p).unwrap().is_empty(),
            "delivered rows are skipped"
        );

        // Rows retained, marked delivered (audit).
        let conn = open_spool_at(p).unwrap();
        let total: i64 = conn
            .query_row("SELECT COUNT(*) FROM messages", [], |r| r.get(0))
            .unwrap();
        assert_eq!(total, 2, "rows retained, not deleted");
    }

    // [unit->REQ-HAZARD-DEFERRED-SURVIVE-DRAIN] an EVENT-PART line persists and
    // drains byte-exact — ties spool storage to the spt-proto chunk format.
    #[test]
    fn event_part_body_persists_byte_exact() {
        let d = perch();
        let p = d.path();
        // A long EVENT line chunked by spt-proto; persist each part as a body.
        let line = spt_proto::event::compose_msg_event("doyle", &"z".repeat(1200));
        let parts = spt_proto::chunk::chunk_if_oversized(&line, 200);
        assert!(parts.len() > 1);
        for part in &parts {
            spool_message_at(p, "", part).unwrap(); // anon → bare body, no header
        }
        let drained = drain_all_at(p).unwrap();
        assert_eq!(
            drained, parts,
            "EVENT-PART bodies persist and drain verbatim, in order"
        );
        // And they reassemble back to the original line.
        assert_eq!(spt_proto::chunk::reassemble_group(&drained).unwrap(), line);
    }

    // [unit->REQ-HAZARD-DEFERRED-DRAIN] schema migrations are idempotent across
    // reopens.
    #[test]
    fn reopen_is_idempotent() {
        let d = perch();
        let p = d.path();
        drop(open_spool_at(p).unwrap());
        let conn = open_spool_at(p).unwrap();
        conn.execute(
            "INSERT INTO messages (from_id, body, created_at, ttl_seconds, delivered, deferred) VALUES (?1,?2,?3,?4,?5,?6)",
            params!["t", "ok", now_epoch(), 0, 0, 1],
        )
        .unwrap();
        let deferred: i64 = conn
            .query_row("SELECT deferred FROM messages WHERE body='ok'", [], |r| {
                r.get(0)
            })
            .unwrap();
        assert_eq!(deferred, 1);
    }

    // [unit->REQ-HAZARD-DEFERRED-DRAIN] .has-messages sentinel is touched on write.
    #[test]
    fn spool_touches_has_messages_sentinel() {
        let d = perch();
        let p = d.path();
        spool_message_at(p, "a", "b").unwrap();
        assert!(p.join(".has-messages").exists());
    }

    // [unit->REQ-HAZARD-RESTART-IDEMPOTENT] the WAN insert is exactly-once on
    // op_id: a replay (same op_id) writes nothing — one spool row no matter how
    // often the wire feed re-presents the record — and the claim survives a
    // reopen (durable across a daemon restart).
    // [unit->REQ-NET-1]
    #[test]
    fn wan_spool_dedups_on_op_id_across_reopen() {
        let d = perch();
        let p = d.path();

        assert!(
            spool_wan_message_at(p, "doyle", "hi", "cafe:7").unwrap(),
            "first insert lands"
        );
        assert!(
            !spool_wan_message_at(p, "doyle", "hi", "cafe:7").unwrap(),
            "replay is a no-op"
        );
        assert_eq!(pending_count_at(p).unwrap(), 1, "one row, not two");

        // The claim is durable: a fresh open (daemon restart) still dedups.
        assert!(wan_seen_at(p, "cafe:7").unwrap());
        assert!(!spool_wan_message_at(p, "doyle", "hi", "cafe:7").unwrap());
        assert_eq!(pending_count_at(p).unwrap(), 1);

        // A different op_id is a different logical send.
        assert!(spool_wan_message_at(p, "doyle", "again", "cafe:8").unwrap());
        assert_eq!(pending_count_at(p).unwrap(), 2);
    }

    // [unit->REQ-HAZARD-RESTART-IDEMPOTENT] the live-TCP path's mark-after-
    // delivery claim: first mark is new, the replay sees it (and the spool
    // fallback respects a TCP-path claim — one op never lands via both paths).
    #[test]
    fn wan_mark_seen_claims_once_and_blocks_spool_path() {
        let d = perch();
        let p = d.path();

        assert!(wan_mark_seen_at(p, "cafe:9").unwrap(), "first claim is new");
        assert!(
            !wan_mark_seen_at(p, "cafe:9").unwrap(),
            "second claim is a replay"
        );
        // A replayed record falling back to the spool path after a TCP-path
        // claim must not double-deliver.
        assert!(!spool_wan_message_at(p, "doyle", "x", "cafe:9").unwrap());
        assert_eq!(
            pending_count_at(p).unwrap(),
            0,
            "no spool row for a TCP-claimed op"
        );
    }
}


spool.rs understood. T2 = delete `format_row`, drains return `DrainedMsg{from,body}`. Map all drain callers + render_event_lines/wire callers to scope the ripple.


crates\spt-msg\tests\killer_quickstart.rs:55:fn parse_frame(frame: &str) -> (Option<String>, String) {
crates\spt-msg\tests\killer_quickstart.rs:89:    let (from, body) = parse_frame(&got);
crates\spt-msg\tests\killer_quickstart.rs:99:    let (rfrom, rbody) = parse_frame(&reply);
crates\spt-msg\tests\killer_quickstart.rs:121:    let (cfrom, cbody) = parse_frame(&backlog[0]);
crates\spt-msg\tests\killer_quickstart.rs:156:    let bodies: Vec<String> = backlog.iter().map(|f| parse_frame(f).1).collect();
crates\spt-msg\src\wire.rs:23:pub fn format_message(from_id: &str, body: &str) -> String {
crates\spt-msg\src\wire.rs:33:pub fn write_message(stream: &mut TcpStream, from_id: &str, body: &str) -> io::Result<()> {
crates\spt-msg\src\wire.rs:34:    let msg = format_message(from_id, body);
crates\spt-msg\src\wire.rs:46:pub fn read_message(stream: &mut TcpStream) -> io::Result<String> {
crates\spt-msg\src\wire.rs:68:    // [unit->REQ-MSG-1] format_message shapes the __REPLY_TO__ contract.
crates\spt-msg\src\wire.rs:72:            format_message("alice", "hi bob"),
crates\spt-msg\src\wire.rs:80:        assert_eq!(format_message("", "no reply"), "no reply\n");
crates\spt-msg\src\wire.rs:91:            read_message(&mut server).unwrap()
crates\spt-msg\src\wire.rs:95:        write_message(&mut client, "alice", "hello\nworld").unwrap();
crates\spt-msg\src\wire.rs:110:            read_message(&mut server)
crates\spt-msg\src\ring.rs:104:        if let Ok(Some(msg)) = spool::drain_one_at(&perch_path) {
crates\spt-msg\src\emit.rs:26:pub fn parse_frame(msg: &str) -> (String, String) {
crates\spt-msg\src\emit.rs:48:pub fn render_event_lines(msg: &str) -> Vec<String> {
crates\spt-msg\src\emit.rs:49:    let (from, body) = parse_frame(msg);
crates\spt-msg\src\emit.rs:66:        let lines = render_event_lines("__REPLY_TO__:lea\ntest message, hi!\n");
crates\spt-msg\src\emit.rs:77:        let lines = render_event_lines("no header here\n");
crates\spt-msg\src\emit.rs:88:        let lines = render_event_lines("__REPLY_TO__:bob\nline one\nline two\n");
crates\spt-msg\src\emit.rs:102:        assert_eq!(render_event_lines(&frame), vec![typed.to_string()]);
crates\spt-msg\src\emit.rs:113:        let lines = render_event_lines(&frame);
crates\spt-msg\src\emit.rs:127:        let lines = render_event_lines(&frame);
crates\spt-msg\src\emit.rs:136:    // [unit->REQ-MSG-4] parse_frame splits header/body and trims only
crates\spt-msg\src\emit.rs:139:    fn parse_frame_shapes() {
crates\spt-msg\src\emit.rs:141:            parse_frame("__REPLY_TO__:a\nb\n"),
crates\spt-msg\src\emit.rs:144:        assert_eq!(parse_frame("bare\n"), (String::new(), "bare".to_string()));
crates\spt-msg\src\emit.rs:147:            parse_frame("__REPLY_TO__:a\nx\ny\n"),
crates\spt-msg\src\ready.rs:107:        let backlog = spool::drain_non_deferred_at(&perch_path)
crates\spt-msg\src\ready.rs:136:        spool::drain_non_deferred_at(&self.perch_path).unwrap_or_default()
crates\spt-msg\src\deliver.rs:101:                    if wire::write_message(&mut stream, from, body).is_ok() {
crates\spt-msg\src\deliver.rs:168:/// (`drain_non_deferred_at`) skips it — it surfaces only when a hook consumer
crates\spt-msg\src\deliver.rs:169:/// drains everything (`drain_all_at` / `peek_all_at`) on its natural cadence.
crates\spt-msg\src\deliver.rs:314:        assert!(spool::drain_non_deferred_at(&perch_path)
crates\spt-msg\src\deliver.rs:319:        let all = spool::drain_all_at(&perch_path).unwrap();
crates\spt-msg\src\listener.rs:131:            Ok((mut stream, _)) => wire::read_message(&mut stream).ok(),
crates\spt-msg\src\listener.rs:143:                Ok((mut stream, _)) => wire::read_message(&mut stream).ok(),
crates\spt-msg\src\listener.rs:221:        wire::write_message(&mut client, "sender", "ping").unwrap();
crates\spt-msg\src\listener.rs:274:            wire::write_message(&mut c, "", "pong").unwrap();
crates\spt-store\src\spool.rs:13://!   ([`drain_non_deferred_at`]) MUST exclude deferred rows — they must not
crates\spt-store\src\spool.rs:17://!   it later. [`drain_all_at`] (the hook path) remains unfiltered.
crates\spt-store\src\spool.rs:91:fn format_row(from_id: &str, body: &str) -> String {
crates\spt-store\src\spool.rs:209:pub fn drain_all_at(perch_path: &Path) -> rusqlite::Result<Vec<String>> {
crates\spt-store\src\spool.rs:216:pub fn drain_non_deferred_at(perch_path: &Path) -> rusqlite::Result<Vec<String>> {
crates\spt-store\src\spool.rs:247:        out.push(format_row(from_id, body));
crates\spt-store\src\spool.rs:253:pub fn drain_one_at(perch_path: &Path) -> rusqlite::Result<Option<String>> {
crates\spt-store\src\spool.rs:273:            Ok(Some(format_row(&from_id, &body)))
crates\spt-store\src\spool.rs:362:        let drained = drain_non_deferred_at(p).unwrap();
crates\spt-store\src\spool.rs:385:            drain_non_deferred_at(p).unwrap(),
crates\spt-store\src\spool.rs:404:        assert_eq!(drain_all_at(p).unwrap(), vec!["__REPLY_TO__:psyche\nbrief"]);
crates\spt-store\src\spool.rs:418:        let first = drain_all_at(p).unwrap();
crates\spt-store\src\spool.rs:424:            drain_all_at(p).unwrap().is_empty(),
crates\spt-store\src\spool.rs:449:        let drained = drain_all_at(p).unwrap();
crates\spt\src\cli.rs:2711:    for line in spt_msg::emit::render_event_lines(msg) {
crates\spt\src\api\delivery.rs:168:    for frame in spool::drain_non_deferred_at(&perch_path).unwrap_or_default() {
crates\spt\src\api\delivery.rs:185:        spool::drain_all_at(&perch_path)
crates\spt\src\api\delivery.rs:187:        spool::drain_non_deferred_at(&perch_path)
crates\spt\src\api\reporting.rs:641:        let rows = spt_store::spool::drain_all_at(&perch_path).unwrap();
crates\spt\src\api\reporting.rs:652:        assert!(spt_store::spool::drain_all_at(&perch_path)
crates\spt\src\api\reporting.rs:692:        assert!(spt_store::spool::drain_non_deferred_at(&perch_path)
crates\spt\src\api\reporting.rs:696:        let all = spt_store::spool::drain_all_at(&perch_path).unwrap();
crates\spt\src\api\startup.rs:314:    for msg in spool::drain_non_deferred_at(&perch_path).unwrap_or_default() {
crates\spt\src\api\startup.rs:329:        for msg in spool::drain_non_deferred_at(&perch_path).unwrap_or_default() {
crates\spt\src\api\startup.rs:339:        for msg in spool::drain_non_deferred_at(&perch_path).unwrap_or_default() {
crates\spt\src\api\startup.rs:408:    for line in spt_msg::emit::render_event_lines(msg) {
crates\spt\src\api\worker.rs:84:        spool::drain_all_at(&perch_path)
crates\spt\src\api\worker.rs:86:        spool::drain_non_deferred_at(&perch_path)
crates\spt\src\api\worker.rs:194:        let got = spool::drain_non_deferred_at(&perch_path).unwrap();
crates\spt-daemon\src\notif.rs:605:            let rows = spool::drain_all_at(&ling).unwrap();
crates\spt-daemon\src\notif.rs:655:            assert_eq!(spool::drain_all_at(&doyle).unwrap().len(), 1);
crates\spt-daemon\src\notif.rs:704:                spool::drain_all_at(&ling).unwrap().is_empty(),
crates\spt-daemon\src\notif.rs:721:            assert_eq!(spool::drain_all_at(&ling).unwrap().len(), 1);
crates\spt-daemon\src\notif.rs:802:            assert_eq!(spool::drain_all_at(&doyle).unwrap().len(), 1);
crates\spt-daemon\src\notif.rs:821:            assert!(spool::drain_all_at(&doyle).unwrap().is_empty());
crates\spt-daemon\src\notif.rs:947:            assert_eq!(spool::drain_all_at(&ling).unwrap().len(), 1);
crates\spt-daemon\src\notif.rs:980:            assert_eq!(spool::drain_all_at(&ling).unwrap().len(), 1, "first-fired");
crates\spt-daemon\src\notif.rs:1189:            assert!(spool::drain_all_at(&ghost).unwrap().is_empty());
crates\spt-daemon\src\resting.rs:413:///   `drain_all_at`) — **gated**.
crates\spt-daemon\src\resting.rs:414:/// - `spt::api::worker::cmd_worker_poll` (`drain_all_at`) — **gated**.
crates\spt-daemon\src\resting.rs:419:/// - `spt_msg::ring` (`drain_one_at` on the ephemeral reply perch) — not
crates\spt-daemon\src\resting.rs:423:/// - The event stream (`drain_non_deferred_at`) was never a deferred surface
crates\spt-daemon\src\relay.rs:63:        let msgs = spool::drain_non_deferred_at(&self.perch_path).unwrap_or_default();
crates\spt-daemon\src\psyrelay.rs:303:            let rows = spool::drain_all_at(&alice).unwrap();
crates\spt-daemon\src\psyrelay.rs:328:            let rows = spool::drain_all_at(&alice).unwrap();
crates\spt-daemon\src\psyrelay.rs:332:            assert!(spool::drain_all_at(&victim).unwrap().is_empty());
crates\spt-daemon\src\psyrelay.rs:358:            let rows = spool::drain_all_at(&doyle).unwrap();
crates\spt-daemon\src\psyrelay.rs:388:                spool::drain_all_at(&doyle).unwrap().is_empty(),
crates\spt-daemon\src\psyrelay.rs:406:            assert!(spool::drain_all_at(&doyle).unwrap().is_empty());
crates\spt-daemon\src\psyrelay.rs:437:            assert!(spool::drain_all_at(&alice).unwrap().is_empty());
crates\spt-daemon\src\psyrelay.rs:448:            assert!(spool::drain_all_at(&alice).unwrap().is_empty());
crates\spt-daemon\src\psyrelay.rs:478:            assert!(spool::drain_all_at(&alice).unwrap().is_empty());


Large but tightly-coupled ripple (compiler-guided). Order: spt-store(spool) → spt-msg(wire/emit/deliver/listener/ready/ring) → spt(api) → spt-daemon → spt-live → sweep. Start T2 foundational: `DrainedMsg` + structural drains in spool.rs.


Edit

/// Format a drained row for delivery, preserving the sister's `__REPLY_TO__`
/// convention: anonymous senders (`from_id == ""`) yield the bare body;
/// named senders prepend a reply-routing header.
fn format_row(from_id: &str, body: &str) -> String {
    if from_id.is_empty() {
        body.to_string()
    } else {
        format!("__REPLY_TO__:{}\n{}", from_id, body)
    }
}

/// A drained spool row carried **structurally** as `(from, body)` (ADR-0020).
/// `from` is `""` for an anonymous sender. The `<EVENT>` envelope is composed
/// once at the delivery edge from this pair — the spool no longer serializes a
/// `__REPLY_TO__` frame (the relic round-trip is gone).
// [impl->REQ-MSG-ENVELOPE]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DrainedMsg {
    /// Reply-routing sender id; `""` = anonymous (no reply target).
    pub from: String,
    /// Message body (may itself be a pre-formed typed `<EVENT>` envelope, which
    /// the delivery edge passes through verbatim).
    pub body: String,
}


<owl_messages>
<owl_message from="doyle" priority="high">seq 9/9 added NOTHING beyond the branch note ("impl branch builds on/merges docs branch @24e0628") + "Go" — you have the full bless, nothing lost. Clean that my docs commit absorbed your working-tree dups; one branch docs+impl → one PR is the right shape.

Your gate-condition list is verbatim correct — that's exactly what I gate on. Two re-emphasis only: (1) the typed-passthrough no-double-wrap unit is THE subtle one — make it adversarial (a notify body whose own from differs from the structural from, assert the body's from wins + single envelope); (2) grep-clean means ZERO __REPLY_TO__ in crates/ (tests included — migrate the assertions, don't leave relic strings in test fixtures).

Build T1→T7, self-verify green, hand me the gate — I reproduce traceable + read the load-bearing tests + the grep-clean, never rubber-stamp. Go.</owl_message>
</owl_messages>

[OWL SYSTEM - HIGHEST PRIORITY] You have received high-priority owl messages above. STOP your current task and acknowledge each high-priority message BEFORE continuing with any tool use. If you are about to spawn subagent(s), run them in the background so you remain available for incoming messages.


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

/// Format a drained row for delivery, preserving the sister's `__REPLY_TO__`
/// convention: anonymous senders (`from_id == ""`) yield the bare body;
/// named senders prepend a reply-routing header.
fn format_row(from_id: &str, body: &str) -> String {
    if from_id.is_empty() {
        body.to_string()
    } else {
        format!("__REPLY_TO__:{}\n{}", from_id, body)
    }
}

/// A drained spool row carried **structurally** as `(from, body)` (ADR-0020).
/// `from` is `""` for an anonymous sender. The `<EVENT>` envelope is composed
/// once at the delivery edge from this pair — the spool no longer serializes a
/// `__REPLY_TO__` frame (the relic round-trip is gone).
// [impl->REQ-MSG-ENVELOPE]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DrainedMsg {
    /// Reply-routing sender id; `""` = anonymous (no reply target).
    pub from: String,
    /// Message body (may itself be a pre-formed typed `<EVENT>` envelope, which
    /// the delivery edge passes through verbatim).
    pub body: String,
}

let _ = std::fs::File::create(perch_path.join(".has-messages"));

-/// Format a drained row for delivery, preserving the sister's `__REPLY_TO__`

-/// convention: anonymous senders (`from_id == ""`) yield the bare body;

-/// named senders prepend a reply-routing header.

-fn format_row(from_id: &str, body: &str) -> String {

-        format!("__REPLY_TO__:{}\n{}", from_id, body)

+/// A drained spool row carried **structurally** as `(from, body)` (ADR-0020).

+/// `from` is `""` for an anonymous sender. The `<EVENT>` envelope is composed

+/// once at the delivery edge from this pair — the spool no longer serializes a

+/// `__REPLY_TO__` frame (the relic round-trip is gone).

+    /// Reply-routing sender id; `""` = anonymous (no reply target).

+    /// Message body (may itself be a pre-formed typed `<EVENT>` envelope, which

+    /// the delivery edge passes through verbatim).

/// Insert a normal (non-deferred) message and touch `.has-messages`.


doyle: full bless, no lost content. Two re-emphases noted — (1) typed-passthrough unit must be **adversarial** (notify body's own `from` ≠ structural `from` → body's from wins, single envelope); (2) grep-clean = ZERO `__REPLY_TO__` incl test fixtures. Baking both into T7. Quick ack, then resume build.


PowerShell

$msg = @'
Ack — both re-emphases locked into T7:
(1) adversarial typed-passthrough unit: notify body with its own from="bodyfrom" rides structural from="structfrom" through frame+spool+drain → assert reaches surface as ONE verbatim <EVENT type="notify" from="bodyfrom"> (body's from wins, structural from ignored, no double-wrap) — on BOTH poll(whole) + listener(chunk-capable).
(2) grep-clean ZERO __REPLY_TO__ in crates/ incl every test fixture/assertion (migrating, not leaving relic strings).
Building bottom-up now (spool DrainedMsg done). Hand you the gate green. Go.
'@
$msg | & $env:OWL send doyle
Write-Output "SEND_EXIT=$LASTEXITCODE"

Ack doyle re-emphases


➜ SENT:doyle
SEND_EXIT=0


Resume build. Update spool drain functions to return `DrainedMsg`.


Edit

pub fn drain_all_at(perch_path: &Path) -> rusqlite::Result<Vec<String>> {
    drain_where(perch_path, false)
}

pub fn drain_all_at(perch_path: &Path) -> rusqlite::Result<Vec<DrainedMsg>> {
    drain_where(perch_path, false)
}


pub fn drain_all_at(perch_path: &Path) -> rusqlite::Result<Vec<String>> {
    drain_where(perch_path, false)
}

pub fn drain_all_at(perch_path: &Path) -> rusqlite::Result<Vec<DrainedMsg>> {
    drain_where(perch_path, false)
}

/// what lets a deferred row reach the user even though the event stream skips

-pub fn drain_all_at(perch_path: &Path) -> rusqlite::Result<Vec<String>> {

+pub fn drain_all_at(perch_path: &Path) -> rusqlite::Result<Vec<DrainedMsg>> {


pub fn drain_non_deferred_at(perch_path: &Path) -> rusqlite::Result<Vec<String>> {
    drain_where(perch_path, true)
}

/// Shared drain body. `non_deferred_only` adds the `deferred = 0` predicate.
fn drain_where(perch_path: &Path, non_deferred_only: bool) -> rusqlite::Result<Vec<String>> {

pub fn drain_non_deferred_at(perch_path: &Path) -> rusqlite::Result<Vec<DrainedMsg>> {
    drain_where(perch_path, true)
}

/// Shared drain body. `non_deferred_only` adds the `deferred = 0` predicate.
fn drain_where(perch_path: &Path, non_deferred_only: bool) -> rusqlite::Result<Vec<DrainedMsg>> {


pub fn drain_non_deferred_at(perch_path: &Path) -> rusqlite::Result<Vec<String>> {
    drain_where(perch_path, true)
}

/// Shared drain body. `non_deferred_only` adds the `deferred = 0` predicate.
fn drain_where(perch_path: &Path, non_deferred_only: bool) -> rusqlite::Result<Vec<String>> {

pub fn drain_non_deferred_at(perch_path: &Path) -> rusqlite::Result<Vec<DrainedMsg>> {
    drain_where(perch_path, true)
}

/// Shared drain body. `non_deferred_only` adds the `deferred = 0` predicate.
fn drain_where(perch_path: &Path, non_deferred_only: bool) -> rusqlite::Result<Vec<DrainedMsg>> {

/// Drain only undelivered **non-deferred** rows (the event-stream path).

/// Deferred rows are left untouched and undelivered.

-pub fn drain_non_deferred_at(perch_path: &Path) -> rusqlite::Result<Vec<String>> {

+pub fn drain_non_deferred_at(perch_path: &Path) -> rusqlite::Result<Vec<DrainedMsg>> {

-fn drain_where(perch_path: &Path, non_deferred_only: bool) -> rusqlite::Result<Vec<String>> {

+fn drain_where(perch_path: &Path, non_deferred_only: bool) -> rusqlite::Result<Vec<DrainedMsg>> {


    let mut out = Vec::with_capacity(rows.len());
    for (id, from_id, body) in &rows {
        conn.execute(
            "UPDATE messages SET delivered = 1 WHERE id = ?1",
            params![id],
        )?;
        out.push(format_row(from_id, body));
    }
    Ok(out)
}

/// Drain the single oldest undelivered row (any deferred status), or `None`.
pub fn drain_one_at(perch_path: &Path) -> rusqlite::Result<Option<String>> {

    let mut out = Vec::with_capacity(rows.len());
    for (id, from_id, body) in rows {
        conn.execute(
            "UPDATE messages SET delivered = 1 WHERE id = ?1",
            params![id],
        )?;
        out.push(DrainedMsg {
            from: from_id,
            body,
        });
    }
    Ok(out)
}

/// Drain the single oldest undelivered row (any deferred status), or `None`.
pub fn drain_one_at(perch_path: &Path) -> rusqlite::Result<Option<DrainedMsg>> {


    let mut out = Vec::with_capacity(rows.len());
    for (id, from_id, body) in &rows {
        conn.execute(
            "UPDATE messages SET delivered = 1 WHERE id = ?1",
            params![id],
        )?;
        out.push(format_row(from_id, body));
    }
    Ok(out)
}

/// Drain the single oldest undelivered row (any deferred status), or `None`.
pub fn drain_one_at(perch_path: &Path) -> rusqlite::Result<Option<String>> {

    let mut out = Vec::with_capacity(rows.len());
    for (id, from_id, body) in rows {
        conn.execute(
            "UPDATE messages SET delivered = 1 WHERE id = ?1",
            params![id],
        )?;
        out.push(DrainedMsg {
            from: from_id,
            body,
        });
    }
    Ok(out)
}

/// Drain the single oldest undelivered row (any deferred status), or `None`.
pub fn drain_one_at(perch_path: &Path) -> rusqlite::Result<Option<DrainedMsg>> {

-        out.push(format_row(from_id, body));

-pub fn drain_one_at(perch_path: &Path) -> rusqlite::Result<Option<String>> {

+pub fn drain_one_at(perch_path: &Path) -> rusqlite::Result<Option<DrainedMsg>> {


    match row {
        Ok((id, from_id, body)) => {
            conn.execute(
                "UPDATE messages SET delivered = 1 WHERE id = ?1",
                params![id],
            )?;
            Ok(Some(format_row(&from_id, &body)))
        }
        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
        Err(e) => Err(e),
    }
}

    match row {
        Ok((id, from_id, body)) => {
            conn.execute(
                "UPDATE messages SET delivered = 1 WHERE id = ?1",
                params![id],
            )?;
            Ok(Some(DrainedMsg {
                from: from_id,
                body,
            }))
        }
        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
        Err(e) => Err(e),
    }
}


    match row {
        Ok((id, from_id, body)) => {
            conn.execute(
                "UPDATE messages SET delivered = 1 WHERE id = ?1",
                params![id],
            )?;
            Ok(Some(format_row(&from_id, &body)))
        }
        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
        Err(e) => Err(e),
    }
}

    match row {
        Ok((id, from_id, body)) => {
            conn.execute(
                "UPDATE messages SET delivered = 1 WHERE id = ?1",
                params![id],
            )?;
            Ok(Some(DrainedMsg {
                from: from_id,
                body,
            }))
        }
        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
        Err(e) => Err(e),
    }
}

-            Ok(Some(format_row(&from_id, &body)))


Now migrate spool tests off `__REPLY_TO__` strings to structural `DrainedMsg`.


        let drained = drain_non_deferred_at(p).unwrap();
        assert_eq!(
            drained,
            vec![
                "__REPLY_TO__:alice\nfirst".to_string(),
                "__REPLY_TO__:carol\nsecond".to_string()
            ],
            "only non-deferred rows, in id order"
        );
    }

        let drained = drain_non_deferred_at(p).unwrap();
        assert_eq!(
            drained,
            vec![
                DrainedMsg {
                    from: "alice".to_string(),
                    body: "first".to_string()
                },
                DrainedMsg {
                    from: "carol".to_string(),
                    body: "second".to_string()
                },
            ],
            "only non-deferred rows, in id order"
        );
    }


        let drained = drain_non_deferred_at(p).unwrap();
        assert_eq!(
            drained,
            vec![
                "__REPLY_TO__:alice\nfirst".to_string(),
                "__REPLY_TO__:carol\nsecond".to_string()
            ],
            "only non-deferred rows, in id order"
        );
    }

        let drained = drain_non_deferred_at(p).unwrap();
        assert_eq!(
            drained,
            vec![
                DrainedMsg {
                    from: "alice".to_string(),
                    body: "first".to_string()
                },
                DrainedMsg {
                    from: "carol".to_string(),
                    body: "second".to_string()
                },
            ],
            "only non-deferred rows, in id order"
        );
    }

-                "__REPLY_TO__:alice\nfirst".to_string(),

-                "__REPLY_TO__:carol\nsecond".to_string()

+                    from: "alice".to_string(),

+                    body: "first".to_string()

+                    from: "carol".to_string(),

+                    body: "second".to_string()


        // Event-stream drain takes only the normal row.
        assert_eq!(
            drain_non_deferred_at(p).unwrap(),
            vec!["__REPLY_TO__:alice\nnormal"]
        );

        // Event-stream drain takes only the normal row.
        assert_eq!(
            drain_non_deferred_at(p).unwrap(),
            vec![DrainedMsg {
                from: "alice".to_string(),
                body: "normal".to_string()
            }]
        );


        // Event-stream drain takes only the normal row.
        assert_eq!(
            drain_non_deferred_at(p).unwrap(),
            vec!["__REPLY_TO__:alice\nnormal"]
        );

        // Event-stream drain takes only the normal row.
        assert_eq!(
            drain_non_deferred_at(p).unwrap(),
            vec![DrainedMsg {
                from: "alice".to_string(),
                body: "normal".to_string()
            }]
        );

-            vec!["__REPLY_TO__:alice\nnormal"]

+                from: "alice".to_string(),

+                body: "normal".to_string()

// Deferred row is still pending (survived).


        // Hook path (unfiltered) delivers it.
        assert_eq!(drain_all_at(p).unwrap(), vec!["__REPLY_TO__:psyche\nbrief"]);
        // Now nothing remains.

        // Hook path (unfiltered) delivers it.
        assert_eq!(
            drain_all_at(p).unwrap(),
            vec![DrainedMsg {
                from: "psyche".to_string(),
                body: "brief".to_string()
            }]
        );
        // Now nothing remains.


        // Hook path (unfiltered) delivers it.
        assert_eq!(drain_all_at(p).unwrap(), vec!["__REPLY_TO__:psyche\nbrief"]);
        // Now nothing remains.

        // Hook path (unfiltered) delivers it.
        assert_eq!(
            drain_all_at(p).unwrap(),
            vec![DrainedMsg {
                from: "psyche".to_string(),
                body: "brief".to_string()
            }]
        );
        // Now nothing remains.

-        assert_eq!(drain_all_at(p).unwrap(), vec!["__REPLY_TO__:psyche\nbrief"]);

+                from: "psyche".to_string(),

+                body: "brief".to_string()


        let first = drain_all_at(p).unwrap();
        assert_eq!(
            first,
            vec!["__REPLY_TO__:alice\nx".to_string(), "anon".to_string()]
        );
        assert!(
            drain_all_at(p).unwrap().is_empty(),
            "delivered rows are skipped"
        );

        let first = drain_all_at(p).unwrap();
        assert_eq!(
            first,
            vec![
                DrainedMsg {
                    from: "alice".to_string(),
                    body: "x".to_string()
                },
                DrainedMsg {
                    from: "".to_string(),
                    body: "anon".to_string()
                },
            ]
        );
        assert!(
            drain_all_at(p).unwrap().is_empty(),
            "delivered rows are skipped"
        );


        let first = drain_all_at(p).unwrap();
        assert_eq!(
            first,
            vec!["__REPLY_TO__:alice\nx".to_string(), "anon".to_string()]
        );
        assert!(
            drain_all_at(p).unwrap().is_empty(),
            "delivered rows are skipped"
        );

        let first = drain_all_at(p).unwrap();
        assert_eq!(
            first,
            vec![
                DrainedMsg {
                    from: "alice".to_string(),
                    body: "x".to_string()
                },
                DrainedMsg {
                    from: "".to_string(),
                    body: "anon".to_string()
                },
            ]
        );
        assert!(
            drain_all_at(p).unwrap().is_empty(),
            "delivered rows are skipped"
        );

-            vec!["__REPLY_TO__:alice\nx".to_string(), "anon".to_string()]

+                    body: "x".to_string()

+                    from: "".to_string(),

+                    body: "anon".to_string()


        let drained = drain_all_at(p).unwrap();
        assert_eq!(
            drained, parts,
            "EVENT-PART bodies persist and drain verbatim, in order"
        );
        // And they reassemble back to the original line.
        assert_eq!(spt_proto::chunk::reassemble_group(&drained).unwrap(), line);
    }

        let drained = drain_all_at(p).unwrap();
        let bodies: Vec<String> = drained.iter().map(|m| m.body.clone()).collect();
        assert!(
            drained.iter().all(|m| m.from.is_empty()),
            "anon rows carry no from"
        );
        assert_eq!(
            bodies, parts,
            "EVENT-PART bodies persist and drain verbatim, in order"
        );
        // And they reassemble back to the original line.
        assert_eq!(spt_proto::chunk::reassemble_group(&bodies).unwrap(), line);
    }


        let drained = drain_all_at(p).unwrap();
        assert_eq!(
            drained, parts,
            "EVENT-PART bodies persist and drain verbatim, in order"
        );
        // And they reassemble back to the original line.
        assert_eq!(spt_proto::chunk::reassemble_group(&drained).unwrap(), line);
    }

        let drained = drain_all_at(p).unwrap();
        let bodies: Vec<String> = drained.iter().map(|m| m.body.clone()).collect();
        assert!(
            drained.iter().all(|m| m.from.is_empty()),
            "anon rows carry no from"
        );
        assert_eq!(
            bodies, parts,
            "EVENT-PART bodies persist and drain verbatim, in order"
        );
        // And they reassemble back to the original line.
        assert_eq!(spt_proto::chunk::reassemble_group(&bodies).unwrap(), line);
    }

spool_message_at(p, "", part).unwrap(); // anon → bare body, no header

+        let bodies: Vec<String> = drained.iter().map(|m| m.body.clone()).collect();

+            drained.iter().all(|m| m.from.is_empty()),

-        assert_eq!(spt_proto::chunk::reassemble_group(&drained).unwrap(), line);

+        assert_eq!(spt_proto::chunk::reassemble_group(&bodies).unwrap(), line);

// [unit->REQ-HAZARD-DEFERRED-DRAIN] schema migrations are idempotent across


spool.rs done (T2). Check spt-store access.rs (1 ref) + whether DrainedMsg needs re-export.


26-//! ## Recent-outbound correlation (`trust/recent-outbound.json`)
27-//!
28-\! The whitelist gates the **unsolicited** class only: an inbound correlated
29://! to the endpoint's own recent outbound (`__REPLY_TO__` traffic) passes from
30-//! any visible node — a stateful firewall, not a blanket block. The
31-\! [`RecentOutbound`] log is the correlation state: the daemon records
32-//! `(endpoint, dest_node)` when an endpoint sends off-node, and the gate's


Comment reference — reword per ADR-0020 (correlation keys on `from`).


//! The whitelist gates the **unsolicited** class only: an inbound correlated
//! to the endpoint's own recent outbound (`__REPLY_TO__` traffic) passes from
//! any visible node — a stateful firewall, not a blanket block. The

//! The whitelist gates the **unsolicited** class only: an inbound correlated
//! to the endpoint's own recent outbound (reply traffic, keyed on the inbound
//! `from`) passes from any visible node — a stateful firewall, not a blanket
//! block. The


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

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


24	//! `users` is the inert ADR-0006 cross-user schema reservation.
25	//!
26	//! ## Recent-outbound correlation (`trust/recent-outbound.json`)
27	//!
28	//! The whitelist gates the **unsolicited** class only: an inbound correlated
29	//! to the endpoint's own recent outbound (`__REPLY_TO__` traffic) passes from
30	//! any visible node — a stateful firewall, not a blanket block. The
31	//! [`RecentOutbound`] log is the correlation state: the daemon records
32	//! `(endpoint, dest_node)` when an endpoint sends off-node, and the gate's
33	//! classifier asks whether `(endpoint, origin_node)` appears within the reply
34	//! window. Durable (the sending CLI and the receiving daemon brain are
35	//! different processes; a brain restart must not flip replies to unsolicited),

//! `users` is the inert ADR-0006 cross-user schema reservation.
//!
//! ## Recent-outbound correlation (`trust/recent-outbound.json`)
//!
//! The whitelist gates the **unsolicited** class only: an inbound correlated
//! to the endpoint's own recent outbound (`__REPLY_TO__` traffic) passes from
//! any visible node — a stateful firewall, not a blanket block. The
//! [`RecentOutbound`] log is the correlation state: the daemon records
//! `(endpoint, dest_node)` when an endpoint sends off-node, and the gate's
//! classifier asks whether `(endpoint, origin_node)` appears within the reply
//! window. Durable (the sending CLI and the receiving daemon brain are
//! different processes; a brain restart must not flip replies to unsolicited),


//! The whitelist gates the **unsolicited** class only: an inbound correlated
//! to the endpoint's own recent outbound (`__REPLY_TO__` traffic) passes from
//! any visible node — a stateful firewall, not a blanket block. The

//! The whitelist gates the **unsolicited** class only: an inbound correlated
//! to the endpoint's own recent outbound (reply traffic, keyed on the inbound
//! `from`) passes from any visible node — a stateful firewall, not a blanket
//! block. The


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

//! The whitelist gates the **unsolicited** class only: an inbound correlated
//! to the endpoint's own recent outbound (`__REPLY_TO__` traffic) passes from
//! any visible node — a stateful firewall, not a blanket block. The

//! The whitelist gates the **unsolicited** class only: an inbound correlated
//! to the endpoint's own recent outbound (reply traffic, keyed on the inbound
//! `from`) passes from any visible node — a stateful firewall, not a blanket
//! block. The

//! ## Recent-outbound correlation (`trust/recent-outbound.json`)

-//! to the endpoint's own recent outbound (`__REPLY_TO__` traffic) passes from

-//! any visible node — a stateful firewall, not a blanket block. The

+//! to the endpoint's own recent outbound (reply traffic, keyed on the inbound

+//! `from`) passes from any visible node — a stateful firewall, not a blanket

//! [`RecentOutbound`] log is the correlation state: the daemon records

//! `(endpoint, dest_node)` when an endpoint sends off-node, and the gate's

//! classifier asks whether `(endpoint, origin_node)` appears within the reply


spt-store T2 done. Now T1 spt-msg. Rewrite wire.rs with structural `(from,body)` frame (inner u32 len(from) + from + body).


//! TCP length-prefixed message framing — **copied verbatim** from the sister
//! project's `common/protocol.rs` (ADR-0001: stable wire formats are copied,
//! not reinvented). This is the on-the-wire contract a sister `spt` and an
//! spt-core `spt` must agree on byte-for-byte.
//!
//! Frame: a 4-byte big-endian length prefix, then that many message bytes.
//! Message content is `__REPLY_TO__:{from}\n{body}\n`; an empty `from` omits
//! the `__REPLY_TO__:` line (anonymous delivery). The reply-to line is how the
//! receiver learns who to reply to — the routing seam REQ-MSG-1 depends on.
// [impl->REQ-MSG-1]

use std::io::{self, Read, Write};
use std::net::TcpStream;

/// Maximum framed message size accepted off the wire (16 MiB). Guards a
/// malicious/garbage length prefix from triggering a huge allocation.
const MAX_MESSAGE_BYTES: usize = 16 * 1024 * 1024;

/// Format a message body for the wire.
/// First line is `__REPLY_TO__:{from}`, the rest is the body, ending in a
/// newline. An empty `from_id` omits the reply-to line (anonymous delivery,
/// used for communes / sign-off where the recipient must not reply).
pub fn format_message(from_id: &str, body: &str) -> String {
    if from_id.is_empty() {
        format!("{}\n", body)
    } else {
        format!("__REPLY_TO__:{}\n{}\n", from_id, body)
    }
}

/// Write a length-prefixed message to a TCP stream.
/// Format: 4-byte big-endian length prefix, then the message bytes.
pub fn write_message(stream: &mut TcpStream, from_id: &str, body: &str) -> io::Result<()> {
    let msg = format_message(from_id, body);
    let msg_bytes = msg.as_bytes();
    let len = msg_bytes.len() as u32;
    stream.write_all(&len.to_be_bytes())?;
    stream.write_all(msg_bytes)?;
    stream.flush()?;
    Ok(())
}

/// Read a length-prefixed message from a TCP stream.
/// Reads the 4-byte big-endian length, then that many bytes, returning a
/// `String`. Rejects an oversized length prefix before allocating.
pub fn read_message(stream: &mut TcpStream) -> io::Result<String> {
    let mut len_buf = [0u8; 4];
    stream.read_exact(&mut len_buf)?;
    let len = u32::from_be_bytes(len_buf) as usize;

    if len > MAX_MESSAGE_BYTES {
        return Err(io::Error::new(
            io::ErrorKind::InvalidData,
            format!("Message too large: {} bytes", len),
        ));
    }

    let mut buf = vec![0u8; len];
    stream.read_exact(&mut buf)?;
    String::from_utf8(buf).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
}

//! TCP length-prefixed message framing.
//!
//! The frame carries `(from, body)` **structurally** — there is no
//! `__REPLY_TO__` serialization (ADR-0020: that string was a mis-elevated
//! relic; the only stable, copy-verbatim format is the `<EVENT>` envelope in
//! [`spt_proto::event`], composed once at the delivery edge, never on the wire).
//! spt-core mandates **no** byte-for-byte TCP interop with any sister `spt`.
//!
//! Frame: a 4-byte big-endian outer length prefix, then that many payload
//! bytes. Payload: `[u32 BE len(from)][from bytes][body bytes]` — the receiver
//! splits it straight back into `(from, body)`. An empty `from` (len 0) is an
//! anonymous delivery (commune / sign-off — the recipient must not reply).
// [impl->REQ-MSG-1]
// [impl->REQ-MSG-ENVELOPE]

use std::io::{self, Read, Write};
use std::net::TcpStream;

/// Maximum framed message size accepted off the wire (16 MiB). Guards a
/// malicious/garbage length prefix from triggering a huge allocation.
const MAX_MESSAGE_BYTES: usize = 16 * 1024 * 1024;

/// Encode `(from, body)` into the structural wire payload:
/// `[u32 BE len(from)][from][body]`. No `__REPLY_TO__`, no newline framing —
/// `from` and `body` are carried as raw byte ranges, so a body that is itself a
/// pre-formed `<EVENT>` envelope rides verbatim.
// [impl->REQ-MSG-ENVELOPE]
pub fn encode_frame(from_id: &str, body: &str) -> Vec<u8> {
    let from_bytes = from_id.as_bytes();
    let body_bytes = body.as_bytes();
    let mut payload = Vec::with_capacity(4 + from_bytes.len() + body_bytes.len());
    payload.extend_from_slice(&(from_bytes.len() as u32).to_be_bytes());
    payload.extend_from_slice(from_bytes);
    payload.extend_from_slice(body_bytes);
    payload
}

/// Decode the structural wire payload back into `(from, body)`. Rejects a
/// truncated/garbage `from` length prefix.
// [impl->REQ-MSG-ENVELOPE]
pub fn decode_frame(payload: &[u8]) -> io::Result<(String, String)> {
    if payload.len() < 4 {
        return Err(io::Error::new(
            io::ErrorKind::InvalidData,
            "frame shorter than the 4-byte from-length prefix",
        ));
    }
    let from_len = u32::from_be_bytes([payload[0], payload[1], payload[2], payload[3]]) as usize;
    if 4 + from_len > payload.len() {
        return Err(io::Error::new(
            io::ErrorKind::InvalidData,
            "from-length prefix overruns the frame",
        ));
    }
    let from = String::from_utf8(payload[4..4 + from_len].to_vec())
        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
    let body = String::from_utf8(payload[4 + from_len..].to_vec())
        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
    Ok((from, body))
}

/// Write a length-prefixed `(from, body)` message to a TCP stream.
/// Format: 4-byte big-endian outer length prefix, then the structural payload.
pub fn write_message(stream: &mut TcpStream, from_id: &str, body: &str) -> io::Result<()> {
    let payload = encode_frame(from_id, body);
    let len = payload.len() as u32;
    stream.write_all(&len.to_be_bytes())?;
    stream.write_all(&payload)?;
    stream.flush()?;
    Ok(())
}

/// Read a length-prefixed message from a TCP stream, returning `(from, body)`.
/// Reads the 4-byte big-endian outer length, then that many bytes, then splits
/// the structural payload. Rejects an oversized length prefix before allocating.
pub fn read_message(stream: &mut TcpStream) -> io::Result<(String, String)> {
    let mut len_buf = [0u8; 4];
    stream.read_exact(&mut len_buf)?;
    let len = u32::from_be_bytes(len_buf) as usize;

    if len > MAX_MESSAGE_BYTES {
        return Err(io::Error::new(
            io::ErrorKind::InvalidData,
            format!("Message too large: {} bytes", len),
        ));
    }

    let mut buf = vec![0u8; len];
    stream.read_exact(&mut buf)?;
    decode_frame(&buf)
}


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

//! TCP length-prefixed message framing.
//!
//! The frame carries `(from, body)` **structurally** — there is no
//! `__REPLY_TO__` serialization (ADR-0020: that string was a mis-elevated
//! relic; the only stable, copy-verbatim format is the `<EVENT>` envelope in
//! [`spt_proto::event`], composed once at the delivery edge, never on the wire).
//! spt-core mandates **no** byte-for-byte TCP interop with any sister `spt`.
//!
//! Frame: a 4-byte big-endian outer length prefix, then that many payload
//! bytes. Payload: `[u32 BE len(from)][from bytes][body bytes]` — the receiver
//! splits it straight back into `(from, body)`. An empty `from` (len 0) is an
//! anonymous delivery (commune / sign-off — the recipient must not reply).
// [impl->REQ-MSG-1]
// [impl->REQ-MSG-ENVELOPE]

use std::io::{self, Read, Write};
use std::net::TcpStream;

/// Maximum framed message size accepted off the wire (16 MiB). Guards a
/// malicious/garbage length prefix from triggering a huge allocation.
const MAX_MESSAGE_BYTES: usize = 16 * 1024 * 1024;

/// Encode `(from, body)` into the structural wire payload:
/// `[u32 BE len(from)][from][body]`. No `__REPLY_TO__`, no newline framing —
/// `from` and `body` are carried as raw byte ranges, so a body that is itself a
/// pre-formed `<EVENT>` envelope rides verbatim.
// [impl->REQ-MSG-ENVELOPE]
pub fn encode_frame(from_id: &str, body: &str) -> Vec<u8> {
    let from_bytes = from_id.as_bytes();
    let body_bytes = body.as_bytes();
    let mut payload = Vec::with_capacity(4 + from_bytes.len() + body_bytes.len());
    payload.extend_from_slice(&(from_bytes.len() as u32).to_be_bytes());
    payload.extend_from_slice(from_bytes);
    payload.extend_from_slice(body_bytes);
    payload
}

/// Decode the structural wire payload back into `(from, body)`. Rejects a
/// truncated/garbage `from` length prefix.
// [impl->REQ-MSG-ENVELOPE]
pub fn decode_frame(payload: &[u8]) -> io::Result<(String, String)> {
    if payload.len() < 4 {
        return Err(io::Error::new(
            io::ErrorKind::InvalidData,
            "frame shorter than the 4-byte from-length prefix",
        ));
    }
    let from_len = u32::from_be_bytes([payload[0], payload[1], payload[2], payload[3]]) as usize;
    if 4 + from_len > payload.len() {
        return Err(io::Error::new(
            io::ErrorKind::InvalidData,
            "from-length prefix overruns the frame",
        ));
    }
    let from = String::from_utf8(payload[4..4 + from_len].to_vec())
        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
    let body = String::from_utf8(payload[4 + from_len..].to_vec())
        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
    Ok((from, body))
}

/// Write a length-prefixed `(from, body)` message to a TCP stream.
/// Format: 4-byte big-endian outer length prefix, then the structural payload.
pub fn write_message(stream: &mut TcpStream, from_id: &str, body: &str) -> io::Result<()> {
    let payload = encode_frame(from_id, body);
    let len = payload.len() as u32;
    stream.write_all(&len.to_be_bytes())?;
    stream.write_all(&payload)?;
    stream.flush()?;
    Ok(())
}

/// Read a length-prefixed message from a TCP stream, returning `(from, body)`.
/// Reads the 4-byte big-endian outer length, then that many bytes, then splits
/// the structural payload. Rejects an oversized length prefix before allocating.
pub fn read_message(stream: &mut TcpStream) -> io::Result<(String, String)> {
    let mut len_buf = [0u8; 4];
    stream.read_exact(&mut len_buf)?;
    let len = u32::from_be_bytes(len_buf) as usize;

    if len > MAX_MESSAGE_BYTES {
        return Err(io::Error::new(
            io::ErrorKind::InvalidData,
            format!("Message too large: {} bytes", len),
        ));
    }

    let mut buf = vec![0u8; len];
    stream.read_exact(&mut buf)?;
    decode_frame(&buf)
}

//! TCP length-prefixed message framing — **copied verbatim** from the sister
//! project's `common/protocol.rs` (ADR-0001: stable wire formats are copied,
//! not reinvented). This is the on-the-wire contract a sister `spt` and an
//! spt-core `spt` must agree on byte-for-byte.
//!
//! Frame: a 4-byte big-endian length prefix, then that many message bytes.
//! Message content is `__REPLY_TO__:{from}\n{body}\n`; an empty `from` omits
//! the `__REPLY_TO__:` line (anonymous delivery). The reply-to line is how the
//! receiver learns who to reply to — the routing seam REQ-MSG-1 depends on.
// [impl->REQ-MSG-1]

use std::io::{self, Read, Write};
use std::net::TcpStream;

/// Maximum framed message size accepted off the wire (16 MiB). Guards a
/// malicious/garbage length prefix from triggering a huge allocation.
const MAX_MESSAGE_BYTES: usize = 16 * 1024 * 1024;

/// Format a message body for the wire.
/// First line is `__REPLY_TO__:{from}`, the rest is the body, ending in a
/// newline. An empty `from_id` omits the reply-to line (anonymous delivery,
/// used for communes / sign-off where the recipient must not reply).
pub fn format_message(from_id: &str, body: &str) -> String {
    if from_id.is_empty() {
        format!("{}\n", body)
    } else {
        format!("__REPLY_TO__:{}\n{}\n", from_id, body)
    }
}

/// Write a length-prefixed message to a TCP stream.
/// Format: 4-byte big-endian length prefix, then the message bytes.
pub fn write_message(stream: &mut TcpStream, from_id: &str, body: &str) -> io::Result<()> {
    let msg = format_message(from_id, body);
    let msg_bytes = msg.as_bytes();
    let len = msg_bytes.len() as u32;
    stream.write_all(&len.to_be_bytes())?;
    stream.write_all(msg_bytes)?;
    stream.flush()?;
    Ok(())
}

/// Read a length-prefixed message from a TCP stream.
/// Reads the 4-byte big-endian length, then that many bytes, returning a
/// `String`. Rejects an oversized length prefix before allocating.
pub fn read_message(stream: &mut TcpStream) -> io::Result<String> {
    let mut len_buf = [0u8; 4];
    stream.read_exact(&mut len_buf)?;
    let len = u32::from_be_bytes(len_buf) as usize;

    if len > MAX_MESSAGE_BYTES {
        return Err(io::Error::new(
            io::ErrorKind::InvalidData,
            format!("Message too large: {} bytes", len),
        ));
    }

    let mut buf = vec![0u8; len];
    stream.read_exact(&mut buf)?;
    String::from_utf8(buf).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::net::{TcpListener, TcpStream};

    // [unit->REQ-MSG-1] format_message shapes the __REPLY_TO__ contract.
    #[test]
    fn format_with_from_prepends_reply_to() {
        assert_eq!(
            format_message("alice", "hi bob"),
            "__REPLY_TO__:alice\nhi bob\n"
        );
    }

    // [unit->REQ-MSG-1] empty from => anonymous (no reply-to line).
    #[test]
    fn format_empty_from_is_anonymous() {
        assert_eq!(format_message("", "no reply"), "no reply\n");
    }

    // [unit->REQ-MSG-1] a framed message round-trips client -> listener byte-exact.
    #[test]
    fn frame_round_trips_over_loopback() {
        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
        let addr = listener.local_addr().unwrap();

        let handle = std::thread::spawn(move || {
            let (mut server, _) = listener.accept().unwrap();
            read_message(&mut server).unwrap()
        });

        let mut client = TcpStream::connect(addr).unwrap();
        write_message(&mut client, "alice", "hello\nworld").unwrap();
        drop(client);

        let got = handle.join().unwrap();
        assert_eq!(got, "__REPLY_TO__:alice\nhello\nworld\n");
    }

    // [unit->REQ-MSG-1] an oversized length prefix is rejected, not allocated.
    #[test]
    fn oversized_length_prefix_rejected() {
        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
        let addr = listener.local_addr().unwrap();

        let handle = std::thread::spawn(move || {
            let (mut server, _) = listener.accept().unwrap();
            read_message(&mut server)
        });

        let mut client = TcpStream::connect(addr).unwrap();
        // Claim 32 MiB (> MAX_MESSAGE_BYTES) then send nothing more.
        let bogus_len = (32u32 * 1024 * 1024).to_be_bytes();
        client.write_all(&bogus_len).unwrap();
        client.flush().unwrap();
        drop(client);

        let result = handle.join().unwrap();
        assert!(result.is_err());
        assert_eq!(result.unwrap_err().kind(), io::ErrorKind::InvalidData);
    }
}

-//! TCP length-prefixed message framing — **copied verbatim** from the sister

-//! project's `common/protocol.rs` (ADR-0001: stable wire formats are copied,

-//! not reinvented). This is the on-the-wire contract a sister `spt` and an

-//! spt-core `spt` must agree on byte-for-byte.

+//! TCP length-prefixed message framing.

-//! Frame: a 4-byte big-endian length prefix, then that many message bytes.

-//! Message content is `__REPLY_TO__:{from}\n{body}\n`; an empty `from` omits

-//! the `__REPLY_TO__:` line (anonymous delivery). The reply-to line is how the

-//! receiver learns who to reply to — the routing seam REQ-MSG-1 depends on.

+//! The frame carries `(from, body)` **structurally** — there is no

+//! `__REPLY_TO__` serialization (ADR-0020: that string was a mis-elevated

+//! relic; the only stable, copy-verbatim format is the `<EVENT>` envelope in

+//! [`spt_proto::event`], composed once at the delivery edge, never on the wire).

+//! spt-core mandates **no** byte-for-byte TCP interop with any sister `spt`.

+//! Frame: a 4-byte big-endian outer length prefix, then that many payload

+//! bytes. Payload: `[u32 BE len(from)][from bytes][body bytes]` — the receiver

+//! splits it straight back into `(from, body)`. An empty `from` (len 0) is an

+//! anonymous delivery (commune / sign-off — the recipient must not reply).

-/// Format a message body for the wire.

-/// First line is `__REPLY_TO__:{from}`, the rest is the body, ending in a

-/// newline. An empty `from_id` omits the reply-to line (anonymous delivery,

-/// used for communes / sign-off where the recipient must not reply).

-pub fn format_message(from_id: &str, body: &str) -> String {

-        format!("__REPLY_TO__:{}\n{}\n", from_id, body)

+/// Encode `(from, body)` into the structural wire payload:

+/// `[u32 BE len(from)][from][body]`. No `__REPLY_TO__`, no newline framing —

+/// `from` and `body` are carried as raw byte ranges, so a body that is itself a

+/// pre-formed `<EVENT>` envelope rides verbatim.

+pub fn encode_frame(from_id: &str, body: &str) -> Vec<u8> {

+    let from_bytes = from_id.as_bytes();

+    let mut payload = Vec::with_capacity(4 + from_bytes.len() + body_bytes.len());

+    payload.extend_from_slice(&(from_bytes.len() as u32).to_be_bytes());

+    payload.extend_from_slice(from_bytes);

+    payload.extend_from_slice(body_bytes);

+/// Decode the structural wire payload back into `(from, body)`. Rejects a

+/// truncated/garbage `from` length prefix.

+pub fn decode_frame(payload: &[u8]) -> io::Result<(String, String)> {

+            io::ErrorKind::InvalidData,

+            "frame shorter than the 4-byte from-length prefix",

+    let from_len = u32::from_be_bytes([payload[0], payload[1], payload[2], payload[3]]) as usize;

+            "from-length prefix overruns the frame",

+    let from = String::from_utf8(payload[4..4 + from_len].to_vec())

+        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;

+    let body = String::from_utf8(payload[4 + from_len..].to_vec())

-/// Write a length-prefixed message to a TCP stream.

-/// Format: 4-byte big-endian length prefix, then the message bytes.

+/// Write a length-prefixed `(from, body)` message to a TCP stream.

+/// Format: 4-byte big-endian outer length prefix, then the structural payload.

-    let msg = format_message(from_id, body);

+    let payload = encode_frame(from_id, body);

-/// Read a length-prefixed message from a TCP stream.

-/// Reads the 4-byte big-endian length, then that many bytes, returning a

-/// `String`. Rejects an oversized length prefix before allocating.

-pub fn read_message(stream: &mut TcpStream) -> io::Result<String> {

+/// Read a length-prefixed message from a TCP stream, returning `(from, body)`.

+/// Reads the 4-byte big-endian outer length, then that many bytes, then splits

+/// the structural payload. Rejects an oversized length prefix before allocating.

+pub fn read_message(stream: &mut TcpStream) -> io::Result<(String, String)> {

-    String::from_utf8(buf).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))


Now migrate wire.rs tests to structural frame.


    // [unit->REQ-MSG-1] format_message shapes the __REPLY_TO__ contract.
    #[test]
    fn format_with_from_prepends_reply_to() {
        assert_eq!(
            format_message("alice", "hi bob"),
            "__REPLY_TO__:alice\nhi bob\n"
        );
    }

    // [unit->REQ-MSG-1] empty from => anonymous (no reply-to line).
    #[test]
    fn format_empty_from_is_anonymous() {
        assert_eq!(format_message("", "no reply"), "no reply\n");
    }

    // [unit->REQ-MSG-1] a framed message round-trips client -> listener byte-exact.
    #[test]
    fn frame_round_trips_over_loopback() {
        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
        let addr = listener.local_addr().unwrap();

        let handle = std::thread::spawn(move || {
            let (mut server, _) = listener.accept().unwrap();
            read_message(&mut server).unwrap()
        });

        let mut client = TcpStream::connect(addr).unwrap();
        write_message(&mut client, "alice", "hello\nworld").unwrap();
        drop(client);

        let got = handle.join().unwrap();
        assert_eq!(got, "__REPLY_TO__:alice\nhello\nworld\n");
    }

    // [unit->REQ-MSG-ENVELOPE] encode/decode carries (from, body) structurally —
    // no __REPLY_TO__ string, body byte-ranges preserved (incl interior newlines).
    #[test]
    fn encode_decode_round_trips_structural() {
        let payload = encode_frame("alice", "hi\nbob");
        assert_eq!(decode_frame(&payload).unwrap(), ("alice".to_string(), "hi\nbob".to_string()));
    }

    // [unit->REQ-MSG-ENVELOPE] empty from => anonymous (len-0 from prefix), body intact.
    #[test]
    fn encode_empty_from_is_anonymous() {
        let payload = encode_frame("", "no reply");
        assert_eq!(decode_frame(&payload).unwrap(), ("".to_string(), "no reply".to_string()));
    }

    // [unit->REQ-MSG-ENVELOPE] a typed <EVENT> body rides the wire verbatim — the
    // structural frame never reparses or rewraps it.
    #[test]
    fn typed_event_body_rides_verbatim() {
        let typed = r#"<EVENT type="notify" from="issuer">done</EVENT>"#;
        let payload = encode_frame("issuer", typed);
        assert_eq!(decode_frame(&payload).unwrap().1, typed);
    }

    // [unit->REQ-MSG-ENVELOPE] a truncated from-length prefix is rejected, not panicked.
    #[test]
    fn decode_rejects_overrunning_from_len() {
        // Claim from_len = 100 on a 6-byte payload.
        let mut bad = (100u32).to_be_bytes().to_vec();
        bad.extend_from_slice(b"ab");
        assert_eq!(
            decode_frame(&bad).unwrap_err().kind(),
            io::ErrorKind::InvalidData
        );
    }

    // [unit->REQ-MSG-1] a framed message round-trips client -> listener as (from, body).
    #[test]
    fn frame_round_trips_over_loopback() {
        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
        let addr = listener.local_addr().unwrap();

        let handle = std::thread::spawn(move || {
            let (mut server, _) = listener.accept().unwrap();
            read_message(&mut server).unwrap()
        });

        let mut client = TcpStream::connect(addr).unwrap();
        write_message(&mut client, "alice", "hello\nworld").unwrap();
        drop(client);

        let got = handle.join().unwrap();
        assert_eq!(got, ("alice".to_string(), "hello\nworld".to_string()));
    }