1	//! Psyche conflict reconcile (ADR-0013, M4-D6b) — the mind merges its own mind.
2	//!
3	//! When cross-node sync surfaces a concurrent context pair (hazard 6.6:
4	//! durable `.conflicts/` artifacts beside the untouched local file), the
5	//! **active instance's node** runs one bounded, stdout-captured Psyche turn
6	//! ([`crate::turn`], the D7.5a driver) over both versions. The merged output
7	//! is written back with a vector of `local ⊔ (⊔ artifacts) + bump(self)` —
8	//! strictly dominating every parent — and only that **dominating write**
9	//! clears the artifacts.
10	//!
11	//! Failure is safe by construction (6.6): a turn error, empty/garbage output,
12	//! or a 6.5 suppression (a fresher direct write landed meanwhile) leaves the
13	//! artifacts exactly where they were, for retry at the next sync/activation.
14	//! Both versions are never lost. Election of *which* node reconciles is the
15	//! daemon's (registry-aware) call; this module is the per-file mechanism.
16	//
17	// [impl->REQ-HAZARD-CONFLICT-BOTH-PRESERVED]
18
19	use crate::context::{write_context_joined, WriteOutcome, WriteSource};
20	use crate::turn::{run_psyche_turn, TurnError};
21	use spt_runtime::AgentRuntime;
22	use spt_store::contextmark::{Marker, VersionVector};
23	use spt_store::contextstore::ContextStore;
24	use std::collections::BTreeMap;
25	use std::path::Path;
26	use std::time::Duration;
27
28	/// What a reconcile pass did for one file.
29	#[derive(Debug)]
30	pub enum ReconcileOutcome {
31	    /// No artifacts pending — nothing to do.
32	    NoConflict,
33	    /// Merged write landed; this many artifacts cleared.
34	    Reconciled { cleared: usize },
35	    /// The Psyche turn failed (error preserved); artifacts untouched.
36	    TurnFailed(TurnError),
37	    /// The merged write was 6.5-suppressed (fresher direct write); artifacts
38	    /// untouched — retry later re-merges against the new local version.
39	    WriteSuppressed,
40	}
41
42	/// Split a stored context blob into `(marker, body)` — marker line stripped
43	/// from the body fed to the Psyche (transport metadata, not mind content).
44	fn split_marker(content: &str) -> (Option<Marker>, &str) {
45	    match Marker::parse_content(content) {
46	        Some(m) => {
47	            let body = content.split_once('\n').map(|(_, rest)| rest).unwrap_or("");
48	            (Some(m), body)
49	        }
50	        None => (None, content),
51	    }
52	}
53
54	/// Compose the reconcile prompt: the instruction plus every version, with the
55	/// wall-clock hints (human tiebreakers only — the vectors already proved
56	/// concurrency; ADR-0013).
57	fn compose_prompt(file_rel: &str, local: &str, locals_ms: Option<u64>, others: &[(String, Option<u64>)]) -> String {
58	    let mut p = String::new();
59	    p.push_str(
60	        "RECONCILE_CONTEXT_CONFLICT: concurrent versions of one of your context \
61	         files follow. Merge them into a single coherent file that preserves \
62	         every fact and intention worth keeping from BOTH; prefer specifics over \
63	         summaries; drop exact duplicates. Output ONLY the merged file body — \
64	         no commentary, no code fences, no marker line.\n\n",
65	    );
66	    p.push_str(&format!("FILE: {file_rel}\n\n"));
67	    p.push_str(&format!(
68	        "=== LOCAL VERSION{} ===\n{}\n\n",
69	        locals_ms.map(|t| format!(" (written ~epoch-ms {t}, hint only)")).unwrap_or_default(),
70	        local.trim_end()
71	    ));
72	    for (i, (body, ms)) in others.iter().enumerate() {
73	        p.push_str(&format!(
74	            "=== REMOTE VERSION {}{} ===\n{}\n\n",
75	            i + 1,
76	            ms.map(|t| format!(" (written ~epoch-ms {t}, hint only)")).unwrap_or_default(),
77	            body.trim_end()
78	        ));
79	    }
80	    p
81	}
82
83	/// Validate + normalize the Psyche's merged output: non-empty after stripping
84	/// any echoed marker line / code fences. `None` = garbage (treat as turn
85	/// failure; artifacts stay).
86	fn sanitize_merged(raw: &str) -> Option<String> {
87	    let mut s = raw.trim();
88	    // Strip an echoed marker line (the prompt forbids it; tolerate anyway).
89	    if Marker::parse_content(s).is_some() {
90	        s = s.split_once('\n').map(|(_, rest)| rest).unwrap_or("").trim();
91	    }
92	    // Strip one optional wrapping code fence.
93	    if let Some(inner) = s.strip_prefix("```") {
94	        let inner = inner.split_once('\n').map(|(_, rest)| rest).unwrap_or(inner);
95	        s = inner.strip_suffix("```").unwrap_or(inner).trim();
96	    }
97	    if s.is_empty() {
98	        return None;
99	    }
100	    Some(s.to_string())

//! Psyche conflict reconcile (ADR-0013, M4-D6b) — the mind merges its own mind.
//!
//! When cross-node sync surfaces a concurrent context pair (hazard 6.6:
//! durable `.conflicts/` artifacts beside the untouched local file), the
//! **active instance's node** runs one bounded, stdout-captured Psyche turn
//! ([`crate::turn`], the D7.5a driver) over both versions. The merged output
//! is written back with a vector of `local ⊔ (⊔ artifacts) + bump(self)` —
//! strictly dominating every parent — and only that **dominating write**
//! clears the artifacts.
//!
//! Failure is safe by construction (6.6): a turn error, empty/garbage output,
//! or a 6.5 suppression (a fresher direct write landed meanwhile) leaves the
//! artifacts exactly where they were, for retry at the next sync/activation.
//! Both versions are never lost. Election of *which* node reconciles is the
//! daemon's (registry-aware) call; this module is the per-file mechanism.
//
// [impl->REQ-HAZARD-CONFLICT-BOTH-PRESERVED]

use crate::context::{write_context_joined, WriteOutcome, WriteSource};
use crate::turn::{run_psyche_turn, TurnError};
use spt_runtime::AgentRuntime;
use spt_store::contextmark::{Marker, VersionVector};
use spt_store::contextstore::ContextStore;
use std::collections::BTreeMap;
use std::path::Path;
use std::time::Duration;

/// What a reconcile pass did for one file.
#[derive(Debug)]
pub enum ReconcileOutcome {
    /// No artifacts pending — nothing to do.
    NoConflict,
    /// Merged write landed; this many artifacts cleared.
    Reconciled { cleared: usize },
    /// The Psyche turn failed (error preserved); artifacts untouched.
    TurnFailed(TurnError),
    /// The merged write was 6.5-suppressed (fresher direct write); artifacts
    /// untouched — retry later re-merges against the new local version.
    WriteSuppressed,
}

/// Split a stored context blob into `(marker, body)` — marker line stripped
/// from the body fed to the Psyche (transport metadata, not mind content).
fn split_marker(content: &str) -> (Option<Marker>, &str) {
    match Marker::parse_content(content) {
        Some(m) => {
            let body = content.split_once('\n').map(|(_, rest)| rest).unwrap_or("");
            (Some(m), body)
        }
        None => (None, content),
    }
}

/// Compose the reconcile prompt: the instruction plus every version, with the
/// wall-clock hints (human tiebreakers only — the vectors already proved
/// concurrency; ADR-0013).
fn compose_prompt(file_rel: &str, local: &str, locals_ms: Option<u64>, others: &[(String, Option<u64>)]) -> String {
    let mut p = String::new();
    p.push_str(
        "RECONCILE_CONTEXT_CONFLICT: concurrent versions of one of your context \
         files follow. Merge them into a single coherent file that preserves \
         every fact and intention worth keeping from BOTH; prefer specifics over \
         summaries; drop exact duplicates. Output ONLY the merged file body — \
         no commentary, no code fences, no marker line.\n\n",
    );
    p.push_str(&format!("FILE: {file_rel}\n\n"));
    p.push_str(&format!(
        "=== LOCAL VERSION{} ===\n{}\n\n",
        locals_ms.map(|t| format!(" (written ~epoch-ms {t}, hint only)")).unwrap_or_default(),
        local.trim_end()
    ));
    for (i, (body, ms)) in others.iter().enumerate() {
        p.push_str(&format!(
            "=== REMOTE VERSION {}{} ===\n{}\n\n",
            i + 1,
            ms.map(|t| format!(" (written ~epoch-ms {t}, hint only)")).unwrap_or_default(),
            body.trim_end()
        ));
    }
    p
}

/// Validate + normalize the Psyche's merged output: non-empty after stripping
/// any echoed marker line / code fences. `None` = garbage (treat as turn
/// failure; artifacts stay).
fn sanitize_merged(raw: &str) -> Option<String> {
    let mut s = raw.trim();
    // Strip an echoed marker line (the prompt forbids it; tolerate anyway).
    if Marker::parse_content(s).is_some() {
        s = s.split_once('\n').map(|(_, rest)| rest).unwrap_or("").trim();
    }
    // Strip one optional wrapping code fence.
    if let Some(inner) = s.strip_prefix("```") {
        let inner = inner.split_once('\n').map(|(_, rest)| rest).unwrap_or(inner);
        s = inner.strip_suffix("```").unwrap_or(inner).trim();
    }
    if s.is_empty() {
        return None;
    }
    Some(s.to_string())


101:### 4.1 Envelope HTML-entity codec ordering — `&` decoded LAST
246:### 7.3 Psyche outbound capture + sanitization  `[REQ-HAZARD-PSYCHE-OUTBOUND-PROXY]`
278:| 4.1 | Envelope decode order, `&` last | spt-proto (public, wire-versioned) |
292:| 7.3 | Psyche outbound captured + `from=`/target stripped + reply-to-sender / notify-to-own-user | live-Psyche driver / daemon relay (ADR-0012) |


100	                Ok(mut stream) => {
101	                    if wire::write_message(&mut stream, from, body).is_ok() {
102	                        return true;
103	                    }
104	                    // Write failed mid-stream — the caller falls back.
105	                }
106	                Err(_) => {
107	                    // Connect refused/timed out. Recycled-pid guard (5.1): if the
108	                    // owning pid is confirmed dead, drop the stale row. If the pid
109	                    // is "alive" (possibly recycled to an unrelated process), the
110	                    // refused connect already prevented misdelivery — the caller
111	                    // falls back.
112	                    if let Some(pid) = registry::lookup_pid(target, owlery) {
113	                        if !proc::is_process_alive(pid) {
114	                            let _ = registry::unregister_address(target, owlery);
115	                        }
116	                    }
117	                }
118	            }
119	        }
120	    }
121	    false
122	}
123
124	/// Core delivery primitive: TCP-first, spool-fallback. Returns which path the
125	/// message took. `from` is written into the `__REPLY_TO__` line (empty =
126	/// anonymous). Used by `send`, `ring`, and reply routing alike.
127	// [impl->REQ-MSG-1]
128	// [impl->REQ-HAZARD-INBOX-NO-DOUBLE]
129	pub fn deliver(target: &str, from: &str, body: &str, owlery: &Path) -> DeliveryPath {
130	    // 1. Try a live TCP delivery. On success, return WITHOUT spooling (4.5: no
131	    //    double-delivery).
132	    if deliver_tcp(target, from, body, owlery) {
133	        return DeliveryPath::Tcp;
134	    }
135
136	    // 2. Spool fallback — the poll drain delivers it when the target starts.
137	    let perch_path = perch::resolve_perch_path(target, ParentHint::Infer);
138	    let _ = spool::spool_message_at(&perch_path, from, body);
139	    DeliveryPath::Spool
140	}
141
142	/// High-level fire-and-forget send. Validates the perch exists and the body is
143	/// non-empty, delivers, and maps the delivery path to a [`SendOutcome`]. No
144	/// I/O, no exit — the caller renders the outcome.
145	///
146	/// Reply routing is *this same call* with `from` set to the replier's id: the
147	/// `__REPLY_TO__` line the original message carried names the target, and the
148	/// reply's own `from` lets the original sender reply back in turn.
149	// [impl->REQ-MSG-1]
150	pub fn send(target: &str, from: &str, body: &str, owlery: &Path) -> SendOutcome {
151	    let trimmed = body.trim();
152	    if trimmed.is_empty() {
153	        return SendOutcome::Empty;
154	    }
155	    // An offline-but-existing perch is still a valid spool target; only a
156	    // wholly absent perch is NoPerch.
157	    if !is_online(target) && !perch_exists(target) {
158	        return SendOutcome::NoPerch;
159	    }
160	    match deliver(target, from, trimmed, owlery) {
161	        DeliveryPath::Tcp => SendOutcome::Sent,
162	        DeliveryPath::Spool => SendOutcome::Queued,
163	    }
164	}
165
166	/// Deferred send (the hook channel): spool-only, **no TCP, no listener wake**.
167	/// The row is tagged `deferred=1` so the live event-stream drain
168	/// (`drain_non_deferred_at`) skips it — it surfaces only when a hook consumer
169	/// drains everything (`drain_all_at` / `peek_all_at`) on its natural cadence.
170	/// This is how a sender hands a message to an agent it must not interrupt.
171	///
172	/// Always `Queued` on a valid non-empty body to an existing perch — deferred
173	/// delivery never reports `Sent` (there is no live handshake).
174	// [impl->REQ-MSG-1]
175	pub fn send_deferred(target: &str, from: &str, body: &str) -> SendOutcome {
176	    let trimmed = body.trim();
177	    if trimmed.is_empty() {
178	        return SendOutcome::Empty;
179	    }

                Ok(mut stream) => {
                    if wire::write_message(&mut stream, from, body).is_ok() {
                        return true;
                    }
                    // Write failed mid-stream — the caller falls back.
                }
                Err(_) => {
                    // Connect refused/timed out. Recycled-pid guard (5.1): if the
                    // owning pid is confirmed dead, drop the stale row. If the pid
                    // is "alive" (possibly recycled to an unrelated process), the
                    // refused connect already prevented misdelivery — the caller
                    // falls back.
                    if let Some(pid) = registry::lookup_pid(target, owlery) {
                        if !proc::is_process_alive(pid) {
                            let _ = registry::unregister_address(target, owlery);
                        }
                    }
                }
            }
        }
    }
    false
}

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

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

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

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


240	### 7.2 Idempotent delivery across brain restart  `[REQ-HAZARD-RESTART-IDEMPOTENT]`
241	- **Failure:** broker queues, spool rows, PTY injection, remote streams, and file transfers cross the broker↔brain boundary; a brain restart duplicates or drops a side effect.
242	- **Invariant:** every side effect crossing the boundary carries a durable ID + replay rule; replay after restart is exactly-once / idempotent. Crash the brain before/after spool write, before/after PTY write, mid-transfer, mid-registry update — no dup, no drop.
243	- **spt-core mapping:** broker↔brain IPC (ADR-0004), self-update handoff.
244	- **Source:** codex Stage A #14.
245
246	### 7.3 Psyche outbound capture + sanitization  `[REQ-HAZARD-PSYCHE-OUTBOUND-PROXY]`
247	- **Failure:** the Psyche's sole outbound channel is its **stdout** (`<EVENT type="reply|notify">` intents — ADR-0012). Two ways to break it: (a) a **null-stdout / detached** live-Psyche driver silently **discards every reply and notify**; (b) the daemon relays a Psyche-supplied `from=`/target **unchanged**, letting a sandboxed Psyche **spoof identity** or address arbitrary endpoints.
248	- **Invariant:** the live-Psyche turn driver **MUST capture stdout** (a bounded, stdin-fed, stdout-captured invocation — **never** `Stdio::null()`); the daemon **MUST strip** every Psyche-supplied `from=`/target/routing attribute, **re-stamp `from=<self_id>`**, and **constrain routing** — `reply` → the `__REPLY_TO__` sender only, `notify` → the agent's own user/subnet only. Body validated per 4.1.
249	- **spt-core mapping:** the live-Psyche turn driver + daemon outbound relay (ADR-0012); `spt-proto::event` type taxonomy (`+reply`/`+notify`). The interim `runtime::spawn_session` `Stdio::null()` path is **not** the live-Psyche driver.
250	- **Source:** grill-with-docs 2026-06-03 (uncovered by prior design passes); sister `src/live/wrapper/claude.rs` (sandbox `["Read","Write","Edit"]` + `parse_markers`).
251
252	### 7.4 Per-agent pulse/psyche/echo scheduling must not serialize across agents  `[REQ-HAZARD-DAEMON-SCHED-NONBLOCKING]`
253	- **Failure:** echo-commune (`run_bounded_stdin`) and the live-Psyche turn driver (D7.5) are **bounded LLM calls that block their calling thread** until the child answers or the timeout fires. Today each agent drives its own pulse from its **own process** (`spt/src/api/{live,startup}.rs`), so blocking is isolated. When the daemon hosts **N per-agent loops** (ADR-0004 target; the `run_pulse_loop` fan-out is currently def+test only), a **single serial driver** that calls these invocations inline lets one agent's slow/hung LLM call **stall every other agent's heartbeat, commune, and reply** — the per-call timeout bounds the worst case *only* when the work is isolated; serial makes the stalls additive.
254	- **Invariant:** each agent's bounded LLM-bearing work (echo-commune summarizer, Psyche turn) runs on its **own thread / off the shared scheduler** — no single-threaded driver iterating all agents may call a blocking invocation inline. One agent's slow/timed-out call must not delay another agent's next tick beyond tolerance.
255	- **spt-core mapping:** the daemon's multi-agent pulse/psyche hosting (ADR-0004, "all Psyche/pulse loops" consolidated); `run_pulse_loop` fan-out; echo-commune + the D7.5 Psyche driver.
256	- **Source:** grill-with-docs 2026-06-03 (forward invariant — the multi-agent fan-out is not yet wired). Distinct from ADR-0002 SERIOUS #6 (crash blast-radius, not scheduling latency).
257
258	### 7.5 WAN-inbound origin is transport truth, never payload  `[REQ-HAZARD-WAN-ORIGIN-AUTH]`
259	<!-- [doc->REQ-HAZARD-WAN-ORIGIN-AUTH] -->
260	- **Failure:** the ADR-0009 access whitelist gates **unsolicited wire inbound by origin node**. If the gate's subject is read from record bytes (an `origin_node`/`from`/`node` field a sender wrote), any sender forges any origin and the whitelist is decoration — same spoof class as 7.3's Psyche-supplied `from=`, now on the cross-node surface.
261	- **Invariant:** the origin the gate (and detection/UX — "node X is driving") consumes is the **QUIC handshake-proven remote node id** (iroh `EndpointId` == Ed25519 node pubkey, the REQ-NET-1 identity binding) read from the **broker's conn/stream table** (`NetStreamInfo::remote_id_hex`), never from payload. Wire records carry **no origin field by design**; a forged one decodes as an ignored unknown field and influences nothing. `from` inside a record is reply-routing metadata only — never an authorization subject.
262	- **spt-core mapping:** `spt_net::net::wanmsg::WanMessage` (no origin field) + `spt_daemon::wan::receive_wan` (origin parameter sourced from the stream table) + every future wire-inbound consumer (D5b attach, D5c transfer, D8 notifs).
263	- **Source:** M4-D5a design (ADR-0009 consequence: the gate must bind to the transport identity).
264
265	---
266
267	## Conformance checklist (condensed)
268
269	| # | Invariant | spt-core surface |
270	|---|---|---|
271	| 1.1 | Grace wait precedes INIT_SIGNOFF | daemon teardown |
272	| 1.4/4.4 | Deferred rows excluded from event-stream drain | daemon spool drain |
273	| 2.1/5.1 | Stable PID/broker-handle over ephemeral PID | liveness detection |
274	| 2.3 | Handoff argv/IPC version-tolerant (newer brain ↔ older broker) | broker↔brain IPC, self-update |
275	| 2.4 | gen_start = now() on cold-start + handoff | per-instance generation |
276	| 3.1 | Ephemeral perch cleanup on all exit paths | `ring` (RAII guard) |
277	| 3.3 | Echo-commune before INIT_SIGNOFF | daemon psyche loop |
278	| 4.1 | Envelope decode order, `&` last | spt-proto (public, wire-versioned) |
279	| 4.2 | Parser panic-free + tolerant | spt-proto |

### 7.2 Idempotent delivery across brain restart  `[REQ-HAZARD-RESTART-IDEMPOTENT]`
- **Failure:** broker queues, spool rows, PTY injection, remote streams, and file transfers cross the broker↔brain boundary; a brain restart duplicates or drops a side effect.
- **Invariant:** every side effect crossing the boundary carries a durable ID + replay rule; replay after restart is exactly-once / idempotent. Crash the brain before/after spool write, before/after PTY write, mid-transfer, mid-registry update — no dup, no drop.
- **spt-core mapping:** broker↔brain IPC (ADR-0004), self-update handoff.
- **Source:** codex Stage A #14.

### 7.3 Psyche outbound capture + sanitization  `[REQ-HAZARD-PSYCHE-OUTBOUND-PROXY]`
- **Failure:** the Psyche's sole outbound channel is its **stdout** (`<EVENT type="reply|notify">` intents — ADR-0012). Two ways to break it: (a) a **null-stdout / detached** live-Psyche driver silently **discards every reply and notify**; (b) the daemon relays a Psyche-supplied `from=`/target **unchanged**, letting a sandboxed Psyche **spoof identity** or address arbitrary endpoints.
- **Invariant:** the live-Psyche turn driver **MUST capture stdout** (a bounded, stdin-fed, stdout-captured invocation — **never** `Stdio::null()`); the daemon **MUST strip** every Psyche-supplied `from=`/target/routing attribute, **re-stamp `from=<self_id>`**, and **constrain routing** — `reply` → the `__REPLY_TO__` sender only, `notify` → the agent's own user/subnet only. Body validated per 4.1.
- **spt-core mapping:** the live-Psyche turn driver + daemon outbound relay (ADR-0012); `spt-proto::event` type taxonomy (`+reply`/`+notify`). The interim `runtime::spawn_session` `Stdio::null()` path is **not** the live-Psyche driver.
- **Source:** grill-with-docs 2026-06-03 (uncovered by prior design passes); sister `src/live/wrapper/claude.rs` (sandbox `["Read","Write","Edit"]` + `parse_markers`).

### 7.4 Per-agent pulse/psyche/echo scheduling must not serialize across agents  `[REQ-HAZARD-DAEMON-SCHED-NONBLOCKING]`
- **Failure:** echo-commune (`run_bounded_stdin`) and the live-Psyche turn driver (D7.5) are **bounded LLM calls that block their calling thread** until the child answers or the timeout fires. Today each agent drives its own pulse from its **own process** (`spt/src/api/{live,startup}.rs`), so blocking is isolated. When the daemon hosts **N per-agent loops** (ADR-0004 target; the `run_pulse_loop` fan-out is currently def+test only), a **single serial driver** that calls these invocations inline lets one agent's slow/hung LLM call **stall every other agent's heartbeat, commune, and reply** — the per-call timeout bounds the worst case *only* when the work is isolated; serial makes the stalls additive.
- **Invariant:** each agent's bounded LLM-bearing work (echo-commune summarizer, Psyche turn) runs on its **own thread / off the shared scheduler** — no single-threaded driver iterating all agents may call a blocking invocation inline. One agent's slow/timed-out call must not delay another agent's next tick beyond tolerance.
- **spt-core mapping:** the daemon's multi-agent pulse/psyche hosting (ADR-0004, "all Psyche/pulse loops" consolidated); `run_pulse_loop` fan-out; echo-commune + the D7.5 Psyche driver.
- **Source:** grill-with-docs 2026-06-03 (forward invariant — the multi-agent fan-out is not yet wired). Distinct from ADR-0002 SERIOUS #6 (crash blast-radius, not scheduling latency).

### 7.5 WAN-inbound origin is transport truth, never payload  `[REQ-HAZARD-WAN-ORIGIN-AUTH]`
<!-- [doc->REQ-HAZARD-WAN-ORIGIN-AUTH] -->
- **Failure:** the ADR-0009 access whitelist gates **unsolicited wire inbound by origin node**. If the gate's subject is read from record bytes (an `origin_node`/`from`/`node` field a sender wrote), any sender forges any origin and the whitelist is decoration — same spoof class as 7.3's Psyche-supplied `from=`, now on the cross-node surface.
- **Invariant:** the origin the gate (and detection/UX — "node X is driving") consumes is the **QUIC handshake-proven remote node id** (iroh `EndpointId` == Ed25519 node pubkey, the REQ-NET-1 identity binding) read from the **broker's conn/stream table** (`NetStreamInfo::remote_id_hex`), never from payload. Wire records carry **no origin field by design**; a forged one decodes as an ignored unknown field and influences nothing. `from` inside a record is reply-routing metadata only — never an authorization subject.
- **spt-core mapping:** `spt_net::net::wanmsg::WanMessage` (no origin field) + `spt_daemon::wan::receive_wan` (origin parameter sourced from the stream table) + every future wire-inbound consumer (D5b attach, D5c transfer, D8 notifs).
- **Source:** M4-D5a design (ADR-0009 consequence: the gate must bind to the transport identity).

---

## Conformance checklist (condensed)

| # | Invariant | spt-core surface |
|---|---|---|
| 1.1 | Grace wait precedes INIT_SIGNOFF | daemon teardown |
| 1.4/4.4 | Deferred rows excluded from event-stream drain | daemon spool drain |
| 2.1/5.1 | Stable PID/broker-handle over ephemeral PID | liveness detection |
| 2.3 | Handoff argv/IPC version-tolerant (newer brain ↔ older broker) | broker↔brain IPC, self-update |
| 2.4 | gen_start = now() on cold-start + handoff | per-instance generation |
| 3.1 | Ephemeral perch cleanup on all exit paths | `ring` (RAII guard) |
| 3.3 | Echo-commune before INIT_SIGNOFF | daemon psyche loop |
| 4.1 | Envelope decode order, `&` last | spt-proto (public, wire-versioned) |
| 4.2 | Parser panic-free + tolerant | spt-proto |


M4-D2-WIRE-PLAN.md
M4-D2f-PLAN.md
M4-D2g-PLAN.md
M4-D3-PLAN.md
M4-D4-PLAN.md
M4-D5-PLAN.md
M4-D6-PLAN.md
M4-D7-PLAN.md


1	# M4-D7 — update peer-propagation into the M3c engine seam (JIT plan)
2
3	**Status:** COMPLETE 2026-06-04 — D7-1 `0d05e5c` (wire records + ReleaseCache,
4	REQ-UPD-1 activated `[impl, unit]`) + D7-2 `b67d432` (serve/request drivers,
5	loopback chain E2E, FAULT-MATRIX rows 18–21), both CI-green FINAL. Acceptance
6	met: chain self-heal with the gate at every hop; tampered relay rejected +
7	never staged; rollback offer rejected pre-fetch; untrusted origin gets no
8	offer. Production pull-trigger loop rides D9 (same deferral as D5/D6 loops).
9	Reqs: **REQ-UPD-1** (activated here); extends evidence on REQ-UPD-2,
10	REQ-HAZARD-UPDATE-ROLLBACK, REQ-HAZARD-WAN-ORIGIN-AUTH.
11
12	## Goal
13
14	The subnet self-heals to the latest signed release over P2P: a node missing the
15	newest spt-core release pulls it from any peer that has it, **the full M3c
16	signature/rollback gate runs on every receiving node before anything is staged**
17	(one compromised node must not poison the subnet — ADR-0004 §D), and a node that
18	verified a release becomes a server for its peers (propagation = pull + restage,
19	no central distributor). Acceptance (M4-PLAN §D7): a signed update self-heals
20	across the subnet; the Ed25519+rollback gate runs on every receiving node; a
21	tampered propagated binary is rejected.
22
23	## Shape (locked by what exists — no new design forks)
24
25	- **The engine is not re-architected (D7a).** `update::plan_verified` is already
26	  the front door: signature + key trust/revocation + channel + expiry +
27	  monotonic version (`release::verify_metadata`) → artifact digest
28	  (`release::verify_artifact`) → classify → `UpdatePlan`. D7 builds the
29	  transport that *feeds* it; apply stays C0's `apply_brain_only` (proven E2E at
30	  M3c) and consent stays C2. Nothing in `update.rs`/`release.rs` changes.
31	- **Transport = the D5c/D6c posture verbatim.** One pull rides one broker bidi
32	  QUIC stream; NDJSON tagged records (`net/ndjson.rs` decoder, unknown kind
33	  skipped); chunks carry absolute byte `offset` (positionally idempotent);
34	  sends fire-and-forget (`op_id: None` — a journaled send starves a pump, D5b);
35	  records carry **no origin field** — the gate's subject is the handshake-proven
36	  `remote_id_hex` from the broker's stream table (KNOWN-HAZARDS 7.5).
37	- **Pull-based, offer-then-fetch (the cheap pre-gate).** Requester sends
38	  `Query { current_version, channel }`; responder answers `Offer { signed }`
39	  (just the `SignedRelease` metadata — small) or `UpToDate`. The requester runs
40	  `verify_metadata` on the offer **before fetching any artifact bytes** — a
41	  rollback / expired / off-channel / bad-sig offer is rejected without pulling
42	  the binary. Only a metadata-verified offer proceeds to `Fetch` → `Chunk`* →
43	  `Done`, then `plan_verified` closes the loop on the real bytes (D7b: the gate
44	  is per-node, never inherited from the sender's claim to have verified).
45	- **Staging = becoming a server (the self-heal mechanism).** A release that
46	  passed `plan_verified` is staged into the node's release cache
47	  (`SPT_HOME`-anchored dir: `release.json` = the `SignedRelease`, `artifact.bin`
48	  = the bytes; atomic temp→rename, same posture as D5c commit). `serve_update`
49	  answers queries from the staged release. Verified-pull ⇒ staged ⇒ servable:
50	  propagation chains A→B→C with the gate at every hop. **Unverified bytes are
51	  never staged** — there is no code path from wire bytes to the cache except
52	  through `plan_verified`.
53	- **Serve gate: trusted peers only, fail-closed.** A query is answered only when
54	  the handshake-proven origin is trusted in ≥1 subnet (`TrustStore`, the same
55	  check as the D6c `p-` tier). Anything else gets `UpToDate`-shaped silence —
56	  refuse by not offering.
57	- **Torn pull = re-query.** No resume protocol: staging is atomic, chunks are
58	  positional, the query is idempotent. FAULT-MATRIX row.
59	- **VerifyPolicy is caller-supplied** (pure posture, same as C1/C2): the
60	  `request_update` driver takes `&VerifyPolicy`. Production policy assembly
61	  (trusted-key store, channel pin, running version) rides the D9 daemon
62	  lifecycle with the trigger loops — same deferral as the D5/D6 serve loops.
63
64	## Slices (each CI-green before the next)
65
66	1. **D7-1 — wire records + release cache.**
67	   - `spt-net/src/net/update.rs`: `UpdRecord` enum — `Query { upd_id,
68	     current_version, channel }`, `Offer { upd_id, metadata_json, signature_hex }`,
69	     `UpToDate { upd_id }`, `Fetch { upd_id }`, `Chunk { upd_id, offset,
70	     data_b64 }`, `Done { upd_id, total }`, `Err { upd_id, message }`;
71	     `UpdDecoder = NdjsonDecoder<UpdRecord>`. Unit tests mirror `sync.rs`:
72	     round-trip all kinds, unknown-kind skip, split-record reassembly, forged
73	     origin-field inert (`[unit->REQ-HAZARD-WAN-ORIGIN-AUTH]`).
74	   - `spt-daemon/src/relcache.rs`: `ReleaseCache` — `stage(signed, artifact)`
75	     (atomic temp→rename pair), `staged() -> Option<(SignedRelease, PathBuf)>`,
76	     `load_artifact()`. Unit tests: stage/load round-trip, restage replaces,
77	     torn temp file ignored.
78	   - Activate **REQ-UPD-1 → `[impl, unit]`** in `traceable-reqs.toml`; tag
79	     evidence in both files.
80	2. **D7-2 — serve/request drivers + loopback propagation E2E.**
81	   - `spt-daemon/src/propagate.rs`:
82	     `serve_update(brain, stream_id, origin_node, net_from_seq, cache, trust)`
83	     — trust-gate the origin, answer Query from the staged release (Offer or
84	     UpToDate), pump chunks on Fetch, Done; Detached/Failed outcomes mirroring
85	     `SyncServeOutcome`.
86	     `request_update(brain, conn_id, open_op, policy, running, cache, scratch)
87	     -> io::Result<UpdatePullOutcome>` — Query → gate the Offer's metadata
88	     (reject pre-fetch) → Fetch → reassemble in scratch → `plan_verified` →
89	     stage → return `Updated(UpdatePlan)` / `UpToDate` /
90	     `Rejected(RejectReason)` (loud + typed, never silent — the §D failure
91	     mode).
92	   - Int tests (`tests/propagate.rs`, two/three loopback brokers — copy the
93	     `tests/sync.rs` harness incl. **stream-table snapshot before the requester
94	     opens**, gotcha: the table holds locally-opened streams too):
95	     - happy path: B (v5) pulls v6 from A; gate passes; plan is `BrainOnly`;
96	       staged bytes byte-identical (`[unit->REQ-UPD-1]`).
97	     - **chain self-heal**: A→B→C — C pulls from B *after* B staged; gate ran
98	       at B and again at C (`[unit->REQ-UPD-1]`).
99	     - **tampered relay**: B's staged artifact bytes are corrupted on disk; C's
100	       pull ends `Rejected(ArtifactMismatch)`, nothing staged at C
101	       (`[unit->REQ-UPD-2]`).
102	     - **rollback offer refused pre-fetch**: A offers v4 to a v5 node — reject
103	       happens with zero Chunk records on the wire
104	       (`[unit->REQ-HAZARD-UPDATE-ROLLBACK]`).
105	     - **untrusted origin refused**: a peer absent from A's trust store queries
106	       — no Offer (`[unit->REQ-HAZARD-WAN-ORIGIN-AUTH]`).
107	   - FAULT-MATRIX rows (same commit): stream loss mid-artifact (re-query),
108	     tampered relay node (per-hop gate), rollback/expired offer (pre-fetch
109	     reject), untrusted query (no offer).
110
111	## NOT in D7
112
113	- Apply/handoff trigger + consent prompt wiring — exists (C0/C2); the
114	  production "verified plan → consent → apply" loop rides the D9 daemon
115	  lifecycle with every other trigger loop.
116	- Release *publishing* tooling (minting/signing real releases) — out of band,
117	  v1 keeps self-fetch as the seed path (ADR-0004 §D: peer-propagation is
118	  *layered on* self-fetch).
119	- Adapter-payload propagation (REQ-UPD-5 transport) — the conductor verifies
120	  payloads regardless of source; carrying adapter payloads over this channel is
121	  a post-v1 extension of the same records.
122	- D7.5 (Psyche outbound relay) — next after D7; separate plan.
123
124	## Conventions (carried)
125
126	- NO `cargo fmt`. Tag `[impl->REQ-…]` / `[unit->REQ-…]` on real evidence.
127	  `traceable-reqs check` from repo root before done. Linux CI clippy
128	  `-D warnings` is the real gate. Push each slice → watch FINAL green before
129	  next (`gh run list --json conclusion`).
130	  Commit trailer: `Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>`.
131

# M4-D7 — update peer-propagation into the M3c engine seam (JIT plan)

**Status:** COMPLETE 2026-06-04 — D7-1 `0d05e5c` (wire records + ReleaseCache,
REQ-UPD-1 activated `[impl, unit]`) + D7-2 `b67d432` (serve/request drivers,
loopback chain E2E, FAULT-MATRIX rows 18–21), both CI-green FINAL. Acceptance
met: chain self-heal with the gate at every hop; tampered relay rejected +
never staged; rollback offer rejected pre-fetch; untrusted origin gets no
offer. Production pull-trigger loop rides D9 (same deferral as D5/D6 loops).
Reqs: **REQ-UPD-1** (activated here); extends evidence on REQ-UPD-2,
REQ-HAZARD-UPDATE-ROLLBACK, REQ-HAZARD-WAN-ORIGIN-AUTH.

## Goal

The subnet self-heals to the latest signed release over P2P: a node missing the
newest spt-core release pulls it from any peer that has it, **the full M3c
signature/rollback gate runs on every receiving node before anything is staged**
(one compromised node must not poison the subnet — ADR-0004 §D), and a node that
verified a release becomes a server for its peers (propagation = pull + restage,
no central distributor). Acceptance (M4-PLAN §D7): a signed update self-heals
across the subnet; the Ed25519+rollback gate runs on every receiving node; a
tampered propagated binary is rejected.

## Shape (locked by what exists — no new design forks)

- **The engine is not re-architected (D7a).** `update::plan_verified` is already
  the front door: signature + key trust/revocation + channel + expiry +
  monotonic version (`release::verify_metadata`) → artifact digest
  (`release::verify_artifact`) → classify → `UpdatePlan`. D7 builds the
  transport that *feeds* it; apply stays C0's `apply_brain_only` (proven E2E at
  M3c) and consent stays C2. Nothing in `update.rs`/`release.rs` changes.
- **Transport = the D5c/D6c posture verbatim.** One pull rides one broker bidi
  QUIC stream; NDJSON tagged records (`net/ndjson.rs` decoder, unknown kind
  skipped); chunks carry absolute byte `offset` (positionally idempotent);
  sends fire-and-forget (`op_id: None` — a journaled send starves a pump, D5b);
  records carry **no origin field** — the gate's subject is the handshake-proven
  `remote_id_hex` from the broker's stream table (KNOWN-HAZARDS 7.5).
- **Pull-based, offer-then-fetch (the cheap pre-gate).** Requester sends
  `Query { current_version, channel }`; responder answers `Offer { signed }`
  (just the `SignedRelease` metadata — small) or `UpToDate`. The requester runs
  `verify_metadata` on the offer **before fetching any artifact bytes** — a
  rollback / expired / off-channel / bad-sig offer is rejected without pulling
  the binary. Only a metadata-verified offer proceeds to `Fetch` → `Chunk`* →
  `Done`, then `plan_verified` closes the loop on the real bytes (D7b: the gate
  is per-node, never inherited from the sender's claim to have verified).
- **Staging = becoming a server (the self-heal mechanism).** A release that
  passed `plan_verified` is staged into the node's release cache
  (`SPT_HOME`-anchored dir: `release.json` = the `SignedRelease`, `artifact.bin`
  = the bytes; atomic temp→rename, same posture as D5c commit). `serve_update`
  answers queries from the staged release. Verified-pull ⇒ staged ⇒ servable:
  propagation chains A→B→C with the gate at every hop. **Unverified bytes are
  never staged** — there is no code path from wire bytes to the cache except
  through `plan_verified`.
- **Serve gate: trusted peers only, fail-closed.** A query is answered only when
  the handshake-proven origin is trusted in ≥1 subnet (`TrustStore`, the same
  check as the D6c `p-` tier). Anything else gets `UpToDate`-shaped silence —
  refuse by not offering.
- **Torn pull = re-query.** No resume protocol: staging is atomic, chunks are
  positional, the query is idempotent. FAULT-MATRIX row.
- **VerifyPolicy is caller-supplied** (pure posture, same as C1/C2): the
  `request_update` driver takes `&VerifyPolicy`. Production policy assembly
  (trusted-key store, channel pin, running version) rides the D9 daemon
  lifecycle with the trigger loops — same deferral as the D5/D6 serve loops.

## Slices (each CI-green before the next)

1. **D7-1 — wire records + release cache.**
   - `spt-net/src/net/update.rs`: `UpdRecord` enum — `Query { upd_id,
     current_version, channel }`, `Offer { upd_id, metadata_json, signature_hex }`,
     `UpToDate { upd_id }`, `Fetch { upd_id }`, `Chunk { upd_id, offset,
     data_b64 }`, `Done { upd_id, total }`, `Err { upd_id, message }`;
     `UpdDecoder = NdjsonDecoder<UpdRecord>`. Unit tests mirror `sync.rs`:
     round-trip all kinds, unknown-kind skip, split-record reassembly, forged
     origin-field inert (`[unit->REQ-HAZARD-WAN-ORIGIN-AUTH]`).
   - `spt-daemon/src/relcache.rs`: `ReleaseCache` — `stage(signed, artifact)`
     (atomic temp→rename pair), `staged() -> Option<(SignedRelease, PathBuf)>`,
     `load_artifact()`. Unit tests: stage/load round-trip, restage replaces,
     torn temp file ignored.
   - Activate **REQ-UPD-1 → `[impl, unit]`** in `traceable-reqs.toml`; tag
     evidence in both files.
2. **D7-2 — serve/request drivers + loopback propagation E2E.**
   - `spt-daemon/src/propagate.rs`:
     `serve_update(brain, stream_id, origin_node, net_from_seq, cache, trust)`
     — trust-gate the origin, answer Query from the staged release (Offer or
     UpToDate), pump chunks on Fetch, Done; Detached/Failed outcomes mirroring
     `SyncServeOutcome`.
     `request_update(brain, conn_id, open_op, policy, running, cache, scratch)
     -> io::Result<UpdatePullOutcome>` — Query → gate the Offer's metadata
     (reject pre-fetch) → Fetch → reassemble in scratch → `plan_verified` →
     stage → return `Updated(UpdatePlan)` / `UpToDate` /
     `Rejected(RejectReason)` (loud + typed, never silent — the §D failure
     mode).
   - Int tests (`tests/propagate.rs`, two/three loopback brokers — copy the
     `tests/sync.rs` harness incl. **stream-table snapshot before the requester
     opens**, gotcha: the table holds locally-opened streams too):
     - happy path: B (v5) pulls v6 from A; gate passes; plan is `BrainOnly`;
       staged bytes byte-identical (`[unit->REQ-UPD-1]`).
     - **chain self-heal**: A→B→C — C pulls from B *after* B staged; gate ran
       at B and again at C (`[unit->REQ-UPD-1]`).
     - **tampered relay**: B's staged artifact bytes are corrupted on disk; C's
       pull ends `Rejected(ArtifactMismatch)`, nothing staged at C
       (`[unit->REQ-UPD-2]`).
     - **rollback offer refused pre-fetch**: A offers v4 to a v5 node — reject
       happens with zero Chunk records on the wire
       (`[unit->REQ-HAZARD-UPDATE-ROLLBACK]`).
     - **untrusted origin refused**: a peer absent from A's trust store queries
       — no Offer (`[unit->REQ-HAZARD-WAN-ORIGIN-AUTH]`).
   - FAULT-MATRIX rows (same commit): stream loss mid-artifact (re-query),
     tampered relay node (per-hop gate), rollback/expired offer (pre-fetch
     reject), untrusted query (no offer).

## NOT in D7

- Apply/handoff trigger + consent prompt wiring — exists (C0/C2); the
  production "verified plan → consent → apply" loop rides the D9 daemon
  lifecycle with every other trigger loop.
- Release *publishing* tooling (minting/signing real releases) — out of band,
  v1 keeps self-fetch as the seed path (ADR-0004 §D: peer-propagation is
  *layered on* self-fetch).
- Adapter-payload propagation (REQ-UPD-5 transport) — the conductor verifies
  payloads regardless of source; carrying adapter payloads over this channel is
  a post-v1 extension of the same records.
- D7.5 (Psyche outbound relay) — next after D7; separate plan.

## Conventions (carried)

- NO `cargo fmt`. Tag `[impl->REQ-…]` / `[unit->REQ-…]` on real evidence.
  `traceable-reqs check` from repo root before done. Linux CI clippy
  `-D warnings` is the real gate. Push each slice → watch FINAL green before
  next (`gh run list --json conclusion`).
  Commit trailer: `Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>`.


175:[Omitted long matching line]
178:The ephemeral, cheap model that builds a **commune delta** from a Self's recent turns and feeds it *into* the **Psyche** as inbound context. A distinct actor from the Psyche — different (cheaper) model, fire-and-forget, **no perch** (not an endpoint type). It authors *commune* deltas only, **never** *reply*/*notify*.
337:- **Producers (open set):** system events (node-paired, pairing-request, update-available, consent-needed) + agent-issued `spt notify`. New producers register later.
340:**`spt notify` (agent-issued subnet notif)**:


No matches found


160	**Env-var aliases:** adapters inject clean env-var aliases for in-session invocation (heirs to today's `$OWL`/`$LIVE`), e.g. **`$SPT` = `spt api`** so a Monitor-bound call reads `$SPT listen <id>`. spt-core supplies the subcommands; the adapter supplies the env aliases (manifest philosophy).
161
162	### Endpoint types
163
164	Each perch advertises an **endpoint type** — a tag that says what shape of entity lives at that perch and what operations it accepts. The set of day-one types:
165
166	**ReadyAgent**:
167	Minimal SPT participant — a perch + a poll listener, no Psyche, no live-agent wrapper. Direct heir to the sister project's "ready agent".
168
169	**LiveAgent**:
170	A Self with a Psyche companion. Composite logical actor; addressable as one ID, but its component perches (the Self's, the Psyche's) live independently. Direct heir to the sister project's "live agent".
171
172	**Psyche**:
173	The Psyche companion's own perch, distinct from its paired LiveAgent's perch. First-class endpoint type so messages addressed to a LiveAgent's Psyche route directly without ambiguity. **Liveness is daemon-held, not PID-based.** Unlike the sister's separate wrapper *process*, the Psyche (under ADR-0004) is a **loop inside the daemon** — it has no dedicated liveness pid, and its summarizer subprocess is ephemeral. Its perch is alive iff the daemon's endpoint table says so, surfaced via a daemon-managed `status` field on `info.json` (the same pattern Shells use), **never** an `is_process_alive(info.pid)` check. The M1/M2a interim (no daemon) keeps it a real process, so per-pid liveness holds *interim*; M3 replaces it with daemon-authoritative liveness (KNOWN-HAZARDS 2.5).
174
175	*I/O & trust boundary (ADR-0012):* the Psyche is a **sandboxed** actor — it may read and write files but **cannot send messages or reach the network itself**. Its inbound context arrives two ways: events/messages the daemon hands it, and **commune/signoff file-drops** (Self → daemon → Psyche; the *Summarizer* authors the commune delta). Its **sole outbound** is **reply/notify intents** the daemon relays as its **outbound proxy** — emitted as `<EVENT type="reply">`/`<EVENT type="notify">` (the shared envelope grammar). A *reply* reaches **only the sender it answers**; a *notify* reaches **only the agent's own user** — the Psyche carries no target and cannot address arbitrary endpoints (the daemon strips/re-stamps `from=` before relaying).
176
177	**Summarizer**:
178	The ephemeral, cheap model that builds a **commune delta** from a Self's recent turns and feeds it *into* the **Psyche** as inbound context. A distinct actor from the Psyche — different (cheaper) model, fire-and-forget, **no perch** (not an endpoint type). It authors *commune* deltas only, **never** *reply*/*notify*.
179	_Avoid_: conflating with the Psyche; "echo-commune model".
180
181	**Worker**:
182	A subagent's perch under a parent LiveAgent. Created on subagent start, torn down on subagent stop. Replaces today's "working perch" concept; first-class type so cross-communication between a Self and its workers (and worker↔worker) is addressable.
183
184	**SptNode**:
185	A machine's participation in an SPT subnet, identified by an Ed25519 public key generated on first run. First-class so networking primitives can address nodes directly as message targets, not only as transport peers. The node identity and network endpoint are hosted by the machine's `spt-daemon` (see Networking), not a separate process.
186
187	**PresenceChannel** (broker endpoint — concept locked, impl deferred past v1):
188	A *broker* endpoint, not an interaction surface. Job: (1) **presence resolution** — track which node + endpoint the user most recently interacted with; (2) **shell brokering** — locate/instantiate the right Shell on that node and relay between the agent and the user. An agent "just knows how to reach the user" by firing at its PresenceChannel; the channel figures out the rest. Also a durable, **shell-agnostic 2-way thread**: messages persist in the channel, not in any one Shell, so the user can be sent a message via a phone messaging-Shell and surface/continue that same agent conversation later at a GameRobot Shell. Shells are interchangeable I/O windows onto the channel's thread.
189
190	Three interaction styles:
191	- **dispatch** — fire-and-forget: "reach the user with this payload"; channel delivers via the best available Shell.
192	- **bind** — sustained drive: "give me a Shell of capability X"; channel instantiates and the agent drives it directly until teardown. Supports operating a *specific* Shell regardless of where the user currently is (agent transience).
193	- **thread** — the persistent conversation that floats across Shells; the user can pick it up from any Shell, and 2-way payloads (text/audio/image/video, subject to the Shell's supported types) flow both directions.
194
195	Presence datum: `(last_active_node, last_active_endpoint, timestamp)`. The `last_active_endpoint` field lets an agent choose between messaging that specific endpoint vs. driving a parallel instance of itself.
196
197	The endpoint type system is **open**: harnesses and downstream projects may register additional types beyond the day-one set. Closed-vs-open semantics for capability advertisement (what operations each type accepts, how routing decides eligibility) are deferred to the design phase.
198
199	### Agent endpoints vs Shells
200
201	Endpoint types split into two families:
202
203	**agent endpoints** — *host* an agent, backed by a harness. ReadyAgent, LiveAgent, Psyche, Worker. Something intelligent runs there.
204
205	**Shell** (first-class concept — model locked, concrete types deferred past v1): a *driven surface*, not an agent. Nothing intelligent runs at a Shell; a remote agent (on another node) drives it. A Shell is a "self-documenting" endpoint that advertises a **typed capability toolset** and a 2-way interaction relationship with the user. It may live behind a node on a platform with zero conventional-harness compatibility (e.g. a gaming handheld). Examples (all deferred): `GameRobot` (a 2D-world avatar — move, gesture, alert-symbol, request-screenshot, preload-message), an OS-notification target, an in-session-inject target. The earlier "presence blueprint" idea collapses into this: a blueprint *is* a Shell type + its capabilities.
206
207	For day-one, only the **seams** that let Shells exist later without a rewrite are in scope (see Networking / Instances and `docs/DEFERRED.md`):
208	- the open endpoint-type system (above),
209	- a messaging substrate whose payloads carry **typed operation commands + arbitrary file blobs** (text/audio/image/video), not just text envelopes,
210	- the Shell-vs-agent-endpoint distinction present in the type model so a Shell type can be registered later.
211
212	All concrete Shells, the PresenceChannel implementation, and presence gossip are deferred.
213
214	**Shells differ structurally from agent endpoints** (full treatment below): a Shell has a node-local perch but **no `tracked/` context** (no mind to sync); its logs are node-local; it is **adapter/platform-bound, not adapter-agnostic**; and its lifecycle is link/teardown, not the dormant/suspended resting model of agent instances.
215
216	#### Shell model (detailed)
217
218	A Shell is a **surface one agent controls** — embodiment of an agent in a new system/environment.
219

**Env-var aliases:** adapters inject clean env-var aliases for in-session invocation (heirs to today's `$OWL`/`$LIVE`), e.g. **`$SPT` = `spt api`** so a Monitor-bound call reads `$SPT listen <id>`. spt-core supplies the subcommands; the adapter supplies the env aliases (manifest philosophy).

### Endpoint types

Each perch advertises an **endpoint type** — a tag that says what shape of entity lives at that perch and what operations it accepts. The set of day-one types:

**ReadyAgent**:
Minimal SPT participant — a perch + a poll listener, no Psyche, no live-agent wrapper. Direct heir to the sister project's "ready agent".

**LiveAgent**:
A Self with a Psyche companion. Composite logical actor; addressable as one ID, but its component perches (the Self's, the Psyche's) live independently. Direct heir to the sister project's "live agent".

**Psyche**:
The Psyche companion's own perch, distinct from its paired LiveAgent's perch. First-class endpoint type so messages addressed to a LiveAgent's Psyche route directly without ambiguity. **Liveness is daemon-held, not PID-based.** Unlike the sister's separate wrapper *process*, the Psyche (under ADR-0004) is a **loop inside the daemon** — it has no dedicated liveness pid, and its summarizer subprocess is ephemeral. Its perch is alive iff the daemon's endpoint table says so, surfaced via a daemon-managed `status` field on `info.json` (the same pattern Shells use), **never** an `is_process_alive(info.pid)` check. The M1/M2a interim (no daemon) keeps it a real process, so per-pid liveness holds *interim*; M3 replaces it with daemon-authoritative liveness (KNOWN-HAZARDS 2.5).

*I/O & trust boundary (ADR-0012):* the Psyche is a **sandboxed** actor — it may read and write files but **cannot send messages or reach the network itself**. Its inbound context arrives two ways: events/messages the daemon hands it, and **commune/signoff file-drops** (Self → daemon → Psyche; the *Summarizer* authors the commune delta). Its **sole outbound** is **reply/notify intents** the daemon relays as its **outbound proxy** — emitted as `<EVENT type="reply">`/`<EVENT type="notify">` (the shared envelope grammar). A *reply* reaches **only the sender it answers**; a *notify* reaches **only the agent's own user** — the Psyche carries no target and cannot address arbitrary endpoints (the daemon strips/re-stamps `from=` before relaying).

**Summarizer**:
The ephemeral, cheap model that builds a **commune delta** from a Self's recent turns and feeds it *into* the **Psyche** as inbound context. A distinct actor from the Psyche — different (cheaper) model, fire-and-forget, **no perch** (not an endpoint type). It authors *commune* deltas only, **never** *reply*/*notify*.
_Avoid_: conflating with the Psyche; "echo-commune model".

**Worker**:
A subagent's perch under a parent LiveAgent. Created on subagent start, torn down on subagent stop. Replaces today's "working perch" concept; first-class type so cross-communication between a Self and its workers (and worker↔worker) is addressable.

**SptNode**:
A machine's participation in an SPT subnet, identified by an Ed25519 public key generated on first run. First-class so networking primitives can address nodes directly as message targets, not only as transport peers. The node identity and network endpoint are hosted by the machine's `spt-daemon` (see Networking), not a separate process.

**PresenceChannel** (broker endpoint — concept locked, impl deferred past v1):
A *broker* endpoint, not an interaction surface. Job: (1) **presence resolution** — track which node + endpoint the user most recently interacted with; (2) **shell brokering** — locate/instantiate the right Shell on that node and relay between the agent and the user. An agent "just knows how to reach the user" by firing at its PresenceChannel; the channel figures out the rest. Also a durable, **shell-agnostic 2-way thread**: messages persist in the channel, not in any one Shell, so the user can be sent a message via a phone messaging-Shell and surface/continue that same agent conversation later at a GameRobot Shell. Shells are interchangeable I/O windows onto the channel's thread.

Three interaction styles:
- **dispatch** — fire-and-forget: "reach the user with this payload"; channel delivers via the best available Shell.
- **bind** — sustained drive: "give me a Shell of capability X"; channel instantiates and the agent drives it directly until teardown. Supports operating a *specific* Shell regardless of where the user currently is (agent transience).
- **thread** — the persistent conversation that floats across Shells; the user can pick it up from any Shell, and 2-way payloads (text/audio/image/video, subject to the Shell's supported types) flow both directions.

Presence datum: `(last_active_node, last_active_endpoint, timestamp)`. The `last_active_endpoint` field lets an agent choose between messaging that specific endpoint vs. driving a parallel instance of itself.

The endpoint type system is **open**: harnesses and downstream projects may register additional types beyond the day-one set. Closed-vs-open semantics for capability advertisement (what operations each type accepts, how routing decides eligibility) are deferred to the design phase.

### Agent endpoints vs Shells

Endpoint types split into two families:

**agent endpoints** — *host* an agent, backed by a harness. ReadyAgent, LiveAgent, Psyche, Worker. Something intelligent runs there.

**Shell** (first-class concept — model locked, concrete types deferred past v1): a *driven surface*, not an agent. Nothing intelligent runs at a Shell; a remote agent (on another node) drives it. A Shell is a "self-documenting" endpoint that advertises a **typed capability toolset** and a 2-way interaction relationship with the user. It may live behind a node on a platform with zero conventional-harness compatibility (e.g. a gaming handheld). Examples (all deferred): `GameRobot` (a 2D-world avatar — move, gesture, alert-symbol, request-screenshot, preload-message), an OS-notification target, an in-session-inject target. The earlier "presence blueprint" idea collapses into this: a blueprint *is* a Shell type + its capabilities.

For day-one, only the **seams** that let Shells exist later without a rewrite are in scope (see Networking / Instances and `docs/DEFERRED.md`):
- the open endpoint-type system (above),
- a messaging substrate whose payloads carry **typed operation commands + arbitrary file blobs** (text/audio/image/video), not just text envelopes,
- the Shell-vs-agent-endpoint distinction present in the type model so a Shell type can be registered later.

All concrete Shells, the PresenceChannel implementation, and presence gossip are deferred.

**Shells differ structurally from agent endpoints** (full treatment below): a Shell has a node-local perch but **no `tracked/` context** (no mind to sync); its logs are node-local; it is **adapter/platform-bound, not adapter-agnostic**; and its lifecycle is link/teardown, not the dormant/suspended resting model of agent instances.

#### Shell model (detailed)

A Shell is a **surface one agent controls** — embodiment of an agent in a new system/environment.


crates\spt-live\src\turn.rs:66:pub fn run_psyche_turn(
crates\spt-live\src\turn.rs:108:        let out = run_psyche_turn(
crates\spt-live\src\turn.rs:120:        let err = run_psyche_turn(
crates\spt-live\src\lib.rs:43:pub use turn::{run_psyche_turn, TurnError, PSYCHE_TURN_ROLE};
crates\spt-live\src\reconcile.rs:20:use crate::turn::{run_psyche_turn, TurnError};
crates\spt-live\src\reconcile.rs:161:    let merged = match run_psyche_turn(runtime, keys, prompt.as_bytes(), timeout) {
crates\spt-daemon\src\lifecycle.rs:212:    pub fn run_pulse_loop<F: FnMut(&TickReport)>(
crates\spt-daemon\src\lifecycle.rs:541:            host.run_pulse_loop(None, &stop, |_| {


1	//! The brain's LiveAgent lifecycle host (B3) — the consolidated re-host of the
2	//! interim `api listen` runner's live half.
3	//!
4	//! In the no-daemon interim model the long-lived `spt api listen` process hosted
5	//! the LiveAgent lifecycle (Psyche spawn, pulse-driven drop ingest, echo fires,
6	//! graceful signoff) directly in the agent's session. Consolidation
7	//! (REQ-DAEMON-1) moves that orchestration **down into `spt-daemon`** so the
8	//! brain runs it as scheduled in-process loops, with a durable, configurable
9	//! pulse period ([`crate::config::DaemonConfig`]) instead of the interim 5s
10	//! constant. The `spt` binary's `LiveHost` becomes a thin shim over this type —
11	//! a re-host, not a rewrite: the heavy lifting stays in the already-pure
12	//! [`spt_live`] seams ([`spawn_psyche`], [`ingest_drops`], [`run_echo_commune`],
13	//! [`graceful_signoff`], [`sweep_stale_signoff`]); the ordering invariants
14	//! (echo-before-signoff 3.3, grace-before-signoff 1.1, stale-sweep 3.2) live
15	//! where they always have.
16	// [impl->REQ-DAEMON-1]
17
18	use std::collections::BTreeMap;
19	use std::path::{Path, PathBuf};
20	use std::sync::atomic::{AtomicBool, Ordering};
21	use std::time::{Duration, SystemTime, UNIX_EPOCH};
22
23	use spt_live::{
24	    fetch_history, graceful_signoff, ingest_drops, run_echo_commune, spawn_psyche,
25	    sweep_stale_signoff, HistoryRecord, Ingested, PsycheHandle, SignoffOutcome,
26	};
27	use spt_runtime::{Manifest, ManifestRuntime};
28	use spt_store::perch::{self, ParentHint};
29	use spt_store::proc::is_process_alive;
30
31	use crate::config::DaemonConfig;
32
33	/// Bound for a bounded history/echo invocation in the host (carried from M2b).
34	const LIVE_BOUNDED_TIMEOUT: Duration = Duration::from_secs(30);
35
36	/// Wall-clock now in epoch millis — precedence-marker stamp for ingested writes.
37	fn now_ms() -> u64 {
38	    SystemTime::now()
39	        .duration_since(UNIX_EPOCH)
40	        .map(|d| d.as_millis() as u64)
41	        .unwrap_or(0)
42	}
43
44	/// ISO-ish stamp for the psyche prompt (opaque to the Psyche; just a timestamp).
45	fn now_stamp() -> String {
46	    format!("epoch-ms:{}", now_ms())
47	}
48
49	/// One pulse tick's observed effects: any drops ingested + whether an echo fired.
50	#[derive(Debug, Clone, Default)]
51	pub struct TickReport {
52	    pub ingested: Vec<Ingested>,
53	    pub echo_fired: bool,
54	}
55
56	/// The daemon-hosted LiveAgent lifecycle: the runtime + watched drop dirs built
57	/// from a live-capable manifest, paced by a config-driven [`DaemonConfig`].
58	pub struct BrainLifecycle {
59	    runtime: ManifestRuntime,
60	    id: String,
61	    /// The project this lifecycle's context routes under (two-tier split,
62	    /// REQ-INST-2): derived once from the watched commune dir's parent — the
63	    /// drop dir lives inside the project (`<project>/.claude/`), so its parent
64	    /// is the project root — falling back to the daemon's cwd for a manifest
65	    /// without watched dirs.
66	    project_id: String,
67	    manifest: Manifest,
68	    commune_dir: Option<PathBuf>,
69	    signoff_dir: Option<PathBuf>,
70	    cfg: DaemonConfig,
71	}
72
73	impl BrainLifecycle {
74	    /// Build a host for `id` from a live-capable manifest (declares
75	    /// `[session.psyche_init]`); `None` for an absent manifest or a plain relay
76	    /// endpoint. Loads the durable [`DaemonConfig`] from disk.
77	    pub fn new_opt(manifest: Option<&Manifest>, id: &str) -> Option<BrainLifecycle> {
78	        Self::with_config(manifest?, id, DaemonConfig::load())
79	    }
80
81	    /// Build a host from a live-capable manifest with an explicit config (the
82	    /// testable seam). `None` if the manifest declares no Psyche.
83	    pub fn with_config(manifest: &Manifest, id: &str, cfg: DaemonConfig) -> Option<BrainLifecycle> {
84	        manifest.session.psyche_init.as_ref()?;
85	        let commune_dir = manifest.session.commune_dir.as_ref().map(PathBuf::from);
86	        // [impl->REQ-INST-2] project identity for the two-tier context split:
87	        // the commune drop dir's parent is the project root; cwd is the
88	        // fallback for a manifest without watched dirs.
89	        let project_anchor = commune_dir
90	            .as_ref()
91	            .and_then(|d| d.parent().map(Path::to_path_buf))
92	            .or_else(|| std::env::current_dir().ok())
93	            .unwrap_or_else(|| PathBuf::from("."));
94	        let project_id = spt_store::project::project_id_for_dir(&project_anchor);
95	        Some(BrainLifecycle {
96	            runtime: ManifestRuntime::new(manifest.clone()),
97	            id: id.to_string(),
98	            project_id,
99	            commune_dir,
100	            signoff_dir: manifest.session.signoff_dir.as_ref().map(PathBuf::from),
101	            manifest: manifest.clone(),
102	            cfg,
103	        })
104	    }
105
106	    /// The configured pulse period (config-driven, not a constant).
107	    pub fn pulse_period(&self) -> Duration {
108	        self.cfg.pulse_period
109	    }
110
111	    /// Base substitution keys spt-core fills for the lifecycle templates.
112	    fn base_keys(&self, session_id: Option<&str>) -> BTreeMap<String, String> {
113	        let mut k = BTreeMap::new();
114	        k.insert("id".to_string(), self.id.clone());
115	        if let Some(sid) = session_id {
116	            k.insert("session_id".to_string(), sid.to_string());
117	        }
118	        k
119	    }
120

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

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

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

use crate::config::DaemonConfig;

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

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

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

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

/// The daemon-hosted LiveAgent lifecycle: the runtime + watched drop dirs built
/// from a live-capable manifest, paced by a config-driven [`DaemonConfig`].
pub struct BrainLifecycle {
    runtime: ManifestRuntime,
    id: String,
    /// The project this lifecycle's context routes under (two-tier split,
    /// REQ-INST-2): derived once from the watched commune dir's parent — the
    /// drop dir lives inside the project (`<project>/.claude/`), so its parent
    /// is the project root — falling back to the daemon's cwd for a manifest
    /// without watched dirs.
    project_id: String,
    manifest: Manifest,
    commune_dir: Option<PathBuf>,
    signoff_dir: Option<PathBuf>,
    cfg: DaemonConfig,
}

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

    /// Build a host from a live-capable manifest with an explicit config (the
    /// testable seam). `None` if the manifest declares no Psyche.
    pub fn with_config(manifest: &Manifest, id: &str, cfg: DaemonConfig) -> Option<BrainLifecycle> {
        manifest.session.psyche_init.as_ref()?;
        let commune_dir = manifest.session.commune_dir.as_ref().map(PathBuf::from);
        // [impl->REQ-INST-2] project identity for the two-tier context split:
        // the commune drop dir's parent is the project root; cwd is the
        // fallback for a manifest without watched dirs.
        let project_anchor = commune_dir
            .as_ref()
            .and_then(|d| d.parent().map(Path::to_path_buf))
            .or_else(|| std::env::current_dir().ok())
            .unwrap_or_else(|| PathBuf::from("."));
        let project_id = spt_store::project::project_id_for_dir(&project_anchor);
        Some(BrainLifecycle {
            runtime: ManifestRuntime::new(manifest.clone()),
            id: id.to_string(),
            project_id,
            commune_dir,
            signoff_dir: manifest.session.signoff_dir.as_ref().map(PathBuf::from),
            manifest: manifest.clone(),
            cfg,
        })
    }

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

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


120
121	    /// The unique watched drop dirs (commune + signoff; usually the same dir).
122	    fn drop_dirs(&self) -> Vec<PathBuf> {
123	        let mut dirs: Vec<PathBuf> = Vec::new();
124	        for d in [self.commune_dir.as_ref(), self.signoff_dir.as_ref()]
125	            .into_iter()
126	            .flatten()
127	        {
128	            if !dirs.contains(d) {
129	                dirs.push(d.clone());
130	            }
131	        }
132	        dirs
133	    }
134
135	    /// Sweep a stale signoff sentinel on start (3.2). No-op without a signoff dir.
136	    pub fn sweep_on_start(&self) {
137	        if let Some(dir) = &self.signoff_dir {
138	            let _ = sweep_stale_signoff(dir, &self.id);
139	        }
140	    }
141
142	    /// Spawn the Psyche companion (T2). `event` is the incoming envelope when the
143	    /// spawn is event-driven; `None` at cold start.
144	    ///
145	    /// The Psyche is **daemon-hosted**: its liveness is daemon-authoritative, not
146	    /// the ephemeral summarizer pid `spawn_psyche` records (2.5). So immediately
147	    /// after binding, mark the perch `online` — from here liveness reads the
148	    /// daemon `status`, and a dead summarizer subprocess no longer flips the perch
149	    /// offline. [`mark_offline`](Self::mark_offline) is the teardown counterpart.
150	    // [impl->REQ-HAZARD-DAEMON-HOSTED-LIVENESS]
151	    pub fn spawn_psyche(
152	        &self,
153	        session_id: Option<&str>,
154	        event: Option<&str>,
155	    ) -> Result<PsycheHandle, String> {
156	        let handle =
157	            spawn_psyche(&self.runtime, &self.id, &self.base_keys(session_id), event, &now_stamp())
158	                .map_err(|e| e.to_string())?;
159	        spt_store::info::set_status(&handle.perch_path, spt_store::liveness::STATUS_ONLINE)
160	            .map_err(|e| format!("mark psyche online: {e}"))?;
161	        Ok(handle)
162	    }
163
164	    /// Mark a daemon-hosted perch `offline` (teardown counterpart of the
165	    /// `online` stamp in [`spawn_psyche`](Self::spawn_psyche)). From this point
166	    /// the single liveness resolver reports the perch dead regardless of any pid
167	    /// still recorded in its `info.json` (2.5). Best-effort: a missing record
168	    /// (perch already gone) is not an error.
169	    // [impl->REQ-HAZARD-DAEMON-HOSTED-LIVENESS]
170	    pub fn mark_offline(perch_path: &std::path::Path) {
171	        let _ = spt_store::info::set_status(perch_path, spt_store::liveness::STATUS_OFFLINE);
172	    }
173
174	    /// Stamp this endpoint's `last_active_ms` recency marker (REQ-UPD-4) — the
175	    /// signal the consent gate resolves "most-recently-active live session"
176	    /// against ([`crate::consent::most_recently_active`]). Best-effort: a hosted
177	    /// perch that has not been bound yet (no `info.json`) is silently skipped, so
178	    /// a pre-bind tick never errors. Stamps the Self perch for this brain's id.
179	    // [impl->REQ-UPD-4]
180	    pub fn touch_active(&self) {
181	        let perch_path = perch::resolve_perch_path(&self.id, ParentHint::Infer);
182	        let _ = spt_store::info::set_last_active(&perch_path, now_ms());
183	    }
184
185	    /// One pulse tick (T5): refresh the activity stamp (REQ-UPD-4), ingest drops
186	    /// from the watched dirs (T4), then if the echo-gate is armed fire exactly one
187	    /// echo-commune (T3).
188	    pub fn pulse_tick(&self, session_id: Option<&str>) -> Result<TickReport, String> {
189	        self.touch_active();
190	        let now = now_ms();
191	        let mut ingested = Vec::new();
192	        for dir in self.drop_dirs() {
193	            let mut got =
194	                ingest_drops(&dir, &self.id, &self.project_id, now, self.cfg.protection_window_ms)
195	                    .map_err(|e| e.to_string())?;
196	            ingested.append(&mut got);
197	        }
198	        let echo_fired = if spt_live::pulse::take_echo_gate(&self.id, ParentHint::Infer) {
199	            self.fire_echo(session_id)?;
200	            true
201	        } else {
202	            false
203	        };
204	        Ok(TickReport { ingested, echo_fired })
205	    }
206
207	    /// Run the scheduled pulse loop until `stop` is set, calling `on_tick` after
208	    /// each tick (so a caller — or a test — observes cadence and can decide to
209	    /// stop). The period comes from the durable config, never a constant. The
210	    /// loop holds no state beyond the cursor on disk, so it is freely
211	    /// killable + respawnable: a fresh loop re-reads the drop dirs and gate.
212	    pub fn run_pulse_loop<F: FnMut(&TickReport)>(
213	        &self,
214	        session_id: Option<&str>,
215	        stop: &AtomicBool,
216	        mut on_tick: F,
217	    ) {
218	        while !stop.load(Ordering::Relaxed) {
219	            match self.pulse_tick(session_id) {
220	                Ok(report) => on_tick(&report),
221	                Err(_) => { /* a transient tick error must not kill the loop */ }
222	            }
223	            // Sleep the configured period in small slices so a stop is honored
224	            // promptly rather than after a full (possibly multi-second) period.
225	            sleep_interruptible(self.cfg.pulse_period, stop);
226	        }
227	    }
228
229	    /// Fetch history per the manifest `[history]` strategy (empty if none).
230	    fn history(&self, session_id: Option<&str>) -> Vec<HistoryRecord> {
231	        let Some(h) = &self.manifest.history else {
232	            return Vec::new();
233	        };
234	        fetch_history(h, &self.base_keys(session_id), &self.id, ParentHint::Infer, LIVE_BOUNDED_TIMEOUT)
235	            .unwrap_or_default()
236	    }
237
238	    /// Run the echo-commune into the commune dir (T3). No-op (Ok) without a dir.
239	    fn fire_echo(&self, session_id: Option<&str>) -> Result<(), String> {
240	        let Some(dir) = &self.commune_dir else {
241	            return Ok(());
242	        };
243	        let history = self.history(session_id);
244	        run_echo_commune(
245	            &self.runtime,
246	            &self.id,
247	            &self.base_keys(session_id),
248	            &history,
249	            dir,
250	            LIVE_BOUNDED_TIMEOUT,
251	        )
252	        .map(|_| ())
253	        .map_err(|e| e.to_string())
254	    }
255
256	    /// Execute the graceful signoff (T6): echo-commune BEFORE teardown (3.3),
257	    /// grace recheck before any signoff write (1.1). The graceful path is an
258	    /// **intentional** shutdown, so it uses zero grace + an always-gone recheck —
259	    /// the Self is leaving on purpose, there is nothing to recover. The
260	    /// supervised-crash path ([`supervised_crash_signoff`](Self::supervised_crash_signoff))
261	    /// supplies the real grace + liveness recheck for an *un*intentional death.
262	    pub fn graceful_signoff(&self, session_id: Option<&str>) -> Result<SignoffOutcome, String> {
263	        self.signoff_with(session_id, Duration::ZERO, || true)
264	    }
265
266	    /// Execute the **supervised-crash** signoff (B7, orphan-watch) — the
267	    /// counterpart of [`graceful_signoff`](Self::graceful_signoff) for an
268	    /// *unexpected* harness death. It runs the identical load-bearing ordering —
269	    /// echo-commune (3.3) → grace wait (1.1) → liveness recheck → teardown — but
270	    /// with the **real** grace window from config and the **real** recheck
271	    /// `still_gone`. A Self that recovered across the grace window (a binary
272	    /// handoff relaunch, a brief stale poll) makes `still_gone` false and is
273	    /// **spared** — the phantom-INIT_SIGNOFF bug (1.1).
274	    ///
275	    /// `still_gone` is the post-grace liveness verdict (true ⇒ the harness owner
276	    /// is still gone ⇒ tear down). [`run_orphan_watch`](Self::run_orphan_watch)
277	    /// supplies the production recheck ([`harness_owner_gone`](Self::harness_owner_gone));
278	    /// it is injected here so the ordering is testable without a real pid.
279	    // [impl->REQ-DAEMON-4]

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

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

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

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

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

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

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

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

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

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

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


35:pub const EVENT_LINE_THRESHOLD: usize = 400;
77:pub fn chunk_if_oversized(envelope_line: &str, threshold: usize) -> Vec<String> {
164:pub fn parse_event_part(line: &str) -> Option<EventPart> {
230:pub fn reassemble_group(part_lines: &[String]) -> Option<String> {
266:pub fn reassemble_stream(lines: &[String]) -> Vec<String> {


crates\spt-runtime\src\manifest.rs:141:            ("echo_commune", &self.echo_commune),
crates\spt-live\src\echo.rs:35:pub const ECHO_COMMUNE_ROLE: &str = "echo_commune";
crates\spt-proto\src\event.rs:243:            r#"<EVENT type="echo_commune" from="doyle-psyche" note="brief">hello world</EVENT>"#;
crates\spt-proto\src\event.rs:246:        assert_eq!(p.event_type.as_deref(), Some("echo_commune"));
crates\spt-proto\src\event.rs:280:        let p = parse_event(r#"<EVENT type="file_drop" path="x" from="d"></EVENT>"#).unwrap();
crates\spt-proto\src\event.rs:281:        assert_eq!(p.event_type.as_deref(), Some("file_drop"));


1	//! echo-commune seam (M2b T3) — the cheap-model brief that keeps a Psyche's
2	//! mind warm between live sessions.
3	//!
4	//! spt-core spawns the `[session.echo_commune]` template (a cheap model — the
5	//! adapter picks it; `claude -p --model haiku …` in the reference manifest),
6	//! feeding it the normalized **history** (T1) on stdin. Its stdout is written
7	//! as a **commune drop-file** (`<id>-commune.md`) that the drop-file ingest (T4)
8	//! later folds into the perch's context.
9	//!
10	//! ## Provenance — the anti-leak rule (`docs/CONTEXT-MEMORY.md`)
11	//! Echo communes synthesize *project-heavy* session logs, so they are
12	//! **project-context-primary / live-context-conservative** — strong project
13	//! signal, few/no live edits. Every commune the echo writes is tagged
14	//! `Source: echo-commune` so the later memformat pass (deferred) inherits the
15	//! correct tiering. Getting provenance right now is cheap; retrofitting is not.
16	//!
17	//! ## Recursion guard
18	//! The echo child runs with the role's `recursion_guard_env` set (applied by
19	//! `ManifestRuntime`), so its *own* harness hooks bail instead of firing a
20	//! second echo (no echo-of-echo).
21	//
22	// Clean-room over the M2a bounded seam; spt-core writes the drop file (it is the
23	// single writer — KNOWN-HAZARDS 6.4), the mind never does.
24	// [impl->REQ-SEAM-HISTORY]
25	// [impl->REQ-HAZARD-DROP-FILE-SINGLE-WRITER]
26
27	use crate::history::HistoryRecord;
28	use spt_runtime::{AgentRuntime, RuntimeError};
29	use spt_store::atomic::atomic_write_string;
30	use std::collections::BTreeMap;
31	use std::path::{Path, PathBuf};
32	use std::time::Duration;
33
34	/// The role spawned for the echo-commune brief.
35	pub const ECHO_COMMUNE_ROLE: &str = "echo_commune";
36
37	/// Provenance tag stamped on echo-authored communes (drives the
38	/// live-conservative tiering — `docs/CONTEXT-MEMORY.md`).
39	pub const SOURCE_ECHO_COMMUNE: &str = "echo-commune";
40
41	/// Outcome of an echo run: the written drop-file path and the brief body.
42	#[derive(Debug, Clone)]
43	pub struct EchoResult {
44	    pub drop_path: PathBuf,
45	    pub body: String,
46	}
47
48	/// An echo-commune failure. Carries an operator message; never panics, never
49	/// hangs (the summarizer runs bounded).
50	#[derive(Debug)]
51	pub enum EchoError {
52	    /// Template fill / spawn / timeout failure from the runtime.
53	    Runtime(RuntimeError),
54	    /// The summarizer exited non-zero (its stderr, for the operator).
55	    NonZero { code: Option<i32>, stderr: String },
56	    /// Writing the commune drop-file failed.
57	    Write(std::io::Error),
58	}
59
60	impl std::fmt::Display for EchoError {
61	    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62	        match self {
63	            EchoError::Runtime(e) => write!(f, "echo-commune spawn failed: {e}"),
64	            EchoError::NonZero { code, stderr } => {
65	                write!(f, "echo-commune exited {code:?}: {}", stderr.trim())
66	            }
67	            EchoError::Write(e) => write!(f, "writing the commune drop-file failed: {e}"),
68	        }
69	    }
70	}
71
72	impl std::error::Error for EchoError {}
73
74	impl From<RuntimeError> for EchoError {
75	    fn from(e: RuntimeError) -> Self {
76	        EchoError::Runtime(e)
77	    }
78	}
79
80	/// Stamp the spt-core-owned provenance header onto a commune body. One header
81	/// line drives the tiering at ingest; the body is the adapter summarizer's
82	/// verbatim output.
83	pub fn stamp_provenance(source: &str, body: &str) -> String {
84	    format!("Source: {source}\n{}\n", body.trim_end())
85	}
86
87	/// Run the echo-commune: feed `history` to the `[session.echo_commune]`
88	/// summarizer (bounded), then write its brief as the `<id>-commune.md` drop-file
89	/// under `commune_dir`, tagged `Source: echo-commune`. spt-core is the **single
90	/// writer** of that file (6.4).

//! echo-commune seam (M2b T3) — the cheap-model brief that keeps a Psyche's
//! mind warm between live sessions.
//!
//! spt-core spawns the `[session.echo_commune]` template (a cheap model — the
//! adapter picks it; `claude -p --model haiku …` in the reference manifest),
//! feeding it the normalized **history** (T1) on stdin. Its stdout is written
//! as a **commune drop-file** (`<id>-commune.md`) that the drop-file ingest (T4)
//! later folds into the perch's context.
//!
//! ## Provenance — the anti-leak rule (`docs/CONTEXT-MEMORY.md`)
//! Echo communes synthesize *project-heavy* session logs, so they are
//! **project-context-primary / live-context-conservative** — strong project
//! signal, few/no live edits. Every commune the echo writes is tagged
//! `Source: echo-commune` so the later memformat pass (deferred) inherits the
//! correct tiering. Getting provenance right now is cheap; retrofitting is not.
//!
//! ## Recursion guard
//! The echo child runs with the role's `recursion_guard_env` set (applied by
//! `ManifestRuntime`), so its *own* harness hooks bail instead of firing a
//! second echo (no echo-of-echo).
//
// Clean-room over the M2a bounded seam; spt-core writes the drop file (it is the
// single writer — KNOWN-HAZARDS 6.4), the mind never does.
// [impl->REQ-SEAM-HISTORY]
// [impl->REQ-HAZARD-DROP-FILE-SINGLE-WRITER]

use crate::history::HistoryRecord;
use spt_runtime::{AgentRuntime, RuntimeError};
use spt_store::atomic::atomic_write_string;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::time::Duration;

/// The role spawned for the echo-commune brief.
pub const ECHO_COMMUNE_ROLE: &str = "echo_commune";

/// Provenance tag stamped on echo-authored communes (drives the
/// live-conservative tiering — `docs/CONTEXT-MEMORY.md`).
pub const SOURCE_ECHO_COMMUNE: &str = "echo-commune";

/// Outcome of an echo run: the written drop-file path and the brief body.
#[derive(Debug, Clone)]
pub struct EchoResult {
    pub drop_path: PathBuf,
    pub body: String,
}

/// An echo-commune failure. Carries an operator message; never panics, never
/// hangs (the summarizer runs bounded).
#[derive(Debug)]
pub enum EchoError {
    /// Template fill / spawn / timeout failure from the runtime.
    Runtime(RuntimeError),
    /// The summarizer exited non-zero (its stderr, for the operator).
    NonZero { code: Option<i32>, stderr: String },
    /// Writing the commune drop-file failed.
    Write(std::io::Error),
}

impl std::fmt::Display for EchoError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            EchoError::Runtime(e) => write!(f, "echo-commune spawn failed: {e}"),
            EchoError::NonZero { code, stderr } => {
                write!(f, "echo-commune exited {code:?}: {}", stderr.trim())
            }
            EchoError::Write(e) => write!(f, "writing the commune drop-file failed: {e}"),
        }
    }
}

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

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

/// Stamp the spt-core-owned provenance header onto a commune body. One header
/// line drives the tiering at ingest; the body is the adapter summarizer's
/// verbatim output.
pub fn stamp_provenance(source: &str, body: &str) -> String {
    format!("Source: {source}\n{}\n", body.trim_end())
}

/// Run the echo-commune: feed `history` to the `[session.echo_commune]`
/// summarizer (bounded), then write its brief as the `<id>-commune.md` drop-file
/// under `commune_dir`, tagged `Source: echo-commune`. spt-core is the **single
/// writer** of that file (6.4).


docs\FAULT-MATRIX.md