1	//! Length-prefixed framing for the broker↔brain IPC.
2	//!
3	//! Frame: a 4-byte big-endian length prefix, then that many bytes of UTF-8
4	//! JSON encoding one [`Envelope`]. Same shape as `spt-msg::wire` (ADR-0001
5	//! framing) but generic over any [`Read`]/[`Write`] — the IPC carrier is a
6	//! local socket (named pipe / UDS), not a `TcpStream`, and tests exercise it
7	//! over in-memory buffers and socket pairs.
8	//!
9	//! The frame *codec* is deliberately transport-agnostic: it is the byte-level
10	//! contract, while [`crate::transport`] owns the OS-specific connection.
11	// [impl->REQ-DAEMON-2]
12
13	use std::io::{self, Read, Write};
14
15	use crate::frame::Envelope;
16
17	/// Maximum framed message size accepted (16 MiB) — guards a garbage/hostile
18	/// length prefix from forcing a huge allocation. Matches `spt-msg::wire`.
19	const MAX_FRAME_BYTES: usize = 16 * 1024 * 1024;
20
21	/// Serialize and write one [`Envelope`] as a length-prefixed JSON frame.
22	pub fn write_frame<W: Write>(w: &mut W, env: &Envelope) -> io::Result<()> {
23	    let body = serde_json::to_vec(env)
24	        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
25	    if body.len() > MAX_FRAME_BYTES {
26	        return Err(io::Error::new(
27	            io::ErrorKind::InvalidData,
28	            format!("frame too large: {} bytes", body.len()),
29	        ));
30	    }
31	    let len = body.len() as u32;
32	    w.write_all(&len.to_be_bytes())?;
33	    w.write_all(&body)?;
34	    w.flush()?;
35	    Ok(())
36	}
37
38	/// Read one length-prefixed JSON frame and decode it into an [`Envelope`].
39	/// Rejects an oversized length prefix before allocating. A clean EOF on the
40	/// length prefix surfaces as [`io::ErrorKind::UnexpectedEof`] (peer hung up).
41	pub fn read_frame<R: Read>(r: &mut R) -> io::Result<Envelope> {
42	    let mut len_buf = [0u8; 4];
43	    r.read_exact(&mut len_buf)?;
44	    let len = u32::from_be_bytes(len_buf) as usize;
45	    if len > MAX_FRAME_BYTES {
46	        return Err(io::Error::new(
47	            io::ErrorKind::InvalidData,
48	            format!("frame too large: {len} bytes"),
49	        ));
50	    }
51	    let mut buf = vec![0u8; len];
52	    r.read_exact(&mut buf)?;
53	    serde_json::from_slice(&buf).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
54	}
55
56	#[cfg(test)]
57	mod tests {
58	    use super::*;
59	    use crate::frame::IPC_PROTOCOL_VERSION;
60	    use serde_json::json;
61
62	    // [unit->REQ-DAEMON-2] a frame round-trips byte-exact through the codec.
63	    #[test]
64	    fn frame_round_trips_through_buffer() {
65	        let env = Envelope::new("attach", json!({ "session": "s1", "n": 7 }));
66	        let mut buf: Vec<u8> = Vec::new();
67	        write_frame(&mut buf, &env).unwrap();
68
69	        let mut cursor = io::Cursor::new(buf);
70	        let got = read_frame(&mut cursor).unwrap();
71	        assert_eq!(got.protocol_version, IPC_PROTOCOL_VERSION);
72	        assert_eq!(got.kind, "attach");
73	        assert_eq!(got.payload, json!({ "session": "s1", "n": 7 }));
74	    }
75
76	    // [unit->REQ-DAEMON-2] multiple frames stream back-to-back without bleed.
77	    #[test]
78	    fn back_to_back_frames_decode_independently() {
79	        let mut buf: Vec<u8> = Vec::new();
80	        write_frame(&mut buf, &Envelope::new("a", json!(1))).unwrap();
81	        write_frame(&mut buf, &Envelope::new("b", json!(2))).unwrap();
82
83	        let mut cursor = io::Cursor::new(buf);
84	        assert_eq!(read_frame(&mut cursor).unwrap().kind, "a");
85	        assert_eq!(read_frame(&mut cursor).unwrap().kind, "b");
86	    }
87
88	    // [unit->REQ-DAEMON-2] an oversized length prefix is rejected, not allocated.
89	    #[test]
90	    fn oversized_prefix_rejected() {
91	        let bogus = (32u32 * 1024 * 1024).to_be_bytes();
92	        let mut cursor = io::Cursor::new(bogus.to_vec());
93	        let err = read_frame(&mut cursor).unwrap_err();
94	        assert_eq!(err.kind(), io::ErrorKind::InvalidData);
95	    }
96	}
97

//! Length-prefixed framing for the broker↔brain IPC.
//!
//! Frame: a 4-byte big-endian length prefix, then that many bytes of UTF-8
//! JSON encoding one [`Envelope`]. Same shape as `spt-msg::wire` (ADR-0001
//! framing) but generic over any [`Read`]/[`Write`] — the IPC carrier is a
//! local socket (named pipe / UDS), not a `TcpStream`, and tests exercise it
//! over in-memory buffers and socket pairs.
//!
//! The frame *codec* is deliberately transport-agnostic: it is the byte-level
//! contract, while [`crate::transport`] owns the OS-specific connection.
// [impl->REQ-DAEMON-2]

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

use crate::frame::Envelope;

/// Maximum framed message size accepted (16 MiB) — guards a garbage/hostile
/// length prefix from forcing a huge allocation. Matches `spt-msg::wire`.
const MAX_FRAME_BYTES: usize = 16 * 1024 * 1024;

/// Serialize and write one [`Envelope`] as a length-prefixed JSON frame.
pub fn write_frame<W: Write>(w: &mut W, env: &Envelope) -> io::Result<()> {
    let body = serde_json::to_vec(env)
        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
    if body.len() > MAX_FRAME_BYTES {
        return Err(io::Error::new(
            io::ErrorKind::InvalidData,
            format!("frame too large: {} bytes", body.len()),
        ));
    }
    let len = body.len() as u32;
    w.write_all(&len.to_be_bytes())?;
    w.write_all(&body)?;
    w.flush()?;
    Ok(())
}

/// Read one length-prefixed JSON frame and decode it into an [`Envelope`].
/// Rejects an oversized length prefix before allocating. A clean EOF on the
/// length prefix surfaces as [`io::ErrorKind::UnexpectedEof`] (peer hung up).
pub fn read_frame<R: Read>(r: &mut R) -> io::Result<Envelope> {
    let mut len_buf = [0u8; 4];
    r.read_exact(&mut len_buf)?;
    let len = u32::from_be_bytes(len_buf) as usize;
    if len > MAX_FRAME_BYTES {
        return Err(io::Error::new(
            io::ErrorKind::InvalidData,
            format!("frame too large: {len} bytes"),
        ));
    }
    let mut buf = vec![0u8; len];
    r.read_exact(&mut buf)?;
    serde_json::from_slice(&buf).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::frame::IPC_PROTOCOL_VERSION;
    use serde_json::json;

    // [unit->REQ-DAEMON-2] a frame round-trips byte-exact through the codec.
    #[test]
    fn frame_round_trips_through_buffer() {
        let env = Envelope::new("attach", json!({ "session": "s1", "n": 7 }));
        let mut buf: Vec<u8> = Vec::new();
        write_frame(&mut buf, &env).unwrap();

        let mut cursor = io::Cursor::new(buf);
        let got = read_frame(&mut cursor).unwrap();
        assert_eq!(got.protocol_version, IPC_PROTOCOL_VERSION);
        assert_eq!(got.kind, "attach");
        assert_eq!(got.payload, json!({ "session": "s1", "n": 7 }));
    }

    // [unit->REQ-DAEMON-2] multiple frames stream back-to-back without bleed.
    #[test]
    fn back_to_back_frames_decode_independently() {
        let mut buf: Vec<u8> = Vec::new();
        write_frame(&mut buf, &Envelope::new("a", json!(1))).unwrap();
        write_frame(&mut buf, &Envelope::new("b", json!(2))).unwrap();

        let mut cursor = io::Cursor::new(buf);
        assert_eq!(read_frame(&mut cursor).unwrap().kind, "a");
        assert_eq!(read_frame(&mut cursor).unwrap().kind, "b");
    }

    // [unit->REQ-DAEMON-2] an oversized length prefix is rejected, not allocated.
    #[test]
    fn oversized_prefix_rejected() {
        let bogus = (32u32 * 1024 * 1024).to_be_bytes();
        let mut cursor = io::Cursor::new(bogus.to_vec());
        let err = read_frame(&mut cursor).unwrap_err();
        assert_eq!(err.kind(), io::ErrorKind::InvalidData);
    }
}


1	//! The versioned broker↔brain IPC frame schema and connection handshake.
2	//!
3	//! This is the single most update-frequency-sensitive contract in spt-core
4	//! (KNOWN-HAZARDS 2.3): self-update (ADR-0004) swaps the brain routinely while
5	//! the broker persists, so **a newer brain must talk to an older broker**. Two
6	//! mechanisms make that hold by construction:
7	//!
8	//! 1. **Forward-compatible envelopes.** Every frame is an [`Envelope`] with an
9	//!    explicit `protocol_version`, a *string* `kind` discriminator, and an
10	//!    opaque `payload` ([`serde_json::Value`]). A string kind means an older
11	//!    broker decoding a frame whose `kind` it does not recognize gets a clean
12	//!    `Envelope` — an unknown *kind* is a logic-layer concern, never a decode
13	//!    error. serde's default (no `deny_unknown_fields`) means unknown *trailing
14	//!    fields* a newer brain adds are silently ignored on the older side.
15	//! 2. **A version handshake.** On connect, the brain sends a [`Hello`]; the
16	//!    broker accepts iff the brain's `protocol_version >= MIN_COMPATIBLE_VERSION`.
17	//!    A newer brain (higher version) is accepted by an older broker; a brain
18	//!    too old to satisfy the broker's floor is rejected with a typed error
19	//!    rather than a silent misparse.
20	// [impl->REQ-HAZARD-HANDOFF-ARGV-COMPAT]
21
22	use serde::{Deserialize, Serialize};
23
24	/// The IPC protocol version this build speaks. Bump **only** on a breaking
25	/// frame-schema change (a field whose absence an older peer cannot tolerate).
26	/// Additive changes — new `kind`s, new optional fields — do **not** bump this.
27	pub const IPC_PROTOCOL_VERSION: u32 = 1;
28
29	/// The oldest peer `protocol_version` this build will still handshake with.
30	/// Held at `1` for v1; raised only when an old version is genuinely retired.
31	pub const MIN_COMPATIBLE_VERSION: u32 = 1;
32
33	/// Which side of the IPC a connecting peer is. The broker is the stable kernel;
34	/// the brain is the restartable logic layer (ADR-0004).
35	#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
36	#[serde(rename_all = "snake_case")]
37	pub enum Role {
38	    /// The stable kernel that holds the un-transferable resources.
39	    Broker,
40	    /// The restartable logic layer that attaches over the IPC.
41	    Brain,
42	}
43
44	/// The first frame a connecting peer sends. Intentionally minimal and
45	/// append-only: future fields must be added with `#[serde(default)]` so an
46	/// older peer decoding a newer `Hello` still succeeds (2.3).
47	#[derive(Debug, Clone, Serialize, Deserialize)]
48	pub struct Hello {
49	    /// The protocol version the sender speaks ([`IPC_PROTOCOL_VERSION`]).
50	    pub protocol_version: u32,
51	    /// Which side the sender is.
52	    pub role: Role,
53	}
54
55	impl Hello {
56	    /// A `Hello` for this build, declaring [`IPC_PROTOCOL_VERSION`].
57	    pub fn current(role: Role) -> Self {
58	        Hello { protocol_version: IPC_PROTOCOL_VERSION, role }
59	    }
60	}
61
62	/// Why a handshake was refused — surfaced typed rather than as a silent
63	/// misparse so an incompatible peer fails loudly (the 2.3 failure mode is a
64	/// peer dying *unlogged* on an arity/version mismatch).
65	#[derive(Debug, Clone, PartialEq, Eq)]
66	pub enum HandshakeError {
67	    /// The peer's `protocol_version` is below this build's compatibility floor.
68	    IncompatibleVersion { peer: u32, min_supported: u32 },
69	    /// The peer announced a role we did not expect on this connection.
70	    UnexpectedRole { expected: Role, got: Role },
71	}
72
73	impl std::fmt::Display for HandshakeError {
74	    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
75	        match self {
76	            HandshakeError::IncompatibleVersion { peer, min_supported } => write!(
77	                f,
78	                "peer protocol version {peer} below minimum supported {min_supported}"
79	            ),
80	            HandshakeError::UnexpectedRole { expected, got } => {
81	                write!(f, "expected peer role {expected:?}, got {got:?}")
82	            }
83	        }
84	    }
85	}
86
87	impl std::error::Error for HandshakeError {}
88
89	/// Decide whether to accept a peer's [`Hello`] on a connection expecting
90	/// `expected_peer`. Accepts any `protocol_version >= MIN_COMPATIBLE_VERSION`
91	/// (a *newer* brain is fine against an *older* broker) and rejects below the
92	/// floor or on a role mismatch. This is the whole of the version-tolerance
93	/// policy — kept in one place so the rule is auditable.
94	// [impl->REQ-HAZARD-HANDOFF-ARGV-COMPAT]
95	pub fn accept_hello(hello: &Hello, expected_peer: Role) -> Result<(), HandshakeError> {
96	    if hello.protocol_version < MIN_COMPATIBLE_VERSION {
97	        return Err(HandshakeError::IncompatibleVersion {
98	            peer: hello.protocol_version,
99	            min_supported: MIN_COMPATIBLE_VERSION,
100	        });
101	    }
102	    if hello.role != expected_peer {
103	        return Err(HandshakeError::UnexpectedRole { expected: expected_peer, got: hello.role });
104	    }
105	    Ok(())
106	}
107
108	/// One IPC message. The forward-compatible envelope: `protocol_version` lets a
109	/// reader scope its interpretation, `kind` is a *string* so an unknown message
110	/// type decodes cleanly (the logic layer decides what to do with an unknown
111	/// kind), and `payload` is opaque JSON so each kind owns its own shape without
112	/// the envelope needing to know it. Unknown trailing fields are ignored on
113	/// decode (serde default), so a newer brain may extend a frame and an older
114	/// broker still parses it.
115	#[derive(Debug, Clone, Serialize, Deserialize)]
116	pub struct Envelope {
117	    /// The protocol version of the sender (for scoped interpretation).
118	    pub protocol_version: u32,
119	    /// The message-kind discriminator. A string, not an enum, so an unrecognized
120	    /// kind is never a decode failure on an older peer.
121	    pub kind: String,
122	    /// The kind-specific body. Absent bodies default to JSON `null`.
123	    #[serde(default)]
124	    pub payload: serde_json::Value,
125	}
126
127	impl Envelope {
128	    /// Build an envelope for this build's protocol version with a JSON payload.
129	    pub fn new(kind: impl Into<String>, payload: serde_json::Value) -> Self {
130	        Envelope { protocol_version: IPC_PROTOCOL_VERSION, kind: kind.into(), payload }
131	    }
132	}
133
134	#[cfg(test)]
135	mod tests {
136	    use super::*;
137	    use serde_json::json;
138
139	    // [unit->REQ-HAZARD-HANDOFF-ARGV-COMPAT]
140	    // The load-bearing forward-compat property: a frame a *newer* brain emits —
141	    // carrying an unknown trailing field — must decode cleanly on an *older*
142	    // broker that has never heard of that field. We simulate the newer wire form
143	    // with raw JSON (a superset of today's Envelope) and decode into the current
144	    // struct; the unknown field is dropped, not rejected.
145	    #[test]
146	    fn envelope_with_unknown_trailing_field_decodes_on_older_peer() {
147	        let newer_wire = json!({
148	            "protocol_version": 2,
149	            "kind": "attach",
150	            "payload": { "session": "abc" },
151	            "future_field_an_older_broker_never_heard_of": { "nested": [1, 2, 3] }
152	        })
153	        .to_string();
154
155	        let decoded: Envelope =
156	            serde_json::from_str(&newer_wire).expect("older peer must tolerate unknown fields");
157	        assert_eq!(decoded.protocol_version, 2);
158	        assert_eq!(decoded.kind, "attach");
159	        assert_eq!(decoded.payload, json!({ "session": "abc" }));
160	    }
161
162	    // [unit->REQ-HAZARD-HANDOFF-ARGV-COMPAT]
163	    // An unknown *kind* is not a decode error — the envelope parses and the
164	    // logic layer is what decides it cannot handle the kind.
165	    #[test]
166	    fn unknown_kind_is_not_a_decode_error() {
167	        let wire = json!({ "protocol_version": 1, "kind": "a_kind_from_the_future" }).to_string();
168	        let decoded: Envelope = serde_json::from_str(&wire).unwrap();
169	        assert_eq!(decoded.kind, "a_kind_from_the_future");
170	        assert!(decoded.payload.is_null()); // payload defaulted
171	    }
172
173	    // [unit->REQ-HAZARD-HANDOFF-ARGV-COMPAT]
174	    // A newer brain (higher protocol version, extra Hello fields) is accepted by
175	    // an older broker — the exact "newer brain ↔ older broker" direction 2.3
176	    // demands.
177	    #[test]
178	    fn handshake_accepts_newer_brain_on_older_broker() {
179	        let newer_hello_wire = json!({
180	            "protocol_version": IPC_PROTOCOL_VERSION + 5,
181	            "role": "brain",
182	            "added_in_a_future_release": true
183	        })
184	        .to_string();
185	        let hello: Hello = serde_json::from_str(&newer_hello_wire).unwrap();
186	        assert_eq!(accept_hello(&hello, Role::Brain), Ok(()));
187	    }
188
189	    // [unit->REQ-HAZARD-HANDOFF-ARGV-COMPAT]
190	    // A peer below the compatibility floor is rejected loudly, not misparsed.
191	    #[test]
192	    fn handshake_rejects_below_floor() {
193	        let hello = Hello { protocol_version: MIN_COMPATIBLE_VERSION - 1, role: Role::Brain };
194	        assert!(matches!(
195	            accept_hello(&hello, Role::Brain),
196	            Err(HandshakeError::IncompatibleVersion { .. })
197	        ));
198	    }
199
200	    // [unit->REQ-HAZARD-HANDOFF-ARGV-COMPAT] role mismatch is a typed refusal.
201	    #[test]
202	    fn handshake_rejects_role_mismatch() {
203	        let hello = Hello::current(Role::Broker);
204	        assert!(matches!(
205	            accept_hello(&hello, Role::Brain),
206	            Err(HandshakeError::UnexpectedRole { .. })
207	        ));
208	    }
209	}
210

//! The versioned broker↔brain IPC frame schema and connection handshake.
//!
//! This is the single most update-frequency-sensitive contract in spt-core
//! (KNOWN-HAZARDS 2.3): self-update (ADR-0004) swaps the brain routinely while
//! the broker persists, so **a newer brain must talk to an older broker**. Two
//! mechanisms make that hold by construction:
//!
//! 1. **Forward-compatible envelopes.** Every frame is an [`Envelope`] with an
//!    explicit `protocol_version`, a *string* `kind` discriminator, and an
//!    opaque `payload` ([`serde_json::Value`]). A string kind means an older
//!    broker decoding a frame whose `kind` it does not recognize gets a clean
//!    `Envelope` — an unknown *kind* is a logic-layer concern, never a decode
//!    error. serde's default (no `deny_unknown_fields`) means unknown *trailing
//!    fields* a newer brain adds are silently ignored on the older side.
//! 2. **A version handshake.** On connect, the brain sends a [`Hello`]; the
//!    broker accepts iff the brain's `protocol_version >= MIN_COMPATIBLE_VERSION`.
//!    A newer brain (higher version) is accepted by an older broker; a brain
//!    too old to satisfy the broker's floor is rejected with a typed error
//!    rather than a silent misparse.
// [impl->REQ-HAZARD-HANDOFF-ARGV-COMPAT]

use serde::{Deserialize, Serialize};

/// The IPC protocol version this build speaks. Bump **only** on a breaking
/// frame-schema change (a field whose absence an older peer cannot tolerate).
/// Additive changes — new `kind`s, new optional fields — do **not** bump this.
pub const IPC_PROTOCOL_VERSION: u32 = 1;

/// The oldest peer `protocol_version` this build will still handshake with.
/// Held at `1` for v1; raised only when an old version is genuinely retired.
pub const MIN_COMPATIBLE_VERSION: u32 = 1;

/// Which side of the IPC a connecting peer is. The broker is the stable kernel;
/// the brain is the restartable logic layer (ADR-0004).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Role {
    /// The stable kernel that holds the un-transferable resources.
    Broker,
    /// The restartable logic layer that attaches over the IPC.
    Brain,
}

/// The first frame a connecting peer sends. Intentionally minimal and
/// append-only: future fields must be added with `#[serde(default)]` so an
/// older peer decoding a newer `Hello` still succeeds (2.3).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Hello {
    /// The protocol version the sender speaks ([`IPC_PROTOCOL_VERSION`]).
    pub protocol_version: u32,
    /// Which side the sender is.
    pub role: Role,
}

impl Hello {
    /// A `Hello` for this build, declaring [`IPC_PROTOCOL_VERSION`].
    pub fn current(role: Role) -> Self {
        Hello { protocol_version: IPC_PROTOCOL_VERSION, role }
    }
}

/// Why a handshake was refused — surfaced typed rather than as a silent
/// misparse so an incompatible peer fails loudly (the 2.3 failure mode is a
/// peer dying *unlogged* on an arity/version mismatch).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HandshakeError {
    /// The peer's `protocol_version` is below this build's compatibility floor.
    IncompatibleVersion { peer: u32, min_supported: u32 },
    /// The peer announced a role we did not expect on this connection.
    UnexpectedRole { expected: Role, got: Role },
}

impl std::fmt::Display for HandshakeError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            HandshakeError::IncompatibleVersion { peer, min_supported } => write!(
                f,
                "peer protocol version {peer} below minimum supported {min_supported}"
            ),
            HandshakeError::UnexpectedRole { expected, got } => {
                write!(f, "expected peer role {expected:?}, got {got:?}")
            }
        }
    }
}

impl std::error::Error for HandshakeError {}

/// Decide whether to accept a peer's [`Hello`] on a connection expecting
/// `expected_peer`. Accepts any `protocol_version >= MIN_COMPATIBLE_VERSION`
/// (a *newer* brain is fine against an *older* broker) and rejects below the
/// floor or on a role mismatch. This is the whole of the version-tolerance
/// policy — kept in one place so the rule is auditable.
// [impl->REQ-HAZARD-HANDOFF-ARGV-COMPAT]
pub fn accept_hello(hello: &Hello, expected_peer: Role) -> Result<(), HandshakeError> {
    if hello.protocol_version < MIN_COMPATIBLE_VERSION {
        return Err(HandshakeError::IncompatibleVersion {
            peer: hello.protocol_version,
            min_supported: MIN_COMPATIBLE_VERSION,
        });
    }
    if hello.role != expected_peer {
        return Err(HandshakeError::UnexpectedRole { expected: expected_peer, got: hello.role });
    }
    Ok(())
}

/// One IPC message. The forward-compatible envelope: `protocol_version` lets a
/// reader scope its interpretation, `kind` is a *string* so an unknown message
/// type decodes cleanly (the logic layer decides what to do with an unknown
/// kind), and `payload` is opaque JSON so each kind owns its own shape without
/// the envelope needing to know it. Unknown trailing fields are ignored on
/// decode (serde default), so a newer brain may extend a frame and an older
/// broker still parses it.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Envelope {
    /// The protocol version of the sender (for scoped interpretation).
    pub protocol_version: u32,
    /// The message-kind discriminator. A string, not an enum, so an unrecognized
    /// kind is never a decode failure on an older peer.
    pub kind: String,
    /// The kind-specific body. Absent bodies default to JSON `null`.
    #[serde(default)]
    pub payload: serde_json::Value,
}

impl Envelope {
    /// Build an envelope for this build's protocol version with a JSON payload.
    pub fn new(kind: impl Into<String>, payload: serde_json::Value) -> Self {
        Envelope { protocol_version: IPC_PROTOCOL_VERSION, kind: kind.into(), payload }
    }
}

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

    // [unit->REQ-HAZARD-HANDOFF-ARGV-COMPAT]
    // The load-bearing forward-compat property: a frame a *newer* brain emits —
    // carrying an unknown trailing field — must decode cleanly on an *older*
    // broker that has never heard of that field. We simulate the newer wire form
    // with raw JSON (a superset of today's Envelope) and decode into the current
    // struct; the unknown field is dropped, not rejected.
    #[test]
    fn envelope_with_unknown_trailing_field_decodes_on_older_peer() {
        let newer_wire = json!({
            "protocol_version": 2,
            "kind": "attach",
            "payload": { "session": "abc" },
            "future_field_an_older_broker_never_heard_of": { "nested": [1, 2, 3] }
        })
        .to_string();

        let decoded: Envelope =
            serde_json::from_str(&newer_wire).expect("older peer must tolerate unknown fields");
        assert_eq!(decoded.protocol_version, 2);
        assert_eq!(decoded.kind, "attach");
        assert_eq!(decoded.payload, json!({ "session": "abc" }));
    }

    // [unit->REQ-HAZARD-HANDOFF-ARGV-COMPAT]
    // An unknown *kind* is not a decode error — the envelope parses and the
    // logic layer is what decides it cannot handle the kind.
    #[test]
    fn unknown_kind_is_not_a_decode_error() {
        let wire = json!({ "protocol_version": 1, "kind": "a_kind_from_the_future" }).to_string();
        let decoded: Envelope = serde_json::from_str(&wire).unwrap();
        assert_eq!(decoded.kind, "a_kind_from_the_future");
        assert!(decoded.payload.is_null()); // payload defaulted
    }

    // [unit->REQ-HAZARD-HANDOFF-ARGV-COMPAT]
    // A newer brain (higher protocol version, extra Hello fields) is accepted by
    // an older broker — the exact "newer brain ↔ older broker" direction 2.3
    // demands.
    #[test]
    fn handshake_accepts_newer_brain_on_older_broker() {
        let newer_hello_wire = json!({
            "protocol_version": IPC_PROTOCOL_VERSION + 5,
            "role": "brain",
            "added_in_a_future_release": true
        })
        .to_string();
        let hello: Hello = serde_json::from_str(&newer_hello_wire).unwrap();
        assert_eq!(accept_hello(&hello, Role::Brain), Ok(()));
    }

    // [unit->REQ-HAZARD-HANDOFF-ARGV-COMPAT]
    // A peer below the compatibility floor is rejected loudly, not misparsed.
    #[test]
    fn handshake_rejects_below_floor() {
        let hello = Hello { protocol_version: MIN_COMPATIBLE_VERSION - 1, role: Role::Brain };
        assert!(matches!(
            accept_hello(&hello, Role::Brain),
            Err(HandshakeError::IncompatibleVersion { .. })
        ));
    }

    // [unit->REQ-HAZARD-HANDOFF-ARGV-COMPAT] role mismatch is a typed refusal.
    #[test]
    fn handshake_rejects_role_mismatch() {
        let hello = Hello::current(Role::Broker);
        assert!(matches!(
            accept_hello(&hello, Role::Brain),
            Err(HandshakeError::UnexpectedRole { .. })
        ));
    }
}


NOTE FROM THE USER: proceed with nextsteps.


Read both Cargo.tomls to confirm deps (does `spt` depend on `spt-daemon`? what's available in spt-daemon?).


1	[package]
2	name = "spt-daemon"
3	version = "0.0.0"
4	edition = "2021"
5	publish = false
6	description = "spt-core broker/brain daemon (M3b): the per-machine supervisor process. A stable broker kernel holds the un-transferable resources (PTY masters from spt-term, harness children, sockets) while a restartable brain holds all logic; a versioned local IPC lets a newer brain talk to an older broker so a self-update swaps the brain with zero endpoint interruption (ADR-0004). Internal supervisor (NOT public SDK, R-ARCH-2) — the `spt` binary is a thin CLI over it. B0 lands the crate + the versioned local-IPC transport."
7
8	[dependencies]
9	# Layer-below set (R-ARCH-1 acyclic): …→spt-live→spt-daemon→spt. The broker
10	# re-hosts spt-term PTYs + the spt-live lifecycle seams; these edges fix the
11	# crate's position in the workspace layering. Tasks B1–B8 consume them.
12	spt-proto = { path = "../spt-proto" }
13	spt-store = { path = "../spt-store" }
14	spt-msg = { path = "../spt-msg" }
15	spt-runtime = { path = "../spt-runtime" }
16	spt-term = { path = "../spt-term" }
17	spt-live = { path = "../spt-live" }
18	serde = { version = "1", features = ["derive"] }
19	serde_json = { version = "1", features = ["preserve_order"] }
20	# PTY output is arbitrary bytes; the forward-compat JSON envelope carries it as
21	# a base64 string in the payload rather than forking the codec for binary.
22	base64 = "0.22"
23	# Cross-platform local IPC: named pipe (Windows) / Unix-domain socket (unix)
24	# behind one API — the OS-split substrate the DaemonTransport trait wraps,
25	# mirroring how spt-term abstracts ConPTY vs forkpty behind SessionSurface.
26	interprocess = "2"
27
28	[dev-dependencies]
29	tempfile = "3"
30

[package]
name = "spt-daemon"
version = "0.0.0"
edition = "2021"
publish = false
description = "spt-core broker/brain daemon (M3b): the per-machine supervisor process. A stable broker kernel holds the un-transferable resources (PTY masters from spt-term, harness children, sockets) while a restartable brain holds all logic; a versioned local IPC lets a newer brain talk to an older broker so a self-update swaps the brain with zero endpoint interruption (ADR-0004). Internal supervisor (NOT public SDK, R-ARCH-2) — the `spt` binary is a thin CLI over it. B0 lands the crate + the versioned local-IPC transport."

[dependencies]
# Layer-below set (R-ARCH-1 acyclic): …→spt-live→spt-daemon→spt. The broker
# re-hosts spt-term PTYs + the spt-live lifecycle seams; these edges fix the
# crate's position in the workspace layering. Tasks B1–B8 consume them.
spt-proto = { path = "../spt-proto" }
spt-store = { path = "../spt-store" }
spt-msg = { path = "../spt-msg" }
spt-runtime = { path = "../spt-runtime" }
spt-term = { path = "../spt-term" }
spt-live = { path = "../spt-live" }
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1", features = ["preserve_order"] }
# PTY output is arbitrary bytes; the forward-compat JSON envelope carries it as
# a base64 string in the payload rather than forking the codec for binary.
base64 = "0.22"
# Cross-platform local IPC: named pipe (Windows) / Unix-domain socket (unix)
# behind one API — the OS-split substrate the DaemonTransport trait wraps,
# mirroring how spt-term abstracts ConPTY vs forkpty behind SessionSurface.
interprocess = "2"

[dev-dependencies]
tempfile = "3"


1	[package]
2	name = "spt"
3	version = "0.0.0"
4	edition = "2021"
5	publish = false
6	description = "spt-core CLI: the single `spt` binary. Thin command layer over spt-msg (send/ring/ready/poll/list/stop/whoami) + the harness-contract `api` surface (spt-runtime)."
7
8	[[bin]]
9	name = "spt"
10	path = "src/main.rs"
11
12	[dependencies]
13	spt-proto = { path = "../spt-proto" }
14	spt-store = { path = "../spt-store" }
15	spt-msg = { path = "../spt-msg" }
16	spt-runtime = { path = "../spt-runtime" }
17	spt-live = { path = "../spt-live" }
18	spt-daemon = { path = "../spt-daemon" }
19	clap = { version = "4", features = ["derive"] }
20
21	[dev-dependencies]
22	tempfile = "3"
23	toml = "0.8"
24

[package]
name = "spt"
version = "0.0.0"
edition = "2021"
publish = false
description = "spt-core CLI: the single `spt` binary. Thin command layer over spt-msg (send/ring/ready/poll/list/stop/whoami) + the harness-contract `api` surface (spt-runtime)."

[[bin]]
name = "spt"
path = "src/main.rs"

[dependencies]
spt-proto = { path = "../spt-proto" }
spt-store = { path = "../spt-store" }
spt-msg = { path = "../spt-msg" }
spt-runtime = { path = "../spt-runtime" }
spt-live = { path = "../spt-live" }
spt-daemon = { path = "../spt-daemon" }
clap = { version = "4", features = ["derive"] }

[dev-dependencies]
tempfile = "3"
toml = "0.8"


Bash

grep -n -B1 -A4 'REQ-START-1\|REQ-START-2' traceable-reqs.toml | head -40; echo '--- usages ---'; grep -rn 'seed::\|write_seed\|read_seed\|remove_seed\|seeds_dir\|seed_path' crates/ --include=*.rs | grep -v 'crates/spt-store/src/seed.rs'


121-[[requirements]]
122:id = "REQ-START-1"
123-title = "Adapters never resolve SPT_HOME; binary on PATH; api bridging only"
124-required_stages = ["impl", "unit"]
125-[[requirements]]
126:id = "REQ-START-2"
127-title = "Harness-hosted startup: api seed then listen"
128-required_stages = ["impl", "unit", "int"]
129-[[requirements]]
130-id = "REQ-START-3"
--- usages ---
crates/spt/src/api/startup.rs:25:use spt_store::seed::{self, Seed};
crates/spt/src/api/startup.rs:53:    match seed::write_seed(&seed) {
crates/spt/src/api/startup.rs:84:    let seed = seed::read_seed(parent_pid).ok_or(BindError::NoSeed(parent_pid))?;
crates/spt/src/api/startup.rs:95:    seed::remove_seed(parent_pid); // consumed; refreshed on the next SessionStart
crates/spt/src/api/startup.rs:298:    fn write_seed(parent_pid: u32, session_id: &str) {
crates/spt/src/api/startup.rs:299:        seed::write_seed(&Seed {
crates/spt/src/api/startup.rs:314:        write_seed(anchor, "sid-1");
crates/spt/src/api/startup.rs:325:        assert!(seed::read_seed(anchor).is_none());
crates/spt/src/api/startup.rs:332:        write_seed(DEAD, "sid-1");
crates/spt/src/api/startup.rs:350:        write_seed(anchor, "");
crates/spt/src/api/startup.rs:360:        write_seed(anchor, "sid-1");
crates/spt/src/api/startup.rs:363:        write_seed(anchor, "sid-2");


- **Sister cite:** CHANGELOG v1.35.1 "IN-05"; `hook_subagent_start.rs:40-51`.

### 2.3 Binary-handoff argv schema must stay backward-compatible
- **Failure:** the sister evaluates Psyche/perch liveness via a dedicated process PID — the wrapper's own pid in `info.json`, checked with `is_process_alive`. Under ADR-0004 the Psyche (and any spt-hosted Self) is a **loop inside the daemon**, not a separate process: it holds no dedicated pid, and its `claude`/summarizer subprocess is ephemeral (spawned per pulse/commune, then exits). If a daemon-hosted perch's `info.json` carries the **daemon's** pid, then *every* hosted endpoint shares one pid, and `is_process_alive(pid)` reads "alive" for a torn-down endpoint as long as the daemon runs — while `clean_stale_entries` (dead-pid deletion) can no longer distinguish a dead endpoint from a live one. The 2.1/5.1 liveness models do **not** cover this third category: the Psyche is neither harness-hosted (no `parent_pid` anchor) nor a broker-held PTY child.
- **Invariant:** for **daemon-hosted** perches (Psyche; spt-hosted Self), liveness is the **daemon's authoritative in-memory endpoint table + a `status` field** on `info.json` (`online|offline|…`), **never** `is_process_alive(info.pid)`. `info.pid` for a daemon-hosted perch is at most a *hosted-by-daemon* marker (the daemon pid), not a liveness signal; registry stale-clean for these rows keys on the daemon's endpoint table, not per-row pid. This reuses the pattern already specified for **Shells** (`info.json` carries daemon-managed `status`, capability resolved by `adapter_name` — CONTEXT "Shell… Not in the subnet registry") and extends it to daemon-hosted *agent* perches.
- **spt-core mapping:** the **M1/M2a interim** model keeps the Psyche/listener a real per-process owner (the `api listen` process), so its per-pid liveness (`deliver::is_online` → `info.read_pid` → `proc::is_process_alive`; `registry::clean_stale_entries`) is correct *interim*. **M3 daemon consolidation replaces it** with daemon-authoritative liveness for hosted perches. Keep the liveness check behind one resolver (mirrors `resolve_address` stale-clean) so the M3 swap is localized — do **not** let the per-pid assumption leak into new call sites.
- **Sister cite:** `src/live/wrapper/orphan.rs` (wrapper-pid liveness); `src/common/list_filter.rs:168-175` (pid-classify); spt-core `crates/spt-store/src/{proc.rs,registry.rs}` + `crates/spt-msg/src/deliver.rs::is_online`.

---

## 3. Lifecycle
- **Sister cite:** `src/live/wrapper/orphan.rs:175-199`; tests A-H:333-565.

---

## 4. Wire / transport
### 5.1 Windows PID recycling false positives
- **Failure:** recycled PID reads alive for the wrong process → orphan misclassification.
- **Invariant:** anchor liveness on the stable parent/harness PID; minimal info.json for supervisor-owned perches; mtime grace window (≥60s) masks transient mismatches.
- **spt-core mapping:** broker-held handles supersede PID polling for spt-hosted sessions; keep the grace window for harness-hosted.
- **Sister cite:** `src/live/wrapper/orphan.rs:141-161`; `src/common/list_filter.rs:168-175`.

### 5.2 Windows EBUSY on atomic rename
- **Sister cite:** CHANGELOG v1.11.20 Phase 35.1.

### 6.4 Drop files are single-writer (supervisor-owned), read-only for the mind
| 2.1/5.1 | Stable PID/broker-handle over ephemeral PID | liveness detection |
| 2.3 | Handoff argv/IPC version-tolerant (newer brain ↔ older broker) | broker↔brain IPC, self-update |
| 2.4 | gen_start = now() on cold-start + handoff | per-instance generation |
| 3.1 | Ephemeral perch cleanup on all exit paths | `ring` (RAII guard) |
| 3.3 | Echo-commune before INIT_SIGNOFF | daemon psyche loop |
| 4.1 | Envelope decode order, `&` last | spt-proto (public, wire-versioned) |
| 4.2 | Parser panic-free + tolerant | spt-proto |
| 4.3 | Stale registry entries → fallback, never hard-fail | subnet registry resolution |
| 4.6 | Addressable-id charset reserves `:`/`@` delimiters | `spt_proto::id` at creation seams |
| 5.2 | tmp-write + atomic-rename + retry (EBUSY) | all state writes, binary swap |


Read testutil + test_home to design the test harness for the IPC-backed seed path.


1	//! Shared test-only home isolation for the `spt` binary's unit tests.
2	//!
3	//! Several test modules (`api::auth`, `api::startup`, …) mutate process-wide env
4	//! (`SPT_HOME` + the auth-proof vars) and touch perches under the owlery. A
5	//! per-module lock is not enough — tests in *different* modules run on different
6	//! threads of the same test process and would clobber each other's `SPT_HOME`.
7	//! [`isolated_home`] serializes them on one process-wide lock and hands each a
8	//! fresh throwaway home, restoring the previous value on drop.
9
10	use std::sync::{Mutex, MutexGuard};
11	use tempfile::TempDir;
12
13	static LOCK: Mutex<()> = Mutex::new(());
14
15	/// RAII guard: holds the global test lock and points `SPT_HOME` at a fresh temp
16	/// dir for the test's duration. Also clears the auth-proof env so a prior
17	/// test's `$OWL_SESSION_ID` / `$SPT_API_TOKEN` can't leak into a fallback.
18	pub struct HomeGuard {
19	    _lock: MutexGuard<'static, ()>,
20	    _tmp: TempDir,
21	    prev_home: Option<String>,
22	}
23
24	impl Drop for HomeGuard {
25	    fn drop(&mut self) {
26	        match &self.prev_home {
27	            Some(v) => std::env::set_var("SPT_HOME", v),
28	            None => std::env::remove_var("SPT_HOME"),
29	        }
30	    }
31	}
32
33	/// Acquire an isolated home for a test. Blocks until any other env-touching
34	/// test releases its guard.
35	pub fn isolated_home() -> HomeGuard {
36	    let lock = LOCK.lock().unwrap_or_else(|e| e.into_inner());
37	    let tmp = TempDir::new().expect("temp SPT_HOME");
38	    let prev_home = std::env::var("SPT_HOME").ok();
39	    std::env::set_var("SPT_HOME", tmp.path());
40	    std::env::remove_var("SPT_API_TOKEN");
41	    std::env::remove_var("OWL_SESSION_ID");
42	    HomeGuard { _lock: lock, _tmp: tmp, prev_home }
43	}
44

//! Shared test-only home isolation for the `spt` binary's unit tests.
//!
//! Several test modules (`api::auth`, `api::startup`, …) mutate process-wide env
//! (`SPT_HOME` + the auth-proof vars) and touch perches under the owlery. A
//! per-module lock is not enough — tests in *different* modules run on different
//! threads of the same test process and would clobber each other's `SPT_HOME`.
//! [`isolated_home`] serializes them on one process-wide lock and hands each a
//! fresh throwaway home, restoring the previous value on drop.

use std::sync::{Mutex, MutexGuard};
use tempfile::TempDir;

static LOCK: Mutex<()> = Mutex::new(());

/// RAII guard: holds the global test lock and points `SPT_HOME` at a fresh temp
/// dir for the test's duration. Also clears the auth-proof env so a prior
/// test's `$OWL_SESSION_ID` / `$SPT_API_TOKEN` can't leak into a fallback.
pub struct HomeGuard {
    _lock: MutexGuard<'static, ()>,
    _tmp: TempDir,
    prev_home: Option<String>,
}

impl Drop for HomeGuard {
    fn drop(&mut self) {
        match &self.prev_home {
            Some(v) => std::env::set_var("SPT_HOME", v),
            None => std::env::remove_var("SPT_HOME"),
        }
    }
}

/// Acquire an isolated home for a test. Blocks until any other env-touching
/// test releases its guard.
pub fn isolated_home() -> HomeGuard {
    let lock = LOCK.lock().unwrap_or_else(|e| e.into_inner());
    let tmp = TempDir::new().expect("temp SPT_HOME");
    let prev_home = std::env::var("SPT_HOME").ok();
    std::env::set_var("SPT_HOME", tmp.path());
    std::env::remove_var("SPT_API_TOKEN");
    std::env::remove_var("OWL_SESSION_ID");
    HomeGuard { _lock: lock, _tmp: tmp, prev_home }
}


1	//! Shared `SPT_HOME` isolation for spt-daemon unit tests (mirrors the spt-live
2	//! `test_support` discipline).
3	//!
4	//! Every `SPT_HOME`-touching test in this binary races on the single global env
5	//! var, so they all serialize on one process-wide `Mutex`.
6
7	use std::path::Path;
8	use std::sync::Mutex;
9
10	static HOME_LOCK: Mutex<()> = Mutex::new(());
11
12	/// Run `body` with `SPT_HOME` pointed at a fresh temp dir, serialized against
13	/// every other `SPT_HOME` test in this binary and restored afterward.
14	pub(crate) fn with_home<F: FnOnce(&Path)>(body: F) {
15	    let _g = HOME_LOCK.lock().unwrap_or_else(|p| p.into_inner());
16	    let prev = std::env::var("SPT_HOME").ok();
17	    let tmp = tempfile::tempdir().unwrap();
18	    std::env::set_var("SPT_HOME", tmp.path());
19	    body(tmp.path());
20	    match prev {
21	        Some(v) => std::env::set_var("SPT_HOME", v),
22	        None => std::env::remove_var("SPT_HOME"),
23	    }
24	}
25

//! Shared `SPT_HOME` isolation for spt-daemon unit tests (mirrors the spt-live
//! `test_support` discipline).
//!
//! Every `SPT_HOME`-touching test in this binary races on the single global env
//! var, so they all serialize on one process-wide `Mutex`.

use std::path::Path;
use std::sync::Mutex;

static HOME_LOCK: Mutex<()> = Mutex::new(());

/// Run `body` with `SPT_HOME` pointed at a fresh temp dir, serialized against
/// every other `SPT_HOME` test in this binary and restored afterward.
pub(crate) fn with_home<F: FnOnce(&Path)>(body: F) {
    let _g = HOME_LOCK.lock().unwrap_or_else(|p| p.into_inner());
    let prev = std::env::var("SPT_HOME").ok();
    let tmp = tempfile::tempdir().unwrap();
    std::env::set_var("SPT_HOME", tmp.path());
    body(tmp.path());
    match prev {
        Some(v) => std::env::set_var("SPT_HOME", v),
        None => std::env::remove_var("SPT_HOME"),
    }
}


grep -rln 'atomic_write_string' crates/spt-store/src/ ; echo '--- int tests exec spt binary? ---'; grep -rln 'api.*seed\|Command::new\|assert_cmd\|cargo run\|env!("CARGO_BIN' crates/*/tests/ 2>/dev/null; echo '--- grep seed in non-store ---'; grep -rln 'seed' crates/spt/tests/ 2>/dev/null


crates/spt-store/src/atomic.rs
crates/spt-store/src/info.rs
crates/spt-store/src/liveness.rs
crates/spt-store/src/seed.rs
--- int tests exec spt binary? ---
crates/spt/tests/contract_e2e.rs
--- grep seed in non-store ---
crates/spt/tests/contract_e2e.rs


Integration test `contract_e2e.rs` uses seed + execs spt binary — the START-2 `int` evidence. Must read + adapt for B6.


1	//! M2a contract E2E (T9): the inbound + outbound halves of the harness contract
2	//! proven end-to-end through the **generic mock adapter** — no vendor harness.
3	//!
4	//! Flow: load the mock manifest → `ManifestRuntime` spawns the `mock-session`
5	//! helper → the helper fires `spt api bind` (skeleton→live) then
6	//! `spt api state idle` → a message is spooled to the perch → `spt api poll`
7	//! drains it. Asserts the round-trip.
8	//!
9	//! A second test exercises the **harness-hosted startup** topology proper —
10	//! `api seed` → `api listen` consumes the seed, binds, and relays a delivered
11	//! message — the REQ-START-2 path.
12	//
13	// [int->REQ-API-1]
14	// [int->REQ-API-2]
15
16	use std::path::PathBuf;
17	use std::process::Command;
18	use std::sync::Mutex;
19	use std::time::Duration;
20
21	/// Both tests mutate the process-global `SPT_HOME` (the in-test spt-store calls
22	/// and the spawned children read it). Serialize them so their homes don't race.
23	static E2E_LOCK: Mutex<()> = Mutex::new(());
24
25	use spt_runtime::{Manifest, ManifestRuntime, AgentRuntime};
26	use spt_store::perch::{self, ParentHint};
27	use spt_store::spool;
28
29	/// Absolute path to a sibling binary in the same target dir as the `spt` test bin.
30	fn sibling_bin(name: &str) -> PathBuf {
31	    let spt = PathBuf::from(env!("CARGO_BIN_EXE_spt"));
32	    spt.with_file_name(format!("{name}{}", std::env::consts::EXE_SUFFIX))
33	}
34
35	fn keys(pairs: &[(&str, &str)]) -> std::collections::BTreeMap<String, String> {
36	    pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect()
37	}
38
39	#[test]
40	fn mock_adapter_drives_the_full_contract() {
41	    let _serial = E2E_LOCK.lock().unwrap_or_else(|e| e.into_inner());
42	    // Isolated home shared by this process *and* the child `spt` invocations
43	    // (inherited via env). This test owns the process, so a plain set_var is safe.
44	    let home = tempfile::tempdir().unwrap();
45	    std::env::set_var("SPT_HOME", home.path());
46
47	    let spt_bin = PathBuf::from(env!("CARGO_BIN_EXE_spt"));
48	    let mock_bin = sibling_bin("mock-session");
49	    assert!(mock_bin.exists(), "mock-session must be built: {}", mock_bin.display());
50
51	    // Load the canonical mock manifest, then point its [session.self] template at
52	    // the freshly-built helper + spt binary (PATH isn't set up in the test rig).
53	    let manifest_path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../adapters/mock/manifest.toml");
54	    let mut manifest = Manifest::from_toml_str(
55	        &std::fs::read_to_string(manifest_path).expect("read mock manifest"),
56	    )
57	    .expect("mock manifest valid");
58	    manifest.session.self_.as_mut().unwrap().command = format!(
59	        "\"{}\" --id {{id}} --session-id {{session_id}} --spt \"{}\"",
60	        mock_bin.display(),
61	        spt_bin.display(),
62	    );
63
64	    let runtime = ManifestRuntime::new(manifest);
65	    let id = "mock-e2e";
66	    let session_id = "sid-e2e-1";
67
68	    // Outbound: spawn the session. Bounded (not detached) so the test waits for
69	    // the helper's bind + state-idle to complete before asserting.
70	    let out = runtime
71	        .run_bounded("self", &keys(&[("id", id), ("session_id", session_id)]), Duration::from_secs(30))
72	        .expect("spawn mock session");
73	    assert!(
74	        out.success(),
75	        "mock-session failed: code={:?}\nstdout={}\nstderr={}",
76	        out.status_code, out.stdout, out.stderr
77	    );
78
79	    // Inbound assertion 1 — `api bind` flipped the perch live (REQ-START-2 /
80	    // REQ-SEAM-POSTSPAWN): info.json exists with the reported session id.
81	    let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);
82	    let rec = spt_store::info::read_info(&perch_path).expect("perch info.json after bind");
83	    assert_eq!(rec.session_id, session_id);
84	    assert_eq!(rec.state, "live_agent");
85
86	    // Inbound assertion 2 — `api state idle` wrote the activity sentinel.
87	    assert!(perch_path.join(".idle").exists(), "state idle must write the .idle sentinel");
88
89	    // A peer spools a message to the now-live perch.
90	    spool::spool_message_at(&perch_path, "tester", "hello over the contract").unwrap();
91
92	    // Inbound assertion 3 — `api poll` drains it (the full delivery round-trip).
93	    let poll = Command::new(&spt_bin)
94	        .args(["api", "--adapter", "mock", "poll", id, "--session-id", session_id])
95	        .env("SPT_HOME", home.path())
96	        .output()
97	        .expect("run api poll");
98	    assert!(poll.status.success(), "api poll exit: {:?}", poll.status);
99	    let stdout = String::from_utf8_lossy(&poll.stdout);
100	    assert!(
101	        stdout.contains("hello over the contract"),
102	        "api poll must deliver the spooled message, got: {stdout:?}"
103	    );
104
105	    // Auth negative — a poll with no proof is refused (REQ-HAZARD-LOCAL-API-AUTH
106	    // exercised through the real binary).
107	    let unauth = Command::new(&spt_bin)
108	        .args(["api", "--adapter", "mock", "poll", id])
109	        .env("SPT_HOME", home.path())
110	        .env_remove("OWL_SESSION_ID")
111	        .output()
112	        .expect("run unauth poll");
113	    assert!(!unauth.status.success(), "poll without proof must be refused");
114	}
115
116	/// Harness-hosted startup proper: `api seed` then `api listen` consumes the seed
117	/// (matched by parent pid, validated against pid-recycle), binds the perch
118	/// skeleton→live, and relays a delivered message.
119	#[test]
120	fn seed_then_listen_binds_and_relays() {
121	    // [int->REQ-START-2]
122	    // [int->REQ-API-2]
123	    let _serial = E2E_LOCK.lock().unwrap_or_else(|e| e.into_inner());
124	    let home = tempfile::tempdir().unwrap();
125	    let spt_bin = PathBuf::from(env!("CARGO_BIN_EXE_spt"));
126	    let id = "listen-e2e";
127	    let session_id = "sid-listen-1";
128	    // The anchor must be a live process; the test runner itself qualifies.
129	    let anchor = std::process::id().to_string();
130
131	    // Pre-spool a message (creates the perch spool dir) so listen drains it on bind.
132	    std::env::set_var("SPT_HOME", home.path());
133	    let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);
134	    spool::spool_message_at(&perch_path, "tester", "delivered via listen").unwrap();
135
136	    // Seed the startup record keyed by the anchor pid.
137	    let seed = Command::new(&spt_bin)
138	        .args(["api", "--adapter", "mock", "seed", "--pid", &anchor, "--session-id", session_id])
139	        .env("SPT_HOME", home.path())
140	        .status()
141	        .expect("run api seed");
142	    assert!(seed.success(), "api seed exit: {seed:?}");
143
144	    // Listen consumes the seed (explicit --parent-pid for a deterministic match),
145	    // binds the perch, drains the backlog, and exits (--once).
146	    let listen = Command::new(&spt_bin)
147	        .args(["api", "--adapter", "mock", "listen", id, "--parent-pid", &anchor, "--once"])
148	        .env("SPT_HOME", home.path())
149	        .output()
150	        .expect("run api listen");
151	    assert!(listen.status.success(), "api listen exit: {:?}", listen.status);
152	    let stdout = String::from_utf8_lossy(&listen.stdout);
153	    assert!(
154	        stdout.contains("delivered via listen"),
155	        "listen must relay the spooled message, got: {stdout:?}"
156	    );
157
158	    // The perch was bound live with the seed's session id.
159	    let rec = spt_store::info::read_info(&perch_path).expect("perch info.json after listen");
160	    assert_eq!(rec.session_id, session_id);
161	    assert_eq!(rec.state, "live_agent");
162	}
163
164	/// M2b LiveAgent lifecycle E2E (T9): prove the full live-agent lifecycle through
165	/// the **generic mock adapter**, harness-agnostic:
166	/// spawn a LiveAgent → its Psyche spawns as a nested perch → a commune drop is
167	/// ingested + deleted by spt-core → a graceful `spt shutdown` fires the
168	/// echo-commune *before* teardown.
169	#[test]
170	fn live_agent_lifecycle_e2e() {
171	    // [int->REQ-SEAM-PSYCHE]
172	    // [int->REQ-SEAM-HISTORY]
173	    // [int->REQ-API-3]
174	    let _serial = E2E_LOCK.lock().unwrap_or_else(|e| e.into_inner());
175	    let home = tempfile::tempdir().unwrap();
176	    std::env::set_var("SPT_HOME", home.path());
177
178	    let spt_bin = PathBuf::from(env!("CARGO_BIN_EXE_spt"));
179	    let mock_bin = sibling_bin("mock-session");
180	    assert!(mock_bin.exists(), "mock-session must be built: {}", mock_bin.display());
181
182	    // An absolute drop dir for the watched commune/signoff files (the committed
183	    // manifest's relative ".mock" is rig-rewritten here, like [session.self]).
184	    let drops = tempfile::tempdir().unwrap();
185
186	    // Load the canonical mock manifest, then point its lifecycle templates at the
187	    // freshly-built helper (PATH isn't set up) + the absolute drop dir.
188	    let manifest_path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../adapters/mock/manifest.toml");
189	    let mut manifest = Manifest::from_toml_str(
190	        &std::fs::read_to_string(manifest_path).expect("read mock manifest"),
191	    )
192	    .expect("mock manifest valid");
193	    let mock = mock_bin.display().to_string();
194	    let drop_dir = drops.path().display().to_string();
195	    manifest.session.commune_dir = Some(drop_dir.clone());
196	    manifest.session.signoff_dir = Some(drop_dir.clone());
197	    manifest.session.psyche_init.as_mut().unwrap().command =
198	        format!("\"{mock}\" --mode psyche --id {{id}} --session-id {{session_id}}");
199	    manifest.session.echo_commune.as_mut().unwrap().command =
200	        format!("\"{mock}\" --mode echo --id {{id}}");
201
202	    // Write the rig-rewritten manifest to a temp file for `--manifest`.
203	    let manifest_file = home.path().join("mock-live.toml");
204	    std::fs::write(&manifest_file, toml::to_string(&manifest).expect("serialize manifest")).unwrap();
205	    let manifest_arg = manifest_file.display().to_string();
206
207	    let id = "live-e2e";
208	    let session_id = "sid-live-1";
209	    let anchor = std::process::id().to_string();
210
211	    // Seed the startup record keyed by the anchor pid.
212	    let seed = Command::new(&spt_bin)
213	        .args(["api", "--adapter", "mock", "seed", "--pid", &anchor, "--session-id", session_id])
214	        .env("SPT_HOME", home.path())
215	        .status()
216	        .expect("run api seed");
217	    assert!(seed.success(), "api seed exit: {seed:?}");
218
219	    // Pre-drop a commune so the pulse tick in `listen --once` ingests it.
220	    std::fs::write(drops.path().join(format!("{id}-commune.md")), "Source: self-commune\nmid-session note").unwrap();
221
222	    // Listen (live-capable manifest): binds, spawns the Psyche, runs one pulse
223	    // tick (ingests the drop), and exits.
224	    let listen = Command::new(&spt_bin)
225	        .args([
226	            "api", "--adapter", "mock", "--manifest", &manifest_arg,
227	            "listen", id, "--parent-pid", &anchor, "--once",
228	        ])
229	        .env("SPT_HOME", home.path())
230	        .output()
231	        .expect("run api listen");
232	    let listen_err = String::from_utf8_lossy(&listen.stderr);
233	    assert!(listen.status.success(), "api listen exit: {:?}\n{listen_err}", listen.status);
234
235	    // 1. The Psyche spawned as a nested perch.
236	    assert!(listen_err.contains("PSYCHE_SPAWNED:"), "psyche must spawn: {listen_err}");
237	    let psyche_perch = perch::resolve_perch_path("live-e2e-psyche", ParentHint::Explicit(id));
238	    let prec = spt_store::info::read_info(&psyche_perch).expect("psyche info.json");
239	    assert_eq!(prec.state, "psyche", "nested perch must be a psyche endpoint");
240
241	    // 2. The commune drop was ingested + deleted by spt-core (single-writer).
242	    assert!(
243	        !drops.path().join(format!("{id}-commune.md")).exists(),
244	        "commune drop must be ingested+deleted"
245	    );
246	    let ctx = perch::resolve_perch_path(id, ParentHint::Infer).join("context.md");
247	    assert!(
248	        std::fs::read_to_string(&ctx).unwrap().contains("mid-session note"),
249	        "ingested commune must land in the context store"
250	    );
251
252	    // 3. Graceful shutdown fires the echo-commune BEFORE teardown (3.3).
253	    let shutdown = Command::new(&spt_bin)
254	        .args([
255	            "api", "--adapter", "mock", "--manifest", &manifest_arg,
256	            "shutdown", id, "--session-id", session_id,
257	        ])
258	        .env("SPT_HOME", home.path())
259	        .output()
260	        .expect("run api shutdown");
261	    let sh_err = String::from_utf8_lossy(&shutdown.stderr);
262	    assert!(shutdown.status.success(), "api shutdown exit: {:?}\n{sh_err}", shutdown.status);
263	    assert!(sh_err.contains("echo_ran=true"), "echo must run on signoff: {sh_err}");
264	    assert!(sh_err.contains("signed_off=true"), "graceful signoff must complete: {sh_err}");
265
266	    // The echo wrote a fresh commune drop (Source: echo-commune) before teardown.
267	    let echo_drop = std::fs::read_to_string(drops.path().join(format!("{id}-commune.md")))
268	        .expect("echo wrote a commune drop");
269	    assert!(echo_drop.contains("Source: echo-commune"), "echo provenance: {echo_drop:?}");
270	    // Soft teardown removed the online marker (6.2).
271	    assert!(
272	        !perch::resolve_ready_file(id, ParentHint::Infer).exists(),
273	        "shutdown must soft-stop (remove the ready marker)"
274	    );
275	}
276

//! M2a contract E2E (T9): the inbound + outbound halves of the harness contract
//! proven end-to-end through the **generic mock adapter** — no vendor harness.
//!
//! Flow: load the mock manifest → `ManifestRuntime` spawns the `mock-session`
//! helper → the helper fires `spt api bind` (skeleton→live) then
//! `spt api state idle` → a message is spooled to the perch → `spt api poll`
//! drains it. Asserts the round-trip.
//!
//! A second test exercises the **harness-hosted startup** topology proper —
//! `api seed` → `api listen` consumes the seed, binds, and relays a delivered
//! message — the REQ-START-2 path.
//
// [int->REQ-API-1]
// [int->REQ-API-2]

use std::path::PathBuf;
use std::process::Command;
use std::sync::Mutex;
use std::time::Duration;

/// Both tests mutate the process-global `SPT_HOME` (the in-test spt-store calls
/// and the spawned children read it). Serialize them so their homes don't race.
static E2E_LOCK: Mutex<()> = Mutex::new(());

use spt_runtime::{Manifest, ManifestRuntime, AgentRuntime};
use spt_store::perch::{self, ParentHint};
use spt_store::spool;

/// Absolute path to a sibling binary in the same target dir as the `spt` test bin.
fn sibling_bin(name: &str) -> PathBuf {
    let spt = PathBuf::from(env!("CARGO_BIN_EXE_spt"));
    spt.with_file_name(format!("{name}{}", std::env::consts::EXE_SUFFIX))
}

fn keys(pairs: &[(&str, &str)]) -> std::collections::BTreeMap<String, String> {
    pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect()
}

#[test]
fn mock_adapter_drives_the_full_contract() {
    let _serial = E2E_LOCK.lock().unwrap_or_else(|e| e.into_inner());
    // Isolated home shared by this process *and* the child `spt` invocations
    // (inherited via env). This test owns the process, so a plain set_var is safe.
    let home = tempfile::tempdir().unwrap();
    std::env::set_var("SPT_HOME", home.path());

    let spt_bin = PathBuf::from(env!("CARGO_BIN_EXE_spt"));
    let mock_bin = sibling_bin("mock-session");
    assert!(mock_bin.exists(), "mock-session must be built: {}", mock_bin.display());

    // Load the canonical mock manifest, then point its [session.self] template at
    // the freshly-built helper + spt binary (PATH isn't set up in the test rig).
    let manifest_path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../adapters/mock/manifest.toml");
    let mut manifest = Manifest::from_toml_str(
        &std::fs::read_to_string(manifest_path).expect("read mock manifest"),
    )
    .expect("mock manifest valid");
    manifest.session.self_.as_mut().unwrap().command = format!(
        "\"{}\" --id {{id}} --session-id {{session_id}} --spt \"{}\"",
        mock_bin.display(),
        spt_bin.display(),
    );

    let runtime = ManifestRuntime::new(manifest);
    let id = "mock-e2e";
    let session_id = "sid-e2e-1";

    // Outbound: spawn the session. Bounded (not detached) so the test waits for
    // the helper's bind + state-idle to complete before asserting.
    let out = runtime
        .run_bounded("self", &keys(&[("id", id), ("session_id", session_id)]), Duration::from_secs(30))
        .expect("spawn mock session");
    assert!(
        out.success(),
        "mock-session failed: code={:?}\nstdout={}\nstderr={}",
        out.status_code, out.stdout, out.stderr
    );

    // Inbound assertion 1 — `api bind` flipped the perch live (REQ-START-2 /
    // REQ-SEAM-POSTSPAWN): info.json exists with the reported session id.
    let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);
    let rec = spt_store::info::read_info(&perch_path).expect("perch info.json after bind");
    assert_eq!(rec.session_id, session_id);
    assert_eq!(rec.state, "live_agent");

    // Inbound assertion 2 — `api state idle` wrote the activity sentinel.
    assert!(perch_path.join(".idle").exists(), "state idle must write the .idle sentinel");

    // A peer spools a message to the now-live perch.
    spool::spool_message_at(&perch_path, "tester", "hello over the contract").unwrap();

    // Inbound assertion 3 — `api poll` drains it (the full delivery round-trip).
    let poll = Command::new(&spt_bin)
        .args(["api", "--adapter", "mock", "poll", id, "--session-id", session_id])
        .env("SPT_HOME", home.path())
        .output()
        .expect("run api poll");
    assert!(poll.status.success(), "api poll exit: {:?}", poll.status);
    let stdout = String::from_utf8_lossy(&poll.stdout);
    assert!(
        stdout.contains("hello over the contract"),
        "api poll must deliver the spooled message, got: {stdout:?}"
    );

    // Auth negative — a poll with no proof is refused (REQ-HAZARD-LOCAL-API-AUTH
    // exercised through the real binary).
    let unauth = Command::new(&spt_bin)
        .args(["api", "--adapter", "mock", "poll", id])
        .env("SPT_HOME", home.path())
        .env_remove("OWL_SESSION_ID")
        .output()
        .expect("run unauth poll");
    assert!(!unauth.status.success(), "poll without proof must be refused");
}

/// Harness-hosted startup proper: `api seed` then `api listen` consumes the seed
/// (matched by parent pid, validated against pid-recycle), binds the perch
/// skeleton→live, and relays a delivered message.
#[test]
fn seed_then_listen_binds_and_relays() {
    // [int->REQ-START-2]
    // [int->REQ-API-2]
    let _serial = E2E_LOCK.lock().unwrap_or_else(|e| e.into_inner());
    let home = tempfile::tempdir().unwrap();
    let spt_bin = PathBuf::from(env!("CARGO_BIN_EXE_spt"));
    let id = "listen-e2e";
    let session_id = "sid-listen-1";
    // The anchor must be a live process; the test runner itself qualifies.
    let anchor = std::process::id().to_string();

    // Pre-spool a message (creates the perch spool dir) so listen drains it on bind.
    std::env::set_var("SPT_HOME", home.path());
    let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);
    spool::spool_message_at(&perch_path, "tester", "delivered via listen").unwrap();

    // Seed the startup record keyed by the anchor pid.
    let seed = Command::new(&spt_bin)
        .args(["api", "--adapter", "mock", "seed", "--pid", &anchor, "--session-id", session_id])
        .env("SPT_HOME", home.path())
        .status()
        .expect("run api seed");
    assert!(seed.success(), "api seed exit: {seed:?}");

    // Listen consumes the seed (explicit --parent-pid for a deterministic match),
    // binds the perch, drains the backlog, and exits (--once).
    let listen = Command::new(&spt_bin)
        .args(["api", "--adapter", "mock", "listen", id, "--parent-pid", &anchor, "--once"])
        .env("SPT_HOME", home.path())
        .output()
        .expect("run api listen");
    assert!(listen.status.success(), "api listen exit: {:?}", listen.status);
    let stdout = String::from_utf8_lossy(&listen.stdout);
    assert!(
        stdout.contains("delivered via listen"),
        "listen must relay the spooled message, got: {stdout:?}"
    );

    // The perch was bound live with the seed's session id.
    let rec = spt_store::info::read_info(&perch_path).expect("perch info.json after listen");
    assert_eq!(rec.session_id, session_id);
    assert_eq!(rec.state, "live_agent");
}

/// M2b LiveAgent lifecycle E2E (T9): prove the full live-agent lifecycle through
/// the **generic mock adapter**, harness-agnostic:
/// spawn a LiveAgent → its Psyche spawns as a nested perch → a commune drop is
/// ingested + deleted by spt-core → a graceful `spt shutdown` fires the
/// echo-commune *before* teardown.
#[test]
fn live_agent_lifecycle_e2e() {
    // [int->REQ-SEAM-PSYCHE]
    // [int->REQ-SEAM-HISTORY]
    // [int->REQ-API-3]
    let _serial = E2E_LOCK.lock().unwrap_or_else(|e| e.into_inner());
    let home = tempfile::tempdir().unwrap();
    std::env::set_var("SPT_HOME", home.path());

    let spt_bin = PathBuf::from(env!("CARGO_BIN_EXE_spt"));
    let mock_bin = sibling_bin("mock-session");
    assert!(mock_bin.exists(), "mock-session must be built: {}", mock_bin.display());

    // An absolute drop dir for the watched commune/signoff files (the committed
    // manifest's relative ".mock" is rig-rewritten here, like [session.self]).
    let drops = tempfile::tempdir().unwrap();

    // Load the canonical mock manifest, then point its lifecycle templates at the
    // freshly-built helper (PATH isn't set up) + the absolute drop dir.
    let manifest_path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../adapters/mock/manifest.toml");
    let mut manifest = Manifest::from_toml_str(
        &std::fs::read_to_string(manifest_path).expect("read mock manifest"),
    )
    .expect("mock manifest valid");
    let mock = mock_bin.display().to_string();
    let drop_dir = drops.path().display().to_string();
    manifest.session.commune_dir = Some(drop_dir.clone());
    manifest.session.signoff_dir = Some(drop_dir.clone());
    manifest.session.psyche_init.as_mut().unwrap().command =
        format!("\"{mock}\" --mode psyche --id {{id}} --session-id {{session_id}}");
    manifest.session.echo_commune.as_mut().unwrap().command =
        format!("\"{mock}\" --mode echo --id {{id}}");

    // Write the rig-rewritten manifest to a temp file for `--manifest`.
    let manifest_file = home.path().join("mock-live.toml");
    std::fs::write(&manifest_file, toml::to_string(&manifest).expect("serialize manifest")).unwrap();
    let manifest_arg = manifest_file.display().to_string();

    let id = "live-e2e";
    let session_id = "sid-live-1";
    let anchor = std::process::id().to_string();

    // Seed the startup record keyed by the anchor pid.
    let seed = Command::new(&spt_bin)
        .args(["api", "--adapter", "mock", "seed", "--pid", &anchor, "--session-id", session_id])
        .env("SPT_HOME", home.path())
        .status()
        .expect("run api seed");
    assert!(seed.success(), "api seed exit: {seed:?}");

    // Pre-drop a commune so the pulse tick in `listen --once` ingests it.
    std::fs::write(drops.path().join(format!("{id}-commune.md")), "Source: self-commune\nmid-session note").unwrap();

    // Listen (live-capable manifest): binds, spawns the Psyche, runs one pulse
    // tick (ingests the drop), and exits.
    let listen = Command::new(&spt_bin)
        .args([
            "api", "--adapter", "mock", "--manifest", &manifest_arg,
            "listen", id, "--parent-pid", &anchor, "--once",
        ])
        .env("SPT_HOME", home.path())
        .output()
        .expect("run api listen");
    let listen_err = String::from_utf8_lossy(&listen.stderr);
    assert!(listen.status.success(), "api listen exit: {:?}\n{listen_err}", listen.status);

    // 1. The Psyche spawned as a nested perch.
    assert!(listen_err.contains("PSYCHE_SPAWNED:"), "psyche must spawn: {listen_err}");
    let psyche_perch = perch::resolve_perch_path("live-e2e-psyche", ParentHint::Explicit(id));
    let prec = spt_store::info::read_info(&psyche_perch).expect("psyche info.json");
    assert_eq!(prec.state, "psyche", "nested perch must be a psyche endpoint");

    // 2. The commune drop was ingested + deleted by spt-core (single-writer).
    assert!(
        !drops.path().join(format!("{id}-commune.md")).exists(),
        "commune drop must be ingested+deleted"
    );
    let ctx = perch::resolve_perch_path(id, ParentHint::Infer).join("context.md");
    assert!(
        std::fs::read_to_string(&ctx).unwrap().contains("mid-session note"),
        "ingested commune must land in the context store"
    );

    // 3. Graceful shutdown fires the echo-commune BEFORE teardown (3.3).
    let shutdown = Command::new(&spt_bin)
        .args([
            "api", "--adapter", "mock", "--manifest", &manifest_arg,
            "shutdown", id, "--session-id", session_id,
        ])
        .env("SPT_HOME", home.path())
        .output()
        .expect("run api shutdown");
    let sh_err = String::from_utf8_lossy(&shutdown.stderr);
    assert!(shutdown.status.success(), "api shutdown exit: {:?}\n{sh_err}", shutdown.status);
    assert!(sh_err.contains("echo_ran=true"), "echo must run on signoff: {sh_err}");
    assert!(sh_err.contains("signed_off=true"), "graceful signoff must complete: {sh_err}");

    // The echo wrote a fresh commune drop (Source: echo-commune) before teardown.
    let echo_drop = std::fs::read_to_string(drops.path().join(format!("{id}-commune.md")))
        .expect("echo wrote a commune drop");
    assert!(echo_drop.contains("Source: echo-commune"), "echo provenance: {echo_drop:?}");
    // Soft teardown removed the online marker (6.2).
    assert!(
        !perch::resolve_ready_file(id, ParentHint::Infer).exists(),
        "shutdown must soft-stop (remove the ready marker)"
    );
}


Check spt-store proc.rs for a cross-platform kill helper (for daemon cleanup in e2e).


grep -n 'pub fn' crates/spt-store/src/proc.rs


9:pub fn is_process_alive(pid: u32) -> bool {
45:pub fn parent_pid() -> Option<u32> {


Plan complete. Writing B6. Start with spt-daemon new modules.


Write

//! Canonical per-machine daemon endpoint names + pid path (B6).
//!
//! The daemon binds two local-IPC listeners — the broker (PTY hosting, B1/B2)
//! and the seed-control channel (B6) — under names derived from the active
//! `spt_home`. Deriving from `spt_home` keeps each isolated home (a test's
//! `SPT_HOME` override, a per-user install) on its own daemon, so parallel test
//! processes never collide on one machine while a real install gets one stable
//! per-home daemon. The auto-start spawn inherits `SPT_HOME`, so the spawned
//! daemon computes the **same** names as the client that started it — that
//! shared derivation is the whole of the rendezvous (no registry lookup).
// [impl->REQ-DAEMON-3]

use std::hash::{Hash, Hasher};
use std::path::PathBuf;

use spt_store::perch::spt_home;

/// A deterministic short tag for the active spt home. `DefaultHasher::new()` is
/// seeded with fixed keys (it is **not** randomized like `RandomState`), so the
/// tag is stable across processes that share the same `spt_home` — exactly what
/// the auto-start handshake needs (the spawned daemon must independently derive
/// the launching client's socket name).
fn home_tag() -> String {
    let mut h = std::collections::hash_map::DefaultHasher::new();
    spt_home().hash(&mut h);
    format!("{:016x}", h.finish())
}

/// The seed-control listener name (B6): the channel `api seed`/`api listen`
/// connect to for the in-memory seed map. It is also the daemon's liveness
/// anchor — present for the daemon's whole lifetime.
pub fn seed_socket_name() -> String {
    format!("spt-daemon-seed-{}.sock", home_tag())
}

/// The broker listener name (B1/B2): PTY hosting + gapless handoff.
pub fn broker_socket_name() -> String {
    format!("spt-daemon-broker-{}.sock", home_tag())
}

/// The daemon's pid file: `<spt_home>/daemon.pid`. Best-effort breadcrumb the
/// running daemon writes on start — discoverable for management/cleanup. Not a
/// liveness signal (liveness is the seed-control ping); a stale pid here is
/// harmless because callers probe the socket, never this file.
pub fn daemon_pid_path() -> PathBuf {
    spt_home().join("daemon.pid")
}


File created successfully at: C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\src\endpoint.rs (file state is current in your context — no need to Read it back)

//! Canonical per-machine daemon endpoint names + pid path (B6).
//!
//! The daemon binds two local-IPC listeners — the broker (PTY hosting, B1/B2)
//! and the seed-control channel (B6) — under names derived from the active
//! `spt_home`. Deriving from `spt_home` keeps each isolated home (a test's
//! `SPT_HOME` override, a per-user install) on its own daemon, so parallel test
//! processes never collide on one machine while a real install gets one stable
//! per-home daemon. The auto-start spawn inherits `SPT_HOME`, so the spawned
//! daemon computes the **same** names as the client that started it — that
//! shared derivation is the whole of the rendezvous (no registry lookup).
// [impl->REQ-DAEMON-3]

use std::hash::{Hash, Hasher};
use std::path::PathBuf;

use spt_store::perch::spt_home;

/// A deterministic short tag for the active spt home. `DefaultHasher::new()` is
/// seeded with fixed keys (it is **not** randomized like `RandomState`), so the
/// tag is stable across processes that share the same `spt_home` — exactly what
/// the auto-start handshake needs (the spawned daemon must independently derive
/// the launching client's socket name).
fn home_tag() -> String {
    let mut h = std::collections::hash_map::DefaultHasher::new();
    spt_home().hash(&mut h);
    format!("{:016x}", h.finish())
}

/// The seed-control listener name (B6): the channel `api seed`/`api listen`
/// connect to for the in-memory seed map. It is also the daemon's liveness
/// anchor — present for the daemon's whole lifetime.
pub fn seed_socket_name() -> String {
    format!("spt-daemon-seed-{}.sock", home_tag())
}

/// The broker listener name (B1/B2): PTY hosting + gapless handoff.
pub fn broker_socket_name() -> String {
    format!("spt-daemon-broker-{}.sock", home_tag())
}

/// The daemon's pid file: `<spt_home>/daemon.pid`. Best-effort breadcrumb the
/// running daemon writes on start — discoverable for management/cleanup. Not a
/// liveness signal (liveness is the seed-control ping); a stale pid here is
/// harmless because callers probe the socket, never this file.
pub fn daemon_pid_path() -> PathBuf {
    spt_home().join("daemon.pid")
}


//! The in-memory daemon seed map (B6 / REQ-START-3) — the in-memory replacement
//! for the retired `spt_store::seed` FILE bridge.
//!
//! Interim (M1/M2a) harness-hosted startup wrote an ephemeral seed *file* keyed
//! by the harness `parent_pid`: `api seed` wrote it, a later `api listen`
//! consumed it. M3b retires that file (the file path goes dead): the seed now
//! lives in the daemon's memory. `api seed` PUTs it over this seed-control IPC;
//! `api listen` TAKEs it (consume-once) by the same `parent_pid`. No file is ever
//! written — the acceptance for B6 (an spt-hosted session binds with no seed
//! file).
//!
//! The seed map is ephemeral cross-invocation **binding material** — the
//! in-memory analog of the file the daemon would otherwise hold on disk, the
//! same category as the accepted-socket bookkeeping the broker already owns
//! (ADR-0004 §B), not routing/registry/parse logic. It is intentionally tiny:
//! put / take by pid. Because seeds are ephemeral (refreshed on the next
//! SessionStart), a daemon restart dropping the map is recoverable — the harness
//! re-seeds.
//!
//! The control protocol rides the same forward-compatible [`Envelope`] + codec +
//! version handshake as the broker IPC; a transient `api` CLI connects as a
//! [`Role::Brain`] logic client, issues one op, reads the reply, and disconnects.
// [impl->REQ-START-3]

use std::collections::HashMap;
use std::io::{self, Read, Write};
use std::sync::Mutex;

use serde::{Deserialize, Serialize};

use spt_store::seed::Seed;

use crate::codec::{read_frame, write_frame};
use crate::frame::{Envelope, Role};
use crate::transport::{recv_hello, send_hello, DaemonTransport, LocalSocketTransport};

/// Client→daemon: store (or refresh) a seed in the in-memory map.
pub const KIND_SEED_PUT: &str = "seed_put";
/// Client→daemon: consume the seed for a `parent_pid` (take-once).
pub const KIND_SEED_TAKE: &str = "seed_take";
/// Daemon→client: the result of a put/take (the seed, or none).
pub const KIND_SEED_RESULT: &str = "seed_result";
/// Client→daemon: liveness probe (the auto-start readiness check).
pub const KIND_PING: &str = "ping";
/// Daemon→client: liveness ack.
pub const KIND_PONG: &str = "pong";

/// `seed_take` payload — which harness pid's seed to consume.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TakeReq {
    /// The stable harness parent pid the seed was keyed under.
    pub parent_pid: u32,
}

/// `seed_result` payload — the seed (present on a hit, absent on a miss). A put
/// echoes the stored seed so the client knows the daemon processed the frame
/// before it closes the connection.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SeedResult {
    /// The seed, when present.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub seed: Option<Seed>,
}

/// The daemon's in-memory seed map, keyed by the stable harness `parent_pid`.
#[derive(Default)]
pub struct SeedRegistry {
    map: Mutex<HashMap<u32, Seed>>,
}

impl SeedRegistry {
    /// A fresh, empty registry.
    pub fn new() -> Self {
        SeedRegistry { map: Mutex::new(HashMap::new()) }
    }

    /// Store (or refresh) a seed keyed by its `parent_pid`.
    pub fn put(&self, seed: Seed) {
        self.map.lock().unwrap().insert(seed.parent_pid, seed);
    }

    /// Consume the seed for `parent_pid` (take-once), if present.
    pub fn take(&self, parent_pid: u32) -> Option<Seed> {
        self.map.lock().unwrap().remove(&parent_pid)
    }

    /// Peek the seed for `parent_pid` without consuming (introspection / tests).
    pub fn peek(&self, parent_pid: u32) -> Option<Seed> {
        self.map.lock().unwrap().get(&parent_pid).cloned()
    }
}

/// Build a `seed_result` envelope carrying `seed` (or none).
fn result_frame(seed: Option<Seed>) -> Envelope {
    Envelope::new(
        KIND_SEED_RESULT,
        serde_json::to_value(SeedResult { seed }).expect("SeedResult serializes"),
    )
}

/// Bind the seed-control channel at `name` and serve it forever, dispatching
/// against `reg`. Each accepted connection is handled inline — the ops are O(1)
/// map touches, so no per-connection thread is needed for B6's load — and a
/// failure on one connection never brings the loop down.
pub fn serve_seed_control(name: &str, reg: &SeedRegistry) -> io::Result<()> {
    let listener = LocalSocketTransport::bind(name)?;
    loop {
        let conn = listener.accept()?;
        let _ = handle_conn(conn, reg);
    }
}

/// Drive one control connection: handshake, then service its op frames until it
/// disconnects.
fn handle_conn<C: Read + Write>(mut conn: C, reg: &SeedRegistry) -> io::Result<()> {
    // A control client announces itself as a brain (a transient logic client of
    // the daemon); the version handshake gives the same forward-compat as the
    // broker IPC.
    recv_hello(&mut conn, Role::Brain)?;
    loop {
        let env = match read_frame(&mut conn) {
            Ok(e) => e,
            Err(_) => return Ok(()), // peer hung up — connection done
        };
        match env.kind.as_str() {
            KIND_SEED_PUT => {
                let seed: Seed = serde_json::from_value(env.payload)
                    .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
                let echo = seed.clone();
                reg.put(seed);
                // Echo the stored seed so the client knows the put landed.
                write_frame(&mut conn, &result_frame(Some(echo)))?;
            }
            KIND_SEED_TAKE => {
                let req: TakeReq = serde_json::from_value(env.payload)
                    .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
                write_frame(&mut conn, &result_frame(reg.take(req.parent_pid)))?;
            }
            KIND_PING => {
                write_frame(&mut conn, &Envelope::new(KIND_PONG, serde_json::Value::Null))?;
            }
            // Forward-compat: an unknown control kind is tolerated, not fatal.
            _ => {}
        }
    }
}

/// Connect to the seed-control channel `name` and announce ourselves as a brain
/// (the api CLI is a transient logic client of the daemon).
fn connect(name: &str) -> io::Result<interprocess::local_socket::Stream> {
    let mut conn = LocalSocketTransport::connect(name)?;
    send_hello(&mut conn, Role::Brain)?;
    Ok(conn)
}

/// PUT a seed into the daemon's in-memory map over the seed-control channel
/// `name`, waiting for the daemon's echo so the caller knows it landed before the
/// connection closes.
// [impl->REQ-START-3]
pub fn put_seed(name: &str, seed: &Seed) -> io::Result<()> {
    let mut conn = connect(name)?;
    write_frame(
        &mut conn,
        &Envelope::new(KIND_SEED_PUT, serde_json::to_value(seed).expect("Seed serializes")),
    )?;
    let _ack = read_frame(&mut conn)?; // the echoed result confirms processing
    Ok(())
}

/// TAKE (consume) the seed for `parent_pid` from the daemon's map over `name`.
/// `Ok(None)` means the daemon holds no seed for that pid.
// [impl->REQ-START-3]
pub fn take_seed(name: &str, parent_pid: u32) -> io::Result<Option<Seed>> {
    let mut conn = connect(name)?;
    write_frame(
        &mut conn,
        &Envelope::new(
            KIND_SEED_TAKE,
            serde_json::to_value(TakeReq { parent_pid }).expect("TakeReq serializes"),
        ),
    )?;
    let env = read_frame(&mut conn)?;
    let res: SeedResult = serde_json::from_value(env.payload)
        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
    Ok(res.seed)
}

/// Probe whether a daemon is serving the seed-control channel `name` (the
/// auto-start liveness check). `Ok(())` iff a daemon answered the ping.
pub fn ping(name: &str) -> io::Result<()> {
    let mut conn = connect(name)?;
    write_frame(&mut conn, &Envelope::new(KIND_PING, serde_json::Value::Null))?;
    let env = read_frame(&mut conn)?;
    if env.kind == KIND_PONG {
        Ok(())
    } else {
        Err(io::Error::new(io::ErrorKind::InvalidData, "expected pong"))
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::endpoint::seed_socket_name;
    use crate::test_home::with_home;
    use std::sync::Arc;
    use std::time::Duration;

    /// Stand up an in-process seed daemon on the current home's seed socket and
    /// wait until it answers. Returns the shared registry so the test can inspect
    /// or seed it directly. The serve thread is leaked (dies with the process).
    fn start(reg: Arc<SeedRegistry>) {
        let name = seed_socket_name();
        let srv = Arc::clone(&reg);
        std::thread::spawn(move || {
            let _ = serve_seed_control(&name, &srv);
        });
        for _ in 0..400 {
            if ping(&seed_socket_name()).is_ok() {
                return;
            }
            std::thread::sleep(Duration::from_millis(5));
        }
        panic!("seed daemon did not come up");
    }

    fn a_seed(pid: u32, sid: &str) -> Seed {
        Seed {
            parent_pid: pid,
            session_id: sid.into(),
            adapter: "mock".into(),
            cwd: None,
            created: "0".into(),
        }
    }

    // [unit->REQ-START-3] a seed PUT over IPC is TAKEn once: the second take
    // misses (consume-once), proving the in-memory map replaces the file bridge.
    #[test]
    fn put_then_take_consumes_once() {
        with_home(|_| {
            let reg = Arc::new(SeedRegistry::new());
            start(Arc::clone(&reg));
            let name = seed_socket_name();

            put_seed(&name, &a_seed(4321, "sid-xyz")).unwrap();
            let got = take_seed(&name, 4321).unwrap().expect("first take hits");
            assert_eq!(got.session_id, "sid-xyz");
            assert_eq!(got.parent_pid, 4321);
            // Consumed: a second take for the same pid misses.
            assert!(take_seed(&name, 4321).unwrap().is_none(), "take is consume-once");
        });
    }

    // [unit->REQ-START-3] taking a pid the daemon never saw is a clean miss.
    #[test]
    fn take_absent_is_none() {
        with_home(|_| {
            let reg = Arc::new(SeedRegistry::new());
            start(Arc::clone(&reg));
            assert!(take_seed(&seed_socket_name(), 999).unwrap().is_none());
        });
    }

    // [unit->REQ-START-3] a re-PUT for the same pid refreshes the stored seed.
    #[test]
    fn put_refreshes_existing() {
        with_home(|_| {
            let reg = Arc::new(SeedRegistry::new());
            start(Arc::clone(&reg));
            let name = seed_socket_name();
            put_seed(&name, &a_seed(7, "old")).unwrap();
            put_seed(&name, &a_seed(7, "new")).unwrap();
            assert_eq!(take_seed(&name, 7).unwrap().unwrap().session_id, "new");
        });
    }
}


File created successfully at: C:\Users\decid\Documents\projects\spt-core\crates\spt-daemon\src\seedmap.rs (file state is current in your context — no need to Read it back)

//! The in-memory daemon seed map (B6 / REQ-START-3) — the in-memory replacement
//! for the retired `spt_store::seed` FILE bridge.
//!
//! Interim (M1/M2a) harness-hosted startup wrote an ephemeral seed *file* keyed
//! by the harness `parent_pid`: `api seed` wrote it, a later `api listen`
//! consumed it. M3b retires that file (the file path goes dead): the seed now
//! lives in the daemon's memory. `api seed` PUTs it over this seed-control IPC;
//! `api listen` TAKEs it (consume-once) by the same `parent_pid`. No file is ever
//! written — the acceptance for B6 (an spt-hosted session binds with no seed
//! file).
//!
//! The seed map is ephemeral cross-invocation **binding material** — the
//! in-memory analog of the file the daemon would otherwise hold on disk, the
//! same category as the accepted-socket bookkeeping the broker already owns
//! (ADR-0004 §B), not routing/registry/parse logic. It is intentionally tiny:
//! put / take by pid. Because seeds are ephemeral (refreshed on the next
//! SessionStart), a daemon restart dropping the map is recoverable — the harness
//! re-seeds.
//!
//! The control protocol rides the same forward-compatible [`Envelope`] + codec +
//! version handshake as the broker IPC; a transient `api` CLI connects as a
//! [`Role::Brain`] logic client, issues one op, reads the reply, and disconnects.
// [impl->REQ-START-3]

use std::collections::HashMap;
use std::io::{self, Read, Write};
use std::sync::Mutex;

use serde::{Deserialize, Serialize};

use spt_store::seed::Seed;

use crate::codec::{read_frame, write_frame};
use crate::frame::{Envelope, Role};
use crate::transport::{recv_hello, send_hello, DaemonTransport, LocalSocketTransport};

/// Client→daemon: store (or refresh) a seed in the in-memory map.
pub const KIND_SEED_PUT: &str = "seed_put";
/// Client→daemon: consume the seed for a `parent_pid` (take-once).
pub const KIND_SEED_TAKE: &str = "seed_take";
/// Daemon→client: the result of a put/take (the seed, or none).
pub const KIND_SEED_RESULT: &str = "seed_result";
/// Client→daemon: liveness probe (the auto-start readiness check).
pub const KIND_PING: &str = "ping";
/// Daemon→client: liveness ack.
pub const KIND_PONG: &str = "pong";

/// `seed_take` payload — which harness pid's seed to consume.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TakeReq {
    /// The stable harness parent pid the seed was keyed under.
    pub parent_pid: u32,
}

/// `seed_result` payload — the seed (present on a hit, absent on a miss). A put
/// echoes the stored seed so the client knows the daemon processed the frame
/// before it closes the connection.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SeedResult {
    /// The seed, when present.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub seed: Option<Seed>,
}

/// The daemon's in-memory seed map, keyed by the stable harness `parent_pid`.
#[derive(Default)]
pub struct SeedRegistry {
    map: Mutex<HashMap<u32, Seed>>,
}

impl SeedRegistry {
    /// A fresh, empty registry.
    pub fn new() -> Self {
        SeedRegistry { map: Mutex::new(HashMap::new()) }
    }

    /// Store (or refresh) a seed keyed by its `parent_pid`.
    pub fn put(&self, seed: Seed) {
        self.map.lock().unwrap().insert(seed.parent_pid, seed);
    }

    /// Consume the seed for `parent_pid` (take-once), if present.
    pub fn take(&self, parent_pid: u32) -> Option<Seed> {
        self.map.lock().unwrap().remove(&parent_pid)
    }

    /// Peek the seed for `parent_pid` without consuming (introspection / tests).
    pub fn peek(&self, parent_pid: u32) -> Option<Seed> {
        self.map.lock().unwrap().get(&parent_pid).cloned()
    }
}

/// Build a `seed_result` envelope carrying `seed` (or none).
fn result_frame(seed: Option<Seed>) -> Envelope {
    Envelope::new(
        KIND_SEED_RESULT,
        serde_json::to_value(SeedResult { seed }).expect("SeedResult serializes"),
    )
}

/// Bind the seed-control channel at `name` and serve it forever, dispatching
/// against `reg`. Each accepted connection is handled inline — the ops are O(1)
/// map touches, so no per-connection thread is needed for B6's load — and a
/// failure on one connection never brings the loop down.
pub fn serve_seed_control(name: &str, reg: &SeedRegistry) -> io::Result<()> {
    let listener = LocalSocketTransport::bind(name)?;
    loop {
        let conn = listener.accept()?;
        let _ = handle_conn(conn, reg);
    }
}

/// Drive one control connection: handshake, then service its op frames until it
/// disconnects.
fn handle_conn<C: Read + Write>(mut conn: C, reg: &SeedRegistry) -> io::Result<()> {
    // A control client announces itself as a brain (a transient logic client of
    // the daemon); the version handshake gives the same forward-compat as the
    // broker IPC.
    recv_hello(&mut conn, Role::Brain)?;
    loop {
        let env = match read_frame(&mut conn) {
            Ok(e) => e,
            Err(_) => return Ok(()), // peer hung up — connection done
        };
        match env.kind.as_str() {
            KIND_SEED_PUT => {
                let seed: Seed = serde_json::from_value(env.payload)
                    .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
                let echo = seed.clone();
                reg.put(seed);
                // Echo the stored seed so the client knows the put landed.
                write_frame(&mut conn, &result_frame(Some(echo)))?;
            }
            KIND_SEED_TAKE => {
                let req: TakeReq = serde_json::from_value(env.payload)
                    .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
                write_frame(&mut conn, &result_frame(reg.take(req.parent_pid)))?;
            }
            KIND_PING => {
                write_frame(&mut conn, &Envelope::new(KIND_PONG, serde_json::Value::Null))?;
            }
            // Forward-compat: an unknown control kind is tolerated, not fatal.
            _ => {}
        }
    }
}

/// Connect to the seed-control channel `name` and announce ourselves as a brain
/// (the api CLI is a transient logic client of the daemon).
fn connect(name: &str) -> io::Result<interprocess::local_socket::Stream> {
    let mut conn = LocalSocketTransport::connect(name)?;
    send_hello(&mut conn, Role::Brain)?;
    Ok(conn)
}

/// PUT a seed into the daemon's in-memory map over the seed-control channel
/// `name`, waiting for the daemon's echo so the caller knows it landed before the
/// connection closes.
// [impl->REQ-START-3]
pub fn put_seed(name: &str, seed: &Seed) -> io::Result<()> {
    let mut conn = connect(name)?;
    write_frame(
        &mut conn,
        &Envelope::new(KIND_SEED_PUT, serde_json::to_value(seed).expect("Seed serializes")),
    )?;
    let _ack = read_frame(&mut conn)?; // the echoed result confirms processing
    Ok(())
}

/// TAKE (consume) the seed for `parent_pid` from the daemon's map over `name`.
/// `Ok(None)` means the daemon holds no seed for that pid.
// [impl->REQ-START-3]
pub fn take_seed(name: &str, parent_pid: u32) -> io::Result<Option<Seed>> {
    let mut conn = connect(name)?;
    write_frame(
        &mut conn,
        &Envelope::new(
            KIND_SEED_TAKE,
            serde_json::to_value(TakeReq { parent_pid }).expect("TakeReq serializes"),
        ),
    )?;
    let env = read_frame(&mut conn)?;
    let res: SeedResult = serde_json::from_value(env.payload)
        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
    Ok(res.seed)
}

/// Probe whether a daemon is serving the seed-control channel `name` (the
/// auto-start liveness check). `Ok(())` iff a daemon answered the ping.
pub fn ping(name: &str) -> io::Result<()> {
    let mut conn = connect(name)?;
    write_frame(&mut conn, &Envelope::new(KIND_PING, serde_json::Value::Null))?;
    let env = read_frame(&mut conn)?;
    if env.kind == KIND_PONG {
        Ok(())
    } else {
        Err(io::Error::new(io::ErrorKind::InvalidData, "expected pong"))
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::endpoint::seed_socket_name;
    use crate::test_home::with_home;
    use std::sync::Arc;
    use std::time::Duration;

    /// Stand up an in-process seed daemon on the current home's seed socket and
    /// wait until it answers. Returns the shared registry so the test can inspect
    /// or seed it directly. The serve thread is leaked (dies with the process).
    fn start(reg: Arc<SeedRegistry>) {
        let name = seed_socket_name();
        let srv = Arc::clone(&reg);
        std::thread::spawn(move || {
            let _ = serve_seed_control(&name, &srv);
        });
        for _ in 0..400 {
            if ping(&seed_socket_name()).is_ok() {
                return;
            }
            std::thread::sleep(Duration::from_millis(5));
        }
        panic!("seed daemon did not come up");
    }

    fn a_seed(pid: u32, sid: &str) -> Seed {
        Seed {
            parent_pid: pid,
            session_id: sid.into(),
            adapter: "mock".into(),
            cwd: None,
            created: "0".into(),
        }
    }

    // [unit->REQ-START-3] a seed PUT over IPC is TAKEn once: the second take
    // misses (consume-once), proving the in-memory map replaces the file bridge.
    #[test]
    fn put_then_take_consumes_once() {
        with_home(|_| {
            let reg = Arc::new(SeedRegistry::new());
            start(Arc::clone(&reg));
            let name = seed_socket_name();

            put_seed(&name, &a_seed(4321, "sid-xyz")).unwrap();
            let got = take_seed(&name, 4321).unwrap().expect("first take hits");
            assert_eq!(got.session_id, "sid-xyz");
            assert_eq!(got.parent_pid, 4321);
            // Consumed: a second take for the same pid misses.
            assert!(take_seed(&name, 4321).unwrap().is_none(), "take is consume-once");
        });
    }

    // [unit->REQ-START-3] taking a pid the daemon never saw is a clean miss.
    #[test]
    fn take_absent_is_none() {
        with_home(|_| {
            let reg = Arc::new(SeedRegistry::new());
            start(Arc::clone(&reg));
            assert!(take_seed(&seed_socket_name(), 999).unwrap().is_none());
        });
    }

    // [unit->REQ-START-3] a re-PUT for the same pid refreshes the stored seed.
    #[test]
    fn put_refreshes_existing() {
        with_home(|_| {
            let reg = Arc::new(SeedRegistry::new());
            start(Arc::clone(&reg));
            let name = seed_socket_name();
            put_seed(&name, &a_seed(7, "old")).unwrap();
            put_seed(&name, &a_seed(7, "new")).unwrap();
            assert_eq!(take_seed(&name, 7).unwrap().unwrap().session_id, "new");
        });
    }
}