1	//! WAN message records over broker QUIC streams (M4-D5a) — the REQ-MSG path,
2	//! now off-node.
3	//!
4	//! D4 gave the broker-owned streams a brain can drive across restarts; D3 gave
5	//! addressing (per-subnet registry + the refuse-ambiguous resolution policy).
6	//! This module is the thin wire seam between them and the existing local
7	//! delivery path: a minimal message record, the shared NDJSON framing
8	//! ([`crate::net::ndjson`]), and a routing helper that decides local-vs-WAN
9	//! from a [`Resolution`]. The receive-side funnel (op-id dedup → access gate →
10	//! `spt_msg::deliver`) lives in the daemon, which owns the perch/owlery
11	//! machinery.
12	//!
13	//! ## Origin is transport truth, never payload (REQ-HAZARD-WAN-ORIGIN-AUTH)
14	//!
15	//! A [`WanMessage`] deliberately carries **no origin-node field**: the access
16	//! gate's subject (ADR-0009 origin-node whitelist) is the QUIC connection's
17	//! handshake-proven remote node id (iroh `EndpointId` == the Ed25519 node
18	//! pubkey, the REQ-NET-1 identity binding) — read from the broker's stream
19	//! table, never from record bytes. A forged `origin_node`/`node` field inside
20	//! the JSON decodes as an ignored unknown field and influences nothing. The
21	//! `from` field is reply-routing metadata (`__REPLY_TO__`), not an
22	//! authorization subject.
23
24	use serde::{Deserialize, Serialize};
25
26	use crate::net::ndjson::{self, NdjsonDecoder};
27	use crate::net::registry::{Ambiguity, Resolution};
28
29	/// One WAN message record: a message for `target` (an endpoint id at the
30	/// receiving node), as sent by `from` (reply-routing metadata). `op_id` is the
31	/// sender-minted durable idempotency key — the receiver dedups on it before
32	/// delivering, so a replayed feed (sender redrive, receiver brain restart
33	/// resubscribing from an earlier cursor) delivers exactly once
34	/// (REQ-HAZARD-RESTART-IDEMPOTENT, now wire-fed).
35	// [impl->REQ-NET-1]
36	// [impl->REQ-HAZARD-WAN-ORIGIN-AUTH]
37	#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
38	pub struct WanMessage {
39	    /// The endpoint the message is for (resolved at the receiving node).
40	    pub target: String,
41	    /// The sender endpoint id — the `__REPLY_TO__` line at delivery (empty =
42	    /// anonymous). Metadata only; never an authorization subject.
43	    pub from: String,
44	    /// The message body.
45	    pub body: String,
46	    /// Sender-minted durable idempotency key, unique per logical send (e.g.
47	    /// `<sender-node-hex>:<epoch>` from the node's own `EpochSource`).
48	    pub op_id: String,
49	}
50
51	impl WanMessage {
52	    /// Encode as one NDJSON line (the JSON document + `\n`).
53	    pub fn encode_line(&self) -> Vec<u8> {
54	        ndjson::encode_line(self)
55	    }
56	}
57
58	/// The WAN message feed's NDJSON reassembler — the shared
59	/// [`NdjsonDecoder`](crate::net::ndjson::NdjsonDecoder) typed to
60	/// [`WanMessage`] records. One decoder per stream.
61	pub type WanDecoder = NdjsonDecoder<WanMessage>;
62
63	/// Where a send should go, decided from a registry [`Resolution`].
64	#[derive(Debug, Clone, PartialEq, Eq)]
65	pub enum WanRoute {
66	    /// A live instance on this node — the existing local path
67	    /// (`spt_msg::deliver`), no wire.
68	    Local,
69	    /// A live instance on another node — dial and send a [`WanMessage`].
70	    /// `subnet` is set when resolution crossed a per-subnet registry.
71	    Remote { subnet: Option<String>, node: String },
72	    /// Resolution refused — force qualification (D3c policy: never guess).
73	    Refused(Ambiguity),
74	    /// No live (routable) instance anywhere.
75	    NotFound,
76	}
77
78	/// Map a [`Resolution`] to a [`WanRoute`] against this node's own id. The
79	/// resolution policy (local-first, refuse-ambiguous, `@node` pin, visibility)
80	/// already ran inside `resolve_across_visible`; this only splits its hit into
81	/// the local and wire legs.
82	// [impl->REQ-NET-1]
83	pub fn route_wan(resolution: Resolution, local_node: &str) -> WanRoute {
84	    match resolution {
85	        Resolution::Resolved { instance, .. } if instance.node == local_node => WanRoute::Local,
86	        Resolution::Resolved { subnet, instance } => {
87	            WanRoute::Remote { subnet, node: instance.node }
88	        }
89	        Resolution::Ambiguous(why) => WanRoute::Refused(why),
90	        Resolution::NotFound => WanRoute::NotFound,
91	    }
92	}
93
94	#[cfg(test)]
95	mod tests {
96	    use super::*;
97	    use crate::net::registry::{Instance, Status};
98
99	    fn resolved(node: &str) -> Resolution {
100	        Resolution::Resolved {
101	            subnet: Some("home".into()),
102	            instance: Instance { node: node.into(), status: Status::Active, epoch: 1 },
103	        }
104	    }
105
106	    // [unit->REQ-NET-1] a record round-trips through the NDJSON line encoding,
107	    // and an unknown future field decodes (wire forward-compat).
108	    #[test]
109	    fn message_round_trips_and_tolerates_unknown_field() {
110	        let m = WanMessage {
111	            target: "ling".into(),
112	            from: "doyle".into(),
113	            body: "hello across the wire".into(),
114	            op_id: "cafe:42".into(),
115	        };
116	        let line = m.encode_line();
117	        assert_eq!(line.last(), Some(&b'\n'));
118	        let back: WanMessage = serde_json::from_slice(&line[..line.len() - 1]).unwrap();
119	        assert_eq!(back, m);
120
121	        let wire = br#"{"target":"ling","from":"doyle","body":"x","op_id":"n:1","added_at_d6":true}"#;
122	        let future: WanMessage = serde_json::from_slice(wire).unwrap();
123	        assert_eq!(future.op_id, "n:1");
124	    }
125
126	    // [unit->REQ-HAZARD-WAN-ORIGIN-AUTH] the record has no origin-node field:
127	    // a forged `origin_node` (or `node`) inside the JSON decodes as an ignored
128	    // unknown field — nothing in the decoded record can impersonate a node.
129	    // The gate's subject comes from the QUIC handshake (the broker's stream
130	    // table), asserted end-to-end in the daemon's wanmsg int test.
131	    #[test]
132	    fn forged_origin_field_is_inert() {
133	        let wire = br#"{"target":"ling","from":"doyle","body":"x","op_id":"n:1","origin_node":"evil","node":"evil"}"#;
134	        let decoded: WanMessage = serde_json::from_slice(wire).unwrap();
135	        // Every field the record actually has is accounted for — there is no
136	        // place for the forged origin to land.
137	        assert_eq!(
138	            decoded,
139	            WanMessage {
140	                target: "ling".into(),
141	                from: "doyle".into(),
142	                body: "x".into(),
143	                op_id: "n:1".into()
144	            }
145	        );
146	    }
147
148	    // [unit->REQ-NET-1] route_wan splits a resolution into the local and wire
149	    // legs and passes refusals through (never guess past an ambiguity).
150	    #[test]
151	    fn route_splits_local_and_remote_and_passes_refusals() {
152	        assert_eq!(route_wan(resolved("here"), "here"), WanRoute::Local);
153	        assert_eq!(
154	            route_wan(resolved("there"), "here"),
155	            WanRoute::Remote { subnet: Some("home".into()), node: "there".into() }
156	        );
157	        let amb = Resolution::Ambiguous(Ambiguity::AcrossNodes(vec!["a".into(), "b".into()]));
158	        assert_eq!(
159	            route_wan(amb, "here"),
160	            WanRoute::Refused(Ambiguity::AcrossNodes(vec!["a".into(), "b".into()]))
161	        );
162	        assert_eq!(route_wan(Resolution::NotFound, "here"), WanRoute::NotFound);
163	    }
164	}
165

//! WAN message records over broker QUIC streams (M4-D5a) — the REQ-MSG path,
//! now off-node.
//!
//! D4 gave the broker-owned streams a brain can drive across restarts; D3 gave
//! addressing (per-subnet registry + the refuse-ambiguous resolution policy).
//! This module is the thin wire seam between them and the existing local
//! delivery path: a minimal message record, the shared NDJSON framing
//! ([`crate::net::ndjson`]), and a routing helper that decides local-vs-WAN
//! from a [`Resolution`]. The receive-side funnel (op-id dedup → access gate →
//! `spt_msg::deliver`) lives in the daemon, which owns the perch/owlery
//! machinery.
//!
//! ## Origin is transport truth, never payload (REQ-HAZARD-WAN-ORIGIN-AUTH)
//!
//! A [`WanMessage`] deliberately carries **no origin-node field**: the access
//! gate's subject (ADR-0009 origin-node whitelist) is the QUIC connection's
//! handshake-proven remote node id (iroh `EndpointId` == the Ed25519 node
//! pubkey, the REQ-NET-1 identity binding) — read from the broker's stream
//! table, never from record bytes. A forged `origin_node`/`node` field inside
//! the JSON decodes as an ignored unknown field and influences nothing. The
//! `from` field is reply-routing metadata (`__REPLY_TO__`), not an
//! authorization subject.

use serde::{Deserialize, Serialize};

use crate::net::ndjson::{self, NdjsonDecoder};
use crate::net::registry::{Ambiguity, Resolution};

/// One WAN message record: a message for `target` (an endpoint id at the
/// receiving node), as sent by `from` (reply-routing metadata). `op_id` is the
/// sender-minted durable idempotency key — the receiver dedups on it before
/// delivering, so a replayed feed (sender redrive, receiver brain restart
/// resubscribing from an earlier cursor) delivers exactly once
/// (REQ-HAZARD-RESTART-IDEMPOTENT, now wire-fed).
// [impl->REQ-NET-1]
// [impl->REQ-HAZARD-WAN-ORIGIN-AUTH]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WanMessage {
    /// The endpoint the message is for (resolved at the receiving node).
    pub target: String,
    /// The sender endpoint id — the `__REPLY_TO__` line at delivery (empty =
    /// anonymous). Metadata only; never an authorization subject.
    pub from: String,
    /// The message body.
    pub body: String,
    /// Sender-minted durable idempotency key, unique per logical send (e.g.
    /// `<sender-node-hex>:<epoch>` from the node's own `EpochSource`).
    pub op_id: String,
}

impl WanMessage {
    /// Encode as one NDJSON line (the JSON document + `\n`).
    pub fn encode_line(&self) -> Vec<u8> {
        ndjson::encode_line(self)
    }
}

/// The WAN message feed's NDJSON reassembler — the shared
/// [`NdjsonDecoder`](crate::net::ndjson::NdjsonDecoder) typed to
/// [`WanMessage`] records. One decoder per stream.
pub type WanDecoder = NdjsonDecoder<WanMessage>;

/// Where a send should go, decided from a registry [`Resolution`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WanRoute {
    /// A live instance on this node — the existing local path
    /// (`spt_msg::deliver`), no wire.
    Local,
    /// A live instance on another node — dial and send a [`WanMessage`].
    /// `subnet` is set when resolution crossed a per-subnet registry.
    Remote { subnet: Option<String>, node: String },
    /// Resolution refused — force qualification (D3c policy: never guess).
    Refused(Ambiguity),
    /// No live (routable) instance anywhere.
    NotFound,
}

/// Map a [`Resolution`] to a [`WanRoute`] against this node's own id. The
/// resolution policy (local-first, refuse-ambiguous, `@node` pin, visibility)
/// already ran inside `resolve_across_visible`; this only splits its hit into
/// the local and wire legs.
// [impl->REQ-NET-1]
pub fn route_wan(resolution: Resolution, local_node: &str) -> WanRoute {
    match resolution {
        Resolution::Resolved { instance, .. } if instance.node == local_node => WanRoute::Local,
        Resolution::Resolved { subnet, instance } => {
            WanRoute::Remote { subnet, node: instance.node }
        }
        Resolution::Ambiguous(why) => WanRoute::Refused(why),
        Resolution::NotFound => WanRoute::NotFound,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::net::registry::{Instance, Status};

    fn resolved(node: &str) -> Resolution {
        Resolution::Resolved {
            subnet: Some("home".into()),
            instance: Instance { node: node.into(), status: Status::Active, epoch: 1 },
        }
    }

    // [unit->REQ-NET-1] a record round-trips through the NDJSON line encoding,
    // and an unknown future field decodes (wire forward-compat).
    #[test]
    fn message_round_trips_and_tolerates_unknown_field() {
        let m = WanMessage {
            target: "ling".into(),
            from: "doyle".into(),
            body: "hello across the wire".into(),
            op_id: "cafe:42".into(),
        };
        let line = m.encode_line();
        assert_eq!(line.last(), Some(&b'\n'));
        let back: WanMessage = serde_json::from_slice(&line[..line.len() - 1]).unwrap();
        assert_eq!(back, m);

        let wire = br#"{"target":"ling","from":"doyle","body":"x","op_id":"n:1","added_at_d6":true}"#;
        let future: WanMessage = serde_json::from_slice(wire).unwrap();
        assert_eq!(future.op_id, "n:1");
    }

    // [unit->REQ-HAZARD-WAN-ORIGIN-AUTH] the record has no origin-node field:
    // a forged `origin_node` (or `node`) inside the JSON decodes as an ignored
    // unknown field — nothing in the decoded record can impersonate a node.
    // The gate's subject comes from the QUIC handshake (the broker's stream
    // table), asserted end-to-end in the daemon's wanmsg int test.
    #[test]
    fn forged_origin_field_is_inert() {
        let wire = br#"{"target":"ling","from":"doyle","body":"x","op_id":"n:1","origin_node":"evil","node":"evil"}"#;
        let decoded: WanMessage = serde_json::from_slice(wire).unwrap();
        // Every field the record actually has is accounted for — there is no
        // place for the forged origin to land.
        assert_eq!(
            decoded,
            WanMessage {
                target: "ling".into(),
                from: "doyle".into(),
                body: "x".into(),
                op_id: "n:1".into()
            }
        );
    }

    // [unit->REQ-NET-1] route_wan splits a resolution into the local and wire
    // legs and passes refusals through (never guess past an ambiguity).
    #[test]
    fn route_splits_local_and_remote_and_passes_refusals() {
        assert_eq!(route_wan(resolved("here"), "here"), WanRoute::Local);
        assert_eq!(
            route_wan(resolved("there"), "here"),
            WanRoute::Remote { subnet: Some("home".into()), node: "there".into() }
        );
        let amb = Resolution::Ambiguous(Ambiguity::AcrossNodes(vec!["a".into(), "b".into()]));
        assert_eq!(
            route_wan(amb, "here"),
            WanRoute::Refused(Ambiguity::AcrossNodes(vec!["a".into(), "b".into()]))
        );
        assert_eq!(route_wan(Resolution::NotFound, "here"), WanRoute::NotFound);
    }
}


217-required_stages = []
218-[[requirements]]
219-id = "REQ-INST-15"
220-title = "Immutable home subnet (assigned at creation: auto-if-one/ask-if-many) + spt fork (cross-subnet clone to a new identity, copy-then-diverge, not re-home); adapter chosen at creation from registered hostable adapters, changed only via launch/resume-under-new (ADR-0010)"
221-required_stages = []
222-[[requirements]]
223:id = "REQ-REACH-1"
224-title = "Off-node remote-drive detection + file transfer"
225-required_stages = []
226-[[requirements]]
227-id = "REQ-REACH-2"
228-title = "Remote command execution (deferred, consent-gated)"
229-required_stages = []


Found 7 files
crates\spt-daemon\src\lib.rs
crates\spt-daemon\src\brain.rs
crates\spt-daemon\src\broker.rs
crates\spt-daemon\src\msg.rs
crates\spt-daemon\src\nethost.rs
crates\spt-daemon\src\lifecycle.rs
crates\spt-daemon\src\consent.rs


ToolSearch

Question: select:TaskCreate,TaskUpdate


Question: select:TaskCreate,TaskUpdate


1	//! WAN message receive funnel (M4-D5a) — wire record → access gate → op-id
2	//! dedup → the existing local delivery path.
3	//!
4	//! The sending node resolved the target (D3c policy), dialed, and wrote
5	//! [`WanMessage`] NDJSON lines onto a broker-owned QUIC stream (D4). This
6	//! module is the receiving node's funnel from a decoded record to the local
7	//! REQ-MSG machinery — it adds exactly two things the local path doesn't have,
8	//! then delegates:
9	//!
10	//! 1. **the access gate** ([`crate::access`], ADR-0009): every wire-inbound
11	//!    record passes [`access_check`] with the **handshake-proven** origin node
12	//!    (the broker stream's `remote_id_hex` — never a payload field,
13	//!    REQ-HAZARD-WAN-ORIGIN-AUTH);
14	//! 2. **op-id idempotency** (REQ-HAZARD-RESTART-IDEMPOTENT): the wire feed
15	//!    replays — a sender's journaled redrive, or this brain restarting and
16	//!    resubscribing from an earlier cursor — so delivery dedups on the
17	//!    sender-minted durable `op_id`.
18	//!
19	//! ## Exactly-once boundaries (honest accounting)
20	//!
21	//! - **Spool path (offline target):** fully exactly-once — the op-id claim and
22	//!   the spool row commit in ONE SQLite transaction
23	//!   ([`spool::spool_wan_message_at`]); a crash on either side of the write
24	//!   replays to the same single row.
25	//! - **Live-TCP path:** check → TCP write → claim. A crash in the gap between
26	//!   a completed TCP write and the claim re-delivers that op on replay (an
27	//!   at-least-once window bounded to one in-flight record; the live listener
28	//!   path has no durable receipt to dedup against). Claim-before-write was
29	//!   rejected because a crash in ITS gap silently drops the message — for a
30	//!   messaging substrate, a rare duplicate beats a rare silent drop. Tracked
31	//!   in `docs/FAULT-MATRIX.md`.
32
33	use std::path::Path;
34
35	use spt_msg::deliver::{deliver_tcp, perch_exists};
36	use spt_net::net::wanmsg::WanMessage;
37	use spt_store::spool;
38
39	use crate::access::{access_check, AccessDecision, InboundClass};
40
41	/// What the funnel did with one inbound record.
42	#[derive(Debug, Clone, PartialEq, Eq)]
43	pub enum WanOutcome {
44	    /// Delivered live over TCP to the target's listener (op claimed after).
45	    DeliveredTcp,
46	    /// Spooled for later drain, atomically with the op claim.
47	    Spooled,
48	    /// The op_id was already claimed — a replay; nothing written.
49	    Duplicate,
50	    /// Refused at the access gate (ADR-0009) — dropped before any delivery.
51	    Refused,
52	    /// No perch for the target on this node — dropped (not claimed: if the
53	    /// perch appears later, a replayed record may still land).
54	    NoPerch,
55	}
56
57	/// Funnel one decoded wire record into local delivery. `origin_node` MUST be
58	/// the QUIC handshake-proven remote node id (the broker's
59	/// `NetStreamInfo::remote_id_hex` for the stream the record arrived on) —
60	/// never anything read out of record bytes (REQ-HAZARD-WAN-ORIGIN-AUTH).
61	// [impl->REQ-NET-1]
62	// [impl->REQ-HAZARD-WAN-ORIGIN-AUTH]
63	// [impl->REQ-HAZARD-RESTART-IDEMPOTENT]
64	pub fn receive_wan(msg: &WanMessage, origin_node: &str, owlery: &Path) -> WanOutcome {
65	    // Gate 2 of ADR-0009 (visibility ran at the sender's resolution; grants
66	    // gate capabilities, not messages). Wire inbound is Unsolicited until the
67	    // D5d reply-correlation lands.
68	    if access_check(&msg.target, origin_node, InboundClass::Unsolicited) == AccessDecision::Deny {
69	        return WanOutcome::Refused;
70	    }
71
72	    if !perch_exists(&msg.target) {
73	        return WanOutcome::NoPerch;
74	    }
75
76	    let perch_path = spt_store::perch::resolve_perch_path(&msg.target, spt_store::perch::ParentHint::Infer);
77
78	    // Replay check first: a claimed op is done regardless of which path
79	    // claimed it.
80	    match spool::wan_seen_at(&perch_path, &msg.op_id) {
81	        Ok(true) => return WanOutcome::Duplicate,
82	        Ok(false) => {}
83	        Err(_) => return WanOutcome::NoPerch,
84	    }
85
86	    // Live-TCP first (mirrors the local TCP-xor-spool discipline, 4.5), claim
87	    // after the completed write (see module docs for the window accounting).
88	    if deliver_tcp(&msg.target, &msg.from, &msg.body, owlery) {
89	        let _ = spool::wan_mark_seen_at(&perch_path, &msg.op_id);
90	        return WanOutcome::DeliveredTcp;
91	    }
92
93	    // Offline: claim + spool in one transaction — exactly-once.
94	    match spool::spool_wan_message_at(&perch_path, &msg.from, &msg.body, &msg.op_id) {
95	        Ok(true) => WanOutcome::Spooled,
96	        Ok(false) => WanOutcome::Duplicate,
97	        Err(_) => WanOutcome::NoPerch,
98	    }
99	}
100

//! WAN message receive funnel (M4-D5a) — wire record → access gate → op-id
//! dedup → the existing local delivery path.
//!
//! The sending node resolved the target (D3c policy), dialed, and wrote
//! [`WanMessage`] NDJSON lines onto a broker-owned QUIC stream (D4). This
//! module is the receiving node's funnel from a decoded record to the local
//! REQ-MSG machinery — it adds exactly two things the local path doesn't have,
//! then delegates:
//!
//! 1. **the access gate** ([`crate::access`], ADR-0009): every wire-inbound
//!    record passes [`access_check`] with the **handshake-proven** origin node
//!    (the broker stream's `remote_id_hex` — never a payload field,
//!    REQ-HAZARD-WAN-ORIGIN-AUTH);
//! 2. **op-id idempotency** (REQ-HAZARD-RESTART-IDEMPOTENT): the wire feed
//!    replays — a sender's journaled redrive, or this brain restarting and
//!    resubscribing from an earlier cursor — so delivery dedups on the
//!    sender-minted durable `op_id`.
//!
//! ## Exactly-once boundaries (honest accounting)
//!
//! - **Spool path (offline target):** fully exactly-once — the op-id claim and
//!   the spool row commit in ONE SQLite transaction
//!   ([`spool::spool_wan_message_at`]); a crash on either side of the write
//!   replays to the same single row.
//! - **Live-TCP path:** check → TCP write → claim. A crash in the gap between
//!   a completed TCP write and the claim re-delivers that op on replay (an
//!   at-least-once window bounded to one in-flight record; the live listener
//!   path has no durable receipt to dedup against). Claim-before-write was
//!   rejected because a crash in ITS gap silently drops the message — for a
//!   messaging substrate, a rare duplicate beats a rare silent drop. Tracked
//!   in `docs/FAULT-MATRIX.md`.

use std::path::Path;

use spt_msg::deliver::{deliver_tcp, perch_exists};
use spt_net::net::wanmsg::WanMessage;
use spt_store::spool;

use crate::access::{access_check, AccessDecision, InboundClass};

/// What the funnel did with one inbound record.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WanOutcome {
    /// Delivered live over TCP to the target's listener (op claimed after).
    DeliveredTcp,
    /// Spooled for later drain, atomically with the op claim.
    Spooled,
    /// The op_id was already claimed — a replay; nothing written.
    Duplicate,
    /// Refused at the access gate (ADR-0009) — dropped before any delivery.
    Refused,
    /// No perch for the target on this node — dropped (not claimed: if the
    /// perch appears later, a replayed record may still land).
    NoPerch,
}

/// Funnel one decoded wire record into local delivery. `origin_node` MUST be
/// the QUIC handshake-proven remote node id (the broker's
/// `NetStreamInfo::remote_id_hex` for the stream the record arrived on) —
/// never anything read out of record bytes (REQ-HAZARD-WAN-ORIGIN-AUTH).
// [impl->REQ-NET-1]
// [impl->REQ-HAZARD-WAN-ORIGIN-AUTH]
// [impl->REQ-HAZARD-RESTART-IDEMPOTENT]
pub fn receive_wan(msg: &WanMessage, origin_node: &str, owlery: &Path) -> WanOutcome {
    // Gate 2 of ADR-0009 (visibility ran at the sender's resolution; grants
    // gate capabilities, not messages). Wire inbound is Unsolicited until the
    // D5d reply-correlation lands.
    if access_check(&msg.target, origin_node, InboundClass::Unsolicited) == AccessDecision::Deny {
        return WanOutcome::Refused;
    }

    if !perch_exists(&msg.target) {
        return WanOutcome::NoPerch;
    }

    let perch_path = spt_store::perch::resolve_perch_path(&msg.target, spt_store::perch::ParentHint::Infer);

    // Replay check first: a claimed op is done regardless of which path
    // claimed it.
    match spool::wan_seen_at(&perch_path, &msg.op_id) {
        Ok(true) => return WanOutcome::Duplicate,
        Ok(false) => {}
        Err(_) => return WanOutcome::NoPerch,
    }

    // Live-TCP first (mirrors the local TCP-xor-spool discipline, 4.5), claim
    // after the completed write (see module docs for the window accounting).
    if deliver_tcp(&msg.target, &msg.from, &msg.body, owlery) {
        let _ = spool::wan_mark_seen_at(&perch_path, &msg.op_id);
        return WanOutcome::DeliveredTcp;
    }

    // Offline: claim + spool in one transaction — exactly-once.
    match spool::spool_wan_message_at(&perch_path, &msg.from, &msg.body, &msg.op_id) {
        Ok(true) => WanOutcome::Spooled,
        Ok(false) => WanOutcome::Duplicate,
        Err(_) => WanOutcome::NoPerch,
    }
}


NOTE FROM THE USER: proceed with next steps.


Bash

wc -l crates/spt-daemon/src/*.rs crates/spt-net/src/net/*.rs

Size up source files


    70 crates/spt-daemon/src/access.rs
   288 crates/spt-daemon/src/adapter_update.rs
   188 crates/spt-daemon/src/attach.rs
   644 crates/spt-daemon/src/brain.rs
   717 crates/spt-daemon/src/broker.rs
    96 crates/spt-daemon/src/codec.rs
   265 crates/spt-daemon/src/config.rs
   164 crates/spt-daemon/src/consent.rs
   242 crates/spt-daemon/src/daemon.rs
   372 crates/spt-daemon/src/digest.rs
   399 crates/spt-daemon/src/digesthub.rs
   426 crates/spt-daemon/src/effect.rs
    53 crates/spt-daemon/src/endpoint.rs
   209 crates/spt-daemon/src/frame.rs
   168 crates/spt-daemon/src/lib.rs
   659 crates/spt-daemon/src/lifecycle.rs
   551 crates/spt-daemon/src/msg.rs
   677 crates/spt-daemon/src/nethost.rs
   137 crates/spt-daemon/src/relay.rs
   459 crates/spt-daemon/src/release.rs
   285 crates/spt-daemon/src/seedmap.rs
    24 crates/spt-daemon/src/test_home.rs
   158 crates/spt-daemon/src/transport.rs
   372 crates/spt-daemon/src/update.rs
    99 crates/spt-daemon/src/wan.rs
   112 crates/spt-net/src/net/attach.rs
   400 crates/spt-net/src/net/endpoint.rs
    71 crates/spt-net/src/net/ndjson.rs
    34 crates/spt-net/src/net/pairing.rs
   852 crates/spt-net/src/net/registry.rs
   168 crates/spt-net/src/net/replicate.rs
   164 crates/spt-net/src/net/wanmsg.rs
  9523 total


2://! interim `api listen` runner's live half.
4://! In the no-daemon interim model the long-lived `spt api listen` process hosted


1	//! The brain's LiveAgent lifecycle host (B3) — the consolidated re-host of the
2	//! interim `api listen` runner's live half.
3	//!
4	//! In the no-daemon interim model the long-lived `spt api listen` process hosted
5	//! the LiveAgent lifecycle (Psyche spawn, pulse-driven drop ingest, echo fires,
6	//! graceful signoff) directly in the agent's session. Consolidation
7	//! (REQ-DAEMON-1) moves that orchestration **down into `spt-daemon`** so the
8	//! brain runs it as scheduled in-process loops, with a durable, configurable
9	//! pulse period ([`crate::config::DaemonConfig`]) instead of the interim 5s
10	//! constant. The `spt` binary's `LiveHost` becomes a thin shim over this type —
11	//! a re-host, not a rewrite: the heavy lifting stays in the already-pure
12	//! [`spt_live`] seams ([`spawn_psyche`], [`ingest_drops`], [`run_echo_commune`],
13	//! [`graceful_signoff`], [`sweep_stale_signoff`]); the ordering invariants
14	//! (echo-before-signoff 3.3, grace-before-signoff 1.1, stale-sweep 3.2) live
15	//! where they always have.
16	// [impl->REQ-DAEMON-1]
17
18	use std::collections::BTreeMap;
19	use std::path::PathBuf;
20	use std::sync::atomic::{AtomicBool, Ordering};
21	use std::time::{Duration, SystemTime, UNIX_EPOCH};
22
23	use spt_live::{
24	    fetch_history, graceful_signoff, ingest_drops, run_echo_commune, spawn_psyche,
25	    sweep_stale_signoff, HistoryRecord, Ingested, PsycheHandle, SignoffOutcome,
26	};
27	use spt_runtime::{Manifest, ManifestRuntime};
28	use spt_store::perch::{self, ParentHint};
29	use spt_store::proc::is_process_alive;
30
31	use crate::config::DaemonConfig;
32
33	/// Bound for a bounded history/echo invocation in the host (carried from M2b).
34	const LIVE_BOUNDED_TIMEOUT: Duration = Duration::from_secs(30);
35
36	/// Wall-clock now in epoch millis — precedence-marker stamp for ingested writes.
37	fn now_ms() -> u64 {
38	    SystemTime::now()
39	        .duration_since(UNIX_EPOCH)
40	        .map(|d| d.as_millis() as u64)
41	        .unwrap_or(0)
42	}
43
44	/// ISO-ish stamp for the psyche prompt (opaque to the Psyche; just a timestamp).
45	fn now_stamp() -> String {
46	    format!("epoch-ms:{}", now_ms())
47	}
48
49	/// One pulse tick's observed effects: any drops ingested + whether an echo fired.
50	#[derive(Debug, Clone, Default)]
51	pub struct TickReport {
52	    pub ingested: Vec<Ingested>,
53	    pub echo_fired: bool,
54	}
55
56	/// The daemon-hosted LiveAgent lifecycle: the runtime + watched drop dirs built
57	/// from a live-capable manifest, paced by a config-driven [`DaemonConfig`].
58	pub struct BrainLifecycle {
59	    runtime: ManifestRuntime,
60	    id: String,
61	    manifest: Manifest,
62	    commune_dir: Option<PathBuf>,
63	    signoff_dir: Option<PathBuf>,
64	    cfg: DaemonConfig,
65	}
66
67	impl BrainLifecycle {
68	    /// Build a host for `id` from a live-capable manifest (declares
69	    /// `[session.psyche_init]`); `None` for an absent manifest or a plain relay
70	    /// endpoint. Loads the durable [`DaemonConfig`] from disk.
71	    pub fn new_opt(manifest: Option<&Manifest>, id: &str) -> Option<BrainLifecycle> {
72	        Self::with_config(manifest?, id, DaemonConfig::load())
73	    }
74
75	    /// Build a host from a live-capable manifest with an explicit config (the
76	    /// testable seam). `None` if the manifest declares no Psyche.
77	    pub fn with_config(manifest: &Manifest, id: &str, cfg: DaemonConfig) -> Option<BrainLifecycle> {
78	        manifest.session.psyche_init.as_ref()?;
79	        Some(BrainLifecycle {
80	            runtime: ManifestRuntime::new(manifest.clone()),
81	            id: id.to_string(),
82	            commune_dir: manifest.session.commune_dir.as_ref().map(PathBuf::from),
83	            signoff_dir: manifest.session.signoff_dir.as_ref().map(PathBuf::from),
84	            manifest: manifest.clone(),
85	            cfg,
86	        })
87	    }
88
89	    /// The configured pulse period (config-driven, not a constant).
90	    pub fn pulse_period(&self) -> Duration {
91	        self.cfg.pulse_period
92	    }
93
94	    /// Base substitution keys spt-core fills for the lifecycle templates.
95	    fn base_keys(&self, session_id: Option<&str>) -> BTreeMap<String, String> {
96	        let mut k = BTreeMap::new();
97	        k.insert("id".to_string(), self.id.clone());
98	        if let Some(sid) = session_id {
99	            k.insert("session_id".to_string(), sid.to_string());
100	        }
101	        k
102	    }
103
104	    /// The unique watched drop dirs (commune + signoff; usually the same dir).
105	    fn drop_dirs(&self) -> Vec<PathBuf> {
106	        let mut dirs: Vec<PathBuf> = Vec::new();
107	        for d in [self.commune_dir.as_ref(), self.signoff_dir.as_ref()]
108	            .into_iter()
109	            .flatten()
110	        {
111	            if !dirs.contains(d) {
112	                dirs.push(d.clone());
113	            }
114	        }
115	        dirs
116	    }
117
118	    /// Sweep a stale signoff sentinel on start (3.2). No-op without a signoff dir.
119	    pub fn sweep_on_start(&self) {
120	        if let Some(dir) = &self.signoff_dir {
121	            let _ = sweep_stale_signoff(dir, &self.id);
122	        }
123	    }
124
125	    /// Spawn the Psyche companion (T2). `event` is the incoming envelope when the
126	    /// spawn is event-driven; `None` at cold start.
127	    ///
128	    /// The Psyche is **daemon-hosted**: its liveness is daemon-authoritative, not
129	    /// the ephemeral summarizer pid `spawn_psyche` records (2.5). So immediately
130	    /// after binding, mark the perch `online` — from here liveness reads the
131	    /// daemon `status`, and a dead summarizer subprocess no longer flips the perch
132	    /// offline. [`mark_offline`](Self::mark_offline) is the teardown counterpart.
133	    // [impl->REQ-HAZARD-DAEMON-HOSTED-LIVENESS]
134	    pub fn spawn_psyche(
135	        &self,
136	        session_id: Option<&str>,
137	        event: Option<&str>,
138	    ) -> Result<PsycheHandle, String> {
139	        let handle =
140	            spawn_psyche(&self.runtime, &self.id, &self.base_keys(session_id), event, &now_stamp())
141	                .map_err(|e| e.to_string())?;
142	        spt_store::info::set_status(&handle.perch_path, spt_store::liveness::STATUS_ONLINE)
143	            .map_err(|e| format!("mark psyche online: {e}"))?;
144	        Ok(handle)
145	    }
146
147	    /// Mark a daemon-hosted perch `offline` (teardown counterpart of the
148	    /// `online` stamp in [`spawn_psyche`](Self::spawn_psyche)). From this point
149	    /// the single liveness resolver reports the perch dead regardless of any pid
150	    /// still recorded in its `info.json` (2.5). Best-effort: a missing record
151	    /// (perch already gone) is not an error.
152	    // [impl->REQ-HAZARD-DAEMON-HOSTED-LIVENESS]
153	    pub fn mark_offline(perch_path: &std::path::Path) {
154	        let _ = spt_store::info::set_status(perch_path, spt_store::liveness::STATUS_OFFLINE);
155	    }
156
157	    /// Stamp this endpoint's `last_active_ms` recency marker (REQ-UPD-4) — the
158	    /// signal the consent gate resolves "most-recently-active live session"
159	    /// against ([`crate::consent::most_recently_active`]). Best-effort: a hosted
160	    /// perch that has not been bound yet (no `info.json`) is silently skipped, so
161	    /// a pre-bind tick never errors. Stamps the Self perch for this brain's id.
162	    // [impl->REQ-UPD-4]
163	    pub fn touch_active(&self) {
164	        let perch_path = perch::resolve_perch_path(&self.id, ParentHint::Infer);
165	        let _ = spt_store::info::set_last_active(&perch_path, now_ms());
166	    }
167
168	    /// One pulse tick (T5): refresh the activity stamp (REQ-UPD-4), ingest drops
169	    /// from the watched dirs (T4), then if the echo-gate is armed fire exactly one
170	    /// echo-commune (T3).
171	    pub fn pulse_tick(&self, session_id: Option<&str>) -> Result<TickReport, String> {
172	        self.touch_active();
173	        let now = now_ms();
174	        let mut ingested = Vec::new();
175	        for dir in self.drop_dirs() {
176	            let mut got =
177	                ingest_drops(&dir, &self.id, ParentHint::Infer, now, self.cfg.protection_window_ms)
178	                    .map_err(|e| e.to_string())?;
179	            ingested.append(&mut got);
180	        }
181	        let echo_fired = if spt_live::pulse::take_echo_gate(&self.id, ParentHint::Infer) {
182	            self.fire_echo(session_id)?;
183	            true
184	        } else {
185	            false
186	        };
187	        Ok(TickReport { ingested, echo_fired })
188	    }
189
190	    /// Run the scheduled pulse loop until `stop` is set, calling `on_tick` after
191	    /// each tick (so a caller — or a test — observes cadence and can decide to
192	    /// stop). The period comes from the durable config, never a constant. The
193	    /// loop holds no state beyond the cursor on disk, so it is freely
194	    /// killable + respawnable: a fresh loop re-reads the drop dirs and gate.
195	    pub fn run_pulse_loop<F: FnMut(&TickReport)>(
196	        &self,
197	        session_id: Option<&str>,
198	        stop: &AtomicBool,
199	        mut on_tick: F,
200	    ) {
201	        while !stop.load(Ordering::Relaxed) {
202	            match self.pulse_tick(session_id) {
203	                Ok(report) => on_tick(&report),
204	                Err(_) => { /* a transient tick error must not kill the loop */ }
205	            }
206	            // Sleep the configured period in small slices so a stop is honored
207	            // promptly rather than after a full (possibly multi-second) period.
208	            sleep_interruptible(self.cfg.pulse_period, stop);
209	        }
210	    }
211
212	    /// Fetch history per the manifest `[history]` strategy (empty if none).
213	    fn history(&self, session_id: Option<&str>) -> Vec<HistoryRecord> {
214	        let Some(h) = &self.manifest.history else {
215	            return Vec::new();
216	        };
217	        fetch_history(h, &self.base_keys(session_id), &self.id, ParentHint::Infer, LIVE_BOUNDED_TIMEOUT)
218	            .unwrap_or_default()
219	    }
220
221	    /// Run the echo-commune into the commune dir (T3). No-op (Ok) without a dir.
222	    fn fire_echo(&self, session_id: Option<&str>) -> Result<(), String> {
223	        let Some(dir) = &self.commune_dir else {
224	            return Ok(());
225	        };
226	        let history = self.history(session_id);
227	        run_echo_commune(
228	            &self.runtime,
229	            &self.id,
230	            &self.base_keys(session_id),
231	            &history,
232	            dir,
233	            LIVE_BOUNDED_TIMEOUT,
234	        )
235	        .map(|_| ())
236	        .map_err(|e| e.to_string())
237	    }
238
239	    /// Execute the graceful signoff (T6): echo-commune BEFORE teardown (3.3),
240	    /// grace recheck before any signoff write (1.1). The graceful path is an
241	    /// **intentional** shutdown, so it uses zero grace + an always-gone recheck —
242	    /// the Self is leaving on purpose, there is nothing to recover. The
243	    /// supervised-crash path ([`supervised_crash_signoff`](Self::supervised_crash_signoff))
244	    /// supplies the real grace + liveness recheck for an *un*intentional death.
245	    pub fn graceful_signoff(&self, session_id: Option<&str>) -> Result<SignoffOutcome, String> {
246	        self.signoff_with(session_id, Duration::ZERO, || true)
247	    }
248
249	    /// Execute the **supervised-crash** signoff (B7, orphan-watch) — the
250	    /// counterpart of [`graceful_signoff`](Self::graceful_signoff) for an
251	    /// *unexpected* harness death. It runs the identical load-bearing ordering —
252	    /// echo-commune (3.3) → grace wait (1.1) → liveness recheck → teardown — but
253	    /// with the **real** grace window from config and the **real** recheck
254	    /// `still_gone`. A Self that recovered across the grace window (a binary
255	    /// handoff relaunch, a brief stale poll) makes `still_gone` false and is
256	    /// **spared** — the phantom-INIT_SIGNOFF bug (1.1).
257	    ///
258	    /// `still_gone` is the post-grace liveness verdict (true ⇒ the harness owner
259	    /// is still gone ⇒ tear down). [`run_orphan_watch`](Self::run_orphan_watch)
260	    /// supplies the production recheck ([`harness_owner_gone`](Self::harness_owner_gone));
261	    /// it is injected here so the ordering is testable without a real pid.
262	    // [impl->REQ-DAEMON-4]
263	    // [impl->REQ-HAZARD-GRACE-BEFORE-SIGNOFF]
264	    // [impl->REQ-HAZARD-ECHO-BEFORE-SIGNOFF]
265	    pub fn supervised_crash_signoff(
266	        &self,
267	        session_id: Option<&str>,
268	        still_gone: impl FnOnce() -> bool,
269	    ) -> Result<SignoffOutcome, String> {
270	        self.signoff_with(session_id, self.cfg.crash_grace, still_gone)
271	    }
272
273	    /// The shared signoff wiring behind both the graceful and supervised-crash
274	    /// paths. The only two things that differ between them are exactly the two
275	    /// the 1.1 hazard turns on — the `grace` window and the `recheck` verdict — so
276	    /// they are the only parameters; the echo (3.3) and teardown effects are
277	    /// identical. Keeping one body guarantees the crash path can never drift out
278	    /// of the echo-before-teardown ordering the graceful path proves.
279	    fn signoff_with(
280	        &self,
281	        session_id: Option<&str>,
282	        grace: Duration,
283	        recheck: impl FnOnce() -> bool,
284	    ) -> Result<SignoffOutcome, String> {
285	        let mut echo_err: Option<String> = None;
286	        let mut teardown_err: Option<String> = None;
287	        let outcome = graceful_signoff::<std::convert::Infallible>(
288	            session_id,
289	            grace,
290	            || {
291	                if let Err(e) = self.fire_echo(session_id) {
292	                    echo_err = Some(e);
293	                }
294	                Ok(())
295	            },
296	            recheck,
297	            || {
298	                let ready = perch::resolve_ready_file(&self.id, ParentHint::Infer);
299	                if let Err(e) = std::fs::remove_file(&ready) {
300	                    if e.kind() != std::io::ErrorKind::NotFound {
301	                        teardown_err = Some(e.to_string());
302	                    }
303	                }
304	                Ok(())
305	            },
306	        )
307	        .unwrap_or(SignoffOutcome { echo_ran: false, signed_off: false });
308	        if let Some(e) = echo_err {
309	            return Err(format!("echo failed during signoff: {e}"));
310	        }
311	        if let Some(e) = teardown_err {
312	            return Err(format!("teardown failed during signoff: {e}"));
313	        }
314	        Ok(outcome)
315	    }
316
317	    /// Whether the supervised Self's **harness owner** is gone. Anchors liveness on
318	    /// the stable harness-session pid (`info.json` `parent_pid`), never an
319	    /// ephemeral poll pid (KNOWN-HAZARDS 2.1): a recycled ephemeral pid could read
320	    /// a foreign process as the Self and mask a real death, or flip a live Self
321	    /// dead. Re-reads the perch fresh so a Self that **rebound** under a new
322	    /// harness (a recovery) — recording a fresh, live `parent_pid` — reads alive.
323	    /// A perch with no recorded owner is treated as gone (nothing supervises it).
324	    // [impl->REQ-DAEMON-4]
325	    pub fn harness_owner_gone(&self) -> bool {
326	        let path = perch::resolve_perch_path(&self.id, ParentHint::Infer);
327	        match spt_store::info::read_info(&path).and_then(|i| i.parent_pid) {
328	            Some(pid) => !is_process_alive(pid),
329	            None => true,
330	        }
331	    }
332
333	    /// Supervise a harness-hosted Self and carry the graceful ordering into the
334	    /// **supervised-crash** path the always-on daemon now enables (B7). On start it
335	    /// sweeps any stale signoff sentinel (3.2) so a prior generation's leftover
336	    /// never reads as a live signoff. It then polls the harness owner pid each
337	    /// cycle (anchored on `parent_pid`, 2.1); on the first observed death it runs
338	    /// [`supervised_crash_signoff`](Self::supervised_crash_signoff) with the real
339	    /// grace + a fresh liveness recheck ([`harness_owner_gone`](Self::harness_owner_gone)),
340	    /// so a transient recovery across the grace window is spared (1.1).
341	    ///
342	    /// Returns the signoff outcome once the watch fires, or `None` if `stop` was
343	    /// set before any death was observed. `owner_pid` is the supervised harness
344	    /// pid; the poll cadence reuses the configured `pulse_period` (the same
345	    /// scheduled-supervision clock as the pulse loop).
346	    // [impl->REQ-DAEMON-4]
347	    // [impl->REQ-HAZARD-STALE-SIGNOFF-SENTINEL]
348	    pub fn run_orphan_watch(
349	        &self,
350	        session_id: Option<&str>,
351	        owner_pid: u32,
352	        stop: &AtomicBool,
353	    ) -> Option<Result<SignoffOutcome, String>> {
354	        // 3.2 — sweep a stale signoff sentinel before supervising, exactly as a
355	        // listener start does, so a prior crash's sentinel can't self-trigger.
356	        self.sweep_on_start();
357	        while !stop.load(Ordering::Relaxed) {
358	            if !is_process_alive(owner_pid) {
359	                // Observed gone — run the crash teardown. The recheck re-reads the
360	                // perch's CURRENT owner, so a Self that rebound across the grace
361	                // window (binary handoff) is spared (1.1).
362	                return Some(self.supervised_crash_signoff(session_id, || self.harness_owner_gone()));
363	            }
364	            sleep_interruptible(self.cfg.pulse_period, stop);
365	        }
366	        None
367	    }
368	}
369
370	/// Sleep `period`, waking early if `stop` is set (10ms slices). Keeps the pulse
371	/// loop responsive to a kill without busy-spinning.
372	fn sleep_interruptible(period: Duration, stop: &AtomicBool) {
373	    const SLICE: Duration = Duration::from_millis(10);
374	    let mut left = period;
375	    while left > Duration::ZERO {
376	        if stop.load(Ordering::Relaxed) {
377	            return;
378	        }
379	        let nap = left.min(SLICE);
380	        std::thread::sleep(nap);
381	        left = left.saturating_sub(nap);
382	    }
383	}
384
385	#[cfg(test)]
386	mod tests {
387	    use super::*;
388	    use crate::test_home::with_home;
389	    use std::sync::atomic::AtomicUsize;
390
391	    // A live-capable manifest: a Psyche init template + a commune dir to watch.
392	    // The Psyche command is a trivial no-op (we never spawn it in these tests;
393	    // we exercise the pulse/ingest loop, which does not launch the Psyche).
394	    #[cfg(windows)]
395	    const NOOP_CMD: &str = "cmd /C rem";
396	    #[cfg(unix)]
397	    const NOOP_CMD: &str = "true";
398
399	    fn live_manifest(commune_dir: &std::path::Path) -> Manifest {
400	        let dir = commune_dir.to_string_lossy().replace('\\', "/");
401	        let toml = format!(
402	            "[adapter]\nname=\"mock\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n\
403	             [session]\ncommune_dir=\"{dir}\"\n\n\
404	             [session.psyche_init]\ncommand='{NOOP_CMD}'\ncwd=\"{{psyche_dir}}\"\nkeys=[]\n"
405	        );
406	        Manifest::from_toml_str(&toml).unwrap()
407	    }
408
409	    // A stdin→stdout passthrough so the echo-commune summarizer succeeds and
410	    // writes a real drop file — lets the signoff tests observe that the echo
411	    // (3.3) actually ran before any teardown.
412	    #[cfg(windows)]
413	    const PASSTHROUGH: &str = "sort";
414	    #[cfg(unix)]
415	    const PASSTHROUGH: &str = "cat";
416
417	    // A live manifest that ALSO declares an echo-commune summarizer, so
418	    // `fire_echo` (and thus the signoff echo step) succeeds and writes a commune.
419	    fn live_manifest_with_echo(commune_dir: &std::path::Path) -> Manifest {
420	        let dir = commune_dir.to_string_lossy().replace('\\', "/");
421	        let toml = format!(
422	            "[adapter]\nname=\"mock\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n\
423	             [session]\ncommune_dir=\"{dir}\"\n\n\
424	             [session.psyche_init]\ncommand='{NOOP_CMD}'\ncwd=\"{{psyche_dir}}\"\nkeys=[]\n\n\
425	             [session.echo_commune]\ncommand='{PASSTHROUGH}'\n"
426	        );
427	        Manifest::from_toml_str(&toml).unwrap()
428	    }
429
430	    /// Create the perch dir + a `ready` marker for `id`, recording `parent_pid`
431	    /// as the supervised harness owner. Returns the perch path.
432	    fn seed_supervised_perch(id: &str, parent_pid: u32) -> std::path::PathBuf {
433	        use spt_store::info::{write_info, InfoJson};
434	        let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);
435	        std::fs::create_dir_all(&perch_path).unwrap();
436	        let mut info = InfoJson::new(id, "epoch-ms:0", parent_pid, "sid-1", "ready");
437	        info.parent_pid = Some(parent_pid);
438	        write_info(&perch_path, &info).unwrap();
439	        let ready = perch::resolve_ready_file(id, ParentHint::Infer);
440	        if let Some(p) = ready.parent() {
441	            std::fs::create_dir_all(p).unwrap();
442	        }
443	        std::fs::write(&ready, "").unwrap();
444	        perch_path
445	    }
446
447	    // A pid that is never a real allocation (above Linux pid_max / Windows
448	    // ranges; positive as i32) — a deterministic stand-in for a dead owner.
449	    const DEAD_PID: u32 = 2_000_000_000;
450
451	    #[test] // [unit->REQ-DAEMON-1] a daemon-hosted pulse tick ingests a commune drop.
452	    fn pulse_tick_ingests_drop() {
453	        with_home(|_| {
454	            let drops = tempfile::tempdir().unwrap();
455	            let manifest = live_manifest(drops.path());
456	            let host = BrainLifecycle::with_config(&manifest, "doyle", DaemonConfig::default())
457	                .expect("live host");
458
459	            std::fs::write(drops.path().join("doyle-commune.md"), "brief").unwrap();
460	            let report = host.pulse_tick(Some("sid-1")).expect("tick");
461	            assert_eq!(report.ingested.len(), 1, "the drop was folded in");
462	            assert!(!report.echo_fired, "no echo gate armed");
463	            assert!(!drops.path().join("doyle-commune.md").exists(), "drop consumed");
464	        });
465	    }
466
467	    // [unit->REQ-HAZARD-DAEMON-HOSTED-LIVENESS] a daemon-hosted Psyche derives
468	    // liveness from the daemon `status`, not its summarizer pid: spawning marks
469	    // the perch `online`, so a dead summarizer subprocess does NOT flip it
470	    // offline; `mark_offline` is the only thing that does.
471	    #[test]
472	    fn daemon_hosted_psyche_liveness_is_status_not_pid() {
473	        use spt_store::info::{read_info, write_info};
474	        use spt_store::liveness::{is_perch_alive, STATUS_ONLINE};
475
476	        // A pid that is never a real allocation — stands in for the exited
477	        // summarizer subprocess (deterministic; no spawn-reap timing flake).
478	        const DEAD_PID: u32 = 2_000_000_000;
479
480	        with_home(|_| {
481	            let drops = tempfile::tempdir().unwrap();
482	            let manifest = live_manifest(drops.path());
483	            let host = BrainLifecycle::with_config(&manifest, "doyle", DaemonConfig::default())
484	                .expect("live host");
485
486	            // Spawn the Psyche — the host binds the nested perch and marks it online.
487	            let handle = host.spawn_psyche(Some("sid-1"), Some("pulse")).expect("spawn psyche");
488	            assert_eq!(
489	                read_info(&handle.perch_path).expect("info.json").status.as_deref(),
490	                Some(STATUS_ONLINE),
491	                "the daemon marked the hosted Psyche online"
492	            );
493
494	            // Simulate the summarizer subprocess exiting: its recorded pid is now
495	            // dead, but the daemon `status` stays online.
496	            let mut rec = read_info(&handle.perch_path).unwrap();
497	            rec.pid = spt_store::info::PidValue::Numeric(DEAD_PID);
498	            write_info(&handle.perch_path, &rec).unwrap();
499	            assert!(
500	                is_perch_alive(&handle.perch_path),
501	                "dead summarizer pid must NOT flip a daemon-hosted perch offline"
502	            );
503
504	            // Only an explicit teardown marks it offline.
505	            BrainLifecycle::mark_offline(&handle.perch_path);
506	            assert!(!is_perch_alive(&handle.perch_path), "mark_offline flips it offline");
507	        });
508	    }
509
510	    #[test] // [unit->REQ-DAEMON-1] the scheduled loop is config-paced + killable.
511	    fn pulse_loop_is_config_paced_and_killable() {
512	        with_home(|_| {
513	            let drops = tempfile::tempdir().unwrap();
514	            let manifest = live_manifest(drops.path());
515	            // Fast period so the test is quick; proves the period is config-driven.
516	            let cfg = DaemonConfig { pulse_period: Duration::from_millis(15), ..DaemonConfig::default() };
517	            let host = BrainLifecycle::with_config(&manifest, "doyle", cfg).expect("live host");
518	            assert_eq!(host.pulse_period(), Duration::from_millis(15));
519
520	            let stop = AtomicBool::new(false);
521	            let ticks = AtomicUsize::new(0);
522	            // Stop the loop from inside on_tick after a few ticks — proves the
523	            // loop actually schedules repeated ticks and honors the stop flag.
524	            host.run_pulse_loop(None, &stop, |_| {
525	                if ticks.fetch_add(1, Ordering::Relaxed) + 1 >= 3 {
526	                    stop.store(true, Ordering::Relaxed);
527	                }
528	            });
529	            assert!(ticks.load(Ordering::Relaxed) >= 3, "loop ran repeated ticks");
530	        });
531	    }
532
533	    // [unit->REQ-DAEMON-4] the supervised-crash path runs the SAME ordering as a
534	    // graceful teardown: the echo-commune (3.3) fires and the perch is torn down
535	    // when the post-grace recheck confirms the harness owner is still gone.
536	    #[test]
537	    fn crash_signoff_tears_down_when_still_gone() {
538	        with_home(|_| {
539	            let drops = tempfile::tempdir().unwrap();
540	            let manifest = live_manifest_with_echo(drops.path());
541	            // A short crash_grace keeps the test quick (the grace wait is real).
542	            let cfg = DaemonConfig { crash_grace: Duration::from_millis(10), ..DaemonConfig::default() };
543	            let host = BrainLifecycle::with_config(&manifest, "doyle", cfg).expect("live host");
544
545	            let ready = perch::resolve_ready_file("doyle", ParentHint::Infer);
546	            std::fs::create_dir_all(ready.parent().unwrap()).unwrap();
547	            std::fs::write(&ready, "").unwrap();
548
549	            // still_gone = true: the Self did not recover across the grace window.
550	            let outcome = host.supervised_crash_signoff(Some("sid-1"), || true).expect("signoff");
551	            assert!(outcome.echo_ran, "echo-before-signoff (3.3) must have run");
552	            assert!(outcome.signed_off, "still-gone Self is torn down");
553	            assert!(
554	                drops.path().join("doyle-commune.md").exists(),
555	                "the echo wrote a final-delta commune before teardown"
556	            );
557	            assert!(!ready.exists(), "teardown removed the ready marker");
558	        });
559	    }
560
561	    // [unit->REQ-HAZARD-GRACE-BEFORE-SIGNOFF] a Self that recovers across the
562	    // grace window is NOT signed off — the phantom-INIT_SIGNOFF bug (1.1). The
563	    // echo still fires (it precedes the recheck), but no teardown happens.
564	    #[test]
565	    fn crash_signoff_spares_recovered_self() {
566	        with_home(|_| {
567	            let drops = tempfile::tempdir().unwrap();
568	            let manifest = live_manifest_with_echo(drops.path());
569	            let cfg = DaemonConfig { crash_grace: Duration::from_millis(10), ..DaemonConfig::default() };
570	            let host = BrainLifecycle::with_config(&manifest, "doyle", cfg).expect("live host");
571
572	            let ready = perch::resolve_ready_file("doyle", ParentHint::Infer);
573	            std::fs::create_dir_all(ready.parent().unwrap()).unwrap();
574	            std::fs::write(&ready, "").unwrap();
575
576	            // still_gone = false: the Self came back alive during the grace wait.
577	            let outcome = host.supervised_crash_signoff(Some("sid-1"), || false).expect("signoff");
578	            assert!(outcome.echo_ran, "echo precedes the recheck, so it still ran");
579	            assert!(!outcome.signed_off, "a recovered Self must never be signed off");
580	            assert!(ready.exists(), "the live Self's ready marker is left intact");
581	        });
582	    }
583
584	    // [unit->REQ-DAEMON-4] liveness for the supervised Self anchors on the stable
585	    // harness pid (`parent_pid`), never an ephemeral poll pid (2.1): a dead owner
586	    // reads gone, a live owner reads present, and no recorded owner reads gone.
587	    #[test]
588	    fn harness_owner_gone_anchors_on_parent_pid() {
589	        with_home(|_| {
590	            let drops = tempfile::tempdir().unwrap();
591	            let manifest = live_manifest(drops.path());
592	            let host = BrainLifecycle::with_config(&manifest, "doyle", DaemonConfig::default())
593	                .expect("live host");
594
595	            // No perch yet → no recorded owner → treated as gone.
596	            assert!(host.harness_owner_gone(), "an unsupervised perch reads gone");
597
598	            // A live owner (this very process) reads present.
599	            seed_supervised_perch("doyle", std::process::id());
600	            assert!(!host.harness_owner_gone(), "a live parent_pid reads present");
601
602	            // A dead owner reads gone.
603	            seed_supervised_perch("doyle", DEAD_PID);
604	            assert!(host.harness_owner_gone(), "a dead parent_pid reads gone");
605	        });
606	    }
607
608	    // [unit->REQ-DAEMON-4] the orphan-watch loop fires the crash signoff on an
609	    // observed owner death, sweeping a stale sentinel first (3.2) and tearing the
610	    // perch down once the recheck confirms the dead owner.
611	    #[test]
612	    fn orphan_watch_fires_on_dead_owner() {
613	        with_home(|_| {
614	            let drops = tempfile::tempdir().unwrap();
615	            let manifest = live_manifest_with_echo(drops.path());
616	            let cfg = DaemonConfig {
617	                pulse_period: Duration::from_millis(10),
618	                crash_grace: Duration::from_millis(10),
619	                ..DaemonConfig::default()
620	            };
621	            let host = BrainLifecycle::with_config(&manifest, "doyle", cfg).expect("live host");
622
623	            // Supervise a perch whose harness owner is already dead.
624	            seed_supervised_perch("doyle", DEAD_PID);
625	            let ready = perch::resolve_ready_file("doyle", ParentHint::Infer);
626	            assert!(ready.exists(), "precondition: ready marker present");
627
628	            let stop = AtomicBool::new(false);
629	            let outcome = host
630	                .run_orphan_watch(Some("sid-1"), DEAD_PID, &stop)
631	                .expect("watch fired")
632	                .expect("signoff ok");
633	            assert!(outcome.echo_ran && outcome.signed_off, "dead owner → echo + teardown");
634	            assert!(!ready.exists(), "the orphan-watch tore the perch down");
635	        });
636	    }
637
638	    // [unit->REQ-DAEMON-4] the orphan-watch honors the stop flag and never fires
639	    // while the supervised owner is alive (no false-positive teardown).
640	    #[test]
641	    fn orphan_watch_honors_stop_for_live_owner() {
642	        with_home(|_| {
643	            let drops = tempfile::tempdir().unwrap();
644	            let manifest = live_manifest_with_echo(drops.path());
645	            let cfg = DaemonConfig { pulse_period: Duration::from_millis(10), ..DaemonConfig::default() };
646	            let host = BrainLifecycle::with_config(&manifest, "doyle", cfg).expect("live host");
647
648	            seed_supervised_perch("doyle", std::process::id());
649	            let ready = perch::resolve_ready_file("doyle", ParentHint::Infer);
650
651	            // Owner is this (alive) process; stop is already set → loop exits with
652	            // no signoff and leaves the perch untouched.
653	            let stop = AtomicBool::new(true);
654	            let outcome = host.run_orphan_watch(Some("sid-1"), std::process::id(), &stop);
655	            assert!(outcome.is_none(), "a live owner under stop never signs off");
656	            assert!(ready.exists(), "a live Self's perch is left intact");
657	        });
658	    }
659	}
660

//! The brain's LiveAgent lifecycle host (B3) — the consolidated re-host of the
//! interim `api listen` runner's live half.
//!
//! In the no-daemon interim model the long-lived `spt api listen` process hosted
//! the LiveAgent lifecycle (Psyche spawn, pulse-driven drop ingest, echo fires,
//! graceful signoff) directly in the agent's session. Consolidation
//! (REQ-DAEMON-1) moves that orchestration **down into `spt-daemon`** so the
//! brain runs it as scheduled in-process loops, with a durable, configurable
//! pulse period ([`crate::config::DaemonConfig`]) instead of the interim 5s
//! constant. The `spt` binary's `LiveHost` becomes a thin shim over this type —
//! a re-host, not a rewrite: the heavy lifting stays in the already-pure
//! [`spt_live`] seams ([`spawn_psyche`], [`ingest_drops`], [`run_echo_commune`],
//! [`graceful_signoff`], [`sweep_stale_signoff`]); the ordering invariants
//! (echo-before-signoff 3.3, grace-before-signoff 1.1, stale-sweep 3.2) live
//! where they always have.
// [impl->REQ-DAEMON-1]

use std::collections::BTreeMap;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, SystemTime, UNIX_EPOCH};

use spt_live::{
    fetch_history, graceful_signoff, ingest_drops, run_echo_commune, spawn_psyche,
    sweep_stale_signoff, HistoryRecord, Ingested, PsycheHandle, SignoffOutcome,
};
use spt_runtime::{Manifest, ManifestRuntime};
use spt_store::perch::{self, ParentHint};
use spt_store::proc::is_process_alive;

use crate::config::DaemonConfig;

/// Bound for a bounded history/echo invocation in the host (carried from M2b).
const LIVE_BOUNDED_TIMEOUT: Duration = Duration::from_secs(30);

/// Wall-clock now in epoch millis — precedence-marker stamp for ingested writes.
fn now_ms() -> u64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_millis() as u64)
        .unwrap_or(0)
}

/// ISO-ish stamp for the psyche prompt (opaque to the Psyche; just a timestamp).
fn now_stamp() -> String {
    format!("epoch-ms:{}", now_ms())
}

/// One pulse tick's observed effects: any drops ingested + whether an echo fired.
#[derive(Debug, Clone, Default)]
pub struct TickReport {
    pub ingested: Vec<Ingested>,
    pub echo_fired: bool,
}

/// The daemon-hosted LiveAgent lifecycle: the runtime + watched drop dirs built
/// from a live-capable manifest, paced by a config-driven [`DaemonConfig`].
pub struct BrainLifecycle {
    runtime: ManifestRuntime,
    id: String,
    manifest: Manifest,
    commune_dir: Option<PathBuf>,
    signoff_dir: Option<PathBuf>,
    cfg: DaemonConfig,
}

impl BrainLifecycle {
    /// Build a host for `id` from a live-capable manifest (declares
    /// `[session.psyche_init]`); `None` for an absent manifest or a plain relay
    /// endpoint. Loads the durable [`DaemonConfig`] from disk.
    pub fn new_opt(manifest: Option<&Manifest>, id: &str) -> Option<BrainLifecycle> {
        Self::with_config(manifest?, id, DaemonConfig::load())
    }

    /// Build a host from a live-capable manifest with an explicit config (the
    /// testable seam). `None` if the manifest declares no Psyche.
    pub fn with_config(manifest: &Manifest, id: &str, cfg: DaemonConfig) -> Option<BrainLifecycle> {
        manifest.session.psyche_init.as_ref()?;
        Some(BrainLifecycle {
            runtime: ManifestRuntime::new(manifest.clone()),
            id: id.to_string(),
            commune_dir: manifest.session.commune_dir.as_ref().map(PathBuf::from),
            signoff_dir: manifest.session.signoff_dir.as_ref().map(PathBuf::from),
            manifest: manifest.clone(),
            cfg,
        })
    }

    /// The configured pulse period (config-driven, not a constant).
    pub fn pulse_period(&self) -> Duration {
        self.cfg.pulse_period
    }

    /// Base substitution keys spt-core fills for the lifecycle templates.
    fn base_keys(&self, session_id: Option<&str>) -> BTreeMap<String, String> {
        let mut k = BTreeMap::new();
        k.insert("id".to_string(), self.id.clone());
        if let Some(sid) = session_id {
            k.insert("session_id".to_string(), sid.to_string());
        }
        k
    }

    /// The unique watched drop dirs (commune + signoff; usually the same dir).
    fn drop_dirs(&self) -> Vec<PathBuf> {
        let mut dirs: Vec<PathBuf> = Vec::new();
        for d in [self.commune_dir.as_ref(), self.signoff_dir.as_ref()]
            .into_iter()
            .flatten()
        {
            if !dirs.contains(d) {
                dirs.push(d.clone());
            }
        }
        dirs
    }

    /// Sweep a stale signoff sentinel on start (3.2). No-op without a signoff dir.
    pub fn sweep_on_start(&self) {
        if let Some(dir) = &self.signoff_dir {
            let _ = sweep_stale_signoff(dir, &self.id);
        }
    }

    /// Spawn the Psyche companion (T2). `event` is the incoming envelope when the
    /// spawn is event-driven; `None` at cold start.
    ///
    /// The Psyche is **daemon-hosted**: its liveness is daemon-authoritative, not
    /// the ephemeral summarizer pid `spawn_psyche` records (2.5). So immediately
    /// after binding, mark the perch `online` — from here liveness reads the
    /// daemon `status`, and a dead summarizer subprocess no longer flips the perch
    /// offline. [`mark_offline`](Self::mark_offline) is the teardown counterpart.
    // [impl->REQ-HAZARD-DAEMON-HOSTED-LIVENESS]
    pub fn spawn_psyche(
        &self,
        session_id: Option<&str>,
        event: Option<&str>,
    ) -> Result<PsycheHandle, String> {
        let handle =
            spawn_psyche(&self.runtime, &self.id, &self.base_keys(session_id), event, &now_stamp())
                .map_err(|e| e.to_string())?;
        spt_store::info::set_status(&handle.perch_path, spt_store::liveness::STATUS_ONLINE)
            .map_err(|e| format!("mark psyche online: {e}"))?;
        Ok(handle)
    }

    /// Mark a daemon-hosted perch `offline` (teardown counterpart of the
    /// `online` stamp in [`spawn_psyche`](Self::spawn_psyche)). From this point
    /// the single liveness resolver reports the perch dead regardless of any pid
    /// still recorded in its `info.json` (2.5). Best-effort: a missing record
    /// (perch already gone) is not an error.
    // [impl->REQ-HAZARD-DAEMON-HOSTED-LIVENESS]
    pub fn mark_offline(perch_path: &std::path::Path) {
        let _ = spt_store::info::set_status(perch_path, spt_store::liveness::STATUS_OFFLINE);
    }

    /// Stamp this endpoint's `last_active_ms` recency marker (REQ-UPD-4) — the
    /// signal the consent gate resolves "most-recently-active live session"
    /// against ([`crate::consent::most_recently_active`]). Best-effort: a hosted
    /// perch that has not been bound yet (no `info.json`) is silently skipped, so
    /// a pre-bind tick never errors. Stamps the Self perch for this brain's id.
    // [impl->REQ-UPD-4]
    pub fn touch_active(&self) {
        let perch_path = perch::resolve_perch_path(&self.id, ParentHint::Infer);
        let _ = spt_store::info::set_last_active(&perch_path, now_ms());
    }

    /// One pulse tick (T5): refresh the activity stamp (REQ-UPD-4), ingest drops
    /// from the watched dirs (T4), then if the echo-gate is armed fire exactly one
    /// echo-commune (T3).
    pub fn pulse_tick(&self, session_id: Option<&str>) -> Result<TickReport, String> {
        self.touch_active();
        let now = now_ms();
        let mut ingested = Vec::new();
        for dir in self.drop_dirs() {
            let mut got =
                ingest_drops(&dir, &self.id, ParentHint::Infer, now, self.cfg.protection_window_ms)
                    .map_err(|e| e.to_string())?;
            ingested.append(&mut got);
        }
        let echo_fired = if spt_live::pulse::take_echo_gate(&self.id, ParentHint::Infer) {
            self.fire_echo(session_id)?;
            true
        } else {
            false
        };
        Ok(TickReport { ingested, echo_fired })
    }

    /// Run the scheduled pulse loop until `stop` is set, calling `on_tick` after
    /// each tick (so a caller — or a test — observes cadence and can decide to
    /// stop). The period comes from the durable config, never a constant. The
    /// loop holds no state beyond the cursor on disk, so it is freely
    /// killable + respawnable: a fresh loop re-reads the drop dirs and gate.
    pub fn run_pulse_loop<F: FnMut(&TickReport)>(
        &self,
        session_id: Option<&str>,
        stop: &AtomicBool,
        mut on_tick: F,
    ) {
        while !stop.load(Ordering::Relaxed) {
            match self.pulse_tick(session_id) {
                Ok(report) => on_tick(&report),
                Err(_) => { /* a transient tick error must not kill the loop */ }
            }
            // Sleep the configured period in small slices so a stop is honored
            // promptly rather than after a full (possibly multi-second) period.
            sleep_interruptible(self.cfg.pulse_period, stop);
        }
    }

    /// Fetch history per the manifest `[history]` strategy (empty if none).
    fn history(&self, session_id: Option<&str>) -> Vec<HistoryRecord> {
        let Some(h) = &self.manifest.history else {
            return Vec::new();
        };
        fetch_history(h, &self.base_keys(session_id), &self.id, ParentHint::Infer, LIVE_BOUNDED_TIMEOUT)
            .unwrap_or_default()
    }

    /// Run the echo-commune into the commune dir (T3). No-op (Ok) without a dir.
    fn fire_echo(&self, session_id: Option<&str>) -> Result<(), String> {
        let Some(dir) = &self.commune_dir else {
            return Ok(());
        };
        let history = self.history(session_id);
        run_echo_commune(
            &self.runtime,
            &self.id,
            &self.base_keys(session_id),
            &history,
            dir,
            LIVE_BOUNDED_TIMEOUT,
        )
        .map(|_| ())
        .map_err(|e| e.to_string())
    }

    /// Execute the graceful signoff (T6): echo-commune BEFORE teardown (3.3),
    /// grace recheck before any signoff write (1.1). The graceful path is an
    /// **intentional** shutdown, so it uses zero grace + an always-gone recheck —
    /// the Self is leaving on purpose, there is nothing to recover. The
    /// supervised-crash path ([`supervised_crash_signoff`](Self::supervised_crash_signoff))
    /// supplies the real grace + liveness recheck for an *un*intentional death.
    pub fn graceful_signoff(&self, session_id: Option<&str>) -> Result<SignoffOutcome, String> {
        self.signoff_with(session_id, Duration::ZERO, || true)
    }

    /// Execute the **supervised-crash** signoff (B7, orphan-watch) — the
    /// counterpart of [`graceful_signoff`](Self::graceful_signoff) for an
    /// *unexpected* harness death. It runs the identical load-bearing ordering —
    /// echo-commune (3.3) → grace wait (1.1) → liveness recheck → teardown — but
    /// with the **real** grace window from config and the **real** recheck
    /// `still_gone`. A Self that recovered across the grace window (a binary
    /// handoff relaunch, a brief stale poll) makes `still_gone` false and is
    /// **spared** — the phantom-INIT_SIGNOFF bug (1.1).
    ///
    /// `still_gone` is the post-grace liveness verdict (true ⇒ the harness owner
    /// is still gone ⇒ tear down). [`run_orphan_watch`](Self::run_orphan_watch)
    /// supplies the production recheck ([`harness_owner_gone`](Self::harness_owner_gone));
    /// it is injected here so the ordering is testable without a real pid.
    // [impl->REQ-DAEMON-4]
    // [impl->REQ-HAZARD-GRACE-BEFORE-SIGNOFF]
    // [impl->REQ-HAZARD-ECHO-BEFORE-SIGNOFF]
    pub fn supervised_crash_signoff(
        &self,
        session_id: Option<&str>,
        still_gone: impl FnOnce() -> bool,
    ) -> Result<SignoffOutcome, String> {
        self.signoff_with(session_id, self.cfg.crash_grace, still_gone)
    }

    /// The shared signoff wiring behind both the graceful and supervised-crash
    /// paths. The only two things that differ between them are exactly the two
    /// the 1.1 hazard turns on — the `grace` window and the `recheck` verdict — so
    /// they are the only parameters; the echo (3.3) and teardown effects are
    /// identical. Keeping one body guarantees the crash path can never drift out
    /// of the echo-before-teardown ordering the graceful path proves.
    fn signoff_with(
        &self,
        session_id: Option<&str>,
        grace: Duration,
        recheck: impl FnOnce() -> bool,
    ) -> Result<SignoffOutcome, String> {
        let mut echo_err: Option<String> = None;
        let mut teardown_err: Option<String> = None;
        let outcome = graceful_signoff::<std::convert::Infallible>(
            session_id,
            grace,
            || {
                if let Err(e) = self.fire_echo(session_id) {
                    echo_err = Some(e);
                }
                Ok(())
            },
            recheck,
            || {
                let ready = perch::resolve_ready_file(&self.id, ParentHint::Infer);
                if let Err(e) = std::fs::remove_file(&ready) {
                    if e.kind() != std::io::ErrorKind::NotFound {
                        teardown_err = Some(e.to_string());
                    }
                }
                Ok(())
            },
        )
        .unwrap_or(SignoffOutcome { echo_ran: false, signed_off: false });
        if let Some(e) = echo_err {
            return Err(format!("echo failed during signoff: {e}"));
        }
        if let Some(e) = teardown_err {
            return Err(format!("teardown failed during signoff: {e}"));
        }
        Ok(outcome)
    }

    /// Whether the supervised Self's **harness owner** is gone. Anchors liveness on
    /// the stable harness-session pid (`info.json` `parent_pid`), never an
    /// ephemeral poll pid (KNOWN-HAZARDS 2.1): a recycled ephemeral pid could read
    /// a foreign process as the Self and mask a real death, or flip a live Self
    /// dead. Re-reads the perch fresh so a Self that **rebound** under a new
    /// harness (a recovery) — recording a fresh, live `parent_pid` — reads alive.
    /// A perch with no recorded owner is treated as gone (nothing supervises it).
    // [impl->REQ-DAEMON-4]
    pub fn harness_owner_gone(&self) -> bool {
        let path = perch::resolve_perch_path(&self.id, ParentHint::Infer);
        match spt_store::info::read_info(&path).and_then(|i| i.parent_pid) {
            Some(pid) => !is_process_alive(pid),
            None => true,
        }
    }

    /// Supervise a harness-hosted Self and carry the graceful ordering into the
    /// **supervised-crash** path the always-on daemon now enables (B7). On start it
    /// sweeps any stale signoff sentinel (3.2) so a prior generation's leftover
    /// never reads as a live signoff. It then polls the harness owner pid each
    /// cycle (anchored on `parent_pid`, 2.1); on the first observed death it runs
    /// [`supervised_crash_signoff`](Self::supervised_crash_signoff) with the real
    /// grace + a fresh liveness recheck ([`harness_owner_gone`](Self::harness_owner_gone)),
    /// so a transient recovery across the grace window is spared (1.1).
    ///
    /// Returns the signoff outcome once the watch fires, or `None` if `stop` was
    /// set before any death was observed. `owner_pid` is the supervised harness
    /// pid; the poll cadence reuses the configured `pulse_period` (the same
    /// scheduled-supervision clock as the pulse loop).
    // [impl->REQ-DAEMON-4]
    // [impl->REQ-HAZARD-STALE-SIGNOFF-SENTINEL]
    pub fn run_orphan_watch(
        &self,
        session_id: Option<&str>,
        owner_pid: u32,
        stop: &AtomicBool,
    ) -> Option<Result<SignoffOutcome, String>> {
        // 3.2 — sweep a stale signoff sentinel before supervising, exactly as a
        // listener start does, so a prior crash's sentinel can't self-trigger.
        self.sweep_on_start();
        while !stop.load(Ordering::Relaxed) {
            if !is_process_alive(owner_pid) {
                // Observed gone — run the crash teardown. The recheck re-reads the
                // perch's CURRENT owner, so a Self that rebound across the grace
                // window (binary handoff) is spared (1.1).
                return Some(self.supervised_crash_signoff(session_id, || self.harness_owner_gone()));
            }
            sleep_interruptible(self.cfg.pulse_period, stop);
        }
        None
    }
}

/// Sleep `period`, waking early if `stop` is set (10ms slices). Keeps the pulse
/// loop responsive to a kill without busy-spinning.
fn sleep_interruptible(period: Duration, stop: &AtomicBool) {
    const SLICE: Duration = Duration::from_millis(10);
    let mut left = period;
    while left > Duration::ZERO {
        if stop.load(Ordering::Relaxed) {
            return;
        }
        let nap = left.min(SLICE);
        std::thread::sleep(nap);
        left = left.saturating_sub(nap);
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::test_home::with_home;
    use std::sync::atomic::AtomicUsize;

    // A live-capable manifest: a Psyche init template + a commune dir to watch.
    // The Psyche command is a trivial no-op (we never spawn it in these tests;
    // we exercise the pulse/ingest loop, which does not launch the Psyche).
    #[cfg(windows)]
    const NOOP_CMD: &str = "cmd /C rem";
    #[cfg(unix)]
    const NOOP_CMD: &str = "true";

    fn live_manifest(commune_dir: &std::path::Path) -> Manifest {
        let dir = commune_dir.to_string_lossy().replace('\\', "/");
        let toml = format!(
            "[adapter]\nname=\"mock\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n\
             [session]\ncommune_dir=\"{dir}\"\n\n\
             [session.psyche_init]\ncommand='{NOOP_CMD}'\ncwd=\"{{psyche_dir}}\"\nkeys=[]\n"
        );
        Manifest::from_toml_str(&toml).unwrap()
    }

    // A stdin→stdout passthrough so the echo-commune summarizer succeeds and
    // writes a real drop file — lets the signoff tests observe that the echo
    // (3.3) actually ran before any teardown.
    #[cfg(windows)]
    const PASSTHROUGH: &str = "sort";
    #[cfg(unix)]
    const PASSTHROUGH: &str = "cat";

    // A live manifest that ALSO declares an echo-commune summarizer, so
    // `fire_echo` (and thus the signoff echo step) succeeds and writes a commune.
    fn live_manifest_with_echo(commune_dir: &std::path::Path) -> Manifest {
        let dir = commune_dir.to_string_lossy().replace('\\', "/");
        let toml = format!(
            "[adapter]\nname=\"mock\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n\
             [session]\ncommune_dir=\"{dir}\"\n\n\
             [session.psyche_init]\ncommand='{NOOP_CMD}'\ncwd=\"{{psyche_dir}}\"\nkeys=[]\n\n\
             [session.echo_commune]\ncommand='{PASSTHROUGH}'\n"
        );
        Manifest::from_toml_str(&toml).unwrap()
    }

    /// Create the perch dir + a `ready` marker for `id`, recording `parent_pid`
    /// as the supervised harness owner. Returns the perch path.
    fn seed_supervised_perch(id: &str, parent_pid: u32) -> std::path::PathBuf {
        use spt_store::info::{write_info, InfoJson};
        let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);
        std::fs::create_dir_all(&perch_path).unwrap();
        let mut info = InfoJson::new(id, "epoch-ms:0", parent_pid, "sid-1", "ready");
        info.parent_pid = Some(parent_pid);
        write_info(&perch_path, &info).unwrap();
        let ready = perch::resolve_ready_file(id, ParentHint::Infer);
        if let Some(p) = ready.parent() {
            std::fs::create_dir_all(p).unwrap();
        }
        std::fs::write(&ready, "").unwrap();
        perch_path
    }

    // A pid that is never a real allocation (above Linux pid_max / Windows
    // ranges; positive as i32) — a deterministic stand-in for a dead owner.
    const DEAD_PID: u32 = 2_000_000_000;

    #[test] // [unit->REQ-DAEMON-1] a daemon-hosted pulse tick ingests a commune drop.
    fn pulse_tick_ingests_drop() {
        with_home(|_| {
            let drops = tempfile::tempdir().unwrap();
            let manifest = live_manifest(drops.path());
            let host = BrainLifecycle::with_config(&manifest, "doyle", DaemonConfig::default())
                .expect("live host");

            std::fs::write(drops.path().join("doyle-commune.md"), "brief").unwrap();
            let report = host.pulse_tick(Some("sid-1")).expect("tick");
            assert_eq!(report.ingested.len(), 1, "the drop was folded in");
            assert!(!report.echo_fired, "no echo gate armed");
            assert!(!drops.path().join("doyle-commune.md").exists(), "drop consumed");
        });
    }

    // [unit->REQ-HAZARD-DAEMON-HOSTED-LIVENESS] a daemon-hosted Psyche derives
    // liveness from the daemon `status`, not its summarizer pid: spawning marks
    // the perch `online`, so a dead summarizer subprocess does NOT flip it
    // offline; `mark_offline` is the only thing that does.
    #[test]
    fn daemon_hosted_psyche_liveness_is_status_not_pid() {
        use spt_store::info::{read_info, write_info};
        use spt_store::liveness::{is_perch_alive, STATUS_ONLINE};

        // A pid that is never a real allocation — stands in for the exited
        // summarizer subprocess (deterministic; no spawn-reap timing flake).
        const DEAD_PID: u32 = 2_000_000_000;

        with_home(|_| {
            let drops = tempfile::tempdir().unwrap();
            let manifest = live_manifest(drops.path());
            let host = BrainLifecycle::with_config(&manifest, "doyle", DaemonConfig::default())
                .expect("live host");

            // Spawn the Psyche — the host binds the nested perch and marks it online.
            let handle = host.spawn_psyche(Some("sid-1"), Some("pulse")).expect("spawn psyche");
            assert_eq!(
                read_info(&handle.perch_path).expect("info.json").status.as_deref(),
                Some(STATUS_ONLINE),
                "the daemon marked the hosted Psyche online"
            );

            // Simulate the summarizer subprocess exiting: its recorded pid is now
            // dead, but the daemon `status` stays online.
            let mut rec = read_info(&handle.perch_path).unwrap();
            rec.pid = spt_store::info::PidValue::Numeric(DEAD_PID);
            write_info(&handle.perch_path, &rec).unwrap();
            assert!(
                is_perch_alive(&handle.perch_path),
                "dead summarizer pid must NOT flip a daemon-hosted perch offline"
            );

            // Only an explicit teardown marks it offline.
            BrainLifecycle::mark_offline(&handle.perch_path);
            assert!(!is_perch_alive(&handle.perch_path), "mark_offline flips it offline");
        });
    }

    #[test] // [unit->REQ-DAEMON-1] the scheduled loop is config-paced + killable.
    fn pulse_loop_is_config_paced_and_killable() {
        with_home(|_| {
            let drops = tempfile::tempdir().unwrap();
            let manifest = live_manifest(drops.path());
            // Fast period so the test is quick; proves the period is config-driven.
            let cfg = DaemonConfig { pulse_period: Duration::from_millis(15), ..DaemonConfig::default() };
            let host = BrainLifecycle::with_config(&manifest, "doyle", cfg).expect("live host");
            assert_eq!(host.pulse_period(), Duration::from_millis(15));

            let stop = AtomicBool::new(false);
            let ticks = AtomicUsize::new(0);
            // Stop the loop from inside on_tick after a few ticks — proves the
            // loop actually schedules repeated ticks and honors the stop flag.
            host.run_pulse_loop(None, &stop, |_| {
                if ticks.fetch_add(1, Ordering::Relaxed) + 1 >= 3 {
                    stop.store(true, Ordering::Relaxed);
                }
            });
            assert!(ticks.load(Ordering::Relaxed) >= 3, "loop ran repeated ticks");
        });
    }

    // [unit->REQ-DAEMON-4] the supervised-crash path runs the SAME ordering as a
    // graceful teardown: the echo-commune (3.3) fires and the perch is torn down
    // when the post-grace recheck confirms the harness owner is still gone.
    #[test]
    fn crash_signoff_tears_down_when_still_gone() {
        with_home(|_| {
            let drops = tempfile::tempdir().unwrap();
            let manifest = live_manifest_with_echo(drops.path());
            // A short crash_grace keeps the test quick (the grace wait is real).
            let cfg = DaemonConfig { crash_grace: Duration::from_millis(10), ..DaemonConfig::default() };
            let host = BrainLifecycle::with_config(&manifest, "doyle", cfg).expect("live host");

            let ready = perch::resolve_ready_file("doyle", ParentHint::Infer);
            std::fs::create_dir_all(ready.parent().unwrap()).unwrap();
            std::fs::write(&ready, "").unwrap();

            // still_gone = true: the Self did not recover across the grace window.
            let outcome = host.supervised_crash_signoff(Some("sid-1"), || true).expect("signoff");
            assert!(outcome.echo_ran, "echo-before-signoff (3.3) must have run");
            assert!(outcome.signed_off, "still-gone Self is torn down");
            assert!(
                drops.path().join("doyle-commune.md").exists(),
                "the echo wrote a final-delta commune before teardown"
            );
            assert!(!ready.exists(), "teardown removed the ready marker");
        });
    }

    // [unit->REQ-HAZARD-GRACE-BEFORE-SIGNOFF] a Self that recovers across the
    // grace window is NOT signed off — the phantom-INIT_SIGNOFF bug (1.1). The
    // echo still fires (it precedes the recheck), but no teardown happens.
    #[test]
    fn crash_signoff_spares_recovered_self() {
        with_home(|_| {
            let drops = tempfile::tempdir().unwrap();
            let manifest = live_manifest_with_echo(drops.path());
            let cfg = DaemonConfig { crash_grace: Duration::from_millis(10), ..DaemonConfig::default() };
            let host = BrainLifecycle::with_config(&manifest, "doyle", cfg).expect("live host");

            let ready = perch::resolve_ready_file("doyle", ParentHint::Infer);
            std::fs::create_dir_all(ready.parent().unwrap()).unwrap();
            std::fs::write(&ready, "").unwrap();

            // still_gone = false: the Self came back alive during the grace wait.
            let outcome = host.supervised_crash_signoff(Some("sid-1"), || false).expect("signoff");
            assert!(outcome.echo_ran, "echo precedes the recheck, so it still ran");
            assert!(!outcome.signed_off, "a recovered Self must never be signed off");
            assert!(ready.exists(), "the live Self's ready marker is left intact");
        });
    }

    // [unit->REQ-DAEMON-4] liveness for the supervised Self anchors on the stable
    // harness pid (`parent_pid`), never an ephemeral poll pid (2.1): a dead owner
    // reads gone, a live owner reads present, and no recorded owner reads gone.
    #[test]
    fn harness_owner_gone_anchors_on_parent_pid() {
        with_home(|_| {
            let drops = tempfile::tempdir().unwrap();
            let manifest = live_manifest(drops.path());
            let host = BrainLifecycle::with_config(&manifest, "doyle", DaemonConfig::default())
                .expect("live host");

            // No perch yet → no recorded owner → treated as gone.
            assert!(host.harness_owner_gone(), "an unsupervised perch reads gone");

            // A live owner (this very process) reads present.
            seed_supervised_perch("doyle", std::process::id());
            assert!(!host.harness_owner_gone(), "a live parent_pid reads present");

            // A dead owner reads gone.
            seed_supervised_perch("doyle", DEAD_PID);
            assert!(host.harness_owner_gone(), "a dead parent_pid reads gone");
        });
    }

    // [unit->REQ-DAEMON-4] the orphan-watch loop fires the crash signoff on an
    // observed owner death, sweeping a stale sentinel first (3.2) and tearing the
    // perch down once the recheck confirms the dead owner.
    #[test]
    fn orphan_watch_fires_on_dead_owner() {
        with_home(|_| {
            let drops = tempfile::tempdir().unwrap();
            let manifest = live_manifest_with_echo(drops.path());
            let cfg = DaemonConfig {
                pulse_period: Duration::from_millis(10),
                crash_grace: Duration::from_millis(10),
                ..DaemonConfig::default()
            };
            let host = BrainLifecycle::with_config(&manifest, "doyle", cfg).expect("live host");

            // Supervise a perch whose harness owner is already dead.
            seed_supervised_perch("doyle", DEAD_PID);
            let ready = perch::resolve_ready_file("doyle", ParentHint::Infer);
            assert!(ready.exists(), "precondition: ready marker present");

            let stop = AtomicBool::new(false);
            let outcome = host
                .run_orphan_watch(Some("sid-1"), DEAD_PID, &stop)
                .expect("watch fired")
                .expect("signoff ok");
            assert!(outcome.echo_ran && outcome.signed_off, "dead owner → echo + teardown");
            assert!(!ready.exists(), "the orphan-watch tore the perch down");
        });
    }

    // [unit->REQ-DAEMON-4] the orphan-watch honors the stop flag and never fires
    // while the supervised owner is alive (no false-positive teardown).
    #[test]
    fn orphan_watch_honors_stop_for_live_owner() {
        with_home(|_| {
            let drops = tempfile::tempdir().unwrap();
            let manifest = live_manifest_with_echo(drops.path());
            let cfg = DaemonConfig { pulse_period: Duration::from_millis(10), ..DaemonConfig::default() };
            let host = BrainLifecycle::with_config(&manifest, "doyle", cfg).expect("live host");

            seed_supervised_perch("doyle", std::process::id());
            let ready = perch::resolve_ready_file("doyle", ParentHint::Infer);

            // Owner is this (alive) process; stop is already set → loop exits with
            // no signoff and leaves the perch untouched.
            let stop = AtomicBool::new(true);
            let outcome = host.run_orphan_watch(Some("sid-1"), std::process::id(), &stop);
            assert!(outcome.is_none(), "a live owner under stop never signs off");
            assert!(ready.exists(), "a live Self's perch is left intact");
        });
    }
}


Lifecycle = Psyche host, not it. Need session/status surface + brain frames. Check frame.rs, nethost.rs, and where serve_attach gets invoked.