1	# Psyche trust boundary: sandboxed tools + stdout `<EVENT>` outbound, daemon-proxied
2
3	## Status
4
5	accepted (2026-06-03)
6
7	## Context
8
9	The Psyche (a LiveAgent's companion mind) is an LLM session. If it held the
10	agent's full capability — sending messages, reaching the network, running
11	arbitrary commands — a confused or adversarially-steered Psyche could exfiltrate
12	or message arbitrary endpoints on the agent's behalf. The sister project
13	(`claude_skill_owl`) resolved this by **sandboxing the Psyche** and making the
14	wrapper its sole proxy; spt-core inherits the model but had not yet documented or
15	ported it, and its interim spawn path silently breaks it (see Consequences).
16
17	This decision was surfaced while grilling the Psyche I/O model and was found to be
18	uncovered by prior design passes.
19
20	## Decision
21
22	**The Psyche is a sandboxed actor whose sole outbound channel is its stdout, which
23	the daemon parses and relays as its proxy.**
24
25	- **Sandbox.** The Psyche session is restricted to `Read`/`Write`/`Edit` tools
26	  only — no `Bash`, no network, no message-send tool (sister: `claude.rs:34`,
27	  `--tools "Read,Edit,Write"`). It **cannot send messages itself.**
28	- **Inbound (two paths, neither is stdout-of-Psyche):** (1) events/messages the
29	  daemon composes onto the Psyche's **stdin**; (2) **commune/signoff file-drops**
30	  carrying context deltas (Self → daemon → Psyche; the *Summarizer* authors the
31	  commune delta — see CONTEXT.md). The Psyche does not read the spool itself.
32	- **Outbound (sole channel): stdout.** The Psyche emits the **same `<EVENT>`
33	  envelope grammar** the rest of SPT uses (ADR-0001, `spt-proto::event`), with two
34	  types it is allowed to author: `<EVENT type="reply">body</EVENT>` and
35	  `<EVENT type="notify">body</EVENT>`. It emits **only** these — never `commune`
36	  (that is the Summarizer's inbound delta, not a Psyche output).
37	- **The daemon is the outbound proxy + sanitizer.** It captures the Psyche's
38	  stdout, parses the `reply`/`notify` envelopes, and **sanitizes before relaying**:
39	  it **strips any `from=`/target/routing attribute the Psyche supplied** and
40	  **re-stamps `from=<self_id>`**, then applies **constrained routing** — a `reply`
41	  goes **only to the `__REPLY_TO__` sender** of the message being answered; a
42	  `notify` reaches **only the agent's own user/subnet(s)**. The Psyche carries no
43	  target and cannot address arbitrary endpoints. Body validation reuses the
44	  `<EVENT>` rules (escape `\n`→`<br>`, KNOWN-HAZARDS 4.1 entity-decode, size cap).
45
46	The trust boundary is therefore **enforced by the daemon at relay time**, not by
47	the markup: the Psyche may *write* a spoofed `from=`, but the daemon discards it.
48
49	## Considered options
50
51	- **File-drop reply/notify** (Psyche writes `<id>-reply.md` like it "writes"
52	  commune) — rejected: file-drops are the *inbound* commune/signoff travel path
53	  (Self → daemon → Psyche), not a Psyche-authored outbound. Conflating them was the
54	  original error this ADR corrects.
55	- **Terse intent tags `<reply>`/`<notify>`, daemon mints the `<EVENT>`** — viable
56	  (keeps the Psyche syntactically unable to author a wire `<EVENT>`), but rejected
57	  in favour of one envelope grammar everywhere; the daemon's strip-and-re-stamp
58	  gives the same anti-spoof guarantee defensively.
59	- **Targeted reply (`<EVENT type="reply" to="<id>">`)** — rejected for v1: lets the
60	  Psyche address beyond the inbound sender, widening the trust surface (the daemon
61	  would have to authorize every target). Reply-to-sender only.
62	- **Pass-through relay** (no sanitization) — rejected: defeats the point of
63	  sandboxing the Psyche.
64
65	## Consequences
66
67	- **The live-Psyche turn driver MUST capture stdout.** spt-core's interim
68	  `runtime.spawn_session` sets `stdout(Stdio::null())` (`crates/spt-runtime/src/
69	  runtime.rs:93-95`) — which **silently discards every Psyche reply/notify**. That
70	  detached null-stdout spawn is **not** the live-Psyche turn driver; the turn must
71	  be a **bounded, stdin-fed, stdout-captured** invocation (mirroring the sister's
72	  per-turn `--resume`), like the echo-commune's `run_bounded_stdin`. Tracked as
73	  KNOWN-HAZARDS 7.3 / `REQ-HAZARD-PSYCHE-OUTBOUND-PROXY`.
74	- **A marker parser must exist.** spt-core has no `<EVENT type="reply|notify">`
75	  parse-and-relay path yet (the sister's `parse_markers`, `claude.rs:594`). It is
76	  the M4 prerequisite feeding the D8 notification primitive (the `notify` half) and
77	  the cross-node reply path (the `reply` half). See M4-PLAN.md task **D7.5**.
78	- **`reply`/`notify` join the `<EVENT>` type taxonomy** (`spt-proto::event`:
79	  `msg`/`alarm`/`commune`/`file_drop`/`echo_commune` → + `reply` + `notify`). The
80	  daemon is the **sole trusted author** of wire `<EVENT>` envelopes carrying a real
81	  `from=`; Psyche-authored attributes are untrusted input.
82	- **Anti-spoof is a conformance invariant**, not best-effort: a Psyche-supplied
83	  `from=`/target that survived to the wire would be an identity-spoof bug.
84
85	## References
86
87	- Builds on **ADR-0004** (Psyche = loop inside the daemon; Summarizer subprocess
88	  ephemeral) and **ADR-0001** (the `<EVENT>` wire format is copy-verbatim from the
89	  sister). CONTEXT.md *Psyche* / *Summarizer* glossary entries. KNOWN-HAZARDS 7.3.
90

# Psyche trust boundary: sandboxed tools + stdout `<EVENT>` outbound, daemon-proxied

## Status

accepted (2026-06-03)

## Context

The Psyche (a LiveAgent's companion mind) is an LLM session. If it held the
agent's full capability — sending messages, reaching the network, running
arbitrary commands — a confused or adversarially-steered Psyche could exfiltrate
or message arbitrary endpoints on the agent's behalf. The sister project
(`claude_skill_owl`) resolved this by **sandboxing the Psyche** and making the
wrapper its sole proxy; spt-core inherits the model but had not yet documented or
ported it, and its interim spawn path silently breaks it (see Consequences).

This decision was surfaced while grilling the Psyche I/O model and was found to be
uncovered by prior design passes.

## Decision

**The Psyche is a sandboxed actor whose sole outbound channel is its stdout, which
the daemon parses and relays as its proxy.**

- **Sandbox.** The Psyche session is restricted to `Read`/`Write`/`Edit` tools
  only — no `Bash`, no network, no message-send tool (sister: `claude.rs:34`,
  `--tools "Read,Edit,Write"`). It **cannot send messages itself.**
- **Inbound (two paths, neither is stdout-of-Psyche):** (1) events/messages the
  daemon composes onto the Psyche's **stdin**; (2) **commune/signoff file-drops**
  carrying context deltas (Self → daemon → Psyche; the *Summarizer* authors the
  commune delta — see CONTEXT.md). The Psyche does not read the spool itself.
- **Outbound (sole channel): stdout.** The Psyche emits the **same `<EVENT>`
  envelope grammar** the rest of SPT uses (ADR-0001, `spt-proto::event`), with two
  types it is allowed to author: `<EVENT type="reply">body</EVENT>` and
  `<EVENT type="notify">body</EVENT>`. It emits **only** these — never `commune`
  (that is the Summarizer's inbound delta, not a Psyche output).
- **The daemon is the outbound proxy + sanitizer.** It captures the Psyche's
  stdout, parses the `reply`/`notify` envelopes, and **sanitizes before relaying**:
  it **strips any `from=`/target/routing attribute the Psyche supplied** and
  **re-stamps `from=<self_id>`**, then applies **constrained routing** — a `reply`
  goes **only to the `__REPLY_TO__` sender** of the message being answered; a
  `notify` reaches **only the agent's own user/subnet(s)**. The Psyche carries no
  target and cannot address arbitrary endpoints. Body validation reuses the
  `<EVENT>` rules (escape `\n`→`<br>`, KNOWN-HAZARDS 4.1 entity-decode, size cap).

The trust boundary is therefore **enforced by the daemon at relay time**, not by
the markup: the Psyche may *write* a spoofed `from=`, but the daemon discards it.

## Considered options

- **File-drop reply/notify** (Psyche writes `<id>-reply.md` like it "writes"
  commune) — rejected: file-drops are the *inbound* commune/signoff travel path
  (Self → daemon → Psyche), not a Psyche-authored outbound. Conflating them was the
  original error this ADR corrects.
- **Terse intent tags `<reply>`/`<notify>`, daemon mints the `<EVENT>`** — viable
  (keeps the Psyche syntactically unable to author a wire `<EVENT>`), but rejected
  in favour of one envelope grammar everywhere; the daemon's strip-and-re-stamp
  gives the same anti-spoof guarantee defensively.
- **Targeted reply (`<EVENT type="reply" to="<id>">`)** — rejected for v1: lets the
  Psyche address beyond the inbound sender, widening the trust surface (the daemon
  would have to authorize every target). Reply-to-sender only.
- **Pass-through relay** (no sanitization) — rejected: defeats the point of
  sandboxing the Psyche.

## Consequences

- **The live-Psyche turn driver MUST capture stdout.** spt-core's interim
  `runtime.spawn_session` sets `stdout(Stdio::null())` (`crates/spt-runtime/src/
  runtime.rs:93-95`) — which **silently discards every Psyche reply/notify**. That
  detached null-stdout spawn is **not** the live-Psyche turn driver; the turn must
  be a **bounded, stdin-fed, stdout-captured** invocation (mirroring the sister's
  per-turn `--resume`), like the echo-commune's `run_bounded_stdin`. Tracked as
  KNOWN-HAZARDS 7.3 / `REQ-HAZARD-PSYCHE-OUTBOUND-PROXY`.
- **A marker parser must exist.** spt-core has no `<EVENT type="reply|notify">`
  parse-and-relay path yet (the sister's `parse_markers`, `claude.rs:594`). It is
  the M4 prerequisite feeding the D8 notification primitive (the `notify` half) and
  the cross-node reply path (the `reply` half). See M4-PLAN.md task **D7.5**.
- **`reply`/`notify` join the `<EVENT>` type taxonomy** (`spt-proto::event`:
  `msg`/`alarm`/`commune`/`file_drop`/`echo_commune` → + `reply` + `notify`). The
  daemon is the **sole trusted author** of wire `<EVENT>` envelopes carrying a real
  `from=`; Psyche-authored attributes are untrusted input.
- **Anti-spoof is a conformance invariant**, not best-effort: a Psyche-supplied
  `from=`/target that survived to the wire would be an identity-spoof bug.

## References

- Builds on **ADR-0004** (Psyche = loop inside the daemon; Summarizer subprocess
  ephemeral) and **ADR-0001** (the `<EVENT>` wire format is copy-verbatim from the
  sister). CONTEXT.md *Psyche* / *Summarizer* glossary entries. KNOWN-HAZARDS 7.3.


Found 7 files
crates\spt-daemon\tests\sync.rs
crates\spt-live\src\reconcile.rs
crates\spt-live\src\turn.rs
crates\spt-store\src\info.rs
crates\spt-daemon\src\digest.rs
crates\spt-runtime\src\runtime.rs
crates\spt-live\src\echo.rs


558:id = "REQ-HAZARD-PSYCHE-OUTBOUND-PROXY"
559-title = "Psyche outbound captured + sanitized: the live-Psyche turn driver captures stdout (never Stdio::null), and the daemon strips/re-stamps Psyche-supplied from=/target and constrains routing (reply→__REPLY_TO__ sender, notify→own user/subnet) (7.3)"
560-required_stages = []  # rule 5: activate when delivered. The <EVENT type=reply|notify> parse-and-relay path does not exist yet; M4-D7.5 (psyche outbound relay, feeds D8) builds + activates it
561-[[requirements]]
562-id = "REQ-HAZARD-DAEMON-SCHED-NONBLOCKING"
563-title = "Per-agent pulse\psyche\echo-commune scheduling must not serialize across agents: each agent's bounded LLM call (echo-commune summarizer, Psyche turn) runs off the shared scheduler so one slow/hung call cannot stall another agent's tick (7.4)"
564-required_stages = []  # rule 5: activate when the daemon multi-agent pulse fan-out is wired. Today run_pulse_loop is def+test only and each agent drives its own pulse from its own process, so blocking is naturally isolated; the invariant binds once the daemon hosts N per-agent loops
565-# --- Added 2026-06-03 at M4-D2 start (ADR-0005 Stage-A red-team #10\#11\#12); rule 3: register before satisfying, rule 5: activate per sub-task commit ---
566-[[requirements]]


1	//! The bounded live-Psyche turn driver (ADR-0012 / M4-PLAN D7.5a, pulled
2	//! forward into D6b per ADR-0013).
3	//!
4	//! Drives one Psyche turn as a **bounded, stdin-fed, stdout-CAPTURED** harness
5	//! invocation of the `psyche_resume` role (per-turn `--resume`, mirroring the
6	//! sister) — **never** the detached `Stdio::null()` `spawn_session`, whose
7	//! discarded stdout is exactly the KNOWN-HAZARDS 7.3 failure (every Psyche
8	//! reply silently dropped). The captured stdout **is** the Psyche's outbound
9	//! channel: D6b consumes it as the reconcile-merge output; D7.5b/c parse it
10	//! for `<EVENT type="reply|notify">` envelopes and relay through the daemon's
11	//! sanitize boundary.
12	//!
13	//! Bounded (REQ-HAZARD-SUBPROCESS-TIMEOUT): a hung harness is killed at the
14	//! deadline, never an indefinite block. Empty stdout is an **error** — a
15	//! driver that yields nothing is indistinguishable from the null-stdout bug
16	//! 7.3 guards against, so it fails loudly instead of returning success.
17	//
18	// [impl->REQ-HAZARD-PSYCHE-OUTBOUND-PROXY]
19	// [impl->REQ-HAZARD-SUBPROCESS-TIMEOUT]
20
21	use spt_runtime::{AgentRuntime, RuntimeError};
22	use std::collections::BTreeMap;
23	use std::time::Duration;
24
25	/// The role a live-Psyche turn resumes — the Psyche's persistent harness
26	/// session (`[session.psyche_resume]`, continue-existing).
27	pub const PSYCHE_TURN_ROLE: &str = "psyche_resume";
28
29	/// Default per-turn budget. Generous — a turn is one LLM call — but finite;
30	/// the caller tunes per use (the reconcile turn passes its own).
31	pub const DEFAULT_TURN_TIMEOUT: Duration = Duration::from_secs(300);
32
33	/// A failed Psyche turn.
34	#[derive(Debug)]
35	pub enum TurnError {
36	    /// Spawn/timeout/template failure from the runtime (timeout = killed, 5.3).
37	    Runtime(RuntimeError),
38	    /// The harness exited non-zero; stderr preserved.
39	    Failed { status_code: Option<i32>, stderr: String },
40	    /// The turn completed but produced no stdout — indistinguishable from the
41	    /// 7.3 null-stdout bug, so it is an error, never a silent success.
42	    EmptyOutput,
43	}
44
45	impl std::fmt::Display for TurnError {
46	    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47	        match self {
48	            TurnError::Runtime(e) => write!(f, "psyche turn runtime: {e}"),
49	            TurnError::Failed { status_code, stderr } => {
50	                write!(f, "psyche turn exited {status_code:?}: {}", stderr.trim())
51	            }
52	            TurnError::EmptyOutput => write!(
53	                f,
54	                "psyche turn produced no stdout (null-stdout shape — hazard 7.3)"
55	            ),
56	        }
57	    }
58	}
59
60	impl std::error::Error for TurnError {}
61
62	/// Run one bounded live-Psyche turn: feed `stdin` to the `psyche_resume` role,
63	/// capture and return its stdout. The substitution `keys` must carry what the
64	/// adapter's resume template needs (typically `session_id`).
65	// [impl->REQ-HAZARD-PSYCHE-OUTBOUND-PROXY]
66	pub fn run_psyche_turn(
67	    runtime: &dyn AgentRuntime,
68	    keys: &BTreeMap<String, String>,
69	    stdin: &[u8],
70	    timeout: Duration,
71	) -> Result<String, TurnError> {
72	    let out = runtime
73	        .run_bounded_stdin(PSYCHE_TURN_ROLE, keys, timeout, Some(stdin))
74	        .map_err(TurnError::Runtime)?;
75	    if !out.success() {
76	        return Err(TurnError::Failed { status_code: out.status_code, stderr: out.stderr });
77	    }
78	    if out.stdout.trim().is_empty() {
79	        return Err(TurnError::EmptyOutput);
80	    }
81	    Ok(out.stdout)
82	}
83
84	#[cfg(test)]
85	mod tests {
86	    use super::*;
87	    use spt_runtime::{Manifest, ManifestRuntime};
88
89	    #[cfg(windows)]
90	    const ECHO_STDIN: &str = "cmd /C findstr x*"; // copies stdin to stdout
91	    #[cfg(unix)]
92	    const ECHO_STDIN: &str = "sh -c cat";
93	    #[cfg(windows)]
94	    const NO_OUTPUT: &str = "cmd /C exit 0";
95	    #[cfg(unix)]
96	    const NO_OUTPUT: &str = "sh -c true";
97
98	    fn rt(cmd: &str) -> ManifestRuntime {
99	        let toml = format!(
100	            "[adapter]\nname=\"mock\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n\
101	             [session.psyche_resume]\ncommand='{cmd}'\n"
102	        );
103	        ManifestRuntime::new(Manifest::from_toml_str(&toml).unwrap())
104	    }
105
106	    #[test] // [unit->REQ-HAZARD-PSYCHE-OUTBOUND-PROXY] the turn is stdin-fed and its stdout is CAPTURED (not discarded).
107	    fn turn_captures_stdout() {
108	        let out = run_psyche_turn(
109	            &rt(ECHO_STDIN),
110	            &BTreeMap::new(),
111	            b"merged mind body",
112	            Duration::from_secs(30),
113	        )
114	        .expect("turn");
115	        assert!(out.contains("merged mind body"), "stdout must round-trip: {out:?}");
116	    }
117
118	    #[test] // [unit->REQ-HAZARD-PSYCHE-OUTBOUND-PROXY] a no-stdout turn FAILS the guard (7.3's null-stdout shape).
119	    fn empty_stdout_is_an_error() {
120	        let err = run_psyche_turn(
121	            &rt(NO_OUTPUT),
122	            &BTreeMap::new(),
123	            b"input",
124	            Duration::from_secs(30),
125	        )
126	        .unwrap_err();
127	        assert!(matches!(err, TurnError::EmptyOutput), "got {err}");
128	    }
129	}
130

//! The bounded live-Psyche turn driver (ADR-0012 / M4-PLAN D7.5a, pulled
//! forward into D6b per ADR-0013).
//!
//! Drives one Psyche turn as a **bounded, stdin-fed, stdout-CAPTURED** harness
//! invocation of the `psyche_resume` role (per-turn `--resume`, mirroring the
//! sister) — **never** the detached `Stdio::null()` `spawn_session`, whose
//! discarded stdout is exactly the KNOWN-HAZARDS 7.3 failure (every Psyche
//! reply silently dropped). The captured stdout **is** the Psyche's outbound
//! channel: D6b consumes it as the reconcile-merge output; D7.5b/c parse it
//! for `<EVENT type="reply|notify">` envelopes and relay through the daemon's
//! sanitize boundary.
//!
//! Bounded (REQ-HAZARD-SUBPROCESS-TIMEOUT): a hung harness is killed at the
//! deadline, never an indefinite block. Empty stdout is an **error** — a
//! driver that yields nothing is indistinguishable from the null-stdout bug
//! 7.3 guards against, so it fails loudly instead of returning success.
//
// [impl->REQ-HAZARD-PSYCHE-OUTBOUND-PROXY]
// [impl->REQ-HAZARD-SUBPROCESS-TIMEOUT]

use spt_runtime::{AgentRuntime, RuntimeError};
use std::collections::BTreeMap;
use std::time::Duration;

/// The role a live-Psyche turn resumes — the Psyche's persistent harness
/// session (`[session.psyche_resume]`, continue-existing).
pub const PSYCHE_TURN_ROLE: &str = "psyche_resume";

/// Default per-turn budget. Generous — a turn is one LLM call — but finite;
/// the caller tunes per use (the reconcile turn passes its own).
pub const DEFAULT_TURN_TIMEOUT: Duration = Duration::from_secs(300);

/// A failed Psyche turn.
#[derive(Debug)]
pub enum TurnError {
    /// Spawn/timeout/template failure from the runtime (timeout = killed, 5.3).
    Runtime(RuntimeError),
    /// The harness exited non-zero; stderr preserved.
    Failed { status_code: Option<i32>, stderr: String },
    /// The turn completed but produced no stdout — indistinguishable from the
    /// 7.3 null-stdout bug, so it is an error, never a silent success.
    EmptyOutput,
}

impl std::fmt::Display for TurnError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            TurnError::Runtime(e) => write!(f, "psyche turn runtime: {e}"),
            TurnError::Failed { status_code, stderr } => {
                write!(f, "psyche turn exited {status_code:?}: {}", stderr.trim())
            }
            TurnError::EmptyOutput => write!(
                f,
                "psyche turn produced no stdout (null-stdout shape — hazard 7.3)"
            ),
        }
    }
}

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

/// Run one bounded live-Psyche turn: feed `stdin` to the `psyche_resume` role,
/// capture and return its stdout. The substitution `keys` must carry what the
/// adapter's resume template needs (typically `session_id`).
// [impl->REQ-HAZARD-PSYCHE-OUTBOUND-PROXY]
pub fn run_psyche_turn(
    runtime: &dyn AgentRuntime,
    keys: &BTreeMap<String, String>,
    stdin: &[u8],
    timeout: Duration,
) -> Result<String, TurnError> {
    let out = runtime
        .run_bounded_stdin(PSYCHE_TURN_ROLE, keys, timeout, Some(stdin))
        .map_err(TurnError::Runtime)?;
    if !out.success() {
        return Err(TurnError::Failed { status_code: out.status_code, stderr: out.stderr });
    }
    if out.stdout.trim().is_empty() {
        return Err(TurnError::EmptyOutput);
    }
    Ok(out.stdout)
}

#[cfg(test)]
mod tests {
    use super::*;
    use spt_runtime::{Manifest, ManifestRuntime};

    #[cfg(windows)]
    const ECHO_STDIN: &str = "cmd /C findstr x*"; // copies stdin to stdout
    #[cfg(unix)]
    const ECHO_STDIN: &str = "sh -c cat";
    #[cfg(windows)]
    const NO_OUTPUT: &str = "cmd /C exit 0";
    #[cfg(unix)]
    const NO_OUTPUT: &str = "sh -c true";

    fn rt(cmd: &str) -> ManifestRuntime {
        let toml = format!(
            "[adapter]\nname=\"mock\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n\
             [session.psyche_resume]\ncommand='{cmd}'\n"
        );
        ManifestRuntime::new(Manifest::from_toml_str(&toml).unwrap())
    }

    #[test] // [unit->REQ-HAZARD-PSYCHE-OUTBOUND-PROXY] the turn is stdin-fed and its stdout is CAPTURED (not discarded).
    fn turn_captures_stdout() {
        let out = run_psyche_turn(
            &rt(ECHO_STDIN),
            &BTreeMap::new(),
            b"merged mind body",
            Duration::from_secs(30),
        )
        .expect("turn");
        assert!(out.contains("merged mind body"), "stdout must round-trip: {out:?}");
    }

    #[test] // [unit->REQ-HAZARD-PSYCHE-OUTBOUND-PROXY] a no-stdout turn FAILS the guard (7.3's null-stdout shape).
    fn empty_stdout_is_an_error() {
        let err = run_psyche_turn(
            &rt(NO_OUTPUT),
            &BTreeMap::new(),
            b"input",
            Duration::from_secs(30),
        )
        .unwrap_err();
        assert!(matches!(err, TurnError::EmptyOutput), "got {err}");
    }
}


Found 3 files
crates\spt-proto\src\payload.rs
crates\spt-proto\src\event.rs
crates\spt-proto\src\chunk.rs


crates\spt-proto\src\envelope.rs
crates\spt-proto\src\chunk.rs
crates\spt-proto\src\endpoint.rs
crates\spt-proto\src\identity.rs
crates\spt-proto\src\version.rs
crates\spt-proto\src\event.rs
crates\spt-proto\src\payload.rs
crates\spt-proto\src\id.rs
crates\spt-proto\src\addr.rs
crates\spt-proto\src\lib.rs


1	//! EVENT envelope grammar: compose and parse the `<EVENT attrs>body</EVENT>`
2	//! wire line. The **format** is copied verbatim from `claude_skill_owl`
3	//! (`src/owl/poll.rs` compose sites — `assemble_event_line`,
4	//! `body_is_typed_event_envelope`) per ADR-0001; this is the stable, public
5	//! wire contract. The general parser is the receiver-side inverse of those
6	//! compose sites (the sister parses receiver-side in the harness/agent layer,
7	//! per the runtime reorientation contract — we make it a first-class function
8	//! here).
9	//!
10	//! ## Wire shapes
11	//! - regular message → `<EVENT type="msg" from="...">body</EVENT>`
12	//! - self-originated alarm → `<EVENT type="alarm" target-time="..." current-time="...">body</EVENT>`
13	//! - pre-formed typed envelope (e.g. `echo_commune`, `file_drop`) → emitted verbatim
14	//!
15	//! ## Codec coupling (KNOWN-HAZARDS 4.1 — amp-last)
16	//! Attribute values use [`event_attr_escape`]/[`event_attr_unescape`]; bodies
17	//! use [`event_body_escape`]/[`event_body_unescape`]. Compose escapes once at
18	//! the source; [`parse_event`] decodes once after framing-parse — never decode
19	//! the full envelope string (that would unescape outer framing tokens too).
20
21	use crate::envelope::{
22	    event_attr_escape, event_attr_unescape, event_body_escape, event_body_unescape,
23	};
24
25	// ---------------------------------------------------------------------------
26	// Compose (verbatim format — sister `assemble_event_line` branches)
27	// ---------------------------------------------------------------------------
28
29	/// Compose a regular message envelope: `<EVENT type="msg" from="F">B</EVENT>`.
30	/// `from` is attr-escaped; `body` is body-escaped (`\n`→`<br>`). Mirrors the
31	/// sister's regular-message branch (`poll.rs` `assemble_event_line`).
32	// [impl->REQ-ARCH-4]
33	pub fn compose_msg_event(from: &str, body: &str) -> String {
34	    format!(
35	        "<EVENT type=\"msg\" from=\"{}\">{}</EVENT>",
36	        event_attr_escape(from),
37	        event_body_escape(body)
38	    )
39	}
40
41	/// Compose an arbitrary typed envelope: `<EVENT type="T" k="v" …>body</EVENT>`.
42	/// `event_type` and every attr value are attr-escaped; `body` is body-escaped.
43	/// Attr order is preserved (type first, then the supplied pairs). This is the
44	/// general composer the payload layer (typed commands / blobs) builds on;
45	/// [`compose_msg_event`] is the `type="msg"` special case.
46	// [impl->REQ-ARCH-4]
47	pub fn compose_typed_event(event_type: &str, attrs: &[(&str, &str)], body: &str) -> String {
48	    let mut s = format!("<EVENT type=\"{}\"", event_attr_escape(event_type));
49	    for (k, v) in attrs {
50	        s.push(' ');
51	        s.push_str(k);
52	        s.push_str("=\"");
53	        s.push_str(&event_attr_escape(v));
54	        s.push('"');
55	    }
56	    s.push('>');
57	    s.push_str(&event_body_escape(body));
58	    s.push_str("</EVENT>");
59	    s
60	}
61
62	/// Compose a self-originated alarm envelope:
63	/// `<EVENT type="alarm" target-time="T" current-time="C">B</EVENT>`.
64	/// ISO-8601 times are attr-escaped (line-safe by construction); `body` is
65	/// body-escaped. Mirrors the sister's alarm branch.
66	// [impl->REQ-ARCH-4]
67	pub fn compose_alarm_event(target_iso: &str, current_iso: &str, body: &str) -> String {
68	    format!(
69	        "<EVENT type=\"alarm\" target-time=\"{}\" current-time=\"{}\">{}</EVENT>",
70	        event_attr_escape(target_iso),
71	        event_attr_escape(current_iso),
72	        event_body_escape(body)
73	    )
74	}
75
76	/// Returns true when `body` is already a fully-formed typed
77	/// `<EVENT ...>...</EVENT>` envelope and must be passed through verbatim — NOT
78	/// re-wrapped inside an outer `<EVENT type="msg" ...>`. Copied verbatim from
79	/// the sister's `body_is_typed_event_envelope` (echo-commune-double-envelope
80	/// fix). Cheap byte-slice check; no XML parse.
81	// [impl->REQ-ARCH-4]
82	pub fn is_typed_event_envelope(body: &str) -> bool {
83	    body.starts_with("<EVENT ") && body.ends_with("</EVENT>")
84	}
85
86	// ---------------------------------------------------------------------------
87	// Parse (receiver-side inverse — panic-free, malformed → None)
88	// ---------------------------------------------------------------------------
89
90	/// A parsed EVENT envelope. `event_type` is the `type="..."` attr if present
91	/// (typed envelopes always carry one; a bare `<EVENT>` would not). `attrs`
92	/// preserves every `key="value"` pair in wire order, values decoded amp-last.
93	/// `body` is the decoded body (`<br>`→`\n`, entities reversed amp-last).
94	#[derive(Debug, Clone, PartialEq, Eq)]
95	pub struct ParsedEvent {
96	    pub event_type: Option<String>,
97	    pub attrs: Vec<(String, String)>,
98	    pub body: String,
99	}
100
101	impl ParsedEvent {
102	    /// Look up the first decoded attribute value for `key`.
103	    pub fn attr(&self, key: &str) -> Option<&str> {
104	        self.attrs
105	            .iter()
106	            .find(|(k, _)| k == key)
107	            .map(|(_, v)| v.as_str())
108	    }
109
110	    /// Convenience: the `from` attribute, if any.
111	    pub fn from(&self) -> Option<&str> {
112	        self.attr("from")
113	    }
114	}
115
116	/// Parse a single `<EVENT attrs>body</EVENT>` wire line into a [`ParsedEvent`].
117	///
118	/// Never panics — every step is `Option`-based; returns `None` on any
119	/// malformed input: missing `<EVENT` open, missing `>` closing the open tag,
120	/// or missing trailing `</EVENT>`. Attribute values and the body are decoded
121	/// with the amp-last invariant.
122	///
123	/// Framing robustness: because attr values and the body are HTML-escaped at
124	/// compose time (`>` → `>`), the first raw `>` after `<EVENT` reliably ends
125	/// the open tag and the trailing `</EVENT>` reliably ends the body — neither
126	/// token can appear raw inside escaped content.
127	///
128	/// This parses ONE envelope line; long bodies that arrive as multiple
129	/// `<EVENT-PART seq=K/M id=...>` lines are reassembled by the T3 chunker first,
130	/// then the reassembled `<EVENT>` is parsed here.
131	// [impl->REQ-ARCH-4]
132	pub fn parse_event(line: &str) -> Option<ParsedEvent> {
133	    let line = line.trim();
134	    // Must be an EVENT (not EVENT-PART — we never parse a chunk as a whole).
135	    let after_open = line.strip_prefix("<EVENT")?;
136	    // Reject `<EVENT-PART ...>` (strip_prefix("<EVENT") leaves "-PART ...").
137	    if after_open.starts_with('-') {
138	        return None;
139	    }
140	    // The open tag is `<EVENT{attrs}>`; first raw `>` closes it (escaped
141	    // content can't contain a raw `>`).
142	    let gt = after_open.find('>')?;
143	    let attr_str = &after_open[..gt];
144	    let rest = &after_open[gt + 1..];
145	    // Body is everything up to the trailing `</EVENT>`.
146	    let body_escaped = rest.strip_suffix("</EVENT>")?;
147
148	    let attrs = parse_attrs(attr_str);
149	    let event_type = attrs
150	        .iter()
151	        .find(|(k, _)| k == "type")
152	        .map(|(_, v)| v.clone());
153
154	    Some(ParsedEvent {
155	        event_type,
156	        attrs,
157	        body: event_body_unescape(body_escaped),
158	    })
159	}
160
161	/// Parse an attribute-segment (`type="msg" from="a&b"`) into ordered
162	/// (key, decoded-value) pairs. Tolerant: skips anything that isn't a
163	/// `key="value"` pair; never panics. Values are decoded amp-last. Because
164	/// values are attr-escaped at compose time, no value can contain a raw `"`.
165	fn parse_attrs(s: &str) -> Vec<(String, String)> {
166	    let mut out = Vec::new();
167	    let mut rest = s;
168	    while let Some(eq) = rest.find("=\"") {
169	        // Key = the run of attr-name chars immediately before `="`.
170	        let key_region = &rest[..eq];
171	        let key_start = key_region
172	            .rfind([' ', '\t'])
173	            .map(|i| i + 1)
174	            .unwrap_or(0);
175	        let key = key_region[key_start..].trim();
176	        let after_eq = &rest[eq + 2..];
177	        let Some(close) = after_eq.find('"') else {
178	            break; // unterminated value — stop, drop the trailing garbage
179	        };
180	        let value = &after_eq[..close];
181	        if !key.is_empty() {
182	            out.push((key.to_string(), event_attr_unescape(value)));
183	        }
184	        rest = &after_eq[close + 1..];
185	    }
186	    out
187	}
188
189	#[cfg(test)]
190	mod tests {
191	    use super::*;
192
193	    // ---- compose → parse round-trip ----
194
195	    // [unit->REQ-ARCH-4]
196	    #[test]
197	    fn msg_event_round_trips() {
198	        let line = compose_msg_event("alice", "hello\nworld < & >");
199	        let p = parse_event(&line).expect("parse");
200	        assert_eq!(p.event_type.as_deref(), Some("msg"));
201	        assert_eq!(p.from(), Some("alice"));
202	        assert_eq!(p.body, "hello\nworld < & >");
203	    }
204
205	    // [unit->REQ-ARCH-4]
206	    #[test]
207	    fn alarm_event_round_trips() {
208	        let line = compose_alarm_event("2026-05-31T12:00:00Z", "2026-05-31T12:00:05Z", "wake up");
209	        let p = parse_event(&line).expect("parse");
210	        assert_eq!(p.event_type.as_deref(), Some("alarm"));
211	        assert_eq!(p.attr("target-time"), Some("2026-05-31T12:00:00Z"));
212	        assert_eq!(p.attr("current-time"), Some("2026-05-31T12:00:05Z"));
213	        assert_eq!(p.body, "wake up");
214	    }
215
216	    // [unit->REQ-ARCH-4] amp-last in attr decode: a literal `<` in `from`
217	    // must NOT double-decode into `<`.
218	    #[test]
219	    fn attr_decode_is_amp_last() {
220	        let line = compose_msg_event("<weird>", "x");
221	        // On the wire the `&` became `&`: from="&lt;weird&gt;".
222	        assert!(line.contains("from=\"&lt;weird&gt;\""));
223	        let p = parse_event(&line).unwrap();
224	        assert_eq!(p.from(), Some("<weird>"));
225	    }
226
227	    // [unit->REQ-ARCH-4] a literal `</EVENT>` / `<EVENT ...>` inside a body must
228	    // survive the framing round-trip (escaped at compose, not re-framed).
229	    #[test]
230	    fn body_with_literal_event_tokens_round_trips() {
231	        let nasty = "try </EVENT> and <EVENT type='inner'>fake</EVENT> in prose";
232	        let line = compose_msg_event("bob", nasty);
233	        // Escaped: no raw framing tokens leaked into the body region.
234	        assert_eq!(line.matches("</EVENT>").count(), 1, "only the real closer");
235	        let p = parse_event(&line).unwrap();
236	        assert_eq!(p.body, nasty);
237	    }
238
239	    // [unit->REQ-ARCH-4] typed envelopes are pass-through and re-parse cleanly.
240	    #[test]
241	    fn typed_envelope_detected_and_parsed() {
242	        let typed =
243	            r#"<EVENT type="echo_commune" from="doyle-psyche" note="brief">hello world</EVENT>"#;
244	        assert!(is_typed_event_envelope(typed));
245	        let p = parse_event(typed).unwrap();
246	        assert_eq!(p.event_type.as_deref(), Some("echo_commune"));
247	        assert_eq!(p.from(), Some("doyle-psyche"));
248	        assert_eq!(p.attr("note"), Some("brief"));
249	        assert_eq!(p.body, "hello world");
250	    }
251
252	    // [unit->REQ-ARCH-4] attr order is preserved.
253	    #[test]
254	    fn attr_order_preserved() {
255	        let p = parse_event(r#"<EVENT type="msg" from="a" seq="1/2" id="deadbeef">b</EVENT>"#)
256	            .unwrap();
257	        let keys: Vec<&str> = p.attrs.iter().map(|(k, _)| k.as_str()).collect();
258	        assert_eq!(keys, vec!["type", "from", "seq", "id"]);
259	    }
260
261	    // ---- malformed → None, never panic ----
262
263	    // [unit->REQ-ARCH-4]
264	    #[test]
265	    fn malformed_inputs_return_none_without_panic() {
266	        assert_eq!(parse_event("not an event"), None);
267	        assert_eq!(parse_event("<EVENT type=\"msg\" from=\"a\">no closer"), None);
268	        assert_eq!(parse_event("<EVENT unterminated attr"), None);
269	        // EVENT-PART is a chunk, not a whole envelope — must not parse here.
270	        assert_eq!(
271	            parse_event(r#"<EVENT-PART seq="1/2" id="ab">chunk</EVENT-PART>"#),
272	            None
273	        );
274	        assert_eq!(parse_event(""), None);
275	    }
276
277	    // [unit->REQ-ARCH-4] empty-body envelope (file_drop shape) parses to "".
278	    #[test]
279	    fn empty_body_envelope_parses() {
280	        let p = parse_event(r#"<EVENT type="file_drop" path="x" from="d"></EVENT>"#).unwrap();
281	        assert_eq!(p.event_type.as_deref(), Some("file_drop"));
282	        assert_eq!(p.body, "");
283	        assert_eq!(p.attr("path"), Some("x"));
284	    }
285
286	    // [unit->REQ-ARCH-4] is_typed_event_envelope rejects non-envelopes.
287	    #[test]
288	    fn typed_predicate_rejects_partials() {
289	        assert!(!is_typed_event_envelope("<EVENT type=\"x\" no close"));
290	        assert!(!is_typed_event_envelope("plain body</EVENT>"));
291	        assert!(!is_typed_event_envelope("plain body"));
292	    }
293	}
294

//! EVENT envelope grammar: compose and parse the `<EVENT attrs>body</EVENT>`
//! wire line. The **format** is copied verbatim from `claude_skill_owl`
//! (`src/owl/poll.rs` compose sites — `assemble_event_line`,
//! `body_is_typed_event_envelope`) per ADR-0001; this is the stable, public
//! wire contract. The general parser is the receiver-side inverse of those
//! compose sites (the sister parses receiver-side in the harness/agent layer,
//! per the runtime reorientation contract — we make it a first-class function
//! here).
//!
//! ## Wire shapes
//! - regular message → `<EVENT type="msg" from="...">body</EVENT>`
//! - self-originated alarm → `<EVENT type="alarm" target-time="..." current-time="...">body</EVENT>`
//! - pre-formed typed envelope (e.g. `echo_commune`, `file_drop`) → emitted verbatim
//!
//! ## Codec coupling (KNOWN-HAZARDS 4.1 — amp-last)
//! Attribute values use [`event_attr_escape`]/[`event_attr_unescape`]; bodies
//! use [`event_body_escape`]/[`event_body_unescape`]. Compose escapes once at
//! the source; [`parse_event`] decodes once after framing-parse — never decode
//! the full envelope string (that would unescape outer framing tokens too).

use crate::envelope::{
    event_attr_escape, event_attr_unescape, event_body_escape, event_body_unescape,
};

// ---------------------------------------------------------------------------
// Compose (verbatim format — sister `assemble_event_line` branches)
// ---------------------------------------------------------------------------

/// Compose a regular message envelope: `<EVENT type="msg" from="F">B</EVENT>`.
/// `from` is attr-escaped; `body` is body-escaped (`\n`→`<br>`). Mirrors the
/// sister's regular-message branch (`poll.rs` `assemble_event_line`).
// [impl->REQ-ARCH-4]
pub fn compose_msg_event(from: &str, body: &str) -> String {
    format!(
        "<EVENT type=\"msg\" from=\"{}\">{}</EVENT>",
        event_attr_escape(from),
        event_body_escape(body)
    )
}

/// Compose an arbitrary typed envelope: `<EVENT type="T" k="v" …>body</EVENT>`.
/// `event_type` and every attr value are attr-escaped; `body` is body-escaped.
/// Attr order is preserved (type first, then the supplied pairs). This is the
/// general composer the payload layer (typed commands / blobs) builds on;
/// [`compose_msg_event`] is the `type="msg"` special case.
// [impl->REQ-ARCH-4]
pub fn compose_typed_event(event_type: &str, attrs: &[(&str, &str)], body: &str) -> String {
    let mut s = format!("<EVENT type=\"{}\"", event_attr_escape(event_type));
    for (k, v) in attrs {
        s.push(' ');
        s.push_str(k);
        s.push_str("=\"");
        s.push_str(&event_attr_escape(v));
        s.push('"');
    }
    s.push('>');
    s.push_str(&event_body_escape(body));
    s.push_str("</EVENT>");
    s
}

/// Compose a self-originated alarm envelope:
/// `<EVENT type="alarm" target-time="T" current-time="C">B</EVENT>`.
/// ISO-8601 times are attr-escaped (line-safe by construction); `body` is
/// body-escaped. Mirrors the sister's alarm branch.
// [impl->REQ-ARCH-4]
pub fn compose_alarm_event(target_iso: &str, current_iso: &str, body: &str) -> String {
    format!(
        "<EVENT type=\"alarm\" target-time=\"{}\" current-time=\"{}\">{}</EVENT>",
        event_attr_escape(target_iso),
        event_attr_escape(current_iso),
        event_body_escape(body)
    )
}

/// Returns true when `body` is already a fully-formed typed
/// `<EVENT ...>...</EVENT>` envelope and must be passed through verbatim — NOT
/// re-wrapped inside an outer `<EVENT type="msg" ...>`. Copied verbatim from
/// the sister's `body_is_typed_event_envelope` (echo-commune-double-envelope
/// fix). Cheap byte-slice check; no XML parse.
// [impl->REQ-ARCH-4]
pub fn is_typed_event_envelope(body: &str) -> bool {
    body.starts_with("<EVENT ") && body.ends_with("</EVENT>")
}

// ---------------------------------------------------------------------------
// Parse (receiver-side inverse — panic-free, malformed → None)
// ---------------------------------------------------------------------------

/// A parsed EVENT envelope. `event_type` is the `type="..."` attr if present
/// (typed envelopes always carry one; a bare `<EVENT>` would not). `attrs`
/// preserves every `key="value"` pair in wire order, values decoded amp-last.
/// `body` is the decoded body (`<br>`→`\n`, entities reversed amp-last).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParsedEvent {
    pub event_type: Option<String>,
    pub attrs: Vec<(String, String)>,
    pub body: String,
}

impl ParsedEvent {
    /// Look up the first decoded attribute value for `key`.
    pub fn attr(&self, key: &str) -> Option<&str> {
        self.attrs
            .iter()
            .find(|(k, _)| k == key)
            .map(|(_, v)| v.as_str())
    }

    /// Convenience: the `from` attribute, if any.
    pub fn from(&self) -> Option<&str> {
        self.attr("from")
    }
}

/// Parse a single `<EVENT attrs>body</EVENT>` wire line into a [`ParsedEvent`].
///
/// Never panics — every step is `Option`-based; returns `None` on any
/// malformed input: missing `<EVENT` open, missing `>` closing the open tag,
/// or missing trailing `</EVENT>`. Attribute values and the body are decoded
/// with the amp-last invariant.
///
/// Framing robustness: because attr values and the body are HTML-escaped at
/// compose time (`>` → `>`), the first raw `>` after `<EVENT` reliably ends
/// the open tag and the trailing `</EVENT>` reliably ends the body — neither
/// token can appear raw inside escaped content.
///
/// This parses ONE envelope line; long bodies that arrive as multiple
/// `<EVENT-PART seq=K/M id=...>` lines are reassembled by the T3 chunker first,
/// then the reassembled `<EVENT>` is parsed here.
// [impl->REQ-ARCH-4]
pub fn parse_event(line: &str) -> Option<ParsedEvent> {
    let line = line.trim();
    // Must be an EVENT (not EVENT-PART — we never parse a chunk as a whole).
    let after_open = line.strip_prefix("<EVENT")?;
    // Reject `<EVENT-PART ...>` (strip_prefix("<EVENT") leaves "-PART ...").
    if after_open.starts_with('-') {
        return None;
    }
    // The open tag is `<EVENT{attrs}>`; first raw `>` closes it (escaped
    // content can't contain a raw `>`).
    let gt = after_open.find('>')?;
    let attr_str = &after_open[..gt];
    let rest = &after_open[gt + 1..];
    // Body is everything up to the trailing `</EVENT>`.
    let body_escaped = rest.strip_suffix("</EVENT>")?;

    let attrs = parse_attrs(attr_str);
    let event_type = attrs
        .iter()
        .find(|(k, _)| k == "type")
        .map(|(_, v)| v.clone());

    Some(ParsedEvent {
        event_type,
        attrs,
        body: event_body_unescape(body_escaped),
    })
}

/// Parse an attribute-segment (`type="msg" from="a&b"`) into ordered
/// (key, decoded-value) pairs. Tolerant: skips anything that isn't a
/// `key="value"` pair; never panics. Values are decoded amp-last. Because
/// values are attr-escaped at compose time, no value can contain a raw `"`.
fn parse_attrs(s: &str) -> Vec<(String, String)> {
    let mut out = Vec::new();
    let mut rest = s;
    while let Some(eq) = rest.find("=\"") {
        // Key = the run of attr-name chars immediately before `="`.
        let key_region = &rest[..eq];
        let key_start = key_region
            .rfind([' ', '\t'])
            .map(|i| i + 1)
            .unwrap_or(0);
        let key = key_region[key_start..].trim();
        let after_eq = &rest[eq + 2..];
        let Some(close) = after_eq.find('"') else {
            break; // unterminated value — stop, drop the trailing garbage
        };
        let value = &after_eq[..close];
        if !key.is_empty() {
            out.push((key.to_string(), event_attr_unescape(value)));
        }
        rest = &after_eq[close + 1..];
    }
    out
}

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

    // ---- compose → parse round-trip ----

    // [unit->REQ-ARCH-4]
    #[test]
    fn msg_event_round_trips() {
        let line = compose_msg_event("alice", "hello\nworld < & >");
        let p = parse_event(&line).expect("parse");
        assert_eq!(p.event_type.as_deref(), Some("msg"));
        assert_eq!(p.from(), Some("alice"));
        assert_eq!(p.body, "hello\nworld < & >");
    }

    // [unit->REQ-ARCH-4]
    #[test]
    fn alarm_event_round_trips() {
        let line = compose_alarm_event("2026-05-31T12:00:00Z", "2026-05-31T12:00:05Z", "wake up");
        let p = parse_event(&line).expect("parse");
        assert_eq!(p.event_type.as_deref(), Some("alarm"));
        assert_eq!(p.attr("target-time"), Some("2026-05-31T12:00:00Z"));
        assert_eq!(p.attr("current-time"), Some("2026-05-31T12:00:05Z"));
        assert_eq!(p.body, "wake up");
    }

    // [unit->REQ-ARCH-4] amp-last in attr decode: a literal `<` in `from`
    // must NOT double-decode into `<`.
    #[test]
    fn attr_decode_is_amp_last() {
        let line = compose_msg_event("<weird>", "x");
        // On the wire the `&` became `&`: from="&lt;weird&gt;".
        assert!(line.contains("from=\"&lt;weird&gt;\""));
        let p = parse_event(&line).unwrap();
        assert_eq!(p.from(), Some("<weird>"));
    }

    // [unit->REQ-ARCH-4] a literal `</EVENT>` / `<EVENT ...>` inside a body must
    // survive the framing round-trip (escaped at compose, not re-framed).
    #[test]
    fn body_with_literal_event_tokens_round_trips() {
        let nasty = "try </EVENT> and <EVENT type='inner'>fake</EVENT> in prose";
        let line = compose_msg_event("bob", nasty);
        // Escaped: no raw framing tokens leaked into the body region.
        assert_eq!(line.matches("</EVENT>").count(), 1, "only the real closer");
        let p = parse_event(&line).unwrap();
        assert_eq!(p.body, nasty);
    }

    // [unit->REQ-ARCH-4] typed envelopes are pass-through and re-parse cleanly.
    #[test]
    fn typed_envelope_detected_and_parsed() {
        let typed =
            r#"<EVENT type="echo_commune" from="doyle-psyche" note="brief">hello world</EVENT>"#;
        assert!(is_typed_event_envelope(typed));
        let p = parse_event(typed).unwrap();
        assert_eq!(p.event_type.as_deref(), Some("echo_commune"));
        assert_eq!(p.from(), Some("doyle-psyche"));
        assert_eq!(p.attr("note"), Some("brief"));
        assert_eq!(p.body, "hello world");
    }

    // [unit->REQ-ARCH-4] attr order is preserved.
    #[test]
    fn attr_order_preserved() {
        let p = parse_event(r#"<EVENT type="msg" from="a" seq="1/2" id="deadbeef">b</EVENT>"#)
            .unwrap();
        let keys: Vec<&str> = p.attrs.iter().map(|(k, _)| k.as_str()).collect();
        assert_eq!(keys, vec!["type", "from", "seq", "id"]);
    }

    // ---- malformed → None, never panic ----

    // [unit->REQ-ARCH-4]
    #[test]
    fn malformed_inputs_return_none_without_panic() {
        assert_eq!(parse_event("not an event"), None);
        assert_eq!(parse_event("<EVENT type=\"msg\" from=\"a\">no closer"), None);
        assert_eq!(parse_event("<EVENT unterminated attr"), None);
        // EVENT-PART is a chunk, not a whole envelope — must not parse here.
        assert_eq!(
            parse_event(r#"<EVENT-PART seq="1/2" id="ab">chunk</EVENT-PART>"#),
            None
        );
        assert_eq!(parse_event(""), None);
    }

    // [unit->REQ-ARCH-4] empty-body envelope (file_drop shape) parses to "".
    #[test]
    fn empty_body_envelope_parses() {
        let p = parse_event(r#"<EVENT type="file_drop" path="x" from="d"></EVENT>"#).unwrap();
        assert_eq!(p.event_type.as_deref(), Some("file_drop"));
        assert_eq!(p.body, "");
        assert_eq!(p.attr("path"), Some("x"));
    }

    // [unit->REQ-ARCH-4] is_typed_event_envelope rejects non-envelopes.
    #[test]
    fn typed_predicate_rejects_partials() {
        assert!(!is_typed_event_envelope("<EVENT type=\"x\" no close"));
        assert!(!is_typed_event_envelope("plain body</EVENT>"));
        assert!(!is_typed_event_envelope("plain body"));
    }
}


crates\spt-msg\tests\killer_quickstart.rs:56:    if let Some(rest) = frame.strip_prefix("__REPLY_TO__:") {
crates\spt-msg\src\wire.rs:7://! Message content is `__REPLY_TO__:{from}\n{body}\n`; an empty `from` omits
crates\spt-msg\src\wire.rs:8://! the `__REPLY_TO__:` line (anonymous delivery). The reply-to line is how the
crates\spt-msg\src\wire.rs:20:/// First line is `__REPLY_TO__:{from}`, the rest is the body, ending in a
crates\spt-msg\src\wire.rs:27:        format!("__REPLY_TO__:{}\n{}\n", from_id, body)
crates\spt-msg\src\wire.rs:68:    // [unit->REQ-MSG-1] format_message shapes the __REPLY_TO__ contract.
crates\spt-msg\src\wire.rs:71:        assert_eq!(format_message("alice", "hi bob"), "__REPLY_TO__:alice\nhi bob\n");
crates\spt-msg\src\wire.rs:96:        assert_eq!(got, "__REPLY_TO__:alice\nhello\nworld\n");
crates\spt-daemon\src\access.rs:21://!   (`__REPLY_TO__`) → allowed from any visible node ("established/related").
crates\spt-daemon\src\access.rs:39://! reply-vs-unsolicited — a `__REPLY_TO__` conversation is message traffic.
crates\spt-daemon\src\access.rs:52:    /// Correlated to the target endpoint's own prior outbound (`__REPLY_TO__`)
crates\spt-msg\src\ring.rs:172:            // Wait for the incoming ring, parse __REPLY_TO__, reply to sender.
crates\spt-msg\src\ring.rs:179:                        .and_then(|l| l.strip_prefix("__REPLY_TO__:"))
crates\spt-net\src\net\wanmsg.rs:21://! `from` field is reply-routing metadata (`__REPLY_TO__`), not an
crates\spt-net\src\net\wanmsg.rs:41:    /// The sender endpoint id — the `__REPLY_TO__` line at delivery (empty =
crates\spt-msg\src\ready.rs:221:        assert_eq!(got.as_deref(), Some("__REPLY_TO__:peer\nhello live\n"));
crates\spt\src\cli.rs:50:        /// Sender id written into __REPLY_TO__ (auto-detected from session if omitted).
crates\spt-msg\src\listener.rs:230:        assert_eq!(got.as_deref(), Some("__REPLY_TO__:sender\nping\n"));
crates\spt-msg\src\lib.rs:7://! Per ADR-0001 the **stable wire format** (length-prefixed `__REPLY_TO__`
crates\spt-msg\src\deliver.rs:125:/// message took. `from` is written into the `__REPLY_TO__` line (empty =
crates\spt-msg\src\deliver.rs:147:/// `__REPLY_TO__` line the original message carried names the target, and the
crates\spt-msg\src\deliver.rs:225:        assert_eq!(got.as_deref(), Some("__REPLY_TO__:sender\nlive msg\n"));
crates\spt-store\src\access.rs:29://! to the endpoint's own recent outbound (`__REPLY_TO__` traffic) passes from
crates\spt-store\src\spool.rs:86:/// Format a drained row for delivery, preserving the sister's `__REPLY_TO__`
crates\spt-store\src\spool.rs:93:        format!("__REPLY_TO__:{}\n{}", from_id, body)
crates\spt-store\src\spool.rs:340:                "__REPLY_TO__:alice\nfirst".to_string(),
crates\spt-store\src\spool.rs:341:                "__REPLY_TO__:carol\nsecond".to_string()
crates\spt-store\src\spool.rs:358:        assert_eq!(drain_non_deferred_at(p).unwrap(), vec!["__REPLY_TO__:alice\nnormal"]);
crates\spt-store\src\spool.rs:367:        assert_eq!(drain_all_at(p).unwrap(), vec!["__REPLY_TO__:psyche\nbrief"]);
crates\spt-store\src\spool.rs:382:        assert_eq!(first, vec!["__REPLY_TO__:alice\nx".to_string(), "anon".to_string()]);


No matches found


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


crates\spt-live\src\test_support.rs
crates\spt-live\src\history.rs
crates\spt-live\src\echo.rs
crates\spt-live\src\psyche.rs
crates\spt-live\src\ingest.rs
crates\spt-live\src\signoff.rs
crates\spt-live\src\pulse.rs
crates\spt-live\src\resume.rs
crates\spt-live\src\turn.rs
crates\spt-live\src\context.rs
crates\spt-live\src\lib.rs
crates\spt-live\src\reconcile.rs


1	//! spawn-psyche seam (M2b T2) — bring a Self's Psyche companion to life.
2	//!
3	//! The Psyche is spawned from the `[session.psyche_init]` template (fresh) or
4	//! `[session.psyche_resume]` (re-entry — T7) as a **nested `<id>-psyche` perch**
5	//! (M0 layout: `owlery/<parent>/nested/<id>-psyche/`). spt-core owns exactly one
6	//! piece of the command's content — the `{psyche_prompt}` substitution: the
7	//! revival essentials (timestamp + the incoming event envelope) per CONTEXT
8	//! §spawn-psyche seam. Everything else — model selection, harness preamble —
9	//! lives in the adapter's opaque template; spt-core selects no model.
10	//!
11	//! The recursion guard (`recursion_guard_env`) is set on the child by
12	//! `ManifestRuntime` so the Psyche's own harness hooks bail instead of spawning
13	//! a Psyche-of-the-Psyche (KNOWN-HAZARDS — echo-commune recursion).
14	//
15	// Clean-room over the M2a spawn seam (the sister's psyche wrapper owns a
16	// `claude -p` subprocess + `.claude/` paths). Interim no-daemon model: the
17	// Psyche is a real detached subprocess and spt-core writes its nested perch
18	// record; M3's daemon re-hosts it as a loop with daemon-authoritative liveness.
19	// [impl->REQ-SEAM-PSYCHE]
20
21	use spt_proto::endpoint::EndpointType;
22	use spt_runtime::{AgentRuntime, RuntimeError};
23	use spt_store::info::{write_info, InfoJson};
24	use spt_store::perch::{resolve_perch_path, ParentHint};
25	use std::collections::BTreeMap;
26	use std::path::PathBuf;
27
28	/// The role spawned for a fresh Psyche.
29	pub const PSYCHE_INIT_ROLE: &str = "psyche_init";
30
31	/// A spawned Psyche companion: its nested id, OS pid, and perch directory.
32	#[derive(Debug, Clone)]
33	pub struct PsycheHandle {
34	    pub id: String,
35	    pub pid: u32,
36	    pub perch_path: PathBuf,
37	}
38
39	/// A spawn-psyche failure. Carries an operator message; never panics.
40	#[derive(Debug)]
41	pub enum PsycheError {
42	    /// Template fill / spawn / role-lookup failure from the runtime.
43	    Runtime(RuntimeError),
44	    /// Creating the nested perch dir or writing its `info.json` failed.
45	    Bind(std::io::Error),
46	}
47
48	impl std::fmt::Display for PsycheError {
49	    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50	        match self {
51	            PsycheError::Runtime(e) => write!(f, "psyche spawn failed: {e}"),
52	            PsycheError::Bind(e) => write!(f, "binding the psyche perch failed: {e}"),
53	        }
54	    }
55	}
56
57	impl std::error::Error for PsycheError {}
58
59	impl From<RuntimeError> for PsycheError {
60	    fn from(e: RuntimeError) -> Self {
61	        PsycheError::Runtime(e)
62	    }
63	}
64
65	/// Compose the **spt-core-owned** `{psyche_prompt}`: the revival essentials fed
66	/// to every Psyche regardless of adapter — a timestamp and the incoming event
67	/// envelope (CONTEXT §spawn-psyche seam). Consistent across adapters so the
68	/// live/project authoring split stays uniform (`docs/CONTEXT-MEMORY.md`); the
69	/// adapter prepends its own static preamble around this.
70	pub fn compose_psyche_prompt(now: &str, event: Option<&str>) -> String {
71	    let mut s = String::with_capacity(64);
72	    s.push_str("PSYCHE REVIVAL\n");
73	    s.push_str("time: ");
74	    s.push_str(now);
75	    s.push('\n');
76	    match event {
77	        Some(ev) if !ev.trim().is_empty() => {
78	            s.push_str("incoming event:\n");
79	            s.push_str(ev.trim_end());
80	            s.push('\n');
81	        }
82	        _ => s.push_str("incoming event: (none)\n"),
83	    }
84	    s
85	}
86
87	/// Spawn a fresh Psyche for `parent_id` and bind its nested perch.
88	///
89	/// `keys` supplies the adapter-template substitution values the caller owns
90	/// (e.g. `session_name`); spt-core overrides the three it owns for a Psyche:
91	/// `id` (the nested `<id>-psyche`), `psyche_dir` (the created perch dir, the
92	/// role's `cwd`), and `psyche_prompt` (composed here). The role's
93	/// `recursion_guard_env` is applied to the child by the runtime.
94	pub fn spawn_psyche(
95	    runtime: &dyn AgentRuntime,
96	    parent_id: &str,
97	    keys: &BTreeMap<String, String>,
98	    event: Option<&str>,
99	    now: &str,
100	) -> Result<PsycheHandle, PsycheError> {
101	    spawn_and_bind(runtime, PSYCHE_INIT_ROLE, parent_id, keys, event, now)
102	}
103
104	/// Shared spawn+bind core for the Psyche roles (`psyche_init` — fresh; and
105	/// `psyche_resume` — re-entry, T7). Injects the three spt-core-owned keys
106	/// (`id` / `psyche_dir` / `psyche_prompt`), spawns the role, and binds the
107	/// nested `<id>-psyche` perch.
108	pub(crate) fn spawn_and_bind(
109	    runtime: &dyn AgentRuntime,
110	    role: &str,
111	    parent_id: &str,
112	    keys: &BTreeMap<String, String>,
113	    event: Option<&str>,
114	    now: &str,
115	) -> Result<PsycheHandle, PsycheError> {
116	    let psyche_id = format!("{parent_id}-psyche");
117	    let perch_path = resolve_perch_path(&psyche_id, ParentHint::Explicit(parent_id));
118	    // The nested perch dir must exist before spawn — the role's `cwd` is it.
119	    std::fs::create_dir_all(&perch_path).map_err(PsycheError::Bind)?;
120
121	    let mut k = keys.clone();
122	    k.insert("id".to_string(), psyche_id.clone());
123	    k.insert(
124	        "psyche_dir".to_string(),
125	        perch_path.to_string_lossy().to_string(),
126	    );
127	    k.insert(
128	        "psyche_prompt".to_string(),
129	        compose_psyche_prompt(now, event),
130	    );
131
132	    let spawned = runtime.spawn_session(role, &k)?;
133
134	    // Bind the nested perch: spt-core writes the Psyche's identity/status record.
135	    // Interim per-pid liveness (M3's daemon makes `status` authoritative).
136	    let session_id = keys.get("session_id").map(String::as_str).unwrap_or("");
137	    let mut rec = InfoJson::new(
138	        &psyche_id,
139	        now,
140	        spawned.pid,
141	        session_id,
142	        &EndpointType::Psyche.as_tag(),
143	    );
144	    rec.parent_pid = None;
145	    write_info(&perch_path, &rec).map_err(PsycheError::Bind)?;
146
147	    Ok(PsycheHandle {
148	        id: psyche_id,
149	        pid: spawned.pid,
150	        perch_path,
151	    })
152	}
153
154	#[cfg(test)]
155	mod tests {
156	    use super::*;
157	    use crate::test_support::with_home;
158	    use spt_runtime::{Manifest, ManifestRuntime};
159	    use std::time::{Duration, Instant};
160
161	    #[test] // [unit->REQ-SEAM-PSYCHE] the prompt carries the timestamp + event envelope.
162	    fn prompt_includes_time_and_event() {
163	        let p = compose_psyche_prompt("2026-06-01T00:00:00Z", Some("MSG from alice: hi"));
164	        assert!(p.contains("time: 2026-06-01T00:00:00Z"));
165	        assert!(p.contains("MSG from alice: hi"));
166	    }
167
168	    #[test] // [unit->REQ-SEAM-PSYCHE] no event ⇒ an explicit "(none)" envelope.
169	    fn prompt_without_event() {
170	        let p = compose_psyche_prompt("t0", None);
171	        assert!(p.contains("incoming event: (none)"));
172	    }
173
174	    // A psyche_init template that records the recursion-guard env value into a
175	    // marker file in its cwd (the nested perch dir) — proves both that the perch
176	    // is the cwd and that the guard env reached the child.
177	    #[cfg(windows)]
178	    const GUARD_CMD: &str = "cmd /C echo %SPT_GUARD% > guard.txt";
179	    #[cfg(unix)]
180	    const GUARD_CMD: &str = "sh -c \"echo $SPT_GUARD > guard.txt\"";
181
182	    fn psyche_manifest() -> Manifest {
183	        let toml = format!(
184	            "[adapter]\nname=\"mock\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n\
185	             [session.psyche_init]\ncommand='{GUARD_CMD}'\ncwd=\"{{psyche_dir}}\"\n\
186	             recursion_guard_env=\"SPT_GUARD\"\nkeys=[\"psyche_dir\",\"psyche_prompt\"]\n"
187	        );
188	        Manifest::from_toml_str(&toml).unwrap()
189	    }
190
191	    #[test] // [unit->REQ-SEAM-PSYCHE] spawning binds a nested psyche perch + sets the guard env.
192	    fn spawn_binds_nested_perch_and_sets_guard() {
193	        with_home(|_| {
194	            let rt = ManifestRuntime::new(psyche_manifest());
195	            let h = spawn_psyche(&rt, "doyle", &BTreeMap::new(), Some("pulse"), "t0")
196	                .expect("spawn psyche");
197
198	            // Nested layout: owlery/doyle/nested/doyle-psyche/.
199	            assert_eq!(h.id, "doyle-psyche");
200	            assert_eq!(
201	                h.perch_path,
202	                resolve_perch_path("doyle-psyche", ParentHint::Explicit("doyle"))
203	            );
204
205	            // info.json records the psyche endpoint type + the spawned pid.
206	            let rec = spt_store::info::read_info(&h.perch_path).expect("info.json");
207	            assert_eq!(rec.state, "psyche");
208	            assert_eq!(rec.id, "doyle-psyche");
209
210	            // The detached child writes the guard value into its cwd; poll for it.
211	            let marker = h.perch_path.join("guard.txt");
212	            let start = Instant::now();
213	            let contents = loop {
214	                if let Ok(s) = std::fs::read_to_string(&marker) {
215	                    if !s.trim().is_empty() {
216	                        break s;
217	                    }
218	                }
219	                assert!(
220	                    start.elapsed() < Duration::from_secs(5),
221	                    "psyche child never wrote its guard marker"
222	                );
223	                std::thread::sleep(Duration::from_millis(20));
224	            };
225	            assert!(contents.contains('1'), "guard env not set on child: {contents:?}");
226	        });
227	    }
228	}
229

//! spawn-psyche seam (M2b T2) — bring a Self's Psyche companion to life.
//!
//! The Psyche is spawned from the `[session.psyche_init]` template (fresh) or
//! `[session.psyche_resume]` (re-entry — T7) as a **nested `<id>-psyche` perch**
//! (M0 layout: `owlery/<parent>/nested/<id>-psyche/`). spt-core owns exactly one
//! piece of the command's content — the `{psyche_prompt}` substitution: the
//! revival essentials (timestamp + the incoming event envelope) per CONTEXT
//! §spawn-psyche seam. Everything else — model selection, harness preamble —
//! lives in the adapter's opaque template; spt-core selects no model.
//!
//! The recursion guard (`recursion_guard_env`) is set on the child by
//! `ManifestRuntime` so the Psyche's own harness hooks bail instead of spawning
//! a Psyche-of-the-Psyche (KNOWN-HAZARDS — echo-commune recursion).
//
// Clean-room over the M2a spawn seam (the sister's psyche wrapper owns a
// `claude -p` subprocess + `.claude/` paths). Interim no-daemon model: the
// Psyche is a real detached subprocess and spt-core writes its nested perch
// record; M3's daemon re-hosts it as a loop with daemon-authoritative liveness.
// [impl->REQ-SEAM-PSYCHE]

use spt_proto::endpoint::EndpointType;
use spt_runtime::{AgentRuntime, RuntimeError};
use spt_store::info::{write_info, InfoJson};
use spt_store::perch::{resolve_perch_path, ParentHint};
use std::collections::BTreeMap;
use std::path::PathBuf;

/// The role spawned for a fresh Psyche.
pub const PSYCHE_INIT_ROLE: &str = "psyche_init";

/// A spawned Psyche companion: its nested id, OS pid, and perch directory.
#[derive(Debug, Clone)]
pub struct PsycheHandle {
    pub id: String,
    pub pid: u32,
    pub perch_path: PathBuf,
}

/// A spawn-psyche failure. Carries an operator message; never panics.
#[derive(Debug)]
pub enum PsycheError {
    /// Template fill / spawn / role-lookup failure from the runtime.
    Runtime(RuntimeError),
    /// Creating the nested perch dir or writing its `info.json` failed.
    Bind(std::io::Error),
}

impl std::fmt::Display for PsycheError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            PsycheError::Runtime(e) => write!(f, "psyche spawn failed: {e}"),
            PsycheError::Bind(e) => write!(f, "binding the psyche perch failed: {e}"),
        }
    }
}

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

impl From<RuntimeError> for PsycheError {
    fn from(e: RuntimeError) -> Self {
        PsycheError::Runtime(e)
    }
}

/// Compose the **spt-core-owned** `{psyche_prompt}`: the revival essentials fed
/// to every Psyche regardless of adapter — a timestamp and the incoming event
/// envelope (CONTEXT §spawn-psyche seam). Consistent across adapters so the
/// live/project authoring split stays uniform (`docs/CONTEXT-MEMORY.md`); the
/// adapter prepends its own static preamble around this.
pub fn compose_psyche_prompt(now: &str, event: Option<&str>) -> String {
    let mut s = String::with_capacity(64);
    s.push_str("PSYCHE REVIVAL\n");
    s.push_str("time: ");
    s.push_str(now);
    s.push('\n');
    match event {
        Some(ev) if !ev.trim().is_empty() => {
            s.push_str("incoming event:\n");
            s.push_str(ev.trim_end());
            s.push('\n');
        }
        _ => s.push_str("incoming event: (none)\n"),
    }
    s
}

/// Spawn a fresh Psyche for `parent_id` and bind its nested perch.
///
/// `keys` supplies the adapter-template substitution values the caller owns
/// (e.g. `session_name`); spt-core overrides the three it owns for a Psyche:
/// `id` (the nested `<id>-psyche`), `psyche_dir` (the created perch dir, the
/// role's `cwd`), and `psyche_prompt` (composed here). The role's
/// `recursion_guard_env` is applied to the child by the runtime.
pub fn spawn_psyche(
    runtime: &dyn AgentRuntime,
    parent_id: &str,
    keys: &BTreeMap<String, String>,
    event: Option<&str>,
    now: &str,
) -> Result<PsycheHandle, PsycheError> {
    spawn_and_bind(runtime, PSYCHE_INIT_ROLE, parent_id, keys, event, now)
}

/// Shared spawn+bind core for the Psyche roles (`psyche_init` — fresh; and
/// `psyche_resume` — re-entry, T7). Injects the three spt-core-owned keys
/// (`id` / `psyche_dir` / `psyche_prompt`), spawns the role, and binds the
/// nested `<id>-psyche` perch.
pub(crate) fn spawn_and_bind(
    runtime: &dyn AgentRuntime,
    role: &str,
    parent_id: &str,
    keys: &BTreeMap<String, String>,
    event: Option<&str>,
    now: &str,
) -> Result<PsycheHandle, PsycheError> {
    let psyche_id = format!("{parent_id}-psyche");
    let perch_path = resolve_perch_path(&psyche_id, ParentHint::Explicit(parent_id));
    // The nested perch dir must exist before spawn — the role's `cwd` is it.
    std::fs::create_dir_all(&perch_path).map_err(PsycheError::Bind)?;

    let mut k = keys.clone();
    k.insert("id".to_string(), psyche_id.clone());
    k.insert(
        "psyche_dir".to_string(),
        perch_path.to_string_lossy().to_string(),
    );
    k.insert(
        "psyche_prompt".to_string(),
        compose_psyche_prompt(now, event),
    );

    let spawned = runtime.spawn_session(role, &k)?;

    // Bind the nested perch: spt-core writes the Psyche's identity/status record.
    // Interim per-pid liveness (M3's daemon makes `status` authoritative).
    let session_id = keys.get("session_id").map(String::as_str).unwrap_or("");
    let mut rec = InfoJson::new(
        &psyche_id,
        now,
        spawned.pid,
        session_id,
        &EndpointType::Psyche.as_tag(),
    );
    rec.parent_pid = None;
    write_info(&perch_path, &rec).map_err(PsycheError::Bind)?;

    Ok(PsycheHandle {
        id: psyche_id,
        pid: spawned.pid,
        perch_path,
    })
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::test_support::with_home;
    use spt_runtime::{Manifest, ManifestRuntime};
    use std::time::{Duration, Instant};

    #[test] // [unit->REQ-SEAM-PSYCHE] the prompt carries the timestamp + event envelope.
    fn prompt_includes_time_and_event() {
        let p = compose_psyche_prompt("2026-06-01T00:00:00Z", Some("MSG from alice: hi"));
        assert!(p.contains("time: 2026-06-01T00:00:00Z"));
        assert!(p.contains("MSG from alice: hi"));
    }

    #[test] // [unit->REQ-SEAM-PSYCHE] no event ⇒ an explicit "(none)" envelope.
    fn prompt_without_event() {
        let p = compose_psyche_prompt("t0", None);
        assert!(p.contains("incoming event: (none)"));
    }

    // A psyche_init template that records the recursion-guard env value into a
    // marker file in its cwd (the nested perch dir) — proves both that the perch
    // is the cwd and that the guard env reached the child.
    #[cfg(windows)]
    const GUARD_CMD: &str = "cmd /C echo %SPT_GUARD% > guard.txt";
    #[cfg(unix)]
    const GUARD_CMD: &str = "sh -c \"echo $SPT_GUARD > guard.txt\"";

    fn psyche_manifest() -> Manifest {
        let toml = format!(
            "[adapter]\nname=\"mock\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n\
             [session.psyche_init]\ncommand='{GUARD_CMD}'\ncwd=\"{{psyche_dir}}\"\n\
             recursion_guard_env=\"SPT_GUARD\"\nkeys=[\"psyche_dir\",\"psyche_prompt\"]\n"
        );
        Manifest::from_toml_str(&toml).unwrap()
    }

    #[test] // [unit->REQ-SEAM-PSYCHE] spawning binds a nested psyche perch + sets the guard env.
    fn spawn_binds_nested_perch_and_sets_guard() {
        with_home(|_| {
            let rt = ManifestRuntime::new(psyche_manifest());
            let h = spawn_psyche(&rt, "doyle", &BTreeMap::new(), Some("pulse"), "t0")
                .expect("spawn psyche");

            // Nested layout: owlery/doyle/nested/doyle-psyche/.
            assert_eq!(h.id, "doyle-psyche");
            assert_eq!(
                h.perch_path,
                resolve_perch_path("doyle-psyche", ParentHint::Explicit("doyle"))
            );

            // info.json records the psyche endpoint type + the spawned pid.
            let rec = spt_store::info::read_info(&h.perch_path).expect("info.json");
            assert_eq!(rec.state, "psyche");
            assert_eq!(rec.id, "doyle-psyche");

            // The detached child writes the guard value into its cwd; poll for it.
            let marker = h.perch_path.join("guard.txt");
            let start = Instant::now();
            let contents = loop {
                if let Ok(s) = std::fs::read_to_string(&marker) {
                    if !s.trim().is_empty() {
                        break s;
                    }
                }
                assert!(
                    start.elapsed() < Duration::from_secs(5),
                    "psyche child never wrote its guard marker"
                );
                std::thread::sleep(Duration::from_millis(20));
            };
            assert!(contents.contains('1'), "guard env not set on child: {contents:?}");
        });
    }
}


1	//! pulse heartbeat (M2b T5) — the interim per-tick driver.
2	//!
3	//! In the no-daemon interim model, the long-running `api listen` process
4	//! (T8) hosts a pulse loop. Each tick does the lightweight, recurring work:
5	//! - **poll the drop dirs** → fold any commune/signoff drops into context
6	//!   (single-writer ingest→delete — T4);
7	//! - **fire the pending echo** → if the echo-gate (`.more-done`, armed by
8	//!   `api state idle`) is set, signal that an echo is due and **clear the gate**
9	//!   so it fires *exactly once*;
10	//! - surface the **idle** flag for the caller's capsule/idle decisions.
11	//!
12	//! The pulse is deliberately thin — it decides *when* an echo is due and
13	//! guarantees once-only; the heavy echo run ([`crate::echo::run_echo_commune`])
14	//! is the caller's (T8), which holds the runtime + history + commune dir. Keep
15	//! all of this behind this one module so M3's daemon re-host (Psyche-as-loop,
16	//! daemon-owned period) is a localized swap, not a rewrite (mirrors M2a's
17	//! seed-file discipline).
18	//
19	// Interim only: the durable pulse loop + its global/per-endpoint period config
20	// belong to the M3 daemon (CONTEXT R-DAEMON-1). No dedicated REQ — the pulse is
21	// throwaway glue under REQ-SEAM-PSYCHE (see M2b-PLAN §New requirements).
22
23	use crate::context::DEFAULT_PROTECTION_WINDOW_MS;
24	use crate::ingest::{ingest_drops, Ingested};
25	use spt_store::perch::{resolve_echo_gate_file, resolve_idle_file, ParentHint};
26	use std::path::Path;
27	use std::time::Duration;
28
29	/// Interim default pulse period. The durable, configurable knob (global +
30	/// per-endpoint override) is the M3 daemon's; this is the interim constant.
31	pub const DEFAULT_PULSE_PERIOD: Duration = Duration::from_secs(5);
32
33	/// Pulse tuning. `period` paces the `api listen` loop; `protection_window_ms`
34	/// is threaded into the drop-file precedence guard (6.5).
35	#[derive(Debug, Clone, Copy)]
36	pub struct PulseConfig {
37	    pub period: Duration,
38	    pub protection_window_ms: u64,
39	}
40
41	impl Default for PulseConfig {
42	    fn default() -> Self {
43	        PulseConfig {
44	            period: DEFAULT_PULSE_PERIOD,
45	            protection_window_ms: DEFAULT_PROTECTION_WINDOW_MS,
46	        }
47	    }
48	}
49
50	/// What one tick observed/did. `echo_due` means the gate was armed and has now
51	/// been cleared — the caller should fire exactly one echo-commune.
52	#[derive(Debug, Clone)]
53	pub struct TickOutcome {
54	    pub ingested: Vec<Ingested>,
55	    pub echo_due: bool,
56	    pub idle: bool,
57	}
58
59	/// Run one pulse tick for `id`: ingest drops from `drop_dir` (two-tier routed
60	/// under `project_id`), then read+clear the echo-gate (once-only fire) and the
61	/// idle flag. `now_ms` stamps the precedence markers of any ingested writes.
62	pub fn tick(
63	    id: &str,
64	    project_id: &str,
65	    hint: ParentHint<'_>,
66	    drop_dir: &Path,
67	    now_ms: u64,
68	    cfg: &PulseConfig,
69	) -> std::io::Result<TickOutcome> {
70	    let ingested = ingest_drops(drop_dir, id, project_id, now_ms, cfg.protection_window_ms)?;
71	    let echo_due = take_echo_gate(id, hint.clone());
72	    let idle = resolve_idle_file(id, hint).exists();
73	    Ok(TickOutcome {
74	        ingested,
75	        echo_due,
76	        idle,
77	    })
78	}
79
80	/// Read-and-clear the echo-gate: returns whether it was armed, removing the

//! pulse heartbeat (M2b T5) — the interim per-tick driver.
//!
//! In the no-daemon interim model, the long-running `api listen` process
//! (T8) hosts a pulse loop. Each tick does the lightweight, recurring work:
//! - **poll the drop dirs** → fold any commune/signoff drops into context
//!   (single-writer ingest→delete — T4);
//! - **fire the pending echo** → if the echo-gate (`.more-done`, armed by
//!   `api state idle`) is set, signal that an echo is due and **clear the gate**
//!   so it fires *exactly once*;
//! - surface the **idle** flag for the caller's capsule/idle decisions.
//!
//! The pulse is deliberately thin — it decides *when* an echo is due and
//! guarantees once-only; the heavy echo run ([`crate::echo::run_echo_commune`])
//! is the caller's (T8), which holds the runtime + history + commune dir. Keep
//! all of this behind this one module so M3's daemon re-host (Psyche-as-loop,
//! daemon-owned period) is a localized swap, not a rewrite (mirrors M2a's
//! seed-file discipline).
//
// Interim only: the durable pulse loop + its global/per-endpoint period config
// belong to the M3 daemon (CONTEXT R-DAEMON-1). No dedicated REQ — the pulse is
// throwaway glue under REQ-SEAM-PSYCHE (see M2b-PLAN §New requirements).

use crate::context::DEFAULT_PROTECTION_WINDOW_MS;
use crate::ingest::{ingest_drops, Ingested};
use spt_store::perch::{resolve_echo_gate_file, resolve_idle_file, ParentHint};
use std::path::Path;
use std::time::Duration;

/// Interim default pulse period. The durable, configurable knob (global +
/// per-endpoint override) is the M3 daemon's; this is the interim constant.
pub const DEFAULT_PULSE_PERIOD: Duration = Duration::from_secs(5);

/// Pulse tuning. `period` paces the `api listen` loop; `protection_window_ms`
/// is threaded into the drop-file precedence guard (6.5).
#[derive(Debug, Clone, Copy)]
pub struct PulseConfig {
    pub period: Duration,
    pub protection_window_ms: u64,
}

impl Default for PulseConfig {
    fn default() -> Self {
        PulseConfig {
            period: DEFAULT_PULSE_PERIOD,
            protection_window_ms: DEFAULT_PROTECTION_WINDOW_MS,
        }
    }
}

/// What one tick observed/did. `echo_due` means the gate was armed and has now
/// been cleared — the caller should fire exactly one echo-commune.
#[derive(Debug, Clone)]
pub struct TickOutcome {
    pub ingested: Vec<Ingested>,
    pub echo_due: bool,
    pub idle: bool,
}

/// Run one pulse tick for `id`: ingest drops from `drop_dir` (two-tier routed
/// under `project_id`), then read+clear the echo-gate (once-only fire) and the
/// idle flag. `now_ms` stamps the precedence markers of any ingested writes.
pub fn tick(
    id: &str,
    project_id: &str,
    hint: ParentHint<'_>,
    drop_dir: &Path,
    now_ms: u64,
    cfg: &PulseConfig,
) -> std::io::Result<TickOutcome> {
    let ingested = ingest_drops(drop_dir, id, project_id, now_ms, cfg.protection_window_ms)?;
    let echo_due = take_echo_gate(id, hint.clone());
    let idle = resolve_idle_file(id, hint).exists();
    Ok(TickOutcome {
        ingested,
        echo_due,
        idle,
    })
}

/// Read-and-clear the echo-gate: returns whether it was armed, removing the


1	//! The broker↔brain message catalog: the kind discriminators and payload
2	//! shapes that ride inside the forward-compatible [`Envelope`].
3	//!
4	//! Split out from [`crate::frame`] (which owns the *envelope mechanics* and the
5	//! version handshake) so the message *vocabulary* can grow additively without
6	//! touching the wire-compat machinery. Every payload is a plain serde struct
7	//! with serde's default unknown-field tolerance, so a newer brain may add a
8	//! field and an older broker still decodes the message (KNOWN-HAZARDS 2.3).
9	//!
10	//! Bytes (PTY input/output) ride as base64 strings — JSON has no native byte
11	//! type, and base64 keeps the single envelope/codec path rather than forking a
12	//! binary frame format.
13	// [impl->REQ-DAEMON-2]
14
15	use base64::engine::general_purpose::STANDARD as B64;
16	use base64::Engine;
17	use serde::{Deserialize, Serialize};
18
19	use crate::frame::Envelope;
20
21	/// Brain→broker: spawn a child under a fresh broker-owned PTY.
22	pub const KIND_SPAWN: &str = "spawn";
23	/// Broker→brain: a spawn succeeded; carries the assigned session id + child pid.
24	pub const KIND_SPAWNED: &str = "spawned";
25	/// Brain→broker: (re)subscribe to a session's output from a given sequence.
26	pub const KIND_SUBSCRIBE: &str = "subscribe";
27	/// Brain→broker: write raw bytes to a session's input (send-keys).
28	pub const KIND_INPUT: &str = "input";
29	/// Brain→broker: resize a session's PTY.
30	pub const KIND_RESIZE: &str = "resize";
31	/// Brain→broker: terminate a session's child.
32	pub const KIND_KILL: &str = "kill";
33	/// Broker→brain: a chunk of a session's output (pushed as the child produces it).
34	pub const KIND_OUTPUT: &str = "output";
35	/// Broker→brain: a session's child exited.
36	pub const KIND_EXIT: &str = "exit";
37	/// Broker→brain: an idempotent effect (an `input` carrying an `op_id`) is durably
38	/// applied **or** was already applied (deduped). The exactly-once ack: a brain
39	/// learns its effect landed, and a replay after a crash is acknowledged the same
40	/// way so the brain can retire it (B5 / KNOWN-HAZARDS 7.2).
41	pub const KIND_APPLIED: &str = "applied";
42	/// Broker→brain: a command failed (carries an operator-facing message).
43	pub const KIND_ERROR: &str = "error";
44	/// Brain→broker: report the broker-owned net endpoint's status (D4a). The brain
45	/// holds no endpoint of its own — the broker owns the node's network presence
46	/// (ADR-0004 §B), so status (node id, dialable address, conn count) is an IPC ask.
47	pub const KIND_NET_STATUS: &str = "net-status";
48	/// Broker→brain: the net endpoint status reply.
49	pub const KIND_NET_STATUS_REPLY: &str = "net-status-reply";
50	/// Brain→broker: dial a peer over the broker-owned endpoint (D4a). With an
51	/// `op_id` the dial is exactly-once (journaled like an idempotent `input`).
52	pub const KIND_NET_DIAL: &str = "net-dial";
53	/// Broker→brain: a dial durably landed — fresh or deduped (`applied_now`
54	/// distinguishes, mirroring [`KIND_APPLIED`]). Carries the broker-minted conn id.
55	pub const KIND_NET_DIALED: &str = "net-dialed";
56	/// Brain→broker: open a bidi QUIC stream on a held connection (D4b). With an
57	/// `op_id` the open is exactly-once (journaled like a dial).
58	pub const KIND_NET_STREAM_OPEN: &str = "net-stream-open";
59	/// Broker→brain: a stream open durably landed (fresh or deduped).
60	pub const KIND_NET_STREAM_OPENED: &str = "net-stream-opened";
61	/// Brain→broker: write bytes to a held stream's send side (D4b). With an
62	/// `op_id` the write is exactly-once (journaled; acked with [`KIND_NET_SENT`]).
63	pub const KIND_NET_STREAM_SEND: &str = "net-stream-send";
64	/// Broker→brain: a journaled stream send durably landed (fresh or deduped).
65	pub const KIND_NET_SENT: &str = "net-sent";
66	/// Brain→broker: list the streams the broker currently holds (D4b). The query
67	/// model a re-attaching brain uses to find streams that arrived while it was
68	/// down — no announce buffer to miss.
69	pub const KIND_NET_STREAMS: &str = "net-streams";
70	/// Broker→brain: the held-streams reply.
71	pub const KIND_NET_STREAMS_REPLY: &str = "net-streams-reply";
72	/// Brain→broker: (re)subscribe to a stream's buffered reads from a cursor —
73	/// the PTY `subscribe` discipline applied to a QUIC stream (gapless resume).
74	pub const KIND_NET_STREAM_SUBSCRIBE: &str = "net-stream-subscribe";
75	/// Broker→brain: a sequenced chunk of a stream's reads (live or replayed).
76	pub const KIND_NET_STREAM_DATA: &str = "net-stream-data";
77	/// Broker→brain: a stream's read side finished cleanly; `next_seq` tells the
78	/// brain the final cursor so it can verify it missed nothing.
79	pub const KIND_NET_STREAM_EOF: &str = "net-stream-eof";
80	/// Brain→broker: (re)subscribe to the broker's conn-liveness event log from a

//! The broker↔brain message catalog: the kind discriminators and payload
//! shapes that ride inside the forward-compatible [`Envelope`].
//!
//! Split out from [`crate::frame`] (which owns the *envelope mechanics* and the
//! version handshake) so the message *vocabulary* can grow additively without
//! touching the wire-compat machinery. Every payload is a plain serde struct
//! with serde's default unknown-field tolerance, so a newer brain may add a
//! field and an older broker still decodes the message (KNOWN-HAZARDS 2.3).
//!
//! Bytes (PTY input/output) ride as base64 strings — JSON has no native byte
//! type, and base64 keeps the single envelope/codec path rather than forking a
//! binary frame format.
// [impl->REQ-DAEMON-2]

use base64::engine::general_purpose::STANDARD as B64;
use base64::Engine;
use serde::{Deserialize, Serialize};

use crate::frame::Envelope;

/// Brain→broker: spawn a child under a fresh broker-owned PTY.
pub const KIND_SPAWN: &str = "spawn";
/// Broker→brain: a spawn succeeded; carries the assigned session id + child pid.
pub const KIND_SPAWNED: &str = "spawned";
/// Brain→broker: (re)subscribe to a session's output from a given sequence.
pub const KIND_SUBSCRIBE: &str = "subscribe";
/// Brain→broker: write raw bytes to a session's input (send-keys).
pub const KIND_INPUT: &str = "input";
/// Brain→broker: resize a session's PTY.
pub const KIND_RESIZE: &str = "resize";
/// Brain→broker: terminate a session's child.
pub const KIND_KILL: &str = "kill";
/// Broker→brain: a chunk of a session's output (pushed as the child produces it).
pub const KIND_OUTPUT: &str = "output";
/// Broker→brain: a session's child exited.
pub const KIND_EXIT: &str = "exit";
/// Broker→brain: an idempotent effect (an `input` carrying an `op_id`) is durably
/// applied **or** was already applied (deduped). The exactly-once ack: a brain
/// learns its effect landed, and a replay after a crash is acknowledged the same
/// way so the brain can retire it (B5 / KNOWN-HAZARDS 7.2).
pub const KIND_APPLIED: &str = "applied";
/// Broker→brain: a command failed (carries an operator-facing message).
pub const KIND_ERROR: &str = "error";
/// Brain→broker: report the broker-owned net endpoint's status (D4a). The brain
/// holds no endpoint of its own — the broker owns the node's network presence
/// (ADR-0004 §B), so status (node id, dialable address, conn count) is an IPC ask.
pub const KIND_NET_STATUS: &str = "net-status";
/// Broker→brain: the net endpoint status reply.
pub const KIND_NET_STATUS_REPLY: &str = "net-status-reply";
/// Brain→broker: dial a peer over the broker-owned endpoint (D4a). With an
/// `op_id` the dial is exactly-once (journaled like an idempotent `input`).
pub const KIND_NET_DIAL: &str = "net-dial";
/// Broker→brain: a dial durably landed — fresh or deduped (`applied_now`
/// distinguishes, mirroring [`KIND_APPLIED`]). Carries the broker-minted conn id.
pub const KIND_NET_DIALED: &str = "net-dialed";
/// Brain→broker: open a bidi QUIC stream on a held connection (D4b). With an
/// `op_id` the open is exactly-once (journaled like a dial).
pub const KIND_NET_STREAM_OPEN: &str = "net-stream-open";
/// Broker→brain: a stream open durably landed (fresh or deduped).
pub const KIND_NET_STREAM_OPENED: &str = "net-stream-opened";
/// Brain→broker: write bytes to a held stream's send side (D4b). With an
/// `op_id` the write is exactly-once (journaled; acked with [`KIND_NET_SENT`]).
pub const KIND_NET_STREAM_SEND: &str = "net-stream-send";
/// Broker→brain: a journaled stream send durably landed (fresh or deduped).
pub const KIND_NET_SENT: &str = "net-sent";
/// Brain→broker: list the streams the broker currently holds (D4b). The query
/// model a re-attaching brain uses to find streams that arrived while it was
/// down — no announce buffer to miss.
pub const KIND_NET_STREAMS: &str = "net-streams";
/// Broker→brain: the held-streams reply.
pub const KIND_NET_STREAMS_REPLY: &str = "net-streams-reply";
/// Brain→broker: (re)subscribe to a stream's buffered reads from a cursor —
/// the PTY `subscribe` discipline applied to a QUIC stream (gapless resume).
pub const KIND_NET_STREAM_SUBSCRIBE: &str = "net-stream-subscribe";
/// Broker→brain: a sequenced chunk of a stream's reads (live or replayed).
pub const KIND_NET_STREAM_DATA: &str = "net-stream-data";
/// Broker→brain: a stream's read side finished cleanly; `next_seq` tells the
/// brain the final cursor so it can verify it missed nothing.
pub const KIND_NET_STREAM_EOF: &str = "net-stream-eof";
/// Brain→broker: (re)subscribe to the broker's conn-liveness event log from a


1	//! The thin, stateless in-session relay (B3) — the only residue of the interim
2	//! `api listen` runner that stays in the harness-hosted session (topology 1).
3	//!
4	//! Consolidation (REQ-DAEMON-1) moves the *lifecycle* — Psyche spawn, pulse,
5	//! echo, drop-ingest — into the daemon brain as scheduled in-process loops. What
6	//! remains running inside the agent's own session is just this relay: it forwards
7	//! inbound messages (the offline spool backlog + live poll deliveries) to a sink
8	//! (stdout for the real session) and holds **no state of its own**.
9	//!
10	//! Because it is stateless and every undelivered message is durable in the perch
11	//! spool, the relay is **freely killable and respawnable without losing state**:
12	//! a killed relay leaves its backlog in the spool, and a fresh relay re-drains it
13	//! on its next pump. That is the property the daemon needs — a routine that kills
14	//! the in-session relay (e.g. to swap it) never drops a message.
15	// [impl->REQ-DAEMON-1]
16
17	use std::path::{Path, PathBuf};
18	use std::sync::atomic::{AtomicBool, Ordering};
19	use std::time::Duration;
20
21	use spt_msg::listener::PollListener;
22	use spt_store::spool;
23
24	/// Poll window for the relay's blocking accept — bounds each cycle so the loop
25	/// stays responsive to a stop signal (mirrors the interim listener cadence).
26	pub const DEFAULT_RELAY_POLL: Duration = Duration::from_millis(200);
27
28	/// A stateless message relay for one perch: a poll listener bound to the id plus
29	/// the perch path it drains. It carries no message buffer — every byte it
30	/// forwards comes straight from the live poll or the durable spool.
31	pub struct Relay {
32	    id: String,
33	    perch_path: PathBuf,
34	    listener: PollListener,
35	}
36
37	impl Relay {
38	    /// Bind a relay for `id` draining `perch_path`, with the default poll window.
39	    pub fn bind(id: &str, perch_path: &Path) -> std::io::Result<Self> {
40	        Self::bind_with(id, perch_path, DEFAULT_RELAY_POLL)
41	    }
42
43	    /// Bind a relay with an explicit poll window.
44	    pub fn bind_with(id: &str, perch_path: &Path, poll: Duration) -> std::io::Result<Self> {
45	        let listener = PollListener::bind_blocking(id, poll)
46	            .map_err(|e| std::io::Error::other(e.to_string()))?;
47	        Ok(Relay { id: id.to_string(), perch_path: perch_path.to_path_buf(), listener })
48	    }
49
50	    /// The relayed endpoint id.
51	    pub fn id(&self) -> &str {
52	        &self.id
53	    }
54
55	    /// Drain the durable offline backlog (non-deferred spool rows) to `sink`.
56	    /// Idempotent across respawns: a fresh relay re-drains whatever a killed one
57	    /// left behind. Returns the number of messages forwarded.
58	    pub fn drain_backlog<F: FnMut(&str)>(&self, mut sink: F) -> usize {
59	        let msgs = spool::drain_non_deferred_at(&self.perch_path).unwrap_or_default();
60	        let n = msgs.len();

//! The thin, stateless in-session relay (B3) — the only residue of the interim
//! `api listen` runner that stays in the harness-hosted session (topology 1).
//!
//! Consolidation (REQ-DAEMON-1) moves the *lifecycle* — Psyche spawn, pulse,
//! echo, drop-ingest — into the daemon brain as scheduled in-process loops. What
//! remains running inside the agent's own session is just this relay: it forwards
//! inbound messages (the offline spool backlog + live poll deliveries) to a sink
//! (stdout for the real session) and holds **no state of its own**.
//!
//! Because it is stateless and every undelivered message is durable in the perch
//! spool, the relay is **freely killable and respawnable without losing state**:
//! a killed relay leaves its backlog in the spool, and a fresh relay re-drains it
//! on its next pump. That is the property the daemon needs — a routine that kills
//! the in-session relay (e.g. to swap it) never drops a message.
// [impl->REQ-DAEMON-1]

use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;

use spt_msg::listener::PollListener;
use spt_store::spool;

/// Poll window for the relay's blocking accept — bounds each cycle so the loop
/// stays responsive to a stop signal (mirrors the interim listener cadence).
pub const DEFAULT_RELAY_POLL: Duration = Duration::from_millis(200);

/// A stateless message relay for one perch: a poll listener bound to the id plus
/// the perch path it drains. It carries no message buffer — every byte it
/// forwards comes straight from the live poll or the durable spool.
pub struct Relay {
    id: String,
    perch_path: PathBuf,
    listener: PollListener,
}

impl Relay {
    /// Bind a relay for `id` draining `perch_path`, with the default poll window.
    pub fn bind(id: &str, perch_path: &Path) -> std::io::Result<Self> {
        Self::bind_with(id, perch_path, DEFAULT_RELAY_POLL)
    }

    /// Bind a relay with an explicit poll window.
    pub fn bind_with(id: &str, perch_path: &Path, poll: Duration) -> std::io::Result<Self> {
        let listener = PollListener::bind_blocking(id, poll)
            .map_err(|e| std::io::Error::other(e.to_string()))?;
        Ok(Relay { id: id.to_string(), perch_path: perch_path.to_path_buf(), listener })
    }

    /// The relayed endpoint id.
    pub fn id(&self) -> &str {
        &self.id
    }

    /// Drain the durable offline backlog (non-deferred spool rows) to `sink`.
    /// Idempotent across respawns: a fresh relay re-drains whatever a killed one
    /// left behind. Returns the number of messages forwarded.
    pub fn drain_backlog<F: FnMut(&str)>(&self, mut sink: F) -> usize {
        let msgs = spool::drain_non_deferred_at(&self.perch_path).unwrap_or_default();
        let n = msgs.len();