1	//! `visibility.json` — per-`(endpoint, subnet)` visibility + sync-membership config
2	//! (D3e, ADR-0006 §5/§6).
3	//!
4	//! **Visibility = "excluded", not "unlisted"** (ADR-0006 §6): an endpoint hidden in
5	//! subnet S is **not advertised AND not routable** from S — a real boundary (ready for
6	//! the cross-user seam), not a cosmetic absence from `list`. The policy:
7	//!
8	//! ```text
9	//! hidden(E, S) = override(E, S)            if an explicit per-(E,S) override exists
10	//!              = S.hide_new_endpoints      (per-subnet, captured at join time)
11	//!                OR E.default_hide         (per-endpoint default_hide_from_new_subnets)
12	//! ```
13	//!
14	//! Both defaults ship OFF (visible). Enforcement points: the registry's
15	//! advertise/resolve paths in `spt-net` consume this store (excluded endpoints never
16	//! enter or resolve from a subnet's registry), and **visibility gates sync** —
17	//! `hidden ⟹ not synced`, whatever the membership list says.
18	//!
19	//! **Subnet-exclusive sync** (ADR-0006 §5): an endpoint's mind replicates only across
20	//! subnets on its **per-endpoint subnet-membership list** ([`sync_subnets`]
21	//! (VisibilityStore::sync_subnets)). An endpoint with **no list configured syncs
22	//! nowhere** — exclusive-by-default, fail-closed; the endpoint-creation flow seeds the
23	//! list with the home subnet (ADR-0010: home is the default sync anchor), and D6's
24	//! replication consumes [`synced`](VisibilityStore::synced) as its gate.
25	//!
26	//! Node-local config under [`identity/`](crate::perch::identity_dir) — this is the
27	//! *owning node's* advertisement policy, not registry data; what other nodes see is
28	//! the *effect* (presence/absence in the subnet registry, D4 replication).
29
30	use std::collections::BTreeMap;
31	use std::path::Path;
32
33	use serde::{Deserialize, Serialize};
34
35	use crate::atomic::atomic_write_string;
36	use crate::perch::visibility_file;
37	use crate::subnet::SubnetError;
38
39	/// The whole `visibility.json` document: per-endpoint defaults, per-`(endpoint,
40	/// subnet)` overrides, and the per-endpoint sync-membership lists.
41	#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
42	pub struct VisibilityStore {
43	    /// Per-endpoint `default_hide_from_new_subnets` (ADR-0006 §6). Absent = `false`.
44	    #[serde(default)]
45	    default_hide: BTreeMap<String, bool>,
46	    /// Explicit per-`(endpoint, subnet)` overrides: `endpoint → subnet → hidden`.
47	    /// An override **wins over the OR-of-defaults in both directions** (it can force
48	    /// an endpoint visible in a hide-by-default subnet, or hide it in a visible one).
49	    #[serde(default)]
50	    overrides: BTreeMap<String, BTreeMap<String, bool>>,
51	    /// Per-endpoint subnet-membership list for **sync** (ADR-0006 §5). The mind
52	    /// replicates only across listed subnets. Absent = sync nowhere (fail-closed;
53	    /// the creation flow seeds `[home]`).
54	    #[serde(default)]
55	    sync_subnets: BTreeMap<String, Vec<String>>,
56	}
57
58	impl VisibilityStore {
59	    /// Load from the canonical [`visibility_file`]. Missing file = all-defaults
60	    /// (every endpoint visible, no sync lists) — never an error.
61	    pub fn load() -> VisibilityStore {
62	        Self::load_from(&visibility_file())
63	    }
64
65	    /// Load from an explicit path (the testable core). Absent ⇒ defaults; corrupt ⇒
66	    /// degrade to defaults rather than panicking (matches the [`SubnetStore`]
67	    /// (crate::subnet::SubnetStore) ethos — damaged config must not brick the node).
68	    pub fn load_from(path: &Path) -> VisibilityStore {
69	        match std::fs::read_to_string(path) {
70	            Ok(s) => serde_json::from_str(&s).unwrap_or_default(),
71	            Err(_) => VisibilityStore::default(),
72	        }
73	    }
74
75	    /// Persist to the canonical [`visibility_file`].
76	    pub fn save(&self) -> Result<(), SubnetError> {
77	        self.save_to(&visibility_file())
78	    }
79
80	    /// Persist to an explicit path (atomic; creates the parent dir).
81	    pub fn save_to(&self, path: &Path) -> Result<(), SubnetError> {
82	        if let Some(parent) = path.parent() {
83	            std::fs::create_dir_all(parent).map_err(|e| SubnetError::Io(e.to_string()))?;
84	        }
85	        let json =
86	            serde_json::to_string_pretty(self).map_err(|e| SubnetError::Io(e.to_string()))?;
87	        atomic_write_string(path, &json).map_err(|e| SubnetError::Io(e.to_string()))
88	    }
89
90	    /// The endpoint's `default_hide_from_new_subnets` flag (absent = `false`).
91	    pub fn default_hide(&self, endpoint: &str) -> bool {
92	        self.default_hide.get(endpoint).copied().unwrap_or(false)
93	    }
94
95	    /// Set the endpoint's `default_hide_from_new_subnets` flag.
96	    pub fn set_default_hide(&mut self, endpoint: &str, hide: bool) {
97	        self.default_hide.insert(endpoint.to_string(), hide);
98	    }
99
100	    /// The explicit per-`(endpoint, subnet)` override, if one exists.
101	    pub fn override_for(&self, endpoint: &str, subnet: &str) -> Option<bool> {
102	        self.overrides.get(endpoint).and_then(|m| m.get(subnet)).copied()
103	    }
104
105	    /// Set (`Some(hidden)`) or clear (`None`) the per-`(endpoint, subnet)` override.
106	    pub fn set_override(&mut self, endpoint: &str, subnet: &str, hidden: Option<bool>) {
107	        match hidden {
108	            Some(h) => {
109	                self.overrides
110	                    .entry(endpoint.to_string())
111	                    .or_default()
112	                    .insert(subnet.to_string(), h);
113	            }
114	            None => {
115	                if let Some(m) = self.overrides.get_mut(endpoint) {
116	                    m.remove(subnet);
117	                    if m.is_empty() {
118	                        self.overrides.remove(endpoint);
119	                    }
120	                }
121	            }
122	        }
123	    }
124
125	    /// Whether the endpoint is **hidden (= excluded)** in the named subnet, per the
126	    /// ADR-0006 §6 policy: an explicit per-`(endpoint, subnet)` override wins in both
127	    /// directions; otherwise `subnet_hide_new` (the subnet's `hide_new_endpoints`,
128	    /// captured at join time — [`SubnetRecord::hide_new_endpoints`]
129	    /// (crate::subnet::SubnetRecord::hide_new_endpoints)) **OR** the endpoint's
130	    /// [`default_hide`](Self::default_hide). Hidden means *not advertised and not
131	    /// routable* — the registry's advertise/resolve enforcement consumes this.
132	    // [impl->REQ-INST-12]
133	    pub fn hidden(&self, endpoint: &str, subnet: &str, subnet_hide_new: bool) -> bool {
134	        match self.override_for(endpoint, subnet) {
135	            Some(explicit) => explicit,
136	            None => subnet_hide_new || self.default_hide(endpoint),
137	        }
138	    }
139
140	    /// The endpoint's sync-membership list (`None` = not configured = sync nowhere).
141	    pub fn sync_subnets(&self, endpoint: &str) -> Option<&[String]> {
142	        self.sync_subnets.get(endpoint).map(Vec::as_slice)
143	    }
144
145	    /// Set the endpoint's sync-membership list (the creation flow seeds `[home]`).
146	    pub fn set_sync_subnets(&mut self, endpoint: &str, subnets: Vec<String>) {
147	        self.sync_subnets.insert(endpoint.to_string(), subnets);
148	    }
149
150	    /// Whether the endpoint's mind **syncs** into the named subnet (the D6
151	    /// replication gate). Subnet-exclusive sync (ADR-0006 §5): the subnet must be on
152	    /// the endpoint's membership list — an unconfigured list syncs **nowhere**
153	    /// (fail-closed). And **visibility gates sync** (ADR-0006 §6): `hidden ⟹ not
154	    /// synced`, even for a listed subnet.
155	    // [impl->REQ-INST-13]
156	    pub fn synced(&self, endpoint: &str, subnet: &str, subnet_hide_new: bool) -> bool {
157	        if self.hidden(endpoint, subnet, subnet_hide_new) {
158	            return false;
159	        }
160	        self.sync_subnets(endpoint)
161	            .is_some_and(|list| list.iter().any(|s| s == subnet))
162	    }
163	}
164
165	#[cfg(test)]
166	mod tests {
167	    use super::*;
168
169	    // [unit->REQ-INST-12] OR-of-defaults: hidden iff the subnet hides new endpoints
170	    // OR the endpoint defaults to hiding; both OFF = visible (the shipped default).
171	    #[test]
172	    fn hidden_is_or_of_defaults() {
173	        let mut vis = VisibilityStore::default();
174	        assert!(!vis.hidden("ling", "home", false), "both defaults off => visible");
175	        assert!(vis.hidden("ling", "home", true), "subnet hide_new_endpoints hides");
176	        vis.set_default_hide("ling", true);
177	        assert!(vis.hidden("ling", "work", false), "endpoint default_hide hides");
178	        assert!(vis.hidden("ling", "home", true), "both on still hides");
179	    }
180
181	    // [unit->REQ-INST-12] an explicit per-(endpoint,subnet) override wins in BOTH
182	    // directions: it forces visible past an OR-true, and hides past an OR-false.
183	    #[test]
184	    fn override_wins_both_directions() {
185	        let mut vis = VisibilityStore::default();
186	        vis.set_default_hide("ling", true);
187	        vis.set_override("ling", "home", Some(false));
188	        assert!(!vis.hidden("ling", "home", true), "visible-override beats OR-true");
189
190	        vis.set_override("other", "home", Some(true));
191	        assert!(vis.hidden("other", "home", false), "hidden-override beats OR-false");
192
193	        // Clearing the override returns control to the OR-of-defaults.
194	        vis.set_override("ling", "home", None);
195	        assert!(vis.hidden("ling", "home", true), "cleared override => defaults rule");
196	    }
197
198	    // [unit->REQ-INST-12] the store persists across reload and a missing/corrupt
199	    // file degrades to all-defaults (visible) — damaged config must not brick the node.
200	    #[test]
201	    fn store_persists_and_degrades_safe() {
202	        let dir = tempfile::tempdir().unwrap();
203	        let path = dir.path().join("identity").join("visibility.json");
204
205	        let mut vis = VisibilityStore::default();
206	        vis.set_default_hide("ling", true);
207	        vis.set_override("ling", "home", Some(false));
208	        vis.set_sync_subnets("ling", vec!["home".to_string()]);
209	        vis.save_to(&path).unwrap();
210
211	        let back = VisibilityStore::load_from(&path);
212	        assert_eq!(back, vis, "roundtrips");
213
214	        let absent = dir.path().join("nope.json");
215	        assert_eq!(VisibilityStore::load_from(&absent), VisibilityStore::default());
216	        let corrupt = dir.path().join("corrupt.json");
217	        std::fs::write(&corrupt, b"{ not json").unwrap();
218	        assert_eq!(VisibilityStore::load_from(&corrupt), VisibilityStore::default());
219	    }
220
221	    // [unit->REQ-INST-13] subnet-exclusive sync: a listed + visible subnet syncs; an
222	    // unlisted one does not; an UNCONFIGURED list syncs nowhere (fail-closed — the
223	    // creation flow seeds [home]).
224	    #[test]
225	    fn synced_requires_membership_listing() {
226	        let mut vis = VisibilityStore::default();
227	        assert!(!vis.synced("ling", "home", false), "no list configured => nowhere");
228	        vis.set_sync_subnets("ling", vec!["home".to_string()]);
229	        assert!(vis.synced("ling", "home", false), "listed + visible => synced");
230	        assert!(!vis.synced("ling", "work", false), "unlisted => not synced");
231	    }
232
233	    // [unit->REQ-INST-13] visibility GATES sync: hidden ⟹ not synced even when the
234	    // subnet is on the membership list (whether hidden by defaults or by override).
235	    #[test]
236	    fn hidden_gates_sync_even_when_listed() {
237	        let mut vis = VisibilityStore::default();
238	        vis.set_sync_subnets("ling", vec!["home".to_string(), "work".to_string()]);
239	        assert!(vis.synced("ling", "home", false), "visible baseline syncs");
240	        assert!(!vis.synced("ling", "home", true), "subnet-default hidden => not synced");
241	        vis.set_override("ling", "work", Some(true));
242	        assert!(!vis.synced("ling", "work", false), "override-hidden => not synced");
243	    }
244	}
245

//! `visibility.json` — per-`(endpoint, subnet)` visibility + sync-membership config
//! (D3e, ADR-0006 §5/§6).
//!
//! **Visibility = "excluded", not "unlisted"** (ADR-0006 §6): an endpoint hidden in
//! subnet S is **not advertised AND not routable** from S — a real boundary (ready for
//! the cross-user seam), not a cosmetic absence from `list`. The policy:
//!
//! ```text
//! hidden(E, S) = override(E, S)            if an explicit per-(E,S) override exists
//!              = S.hide_new_endpoints      (per-subnet, captured at join time)
//!                OR E.default_hide         (per-endpoint default_hide_from_new_subnets)
//! ```
//!
//! Both defaults ship OFF (visible). Enforcement points: the registry's
//! advertise/resolve paths in `spt-net` consume this store (excluded endpoints never
//! enter or resolve from a subnet's registry), and **visibility gates sync** —
//! `hidden ⟹ not synced`, whatever the membership list says.
//!
//! **Subnet-exclusive sync** (ADR-0006 §5): an endpoint's mind replicates only across
//! subnets on its **per-endpoint subnet-membership list** ([`sync_subnets`]
//! (VisibilityStore::sync_subnets)). An endpoint with **no list configured syncs
//! nowhere** — exclusive-by-default, fail-closed; the endpoint-creation flow seeds the
//! list with the home subnet (ADR-0010: home is the default sync anchor), and D6's
//! replication consumes [`synced`](VisibilityStore::synced) as its gate.
//!
//! Node-local config under [`identity/`](crate::perch::identity_dir) — this is the
//! *owning node's* advertisement policy, not registry data; what other nodes see is
//! the *effect* (presence/absence in the subnet registry, D4 replication).

use std::collections::BTreeMap;
use std::path::Path;

use serde::{Deserialize, Serialize};

use crate::atomic::atomic_write_string;
use crate::perch::visibility_file;
use crate::subnet::SubnetError;

/// The whole `visibility.json` document: per-endpoint defaults, per-`(endpoint,
/// subnet)` overrides, and the per-endpoint sync-membership lists.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct VisibilityStore {
    /// Per-endpoint `default_hide_from_new_subnets` (ADR-0006 §6). Absent = `false`.
    #[serde(default)]
    default_hide: BTreeMap<String, bool>,
    /// Explicit per-`(endpoint, subnet)` overrides: `endpoint → subnet → hidden`.
    /// An override **wins over the OR-of-defaults in both directions** (it can force
    /// an endpoint visible in a hide-by-default subnet, or hide it in a visible one).
    #[serde(default)]
    overrides: BTreeMap<String, BTreeMap<String, bool>>,
    /// Per-endpoint subnet-membership list for **sync** (ADR-0006 §5). The mind
    /// replicates only across listed subnets. Absent = sync nowhere (fail-closed;
    /// the creation flow seeds `[home]`).
    #[serde(default)]
    sync_subnets: BTreeMap<String, Vec<String>>,
}

impl VisibilityStore {
    /// Load from the canonical [`visibility_file`]. Missing file = all-defaults
    /// (every endpoint visible, no sync lists) — never an error.
    pub fn load() -> VisibilityStore {
        Self::load_from(&visibility_file())
    }

    /// Load from an explicit path (the testable core). Absent ⇒ defaults; corrupt ⇒
    /// degrade to defaults rather than panicking (matches the [`SubnetStore`]
    /// (crate::subnet::SubnetStore) ethos — damaged config must not brick the node).
    pub fn load_from(path: &Path) -> VisibilityStore {
        match std::fs::read_to_string(path) {
            Ok(s) => serde_json::from_str(&s).unwrap_or_default(),
            Err(_) => VisibilityStore::default(),
        }
    }

    /// Persist to the canonical [`visibility_file`].
    pub fn save(&self) -> Result<(), SubnetError> {
        self.save_to(&visibility_file())
    }

    /// Persist to an explicit path (atomic; creates the parent dir).
    pub fn save_to(&self, path: &Path) -> Result<(), SubnetError> {
        if let Some(parent) = path.parent() {
            std::fs::create_dir_all(parent).map_err(|e| SubnetError::Io(e.to_string()))?;
        }
        let json =
            serde_json::to_string_pretty(self).map_err(|e| SubnetError::Io(e.to_string()))?;
        atomic_write_string(path, &json).map_err(|e| SubnetError::Io(e.to_string()))
    }

    /// The endpoint's `default_hide_from_new_subnets` flag (absent = `false`).
    pub fn default_hide(&self, endpoint: &str) -> bool {
        self.default_hide.get(endpoint).copied().unwrap_or(false)
    }

    /// Set the endpoint's `default_hide_from_new_subnets` flag.
    pub fn set_default_hide(&mut self, endpoint: &str, hide: bool) {
        self.default_hide.insert(endpoint.to_string(), hide);
    }

    /// The explicit per-`(endpoint, subnet)` override, if one exists.
    pub fn override_for(&self, endpoint: &str, subnet: &str) -> Option<bool> {
        self.overrides.get(endpoint).and_then(|m| m.get(subnet)).copied()
    }

    /// Set (`Some(hidden)`) or clear (`None`) the per-`(endpoint, subnet)` override.
    pub fn set_override(&mut self, endpoint: &str, subnet: &str, hidden: Option<bool>) {
        match hidden {
            Some(h) => {
                self.overrides
                    .entry(endpoint.to_string())
                    .or_default()
                    .insert(subnet.to_string(), h);
            }
            None => {
                if let Some(m) = self.overrides.get_mut(endpoint) {
                    m.remove(subnet);
                    if m.is_empty() {
                        self.overrides.remove(endpoint);
                    }
                }
            }
        }
    }

    /// Whether the endpoint is **hidden (= excluded)** in the named subnet, per the
    /// ADR-0006 §6 policy: an explicit per-`(endpoint, subnet)` override wins in both
    /// directions; otherwise `subnet_hide_new` (the subnet's `hide_new_endpoints`,
    /// captured at join time — [`SubnetRecord::hide_new_endpoints`]
    /// (crate::subnet::SubnetRecord::hide_new_endpoints)) **OR** the endpoint's
    /// [`default_hide`](Self::default_hide). Hidden means *not advertised and not
    /// routable* — the registry's advertise/resolve enforcement consumes this.
    // [impl->REQ-INST-12]
    pub fn hidden(&self, endpoint: &str, subnet: &str, subnet_hide_new: bool) -> bool {
        match self.override_for(endpoint, subnet) {
            Some(explicit) => explicit,
            None => subnet_hide_new || self.default_hide(endpoint),
        }
    }

    /// The endpoint's sync-membership list (`None` = not configured = sync nowhere).
    pub fn sync_subnets(&self, endpoint: &str) -> Option<&[String]> {
        self.sync_subnets.get(endpoint).map(Vec::as_slice)
    }

    /// Set the endpoint's sync-membership list (the creation flow seeds `[home]`).
    pub fn set_sync_subnets(&mut self, endpoint: &str, subnets: Vec<String>) {
        self.sync_subnets.insert(endpoint.to_string(), subnets);
    }

    /// Whether the endpoint's mind **syncs** into the named subnet (the D6
    /// replication gate). Subnet-exclusive sync (ADR-0006 §5): the subnet must be on
    /// the endpoint's membership list — an unconfigured list syncs **nowhere**
    /// (fail-closed). And **visibility gates sync** (ADR-0006 §6): `hidden ⟹ not
    /// synced`, even for a listed subnet.
    // [impl->REQ-INST-13]
    pub fn synced(&self, endpoint: &str, subnet: &str, subnet_hide_new: bool) -> bool {
        if self.hidden(endpoint, subnet, subnet_hide_new) {
            return false;
        }
        self.sync_subnets(endpoint)
            .is_some_and(|list| list.iter().any(|s| s == subnet))
    }
}

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

    // [unit->REQ-INST-12] OR-of-defaults: hidden iff the subnet hides new endpoints
    // OR the endpoint defaults to hiding; both OFF = visible (the shipped default).
    #[test]
    fn hidden_is_or_of_defaults() {
        let mut vis = VisibilityStore::default();
        assert!(!vis.hidden("ling", "home", false), "both defaults off => visible");
        assert!(vis.hidden("ling", "home", true), "subnet hide_new_endpoints hides");
        vis.set_default_hide("ling", true);
        assert!(vis.hidden("ling", "work", false), "endpoint default_hide hides");
        assert!(vis.hidden("ling", "home", true), "both on still hides");
    }

    // [unit->REQ-INST-12] an explicit per-(endpoint,subnet) override wins in BOTH
    // directions: it forces visible past an OR-true, and hides past an OR-false.
    #[test]
    fn override_wins_both_directions() {
        let mut vis = VisibilityStore::default();
        vis.set_default_hide("ling", true);
        vis.set_override("ling", "home", Some(false));
        assert!(!vis.hidden("ling", "home", true), "visible-override beats OR-true");

        vis.set_override("other", "home", Some(true));
        assert!(vis.hidden("other", "home", false), "hidden-override beats OR-false");

        // Clearing the override returns control to the OR-of-defaults.
        vis.set_override("ling", "home", None);
        assert!(vis.hidden("ling", "home", true), "cleared override => defaults rule");
    }

    // [unit->REQ-INST-12] the store persists across reload and a missing/corrupt
    // file degrades to all-defaults (visible) — damaged config must not brick the node.
    #[test]
    fn store_persists_and_degrades_safe() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("identity").join("visibility.json");

        let mut vis = VisibilityStore::default();
        vis.set_default_hide("ling", true);
        vis.set_override("ling", "home", Some(false));
        vis.set_sync_subnets("ling", vec!["home".to_string()]);
        vis.save_to(&path).unwrap();

        let back = VisibilityStore::load_from(&path);
        assert_eq!(back, vis, "roundtrips");

        let absent = dir.path().join("nope.json");
        assert_eq!(VisibilityStore::load_from(&absent), VisibilityStore::default());
        let corrupt = dir.path().join("corrupt.json");
        std::fs::write(&corrupt, b"{ not json").unwrap();
        assert_eq!(VisibilityStore::load_from(&corrupt), VisibilityStore::default());
    }

    // [unit->REQ-INST-13] subnet-exclusive sync: a listed + visible subnet syncs; an
    // unlisted one does not; an UNCONFIGURED list syncs nowhere (fail-closed — the
    // creation flow seeds [home]).
    #[test]
    fn synced_requires_membership_listing() {
        let mut vis = VisibilityStore::default();
        assert!(!vis.synced("ling", "home", false), "no list configured => nowhere");
        vis.set_sync_subnets("ling", vec!["home".to_string()]);
        assert!(vis.synced("ling", "home", false), "listed + visible => synced");
        assert!(!vis.synced("ling", "work", false), "unlisted => not synced");
    }

    // [unit->REQ-INST-13] visibility GATES sync: hidden ⟹ not synced even when the
    // subnet is on the membership list (whether hidden by defaults or by override).
    #[test]
    fn hidden_gates_sync_even_when_listed() {
        let mut vis = VisibilityStore::default();
        vis.set_sync_subnets("ling", vec!["home".to_string(), "work".to_string()]);
        assert!(vis.synced("ling", "home", false), "visible baseline syncs");
        assert!(!vis.synced("ling", "home", true), "subnet-default hidden => not synced");
        vis.set_override("ling", "work", Some(true));
        assert!(!vis.synced("ling", "work", false), "override-hidden => not synced");
    }
}


crates\spt-daemon\src\config.rs:80:    /// seamless, prompt-free path.
crates\spt-daemon\src\consent.rs:7://! (the seamless, prompt-free path). C2 ships the load-bearing, fully-testable
crates\spt-daemon\src\consent.rs:11://!   otherwise [`ConsentDecision::NeedsConsent`] carrying the resolved target the
crates\spt-daemon\src\consent.rs:12://!   orchestrator should prompt.
crates\spt-daemon\src\consent.rs:13://! - [`most_recently_active`] — resolve that target over the **v1 registry**
crates\spt-daemon\src\consent.rs:18://! What C2 deliberately does **not** build: the interactive prompt UX itself.
crates\spt-daemon\src\consent.rs:21://! (e.g. AskUserQuestion). [`ConsentDecision::NeedsConsent`] is the typed seam
crates\spt-daemon\src\consent.rs:31:/// The session a consent prompt should be delivered to: the user's
crates\spt-daemon\src\consent.rs:47:    /// Full-auto is opted in — apply without a prompt (the seamless path).
crates\spt-daemon\src\consent.rs:50:    /// resolved session to prompt, or `None` when no live session could be
crates\spt-daemon\src\consent.rs:52:    /// rather than applying unprompted — the gate never silently proceeds).
crates\spt-daemon\src\consent.rs:53:    NeedsConsent(Option<ConsentTarget>),
crates\spt-daemon\src\consent.rs:57:/// otherwise [`ConsentDecision::NeedsConsent`] with the `target` to prompt. No
crates\spt-daemon\src\consent.rs:59:/// [`most_recently_active`]) so the decision is deterministic and testable.
crates\spt-daemon\src\consent.rs:65:        ConsentDecision::NeedsConsent(target)
crates\spt-daemon\src\consent.rs:70:/// consent-prompt target (REQ-UPD-4). Enumerates the top-level (`Self_`) perches,
crates\spt-daemon\src\consent.rs:80:pub fn most_recently_active(owlery: &Path) -> Option<ConsentTarget> {
crates\spt-daemon\src\consent.rs:85:            continue; // only a *live* session can answer a prompt
crates\spt-daemon\src\consent.rs:118:    // [unit->REQ-UPD-4] full-auto opts out of the prompt; default gates with the
crates\spt-daemon\src\consent.rs:127:            ConsentDecision::NeedsConsent(Some(target))
crates\spt-daemon\src\consent.rs:129:        // Gated with no resolvable session is still NeedsConsent (never auto).
crates\spt-daemon\src\consent.rs:130:        assert_eq!(decide(false, None), ConsentDecision::NeedsConsent(None));
crates\spt-daemon\src\consent.rs:136:    fn resolves_most_recently_active_live_session() {
crates\spt-daemon\src\consent.rs:147:        let target = most_recently_active(&owlery).expect("a live session resolves");
crates\spt-daemon\src\consent.rs:153:    // never proceeds unprompted).
crates\spt-daemon\src\consent.rs:159:        assert!(most_recently_active(&owlery).is_none());
crates\spt-daemon\src\consent.rs:162:        assert!(most_recently_active(&owlery).is_none());
crates\spt-daemon\src\lib.rs:148:pub use consent::{decide as decide_consent, most_recently_active, ConsentDecision, ConsentTarget};
crates\spt-daemon\src\lifecycle.rs:44:/// ISO-ish stamp for the psyche prompt (opaque to the Psyche; just a timestamp).
crates\spt-daemon\src\lifecycle.rs:176:    /// against ([`crate::consent::most_recently_active`]). Best-effort: a hosted
crates\spt-daemon\src\lifecycle.rs:224:            // promptly rather than after a full (possibly multi-second) period.
crates\spt-daemon\src\nethost.rs:65:    /// no per-test-binary Windows firewall prompt).
crates\spt-daemon\src\nethost.rs:614:    /// CLOSE so peers' closed-watchers fire promptly (a bare host drop kills
crates\spt-daemon\src\relay.rs:125:    #[test] // [unit->REQ-DAEMON-1] run() exits promptly when the stop flag is set.


1	//! The broker-owned network host (D4a) — ADR-0004 §B made real for the wire.
2	//!
3	//! [`NetHost`] is the broker's ownership of the node's live network presence:
4	//! the iroh endpoint ([`spt_net::net::endpoint::NetEndpoint`]) and every
5	//! accepted/dialed QUIC connection. A live cross-node connection is exactly the
6	//! kind of resource ADR-0004 §B assigns to the broker — a consumer would lose
7	//! continuity on brain restart — so the endpoint is constructed *here*, in the
8	//! stable kernel, never in the brain. The brain drives net operations over the
9	//! versioned IPC ([`crate::msg::KIND_NET_STATUS`] / [`crate::msg::KIND_NET_DIAL`])
10	//! and may detach/reattach freely; connections persist in the broker's table.
11	//!
12	//! ## Runtime placement (M4-D4-PLAN locked decision)
13	//!
14	//! The broker is otherwise sync/thread-based; iroh requires tokio. So the host
15	//! owns a **dedicated multi-thread tokio runtime** on named worker threads
16	//! (`spt-broker-net`): QUIC objects live in structures only the runtime's tasks
17	//! and the host's own `block_on` bridges touch, and the runtime drops with the
18	//! host (broker teardown stops all net tasks). Spike #3's broker binary proved
19	//! exactly this shape — a broker-owned endpoint surviving brain kill/restart.
20	//!
21	//! ## Ownership closes two §B rows by construction
22	//!
23	//! `MdnsAddressLookup` and the relay session are both constructed *inside* the
24	//! iroh endpoint, so broker ownership of the endpoint closes the ADR-0004 §B
25	//! "mDNS socket / relay session" row with no extra machinery: there is no
26	//! separate socket to own.
27	// [impl->REQ-DAEMON-2]
28
29	use std::collections::{HashMap, VecDeque};
30	use std::io;
31	use std::sync::atomic::{AtomicU64, Ordering};
32	use std::sync::{Arc, Mutex};
33
34	use spt_net::net::endpoint::{
35	    BindScope, Connection, EndpointAddr, LocalDiscovery, NetEndpoint, RecvStream, RelayPolicy,
36	    SendStream, SPT_NET_ALPN,
37	};
38	use spt_proto::identity::{Identity, PublicKey};
39
40	use crate::broker::SharedSend;
41	use crate::codec::write_frame;
42	use crate::msg::{
43	    net_presence_event_envelope, net_stream_data_envelope, net_stream_eof_envelope,
44	    NetPresenceEvent, NetStreamInfo, PRESENCE_CONNECTED, PRESENCE_DISCONNECTED,
45	};
46
47	/// The reserved [`crate::effect::EffectKey`] session namespace for net-scoped
48	/// effects (a dial has no PTY session). Broker session ids are minted from 1
49	/// (`Broker::next_id` starts at 1), so `0` can never collide with a real session.
50	pub const NET_EFFECT_SESSION: u64 = 0;
51
52	/// Everything needed to stand the broker's endpoint up: the node's identity
53	/// (the endpoint *is* the node's name — EndpointId == pubkey), the relay stance
54	/// (from `daemon.json` in production; [`RelayPolicy::Disabled`] in hermetic
55	/// tests), and whether mDNS LAN discovery rides along.
56	pub struct NetConfig {
57	    /// The node identity the endpoint binds as (seed → same ed25519 keypair).
58	    pub identity: Identity,
59	    /// Relay stance (ADR-0002): n0 default / self-host / disabled.
60	    pub relay: RelayPolicy,
61	    /// mDNS LAN discovery on/off (off in hermetic tests).
62	    pub discovery: LocalDiscovery,
63	    /// Socket bind scope: [`BindScope::All`] in production;
64	    /// [`BindScope::Loopback`] in hermetic tests (no real interface touched,
65	    /// no per-test-binary Windows firewall prompt).
66	    pub scope: BindScope,
67	}
68
69	/// Default bounded stream-read ring depth (chunks) — same sizing rationale as
70	/// the PTY `OutputLog`: large enough that a brain-restart window never evicts
71	/// unreplayed chunks in practice; bounded so an unsubscribed stream can't
72	/// buffer without limit.
73	const DEFAULT_STREAM_RING_CHUNKS: usize = 4096;
74
75	/// Max bytes pulled per stream read (one ring chunk).
76	const STREAM_READ_CHUNK: usize = 64 * 1024;
77
78	/// One live QUIC connection the broker holds.
79	struct ConnEntry {
80	    /// The connection itself — kept alive by ownership here; dropping it closes
81	    /// the QUIC connection. Cloned (cheap handle) for stream opens.
82	    conn: Connection,
83	    /// The remote node's stable name (hex pubkey), proven by the QUIC handshake.
84	    remote_id_hex: String,
85	}
86
87	/// A bounded, sequenced read log for one QUIC stream — the PTY `OutputLog`
88	/// discipline applied to the network (D4b): the stream's single read pump is
89	/// the only producer; a brain connection attaches as the subscriber; every
90	/// subscriber send happens under the enclosing `Mutex`, so live chunks and a
91	/// replay can never interleave out of `seq` order. Chunks produced while no
92	/// brain is attached sit in the ring and replay on the next subscribe — a
93	/// brain restart loses nothing (Spike #3 invariant B/D).
94	// [impl->REQ-DAEMON-2]
95	struct StreamLog {
96	    stream_id: u64,
97	    next_seq: u64,
98	    ring: VecDeque<(u64, Vec<u8>)>,
99	    cap_chunks: usize,
100	    subscriber: Option<SharedSend>,
101	    /// Read side finished cleanly (peer called finish / conn ended).
102	    finished: bool,
103	}
104
105	impl StreamLog {
106	    fn new(stream_id: u64, cap_chunks: usize) -> Self {
107	        StreamLog {
108	            stream_id,
109	            next_seq: 0,
110	            ring: VecDeque::new(),
111	            cap_chunks,
112	            subscriber: None,
113	            finished: false,
114	        }
115	    }
116
117	    /// Append a freshly-read chunk: assign the next `seq`, retain it in the
118	    /// bounded ring, live-send to the current subscriber. Read-pump only.
119	    fn append(&mut self, bytes: &[u8]) {
120	        let seq = self.next_seq;

//! The broker-owned network host (D4a) — ADR-0004 §B made real for the wire.
//!
//! [`NetHost`] is the broker's ownership of the node's live network presence:
//! the iroh endpoint ([`spt_net::net::endpoint::NetEndpoint`]) and every
//! accepted/dialed QUIC connection. A live cross-node connection is exactly the
//! kind of resource ADR-0004 §B assigns to the broker — a consumer would lose
//! continuity on brain restart — so the endpoint is constructed *here*, in the
//! stable kernel, never in the brain. The brain drives net operations over the
//! versioned IPC ([`crate::msg::KIND_NET_STATUS`] / [`crate::msg::KIND_NET_DIAL`])
//! and may detach/reattach freely; connections persist in the broker's table.
//!
//! ## Runtime placement (M4-D4-PLAN locked decision)
//!
//! The broker is otherwise sync/thread-based; iroh requires tokio. So the host
//! owns a **dedicated multi-thread tokio runtime** on named worker threads
//! (`spt-broker-net`): QUIC objects live in structures only the runtime's tasks
//! and the host's own `block_on` bridges touch, and the runtime drops with the
//! host (broker teardown stops all net tasks). Spike #3's broker binary proved
//! exactly this shape — a broker-owned endpoint surviving brain kill/restart.
//!
//! ## Ownership closes two §B rows by construction
//!
//! `MdnsAddressLookup` and the relay session are both constructed *inside* the
//! iroh endpoint, so broker ownership of the endpoint closes the ADR-0004 §B
//! "mDNS socket / relay session" row with no extra machinery: there is no
//! separate socket to own.
// [impl->REQ-DAEMON-2]

use std::collections::{HashMap, VecDeque};
use std::io;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};

use spt_net::net::endpoint::{
    BindScope, Connection, EndpointAddr, LocalDiscovery, NetEndpoint, RecvStream, RelayPolicy,
    SendStream, SPT_NET_ALPN,
};
use spt_proto::identity::{Identity, PublicKey};

use crate::broker::SharedSend;
use crate::codec::write_frame;
use crate::msg::{
    net_presence_event_envelope, net_stream_data_envelope, net_stream_eof_envelope,
    NetPresenceEvent, NetStreamInfo, PRESENCE_CONNECTED, PRESENCE_DISCONNECTED,
};

/// The reserved [`crate::effect::EffectKey`] session namespace for net-scoped
/// effects (a dial has no PTY session). Broker session ids are minted from 1
/// (`Broker::next_id` starts at 1), so `0` can never collide with a real session.
pub const NET_EFFECT_SESSION: u64 = 0;

/// Everything needed to stand the broker's endpoint up: the node's identity
/// (the endpoint *is* the node's name — EndpointId == pubkey), the relay stance
/// (from `daemon.json` in production; [`RelayPolicy::Disabled`] in hermetic
/// tests), and whether mDNS LAN discovery rides along.
pub struct NetConfig {
    /// The node identity the endpoint binds as (seed → same ed25519 keypair).
    pub identity: Identity,
    /// Relay stance (ADR-0002): n0 default / self-host / disabled.
    pub relay: RelayPolicy,
    /// mDNS LAN discovery on/off (off in hermetic tests).
    pub discovery: LocalDiscovery,
    /// Socket bind scope: [`BindScope::All`] in production;
    /// [`BindScope::Loopback`] in hermetic tests (no real interface touched,
    /// no per-test-binary Windows firewall prompt).
    pub scope: BindScope,
}

/// Default bounded stream-read ring depth (chunks) — same sizing rationale as
/// the PTY `OutputLog`: large enough that a brain-restart window never evicts
/// unreplayed chunks in practice; bounded so an unsubscribed stream can't
/// buffer without limit.
const DEFAULT_STREAM_RING_CHUNKS: usize = 4096;

/// Max bytes pulled per stream read (one ring chunk).
const STREAM_READ_CHUNK: usize = 64 * 1024;

/// One live QUIC connection the broker holds.
struct ConnEntry {
    /// The connection itself — kept alive by ownership here; dropping it closes
    /// the QUIC connection. Cloned (cheap handle) for stream opens.
    conn: Connection,
    /// The remote node's stable name (hex pubkey), proven by the QUIC handshake.
    remote_id_hex: String,
}

/// A bounded, sequenced read log for one QUIC stream — the PTY `OutputLog`
/// discipline applied to the network (D4b): the stream's single read pump is
/// the only producer; a brain connection attaches as the subscriber; every
/// subscriber send happens under the enclosing `Mutex`, so live chunks and a
/// replay can never interleave out of `seq` order. Chunks produced while no
/// brain is attached sit in the ring and replay on the next subscribe — a
/// brain restart loses nothing (Spike #3 invariant B/D).
// [impl->REQ-DAEMON-2]
struct StreamLog {
    stream_id: u64,
    next_seq: u64,
    ring: VecDeque<(u64, Vec<u8>)>,
    cap_chunks: usize,
    subscriber: Option<SharedSend>,
    /// Read side finished cleanly (peer called finish / conn ended).
    finished: bool,
}

impl StreamLog {
    fn new(stream_id: u64, cap_chunks: usize) -> Self {
        StreamLog {
            stream_id,
            next_seq: 0,
            ring: VecDeque::new(),
            cap_chunks,
            subscriber: None,
            finished: false,
        }
    }

    /// Append a freshly-read chunk: assign the next `seq`, retain it in the
    /// bounded ring, live-send to the current subscriber. Read-pump only.
    fn append(&mut self, bytes: &[u8]) {
        let seq = self.next_seq;


1	//! Context bundle sync over broker streams (M4-D6c, ADR-0013, REQ-NET-3) —
2	//! the serve/request drivers for the [`SyncRecord`] protocol, the server-side
3	//! ref gate, and the post-pull reconcile wiring.
4	//!
5	//! One pull rides one bidi QUIC stream: the requester's daemon opens it and
6	//! sends [`SyncRecord::Request`] naming the context branches it wants plus its
7	//! current tips; the responder's [`serve_sync`] gates every ref, builds an
8	//! incremental bundle ([`BranchStore::create_bundle`]), and streams it back as
9	//! positional chunks. The requester fetches the bundle into quarantine and
10	//! rules per file with the vector driver ([`spt_store::syncmerge`]); surfaced
11	//! conflicts feed the Psyche reconcile when this node is the elected
12	//! reconciler ([`reconcile_after_sync`]).
13	//!
14	//! ## The server-side gate (REQ-INST-5, REQ-INST-13 — fail-closed)
15	//!
16	//! The requester names refs, but naming is not entitlement: [`SyncPolicy`]
17	//! re-checks every requested ref on the responder under the
18	//! **handshake-proven** origin node (never payload bytes, KNOWN-HAZARDS 7.5):
19	//!
20	//! - `a-<id>` is served only when some subnet S has the origin **pinned in the
21	//!   trust store** AND `synced(id, S)` holds (membership list + visibility —
22	//!   the D3e gate, hidden ⟹ not synced). A hidden or unsynced mind is refused
23	//!   even when asked for by name.
24	//! - `p-<project>` is served only to an origin trusted in at least one subnet,
25	//!   and only for a project this node actually hosts (the branch exists).
26	//!   *Honest residual:* the p- tier is branch-granular — a hidden endpoint's
27	//!   per-project slice inside a hosted project's branch rides that branch;
28	//!   per-file p- filtering is post-v1 (CONTEXT-MEMORY §Deferred).
29	//! - Anything else (machinery refs, `refs/spt-sync/…`) is never served.
30	//!
31	//! ## Wire discipline + recovery
32	//!
33	//! Same posture as the D5c transfer channel: fire-and-forget sends (`op_id:
34	//! None` — a journaled send starves a pump), chunks dedup by absolute offset.
35	//! There is deliberately **no resume protocol**: a torn pull (stream lost
36	//! mid-bundle) is recovered by simply requesting again — the apply is
37	//! idempotent (an already-joined tip short-circuits on ancestry), the
38	//! FAULT-MATRIX "stream loss mid-bundle" row.
39	// [impl->REQ-NET-3]
40	// [impl->REQ-HAZARD-WAN-ORIGIN-AUTH]
41
42	use std::collections::BTreeMap;
43	use std::io::{self, Seek, SeekFrom, Write};
44	use std::path::{Path, PathBuf};
45	use std::time::Duration;
46
47	use spt_live::reconcile::{reconcile_file, ReconcileOutcome, ReconcileRequest};
48	use spt_net::net::registry::Instance;
49	use spt_net::net::sync::{SyncDecoder, SyncRecord};
50	use spt_runtime::AgentRuntime;
51	use spt_store::contextstore::ContextStore;
52	use spt_store::syncmerge::{apply_fetched, worktree_for_branch, ApplyReport};
53	use spt_store::trust::TrustStore;
54	use spt_store::visibility::VisibilityStore;
55
56	use crate::brain::{Brain, BrokerEvent};
57	use crate::msg::{decode_bytes, encode_bytes};
58	use crate::reconcile::is_reconciler;
59
60	/// Raw bytes per [`SyncRecord::Chunk`] (base64 inside the JSON line — the
61	/// shared NDJSON posture, no binary fork of the codec).
62	const CHUNK_BYTES: usize = 64 * 1024;
63
64	/// The responder's per-ref sync gate: the node's subnet membership (name +
65	/// `hide_new_endpoints` flag), its pinned peers, and its visibility/sync
66	/// config. Loaded from the canonical stores in production; tests construct it
67	/// directly.
68	// [impl->REQ-INST-5]
69	pub struct SyncPolicy {
70	    /// `(subnet name, hide_new_endpoints)` for every subnet this node is in.
71	    pub subnets: Vec<(String, bool)>,
72	    pub trust: TrustStore,
73	    pub vis: VisibilityStore,
74	}
75
76	impl SyncPolicy {
77	    /// Load from the node's canonical stores (`subnet.json`, `trust/peers.json`,
78	    /// `visibility.json`). Re-read per pull — config edits take effect on the
79	    /// next request without a daemon restart (the access-gate stance).
80	    pub fn load() -> SyncPolicy {

//! Context bundle sync over broker streams (M4-D6c, ADR-0013, REQ-NET-3) —
//! the serve/request drivers for the [`SyncRecord`] protocol, the server-side
//! ref gate, and the post-pull reconcile wiring.
//!
//! One pull rides one bidi QUIC stream: the requester's daemon opens it and
//! sends [`SyncRecord::Request`] naming the context branches it wants plus its
//! current tips; the responder's [`serve_sync`] gates every ref, builds an
//! incremental bundle ([`BranchStore::create_bundle`]), and streams it back as
//! positional chunks. The requester fetches the bundle into quarantine and
//! rules per file with the vector driver ([`spt_store::syncmerge`]); surfaced
//! conflicts feed the Psyche reconcile when this node is the elected
//! reconciler ([`reconcile_after_sync`]).
//!
//! ## The server-side gate (REQ-INST-5, REQ-INST-13 — fail-closed)
//!
//! The requester names refs, but naming is not entitlement: [`SyncPolicy`]
//! re-checks every requested ref on the responder under the
//! **handshake-proven** origin node (never payload bytes, KNOWN-HAZARDS 7.5):
//!
//! - `a-<id>` is served only when some subnet S has the origin **pinned in the
//!   trust store** AND `synced(id, S)` holds (membership list + visibility —
//!   the D3e gate, hidden ⟹ not synced). A hidden or unsynced mind is refused
//!   even when asked for by name.
//! - `p-<project>` is served only to an origin trusted in at least one subnet,
//!   and only for a project this node actually hosts (the branch exists).
//!   *Honest residual:* the p- tier is branch-granular — a hidden endpoint's
//!   per-project slice inside a hosted project's branch rides that branch;
//!   per-file p- filtering is post-v1 (CONTEXT-MEMORY §Deferred).
//! - Anything else (machinery refs, `refs/spt-sync/…`) is never served.
//!
//! ## Wire discipline + recovery
//!
//! Same posture as the D5c transfer channel: fire-and-forget sends (`op_id:
//! None` — a journaled send starves a pump), chunks dedup by absolute offset.
//! There is deliberately **no resume protocol**: a torn pull (stream lost
//! mid-bundle) is recovered by simply requesting again — the apply is
//! idempotent (an already-joined tip short-circuits on ancestry), the
//! FAULT-MATRIX "stream loss mid-bundle" row.
// [impl->REQ-NET-3]
// [impl->REQ-HAZARD-WAN-ORIGIN-AUTH]

use std::collections::BTreeMap;
use std::io::{self, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
use std::time::Duration;

use spt_live::reconcile::{reconcile_file, ReconcileOutcome, ReconcileRequest};
use spt_net::net::registry::Instance;
use spt_net::net::sync::{SyncDecoder, SyncRecord};
use spt_runtime::AgentRuntime;
use spt_store::contextstore::ContextStore;
use spt_store::syncmerge::{apply_fetched, worktree_for_branch, ApplyReport};
use spt_store::trust::TrustStore;
use spt_store::visibility::VisibilityStore;

use crate::brain::{Brain, BrokerEvent};
use crate::msg::{decode_bytes, encode_bytes};
use crate::reconcile::is_reconciler;

/// Raw bytes per [`SyncRecord::Chunk`] (base64 inside the JSON line — the
/// shared NDJSON posture, no binary fork of the codec).
const CHUNK_BYTES: usize = 64 * 1024;

/// The responder's per-ref sync gate: the node's subnet membership (name +
/// `hide_new_endpoints` flag), its pinned peers, and its visibility/sync
/// config. Loaded from the canonical stores in production; tests construct it
/// directly.
// [impl->REQ-INST-5]
pub struct SyncPolicy {
    /// `(subnet name, hide_new_endpoints)` for every subnet this node is in.
    pub subnets: Vec<(String, bool)>,
    pub trust: TrustStore,
    pub vis: VisibilityStore,
}

impl SyncPolicy {
    /// Load from the node's canonical stores (`subnet.json`, `trust/peers.json`,
    /// `visibility.json`). Re-read per pull — config edits take effect on the
    /// next request without a daemon restart (the access-gate stance).
    pub fn load() -> SyncPolicy {


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> {

    // [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> {


1	//! The self-update engine (C0) — the update-class taxonomy and the brain-only
2	//! zero-interruption swap.
3	//!
4	//! ADR-0004 §A splits a self-update into **three classes**, each with its own
5	//! invariant, decided on two axes against the *running* broker:
6	//!
7	//! - **brain-only** — the routine case. The running broker can already host the
8	//!   release's brain (the versioned-IPC handshake accepts it) and the broker's
9	//!   held-resource ABI is unchanged, so only the logic swaps. **No endpoint
10	//!   process terminates or suspends** ([`REQ-UPD-3`], absolute for this class) —
11	//!   proven E2E over the M3b handoff substrate.
12	//! - **broker-compatible** — the release ships a newer broker that the running
13	//!   broker's IPC floor cannot host the new brain against, but the held-resource
14	//!   *type* is unchanged, so the new broker hot-adopts the existing handles
15	//!   behind the versioned IPC; endpoints still survive. (Apply path deferred —
16	//!   no spike yet, ADR-0004 §A.)
17	//! - **broker-breaking** — the held-resource *type* or an OS PTY/socket API
18	//!   changed; this needs a *planned endpoint-cycle* and MAY suspend endpoints
19	//!   with consent + scheduling. [`REQ-UPD-3`] is explicitly weakened here.
20	//!
21	//! C0 implements the **classifier** ([`classify`]) and the **brain-only apply**
22	//! ([`apply_brain_only`]). The apply *triggers a handoff* over the existing M3b
23	//! substrate ([`crate::brain::Brain::handoff`]) — it does **not** re-implement
24	//! one (clean-room posture, `M3c-PLAN.md` §C0). The signing/rollback hardening
25	//! that gates *which* binary is even eligible to swap arrives in C1.
26	// [impl->REQ-UPD-3]
27
28	use std::io;
29
30	use crate::brain::{Brain, BrainState};
31	use crate::frame::{IPC_PROTOCOL_VERSION, MIN_COMPATIBLE_VERSION};
32	use crate::release::{verify_artifact, verify_metadata, RejectReason, SignedRelease, VerifyPolicy};
33
34	/// The held-resource-type ABI of this build's broker — the broker-breaking axis
35	/// (ADR-0004 §A/§B). Bumped **only** when the *kind* of resource the broker holds
36	/// changes: a new held-resource type, or a new OS PTY/socket API that changes how
37	/// an existing handle is carried. A pure logic change (the routine update) never
38	/// touches this — that is precisely what keeps the routine case *brain-only*.
39	pub const BROKER_RESOURCE_ABI: u32 = 1;
40
41	/// What the currently-running broker provides — the fixed point an incoming
42	/// release is classified against (ADR-0004 §A). Captured from the live broker,
43	/// not from this build, because the whole point of a self-update is that the
44	/// *running* broker may be older than the binary proposing the update.
45	#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46	pub struct BrokerAbi {
47	    /// The IPC protocol version the running broker speaks.
48	    pub ipc_version: u32,
49	    /// The oldest brain IPC version the running broker will handshake
50	    /// (`accept_hello`'s floor). A release brain at or above this can attach to
51	    /// the running broker as-is.
52	    pub min_compatible: u32,
53	    /// The held-resource-type ABI tag ([`BROKER_RESOURCE_ABI`]) — the
54	    /// broker-breaking axis.
55	    pub resource_abi: u32,
56	}
57
58	impl BrokerAbi {
59	    /// The ABI of *this* build's broker. In the live daemon the running broker's
60	    /// ABI is read off the broker over the IPC; this is the value a broker built
61	    /// from this source reports (and the classifier's stand-in when the running
62	    /// broker is this same build).
63	    pub fn current() -> Self {
64	        BrokerAbi {
65	            ipc_version: IPC_PROTOCOL_VERSION,
66	            min_compatible: MIN_COMPATIBLE_VERSION,
67	            resource_abi: BROKER_RESOURCE_ABI,
68	        }
69	    }
70	}

//! The self-update engine (C0) — the update-class taxonomy and the brain-only
//! zero-interruption swap.
//!
//! ADR-0004 §A splits a self-update into **three classes**, each with its own
//! invariant, decided on two axes against the *running* broker:
//!
//! - **brain-only** — the routine case. The running broker can already host the
//!   release's brain (the versioned-IPC handshake accepts it) and the broker's
//!   held-resource ABI is unchanged, so only the logic swaps. **No endpoint
//!   process terminates or suspends** ([`REQ-UPD-3`], absolute for this class) —
//!   proven E2E over the M3b handoff substrate.
//! - **broker-compatible** — the release ships a newer broker that the running
//!   broker's IPC floor cannot host the new brain against, but the held-resource
//!   *type* is unchanged, so the new broker hot-adopts the existing handles
//!   behind the versioned IPC; endpoints still survive. (Apply path deferred —
//!   no spike yet, ADR-0004 §A.)
//! - **broker-breaking** — the held-resource *type* or an OS PTY/socket API
//!   changed; this needs a *planned endpoint-cycle* and MAY suspend endpoints
//!   with consent + scheduling. [`REQ-UPD-3`] is explicitly weakened here.
//!
//! C0 implements the **classifier** ([`classify`]) and the **brain-only apply**
//! ([`apply_brain_only`]). The apply *triggers a handoff* over the existing M3b
//! substrate ([`crate::brain::Brain::handoff`]) — it does **not** re-implement
//! one (clean-room posture, `M3c-PLAN.md` §C0). The signing/rollback hardening
//! that gates *which* binary is even eligible to swap arrives in C1.
// [impl->REQ-UPD-3]

use std::io;

use crate::brain::{Brain, BrainState};
use crate::frame::{IPC_PROTOCOL_VERSION, MIN_COMPATIBLE_VERSION};
use crate::release::{verify_artifact, verify_metadata, RejectReason, SignedRelease, VerifyPolicy};

/// The held-resource-type ABI of this build's broker — the broker-breaking axis
/// (ADR-0004 §A/§B). Bumped **only** when the *kind* of resource the broker holds
/// changes: a new held-resource type, or a new OS PTY/socket API that changes how
/// an existing handle is carried. A pure logic change (the routine update) never
/// touches this — that is precisely what keeps the routine case *brain-only*.
pub const BROKER_RESOURCE_ABI: u32 = 1;

/// What the currently-running broker provides — the fixed point an incoming
/// release is classified against (ADR-0004 §A). Captured from the live broker,
/// not from this build, because the whole point of a self-update is that the
/// *running* broker may be older than the binary proposing the update.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct BrokerAbi {
    /// The IPC protocol version the running broker speaks.
    pub ipc_version: u32,
    /// The oldest brain IPC version the running broker will handshake
    /// (`accept_hello`'s floor). A release brain at or above this can attach to
    /// the running broker as-is.
    pub min_compatible: u32,
    /// The held-resource-type ABI tag ([`BROKER_RESOURCE_ABI`]) — the
    /// broker-breaking axis.
    pub resource_abi: u32,
}

impl BrokerAbi {
    /// The ABI of *this* build's broker. In the live daemon the running broker's
    /// ABI is read off the broker over the IPC; this is the value a broker built
    /// from this source reports (and the classifier's stand-in when the running
    /// broker is this same build).
    pub fn current() -> Self {
        BrokerAbi {
            ipc_version: IPC_PROTOCOL_VERSION,
            min_compatible: MIN_COMPATIBLE_VERSION,
            resource_abi: BROKER_RESOURCE_ABI,
        }
    }
}


Grep

struct Session|pub struct Manifest|commune_dir|signoff_dir|psyche_resume|notif


28:pub struct Manifest {
96:/// `[session]` — the watched-dir keys (`commune_dir`/`signoff_dir`) co-located
99:pub struct Session {
103:    pub commune_dir: Option<String>,
105:    pub signoff_dir: Option<String>,
114:    pub psyche_resume: Option<SessionRole>,
124:        self.commune_dir.is_none()
125:            && self.signoff_dir.is_none()
128:            && self.psyche_resume.is_none()
140:            ("psyche_resume", &self.psyche_resume),
157:pub struct SessionRole {
630:commune_dir = ".claude"
631:signoff_dir = ".claude"
706:        assert_eq!(m.session.commune_dir.as_deref(), Some(".claude"));


1	# M4-D7.5 — Psyche outbound relay channel (JIT plan)
2
3	**Status:** COMPLETE 2026-06-04 — D7.5-1 `6fac539` (taxonomy +reply/+notify,
4	intent parser with the structural strip) + D7.5-2 `3f771e1` (daemon relay +
5	sanitize boundary, REQ-HAZARD-PSYCHE-OUTBOUND-PROXY activated `[impl, unit]`,
6	FAULT-MATRIX rows 22–25), both CI-green FINAL. Acceptance met: reply relays to
7	the inbound sender with the re-stamped from; notify reaches the user (own Self
8	perch, daemon-authored envelope); Psyche-supplied `from=`/`to=` stripped
9	(anti-spoof negative); a null-stdout driver fails the guard with zero
10	deliveries. Production turn-trigger loop rides D9; the notify surface swaps to
11	the notif primitive in D8c. (D7.5-1's first run flaked on gravity — sync
12	stream-wait 2s budget under load; hardened to 10s in D7.5-2.)
13
14	## Goal
15
16	The Psyche's sole outbound channel comes alive end-to-end (ADR-0012 / hazard
17	7.3): a bounded Psyche turn's **captured stdout** is parsed for
18	`<EVENT type="reply">` / `<EVENT type="notify">` intents, and the daemon —
19	the Psyche's outbound proxy — **sanitizes and relays** them: every
20	Psyche-supplied `from=`/target/routing attribute is stripped, `from=` is
21	re-stamped with the daemon-known psyche id, and routing is **constrained** —
22	a `reply` goes only to the `__REPLY_TO__` sender of the message being
23	answered; a `notify` reaches only the agent's own user. Acceptance
24	(M4-PLAN §D7.5): a Psyche turn emitting `<EVENT type="reply">` relays to the
25	inbound sender; `<EVENT type="notify">` reaches the user; a Psyche-supplied
26	`from=` is stripped (anti-spoof negative test); a null-stdout driver fails
27	the guard.
28
29	D7.5a (the bounded, stdin-fed, stdout-captured turn driver) already landed in
30	D6b (`spt-live/src/turn.rs`, ADR-0013 pull-forward) — including the
31	`EmptyOutput` guard (a no-stdout turn fails loudly, never silent success).
32	This plan is **D7.5b + D7.5c** consuming that driver.
33
34	## Shape (locked by what exists — no new design forks)
35
36	- **One envelope grammar everywhere (ADR-0012 considered-options).** The Psyche
37	  authors the same `<EVENT>` grammar the wire uses (`spt-proto::event`); the
38	  taxonomy grows `+reply` `+notify`. The daemon's strip-and-re-stamp gives the
39	  anti-spoof guarantee defensively — the markup is never trusted.
40	- **Sanitize is structural, not best-effort.** The intent parser yields
41	  `PsycheIntent::Reply { body }` / `Notify { body }` — **body only, no attr
42	  fields exist on the type**. A spoofed `from=`/`to=` is discarded at parse
43	  and is unrepresentable downstream; the relay then composes the wire envelope
44	  itself with the re-stamped `from=<psyche_id>`. The negative test still
45	  proves the end-to-end wire shape (spoofed attrs in stdout → relayed message
46	  carries the daemon-stamped from; the spoof target gets nothing).
47	- **Constrained routing, fail-closed.** `reply` → the `__REPLY_TO__` sender of
48	  the inbound being answered, supplied by the caller; **no sender ⇒ the reply
49	  is dropped with a typed outcome** (never broadcast, never guessed).
50	  `notify` → the agent's **own Self perch** (the user's surface today —
51	  the session relay); D8 refactors this leg into the notif primitive
52	  (M4-PLAN D8c: notify-from-psyche is D8's Psyche-producer path).
53	- **Body validation = the 4.1 codec + a cap.** Bodies are composed/escaped via
54	  `spt-proto` (`compose_msg_event` / `compose_typed_event` — `\n`→`<br>`,
55	  amp-last entities); oversize delivery chunking already exists (T3
56	  `EVENT-PART`). The relay adds a sanity cap on a runaway Psyche body
57	  (64 KiB) — over-cap intents drop with a typed outcome.
58	- **Transport = the existing msg substrate.** Relay delivery is
59	  `spt_msg::send` (TCP-first, spool-fallback, `__REPLY_TO__` carries the
60	  re-stamped from). Nothing new on the wire; the WAN leg rides the existing
61	  D5a path when the sender is remote.
62	- **When a turn fires is NOT this slice.** The production "inbound psyche
63	  message → turn → relay" trigger loop rides the D9 daemon lifecycle with
64	  every other trigger loop (same deferral as D5/D6/D7 serve loops). D7.5
65	  ships the per-turn machinery: `turn → parse → sanitize → relay` as one
66	  callable driver.
67
68	## Slices (each CI-green before the next)
69
70	1. **D7.5-1 — event-type taxonomy + Psyche intent parser (D7.5b).**
71	   - `spt-proto/src/event.rs`: named event-type constants for the taxonomy
72	     (`msg`, `alarm`, `commune`, `file_drop`, `echo_commune`, **+ `reply`,
73	     + `notify`**) and `is_psyche_authorable_type` (true only for
74	     reply/notify — the Psyche never authors `commune`; that is the
75	     Summarizer's inbound delta). `[impl->REQ-ARCH-4]` on the grammar.
76	   - `spt-live/src/outbound.rs`: `PsycheIntent { Reply { body }, Notify
77	     { body } }` + `parse_psyche_intents(stdout: &str) -> Vec<PsycheIntent>` —
78	     scan the captured stdout line-by-line with `spt_proto::event::parse_event`,
79	     keep only `reply`/`notify` envelopes (decoded bodies), drop every
80	     supplied attribute (the structural strip), ignore prose / other event
81	     types / `EVENT-PART` lines. `[impl->REQ-HAZARD-PSYCHE-OUTBOUND-PROXY]`.
82	   - Unit tests: reply + notify parsed in stdout order; prose-with-markers
83	     interleaved; spoofed `from=`/`to=` attrs unrepresentable in the result;
84	     `commune`/`msg`/`echo_commune` types from a confused Psyche ignored;
85	     empty body envelope yields an intent with empty body (relay drops it);
86	     malformed envelopes skipped panic-free.
87	2. **D7.5-2 — daemon relay + sanitize boundary + E2E (D7.5c).**
88	   - `spt-daemon/src/psyrelay.rs`:
89	     - `RelayOutcome` — `Replied { to }`, `Notified { to }`,
90	       `DroppedNoReplyTarget`, `DroppedEmptyBody`, `DroppedOversize` (loud +
91	       typed, never silent).
92	     - `relay_psyche_outbound(psyche_id, self_id, reply_to: Option<&str>,
93	       intents, owlery) -> Vec<RelayOutcome>` — the trust boundary:
94	       `Reply` → `spt_msg::send(reply_to, psyche_id, body)`; `None` target →
95	       `DroppedNoReplyTarget`. `Notify` → compose
96	       `<EVENT type="notify" from="<psyche_id>">body</EVENT>`
97	       (`compose_typed_event`; typed envelopes pass through delivery verbatim)
98	       → `spt_msg::send(self_id, …)`. Empty/oversize bodies dropped.
99	     - `psyche_turn_and_relay(runtime, keys, inbound, reply_to, psyche_id,
100	       self_id, owlery, timeout)` — the per-turn driver: compose stdin from
101	       the inbound envelope → `run_psyche_turn` (D7.5a) → `parse_psyche_intents`
102	       → `relay_psyche_outbound`. A `TurnError` (incl. `EmptyOutput` — the
103	       null-stdout shape) relays **nothing**.
104	   - Tests (mock `psyche_resume` manifests that print scripted stdout):
105	     - happy reply: turn emits a reply envelope → the sender's perch spool
106	       holds `__REPLY_TO__:<psyche_id>` + the body
107	       (`[unit->REQ-HAZARD-PSYCHE-OUTBOUND-PROXY]`).
108	     - **anti-spoof negative**: stdout emits
109	       `<EVENT type="reply" from="evil" to="victim">…</EVENT>` → the relayed
110	       message carries the daemon-stamped from; the `victim` perch receives
111	       nothing; no Psyche-supplied attribute survives to the wire
112	       (`[unit->REQ-HAZARD-PSYCHE-OUTBOUND-PROXY]`).
113	     - notify constrained to own user: lands at the Self perch as the typed
114	       notify envelope with the re-stamped from; carries no addressing the
115	       Psyche chose (`[unit->REQ-HAZARD-PSYCHE-OUTBOUND-PROXY]`).
116	     - reply with no inbound sender → `DroppedNoReplyTarget`, zero deliveries.
117	     - **null-stdout driver fails the guard**: a no-output `psyche_resume` →
118	       `TurnError::EmptyOutput` surfaces from `psyche_turn_and_relay`, zero
119	       deliveries (the 7.3(a) acceptance leg, E2E shape over the existing
120	       turn.rs unit).
121	     - prose-only stdout → zero intents, zero deliveries (a chatty Psyche that
122	       emitted no envelope relays nothing).
123	   - **Activate `REQ-HAZARD-PSYCHE-OUTBOUND-PROXY` → `[impl, unit]`** in
124	     `traceable-reqs.toml` (turn.rs tags from D6b + this slice's evidence).
125	   - FAULT-MATRIX rows (same commit): Psyche emits spoofed routing (inert —
126	     stripped structurally); Psyche turn with no reply target (fail-closed
127	     drop); null-stdout driver (loud guard, row 16 sibling); runaway oversize
128	     body (typed drop).
129
130	## NOT in D7.5
131
132	- The production trigger loop (when an inbound message fires a Psyche turn) —
133	  D9 daemon lifecycle, with the D5/D6/D7 loops.
134	- The notif primitive — D8; the notify leg's delivery surface (Self perch)
135	  is the documented interim, swapped in D8c.
136	- Targeted reply (`to=`) — rejected for v1 by ADR-0012 (reply-to-sender only).
137	- Summarizer changes — it authors commune deltas only, untouched here.
138
139	## Conventions (carried)
140
141	- NO `cargo fmt`. Tag `[impl->REQ-…]` / `[unit->REQ-…]` on real evidence.
142	  `traceable-reqs check` from repo root before done. Linux CI clippy
143	  `-D warnings` is the real gate. Push each slice → watch FINAL green before
144	  next (`gh run list --json conclusion`).
145	  Commit trailer: `Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>`.
146

# M4-D7.5 — Psyche outbound relay channel (JIT plan)

**Status:** COMPLETE 2026-06-04 — D7.5-1 `6fac539` (taxonomy +reply/+notify,
intent parser with the structural strip) + D7.5-2 `3f771e1` (daemon relay +
sanitize boundary, REQ-HAZARD-PSYCHE-OUTBOUND-PROXY activated `[impl, unit]`,
FAULT-MATRIX rows 22–25), both CI-green FINAL. Acceptance met: reply relays to
the inbound sender with the re-stamped from; notify reaches the user (own Self
perch, daemon-authored envelope); Psyche-supplied `from=`/`to=` stripped
(anti-spoof negative); a null-stdout driver fails the guard with zero
deliveries. Production turn-trigger loop rides D9; the notify surface swaps to
the notif primitive in D8c. (D7.5-1's first run flaked on gravity — sync
stream-wait 2s budget under load; hardened to 10s in D7.5-2.)

## Goal

The Psyche's sole outbound channel comes alive end-to-end (ADR-0012 / hazard
7.3): a bounded Psyche turn's **captured stdout** is parsed for
`<EVENT type="reply">` / `<EVENT type="notify">` intents, and the daemon —
the Psyche's outbound proxy — **sanitizes and relays** them: every
Psyche-supplied `from=`/target/routing attribute is stripped, `from=` is
re-stamped with the daemon-known psyche id, and routing is **constrained** —
a `reply` goes only to the `__REPLY_TO__` sender of the message being
answered; a `notify` reaches only the agent's own user. Acceptance
(M4-PLAN §D7.5): a Psyche turn emitting `<EVENT type="reply">` relays to the
inbound sender; `<EVENT type="notify">` reaches the user; a Psyche-supplied
`from=` is stripped (anti-spoof negative test); a null-stdout driver fails
the guard.

D7.5a (the bounded, stdin-fed, stdout-captured turn driver) already landed in
D6b (`spt-live/src/turn.rs`, ADR-0013 pull-forward) — including the
`EmptyOutput` guard (a no-stdout turn fails loudly, never silent success).
This plan is **D7.5b + D7.5c** consuming that driver.

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

- **One envelope grammar everywhere (ADR-0012 considered-options).** The Psyche
  authors the same `<EVENT>` grammar the wire uses (`spt-proto::event`); the
  taxonomy grows `+reply` `+notify`. The daemon's strip-and-re-stamp gives the
  anti-spoof guarantee defensively — the markup is never trusted.
- **Sanitize is structural, not best-effort.** The intent parser yields
  `PsycheIntent::Reply { body }` / `Notify { body }` — **body only, no attr
  fields exist on the type**. A spoofed `from=`/`to=` is discarded at parse
  and is unrepresentable downstream; the relay then composes the wire envelope
  itself with the re-stamped `from=<psyche_id>`. The negative test still
  proves the end-to-end wire shape (spoofed attrs in stdout → relayed message
  carries the daemon-stamped from; the spoof target gets nothing).
- **Constrained routing, fail-closed.** `reply` → the `__REPLY_TO__` sender of
  the inbound being answered, supplied by the caller; **no sender ⇒ the reply
  is dropped with a typed outcome** (never broadcast, never guessed).
  `notify` → the agent's **own Self perch** (the user's surface today —
  the session relay); D8 refactors this leg into the notif primitive
  (M4-PLAN D8c: notify-from-psyche is D8's Psyche-producer path).
- **Body validation = the 4.1 codec + a cap.** Bodies are composed/escaped via
  `spt-proto` (`compose_msg_event` / `compose_typed_event` — `\n`→`<br>`,
  amp-last entities); oversize delivery chunking already exists (T3
  `EVENT-PART`). The relay adds a sanity cap on a runaway Psyche body
  (64 KiB) — over-cap intents drop with a typed outcome.
- **Transport = the existing msg substrate.** Relay delivery is
  `spt_msg::send` (TCP-first, spool-fallback, `__REPLY_TO__` carries the
  re-stamped from). Nothing new on the wire; the WAN leg rides the existing
  D5a path when the sender is remote.
- **When a turn fires is NOT this slice.** The production "inbound psyche
  message → turn → relay" trigger loop rides the D9 daemon lifecycle with
  every other trigger loop (same deferral as D5/D6/D7 serve loops). D7.5
  ships the per-turn machinery: `turn → parse → sanitize → relay` as one
  callable driver.

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

1. **D7.5-1 — event-type taxonomy + Psyche intent parser (D7.5b).**
   - `spt-proto/src/event.rs`: named event-type constants for the taxonomy
     (`msg`, `alarm`, `commune`, `file_drop`, `echo_commune`, **+ `reply`,
     + `notify`**) and `is_psyche_authorable_type` (true only for
     reply/notify — the Psyche never authors `commune`; that is the
     Summarizer's inbound delta). `[impl->REQ-ARCH-4]` on the grammar.
   - `spt-live/src/outbound.rs`: `PsycheIntent { Reply { body }, Notify
     { body } }` + `parse_psyche_intents(stdout: &str) -> Vec<PsycheIntent>` —
     scan the captured stdout line-by-line with `spt_proto::event::parse_event`,
     keep only `reply`/`notify` envelopes (decoded bodies), drop every
     supplied attribute (the structural strip), ignore prose / other event
     types / `EVENT-PART` lines. `[impl->REQ-HAZARD-PSYCHE-OUTBOUND-PROXY]`.
   - Unit tests: reply + notify parsed in stdout order; prose-with-markers
     interleaved; spoofed `from=`/`to=` attrs unrepresentable in the result;
     `commune`/`msg`/`echo_commune` types from a confused Psyche ignored;
     empty body envelope yields an intent with empty body (relay drops it);
     malformed envelopes skipped panic-free.
2. **D7.5-2 — daemon relay + sanitize boundary + E2E (D7.5c).**
   - `spt-daemon/src/psyrelay.rs`:
     - `RelayOutcome` — `Replied { to }`, `Notified { to }`,
       `DroppedNoReplyTarget`, `DroppedEmptyBody`, `DroppedOversize` (loud +
       typed, never silent).
     - `relay_psyche_outbound(psyche_id, self_id, reply_to: Option<&str>,
       intents, owlery) -> Vec<RelayOutcome>` — the trust boundary:
       `Reply` → `spt_msg::send(reply_to, psyche_id, body)`; `None` target →
       `DroppedNoReplyTarget`. `Notify` → compose
       `<EVENT type="notify" from="<psyche_id>">body</EVENT>`
       (`compose_typed_event`; typed envelopes pass through delivery verbatim)
       → `spt_msg::send(self_id, …)`. Empty/oversize bodies dropped.
     - `psyche_turn_and_relay(runtime, keys, inbound, reply_to, psyche_id,
       self_id, owlery, timeout)` — the per-turn driver: compose stdin from
       the inbound envelope → `run_psyche_turn` (D7.5a) → `parse_psyche_intents`
       → `relay_psyche_outbound`. A `TurnError` (incl. `EmptyOutput` — the
       null-stdout shape) relays **nothing**.
   - Tests (mock `psyche_resume` manifests that print scripted stdout):
     - happy reply: turn emits a reply envelope → the sender's perch spool
       holds `__REPLY_TO__:<psyche_id>` + the body
       (`[unit->REQ-HAZARD-PSYCHE-OUTBOUND-PROXY]`).
     - **anti-spoof negative**: stdout emits
       `<EVENT type="reply" from="evil" to="victim">…</EVENT>` → the relayed
       message carries the daemon-stamped from; the `victim` perch receives
       nothing; no Psyche-supplied attribute survives to the wire
       (`[unit->REQ-HAZARD-PSYCHE-OUTBOUND-PROXY]`).
     - notify constrained to own user: lands at the Self perch as the typed
       notify envelope with the re-stamped from; carries no addressing the
       Psyche chose (`[unit->REQ-HAZARD-PSYCHE-OUTBOUND-PROXY]`).
     - reply with no inbound sender → `DroppedNoReplyTarget`, zero deliveries.
     - **null-stdout driver fails the guard**: a no-output `psyche_resume` →
       `TurnError::EmptyOutput` surfaces from `psyche_turn_and_relay`, zero
       deliveries (the 7.3(a) acceptance leg, E2E shape over the existing
       turn.rs unit).
     - prose-only stdout → zero intents, zero deliveries (a chatty Psyche that
       emitted no envelope relays nothing).
   - **Activate `REQ-HAZARD-PSYCHE-OUTBOUND-PROXY` → `[impl, unit]`** in
     `traceable-reqs.toml` (turn.rs tags from D6b + this slice's evidence).
   - FAULT-MATRIX rows (same commit): Psyche emits spoofed routing (inert —
     stripped structurally); Psyche turn with no reply target (fail-closed
     drop); null-stdout driver (loud guard, row 16 sibling); runaway oversize
     body (typed drop).

## NOT in D7.5

- The production trigger loop (when an inbound message fires a Psyche turn) —
  D9 daemon lifecycle, with the D5/D6/D7 loops.
- The notif primitive — D8; the notify leg's delivery surface (Self perch)
  is the documented interim, swapped in D8c.
- Targeted reply (`to=`) — rejected for v1 by ADR-0012 (reply-to-sender only).
- Summarizer changes — it authors commune deltas only, untouched here.

## 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>`.


30:pub const EVENT_TYPE_MSG: &str = "msg";
32:pub const EVENT_TYPE_ALARM: &str = "alarm";
34:pub const EVENT_TYPE_COMMUNE: &str = "commune";
36:pub const EVENT_TYPE_FILE_DROP: &str = "file_drop";
38:pub const EVENT_TYPE_ECHO_COMMUNE: &str = "echo_commune";
41:pub const EVENT_TYPE_REPLY: &str = "reply";
44:pub const EVENT_TYPE_NOTIFY: &str = "notify";
52:pub fn is_psyche_authorable_type(event_type: &str) -> bool {
53:    event_type == EVENT_TYPE_REPLY || event_type == EVENT_TYPE_NOTIFY
64:pub fn compose_msg_event(from: &str, body: &str) -> String {
78:pub fn compose_typed_event(event_type: &str, attrs: &[(&str, &str)], body: &str) -> String {
98:pub fn compose_alarm_event(target_iso: &str, current_iso: &str, body: &str) -> String {
113:pub fn is_typed_event_envelope(body: &str) -> bool {
134:    pub fn attr(&self, key: &str) -> Option<&str> {
142:    pub fn from(&self) -> Option<&str> {
163:pub fn parse_event(line: &str) -> Option<ParsedEvent> {
321:        assert!(is_psyche_authorable_type(EVENT_TYPE_REPLY));
322:        assert!(is_psyche_authorable_type(EVENT_TYPE_NOTIFY));
324:            EVENT_TYPE_MSG,
325:            EVENT_TYPE_ALARM,
326:            EVENT_TYPE_COMMUNE,
327:            EVENT_TYPE_FILE_DROP,
328:            EVENT_TYPE_ECHO_COMMUNE,


23:pub struct EpochSource {
30:    pub fn load() -> EpochSource {
38:    pub fn load_from(path: &Path) -> EpochSource {
47:    pub fn current(&self) -> u64 {
55:    pub fn next_epoch(&mut self) -> std::io::Result<u64> {


96	/// `[session]` — the watched-dir keys (`commune_dir`/`signoff_dir`) co-located
97	/// with the fixed set of `[session.<role>]` command templates.
98	#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
99	pub struct Session {
100	    // Watched dirs (direct `[session]` keys). Filenames are spt-core-fixed;
101	    // only the directory is adapter-declared (MANIFEST §watched dirs).
102	    #[serde(default, skip_serializing_if = "Option::is_none")]
103	    pub commune_dir: Option<String>,
104	    #[serde(default, skip_serializing_if = "Option::is_none")]
105	    pub signoff_dir: Option<String>,
106	    // Role templates (`[session.<role>]`). `self` is the agent's own session;
107	    // the `psyche_*`/`echo_commune`/`signoff` roles are parsed here but only
108	    // *executed* in M2b.
109	    #[serde(rename = "self", default, skip_serializing_if = "Option::is_none")]
110	    pub self_: Option<SessionRole>,
111	    #[serde(default, skip_serializing_if = "Option::is_none")]
112	    pub psyche_init: Option<SessionRole>,
113	    #[serde(default, skip_serializing_if = "Option::is_none")]
114	    pub psyche_resume: Option<SessionRole>,
115	    #[serde(default, skip_serializing_if = "Option::is_none")]
116	    pub echo_commune: Option<SessionRole>,
117	    #[serde(default, skip_serializing_if = "Option::is_none")]
118	    pub signoff: Option<SessionRole>,
119	}
120
121	impl Session {
122	    /// True when no dirs and no roles are declared (skip-serialize helper).
123	    pub fn is_empty(&self) -> bool {
124	        self.commune_dir.is_none()
125	            && self.signoff_dir.is_none()
126	            && self.self_.is_none()
127	            && self.psyche_init.is_none()
128	            && self.psyche_resume.is_none()
129	            && self.echo_commune.is_none()
130	            && self.signoff.is_none()
131	    }
132
133	    /// All declared role templates, paired with their role name. Used by the
134	    /// runtime to enumerate spawnable roles.
135	    pub fn roles(&self) -> Vec<(&'static str, &SessionRole)> {
136	        let mut out = Vec::new();
137	        for (name, role) in [
138	            ("self", &self.self_),
139	            ("psyche_init", &self.psyche_init),
140	            ("psyche_resume", &self.psyche_resume),
141	            ("echo_commune", &self.echo_commune),
142	            ("signoff", &self.signoff),
143	        ] {
144	            if let Some(r) = role {
145	                out.push((name, r));
146	            }
147	        }
148	        out
149	    }
150	}
151
152	/// `[session.<role>]` — one opaque outbound command template plus its spawn
153	/// context. Model/tools/flags all live inside `command`, never as fields
154	/// (MANIFEST §session roles). No nested tables here (keeps TOML round-trip
155	/// emission scalar-before-table clean).
156	#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
157	pub struct SessionRole {
158	    /// Opaque command line, with `{key}` substitution placeholders.
159	    pub command: String,
160	    #[serde(default, skip_serializing_if = "Option::is_none")]
161	    pub cwd: Option<String>,
162	    /// Env var set on summarizer children so their own hooks bail (recursion
163	    /// guard).
164	    #[serde(default, skip_serializing_if = "Option::is_none")]
165	    pub recursion_guard_env: Option<String>,
166	    #[serde(default)]
167	    pub detach: bool,
168	    /// Env vars to strip from the child's inherited environment.
169	    #[serde(default, skip_serializing_if = "Vec::is_empty")]
170	    pub env_remove: Vec<String>,
171	    /// Substitution keys spt-core guarantees to fill for this role.
172	    #[serde(default, skip_serializing_if = "Vec::is_empty")]
173	    pub keys: Vec<String>,
174	}
175
176	/// `[env.<VAR>]` — a single env-var directive.
177	#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
178	pub struct EnvVar {
179	    pub direction: EnvDirection,
180	    /// Value to inject (with substitution); required for `inject`.
181	    #[serde(default, skip_serializing_if = "Option::is_none")]
182	    pub value: Option<String>,
183	    /// Harness-hosted injection channel (spt-hosted inherits from the broker).
184	    #[serde(default, skip_serializing_if = "Option::is_none")]
185	    pub channel: Option<String>,

/// `[session]` — the watched-dir keys (`commune_dir`/`signoff_dir`) co-located
/// with the fixed set of `[session.<role>]` command templates.
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct Session {
    // Watched dirs (direct `[session]` keys). Filenames are spt-core-fixed;
    // only the directory is adapter-declared (MANIFEST §watched dirs).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub commune_dir: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub signoff_dir: Option<String>,
    // Role templates (`[session.<role>]`). `self` is the agent's own session;
    // the `psyche_*`/`echo_commune`/`signoff` roles are parsed here but only
    // *executed* in M2b.
    #[serde(rename = "self", default, skip_serializing_if = "Option::is_none")]
    pub self_: Option<SessionRole>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub psyche_init: Option<SessionRole>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub psyche_resume: Option<SessionRole>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub echo_commune: Option<SessionRole>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub signoff: Option<SessionRole>,
}

impl Session {
    /// True when no dirs and no roles are declared (skip-serialize helper).
    pub fn is_empty(&self) -> bool {
        self.commune_dir.is_none()
            && self.signoff_dir.is_none()
            && self.self_.is_none()
            && self.psyche_init.is_none()
            && self.psyche_resume.is_none()
            && self.echo_commune.is_none()
            && self.signoff.is_none()
    }

    /// All declared role templates, paired with their role name. Used by the
    /// runtime to enumerate spawnable roles.
    pub fn roles(&self) -> Vec<(&'static str, &SessionRole)> {
        let mut out = Vec::new();
        for (name, role) in [
            ("self", &self.self_),
            ("psyche_init", &self.psyche_init),
            ("psyche_resume", &self.psyche_resume),
            ("echo_commune", &self.echo_commune),
            ("signoff", &self.signoff),
        ] {
            if let Some(r) = role {
                out.push((name, r));
            }
        }
        out
    }
}

/// `[session.<role>]` — one opaque outbound command template plus its spawn
/// context. Model/tools/flags all live inside `command`, never as fields
/// (MANIFEST §session roles). No nested tables here (keeps TOML round-trip
/// emission scalar-before-table clean).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SessionRole {
    /// Opaque command line, with `{key}` substitution placeholders.
    pub command: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub cwd: Option<String>,
    /// Env var set on summarizer children so their own hooks bail (recursion
    /// guard).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub recursion_guard_env: Option<String>,
    #[serde(default)]
    pub detach: bool,
    /// Env vars to strip from the child's inherited environment.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub env_remove: Vec<String>,
    /// Substitution keys spt-core guarantees to fill for this role.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub keys: Vec<String>,
}

/// `[env.<VAR>]` — a single env-var directive.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct EnvVar {
    pub direction: EnvDirection,
    /// Value to inject (with substitution); required for `inject`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub value: Option<String>,
    /// Harness-hosted injection channel (spt-hosted inherits from the broker).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub channel: Option<String>,


Research D8 prerequisites (M4-PLAN §D8, D3 distribution, ADR-0007, boundaries, update/consent producers)

Read M4-PLAN §D8, ADR-0007, D3/D4d replication machinery (replicate.rs, registry.rs, ndjson.rs), spool.rs, consent.rs most_recently_active, psyrelay.rs notify leg, visibility.rs, update-prompt producer (M3c), reported boundaries (api boundary/wake/new-session) — enough to draft M4-D8-PLAN.md.

Draft M4-D8-PLAN.md (slices D8a–D8d)

Author the D8 phase plan: notif first-class kind + per-subnet replicated spool (D8a, REQ-NOTIF-1), first-fire/resurface/suppression (D8b), refactor update+consent producers + swap D7.5 notify leg (D8c), spt notify CLI + notif_command manifest seam (D8d, REQ-NOTIF-2). Commit plan.