1	//! Self-update consent gating (C2) — the REQ-UPD-4 decision + the
2	//! most-recently-active-session resolver.
3	//!
4	//! ADR-0004 makes self-update **not fully automatic by default**: a verified,
5	//! eligible update is gated on a user confirmation delivered to the user's
6	//! **most-recently-active live session**, with an explicit opt-in to full-auto
7	//! (the seamless, prompt-free path). C2 ships the load-bearing, fully-testable
8	//! core of that:
9	//!
10	//! - [`decide`] — the **pure** gate: full-auto ⇒ [`ConsentDecision::AutoApproved`];
11	//!   otherwise [`ConsentDecision::NeedsConsent`] carrying the resolved target the
12	//!   orchestrator should prompt.
13	//! - [`most_recently_active`] — resolve that target over the **v1 registry**
14	//!   (live perches + their `last_active_ms` recency stamp). This is the
15	//!   registry-resolution precursor CONTEXT names; the deferred PresenceChannel
16	//!   later generalizes it.
17	//!
18	//! What C2 deliberately does **not** build: the interactive prompt UX itself.
19	//! That rides the deferred notification primitive (ADR-0007 — `update-available`
20	//! / `consent-needed` producers) and the harness's native confirm affordance
21	//! (e.g. AskUserQuestion). [`ConsentDecision::NeedsConsent`] is the typed seam
22	//! those plug into; nothing here reaches into a session.
23	// [impl->REQ-UPD-4]
24
25	use std::path::{Path, PathBuf};
26
27	use spt_store::info::read_info;
28	use spt_store::liveness::is_perch_alive;
29	use spt_store::perch::{self, ParentHint};
30
31	/// The session a consent prompt should be delivered to: the user's
32	/// most-recently-active live perch. Carries enough to address it (id + path) and
33	/// the recency that won it the selection (for logging / tie-break audit).
34	#[derive(Debug, Clone, PartialEq, Eq)]
35	pub struct ConsentTarget {
36	    /// The agent/perch id of the most-recently-active live session.
37	    pub id: String,
38	    /// Its resolved perch directory.
39	    pub perch_path: PathBuf,
40	    /// Its `last_active_ms` recency stamp (0 if never stamped — least-recent).
41	    pub last_active_ms: u64,
42	}
43
44	/// The gate's verdict for a verified, eligible update (REQ-UPD-4).
45	#[derive(Debug, Clone, PartialEq, Eq)]
46	pub enum ConsentDecision {
47	    /// Full-auto is opted in — apply without a prompt (the seamless path).
48	    AutoApproved,
49	    /// Default-gated — hold the update until the user confirms. Carries the
50	    /// resolved session to prompt, or `None` when no live session could be
51	    /// resolved (the orchestrator then defers / falls back to a node-level surface
52	    /// rather than applying unprompted — the gate never silently proceeds).
53	    NeedsConsent(Option<ConsentTarget>),
54	}
55
56	/// The pure consent gate (REQ-UPD-4): `full_auto` ⇒ [`ConsentDecision::AutoApproved`];
57	/// otherwise [`ConsentDecision::NeedsConsent`] with the `target` to prompt. No
58	/// I/O, no clock — the caller supplies the resolved target (via
59	/// [`most_recently_active`]) so the decision is deterministic and testable.
60	// [impl->REQ-UPD-4]
61	pub fn decide(full_auto: bool, target: Option<ConsentTarget>) -> ConsentDecision {
62	    if full_auto {
63	        ConsentDecision::AutoApproved
64	    } else {
65	        ConsentDecision::NeedsConsent(target)
66	    }
67	}
68
69	/// Resolve the user's **most-recently-active live session** in `owlery` — the
70	/// consent-prompt target (REQ-UPD-4). Enumerates the top-level (`Self_`) perches,
71	/// keeps the ones that are **currently live** (the single liveness resolver), and
72	/// returns the one with the greatest `last_active_ms`. A perch never stamped
73	/// (`last_active_ms` absent) ranks as `0` (least-recent). `None` if no live
74	/// session exists.
75	///
76	/// This is the **v1 registry resolution** the consent flow rides — live perches +
77	/// their recency stamp, not the deferred PresenceChannel. Tolerant: an
78	/// unreadable perch is skipped, never a panic.
79	// [impl->REQ-UPD-4]
80	pub fn most_recently_active(owlery: &Path) -> Option<ConsentTarget> {
81	    let mut best: Option<ConsentTarget> = None;
82	    for id in perch::list_self_perch_ids(owlery) {
83	        let perch_path = perch::resolve_perch_path_in(owlery, &id, ParentHint::Infer);
84	        if !is_perch_alive(&perch_path) {
85	            continue; // only a *live* session can answer a prompt
86	        }
87	        let last_active_ms = read_info(&perch_path).and_then(|i| i.last_active_ms).unwrap_or(0);
88	        let better = match &best {
89	            Some(cur) => last_active_ms > cur.last_active_ms,
90	            None => true,
91	        };
92	        if better {
93	            best = Some(ConsentTarget { id, perch_path, last_active_ms });
94	        }
95	    }
96	    best
97	}
98
99	#[cfg(test)]
100	mod tests {
101	    use super::*;
102	    use spt_store::info::{set_last_active, set_status, write_info, InfoJson};
103	    use spt_store::liveness::{STATUS_OFFLINE, STATUS_ONLINE};
104
105	    /// Create a bound, daemon-hosted Self perch under `owlery` with a `status` and
106	    /// an optional recency stamp.
107	    fn seed_perch(owlery: &Path, id: &str, status: &str, last_active: Option<u64>) -> PathBuf {
108	        let p = perch::resolve_perch_path_in(owlery, id, ParentHint::Infer);
109	        std::fs::create_dir_all(&p).unwrap();
110	        write_info(&p, &InfoJson::new(id, "t", 4242, "sid", "live_agent")).unwrap();
111	        set_status(&p, status).unwrap();
112	        if let Some(ms) = last_active {
113	            set_last_active(&p, ms).unwrap();
114	        }
115	        p
116	    }
117
118	    // [unit->REQ-UPD-4] full-auto opts out of the prompt; default gates with the
119	    // resolved target attached.
120	    #[test]
121	    fn decide_honors_full_auto_flag() {
122	        let target =
123	            ConsentTarget { id: "doyle".into(), perch_path: PathBuf::from("/x"), last_active_ms: 9 };
124	        assert_eq!(decide(true, Some(target.clone())), ConsentDecision::AutoApproved);
125	        assert_eq!(
126	            decide(false, Some(target.clone())),
127	            ConsentDecision::NeedsConsent(Some(target))
128	        );
129	        // Gated with no resolvable session is still NeedsConsent (never auto).
130	        assert_eq!(decide(false, None), ConsentDecision::NeedsConsent(None));
131	    }
132
133	    // [unit->REQ-UPD-4] the resolver picks the live perch with the greatest
134	    // last_active_ms; offline perches are excluded even if more recently active.
135	    #[test]
136	    fn resolves_most_recently_active_live_session() {
137	        let tmp = tempfile::tempdir().unwrap();
138	        let owlery = tmp.path().join("owlery");
139
140	        seed_perch(&owlery, "doyle", STATUS_ONLINE, Some(100));
141	        seed_perch(&owlery, "ling", STATUS_ONLINE, Some(500)); // most recent live
142	        // More recently active than ling, but OFFLINE → excluded.
143	        seed_perch(&owlery, "dunsen", STATUS_OFFLINE, Some(9_000));
144	        // Live but never stamped → ranks as 0, never wins over a stamped peer.
145	        seed_perch(&owlery, "ghost", STATUS_ONLINE, None);
146
147	        let target = most_recently_active(&owlery).expect("a live session resolves");
148	        assert_eq!(target.id, "ling");
149	        assert_eq!(target.last_active_ms, 500);
150	    }
151
152	    // [unit->REQ-UPD-4] no live session ⇒ no target (the gate then defers; it
153	    // never proceeds unprompted).
154	    #[test]
155	    fn no_live_session_resolves_to_none() {
156	        let tmp = tempfile::tempdir().unwrap();
157	        let owlery = tmp.path().join("owlery");
158	        // Empty owlery.
159	        assert!(most_recently_active(&owlery).is_none());
160	        // Only an offline perch → still None.
161	        seed_perch(&owlery, "doyle", STATUS_OFFLINE, Some(100));
162	        assert!(most_recently_active(&owlery).is_none());
163	    }
164	}
165

//! Self-update consent gating (C2) — the REQ-UPD-4 decision + the
//! most-recently-active-session resolver.
//!
//! ADR-0004 makes self-update **not fully automatic by default**: a verified,
//! eligible update is gated on a user confirmation delivered to the user's
//! **most-recently-active live session**, with an explicit opt-in to full-auto
//! (the seamless, prompt-free path). C2 ships the load-bearing, fully-testable
//! core of that:
//!
//! - [`decide`] — the **pure** gate: full-auto ⇒ [`ConsentDecision::AutoApproved`];
//!   otherwise [`ConsentDecision::NeedsConsent`] carrying the resolved target the
//!   orchestrator should prompt.
//! - [`most_recently_active`] — resolve that target over the **v1 registry**
//!   (live perches + their `last_active_ms` recency stamp). This is the
//!   registry-resolution precursor CONTEXT names; the deferred PresenceChannel
//!   later generalizes it.
//!
//! What C2 deliberately does **not** build: the interactive prompt UX itself.
//! That rides the deferred notification primitive (ADR-0007 — `update-available`
//! / `consent-needed` producers) and the harness's native confirm affordance
//! (e.g. AskUserQuestion). [`ConsentDecision::NeedsConsent`] is the typed seam
//! those plug into; nothing here reaches into a session.
// [impl->REQ-UPD-4]

use std::path::{Path, PathBuf};

use spt_store::info::read_info;
use spt_store::liveness::is_perch_alive;
use spt_store::perch::{self, ParentHint};

/// The session a consent prompt should be delivered to: the user's
/// most-recently-active live perch. Carries enough to address it (id + path) and
/// the recency that won it the selection (for logging / tie-break audit).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConsentTarget {
    /// The agent/perch id of the most-recently-active live session.
    pub id: String,
    /// Its resolved perch directory.
    pub perch_path: PathBuf,
    /// Its `last_active_ms` recency stamp (0 if never stamped — least-recent).
    pub last_active_ms: u64,
}

/// The gate's verdict for a verified, eligible update (REQ-UPD-4).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ConsentDecision {
    /// Full-auto is opted in — apply without a prompt (the seamless path).
    AutoApproved,
    /// Default-gated — hold the update until the user confirms. Carries the
    /// resolved session to prompt, or `None` when no live session could be
    /// resolved (the orchestrator then defers / falls back to a node-level surface
    /// rather than applying unprompted — the gate never silently proceeds).
    NeedsConsent(Option<ConsentTarget>),
}

/// The pure consent gate (REQ-UPD-4): `full_auto` ⇒ [`ConsentDecision::AutoApproved`];
/// otherwise [`ConsentDecision::NeedsConsent`] with the `target` to prompt. No
/// I/O, no clock — the caller supplies the resolved target (via
/// [`most_recently_active`]) so the decision is deterministic and testable.
// [impl->REQ-UPD-4]
pub fn decide(full_auto: bool, target: Option<ConsentTarget>) -> ConsentDecision {
    if full_auto {
        ConsentDecision::AutoApproved
    } else {
        ConsentDecision::NeedsConsent(target)
    }
}

/// Resolve the user's **most-recently-active live session** in `owlery` — the
/// consent-prompt target (REQ-UPD-4). Enumerates the top-level (`Self_`) perches,
/// keeps the ones that are **currently live** (the single liveness resolver), and
/// returns the one with the greatest `last_active_ms`. A perch never stamped
/// (`last_active_ms` absent) ranks as `0` (least-recent). `None` if no live
/// session exists.
///
/// This is the **v1 registry resolution** the consent flow rides — live perches +
/// their recency stamp, not the deferred PresenceChannel. Tolerant: an
/// unreadable perch is skipped, never a panic.
// [impl->REQ-UPD-4]
pub fn most_recently_active(owlery: &Path) -> Option<ConsentTarget> {
    let mut best: Option<ConsentTarget> = None;
    for id in perch::list_self_perch_ids(owlery) {
        let perch_path = perch::resolve_perch_path_in(owlery, &id, ParentHint::Infer);
        if !is_perch_alive(&perch_path) {
            continue; // only a *live* session can answer a prompt
        }
        let last_active_ms = read_info(&perch_path).and_then(|i| i.last_active_ms).unwrap_or(0);
        let better = match &best {
            Some(cur) => last_active_ms > cur.last_active_ms,
            None => true,
        };
        if better {
            best = Some(ConsentTarget { id, perch_path, last_active_ms });
        }
    }
    best
}

#[cfg(test)]
mod tests {
    use super::*;
    use spt_store::info::{set_last_active, set_status, write_info, InfoJson};
    use spt_store::liveness::{STATUS_OFFLINE, STATUS_ONLINE};

    /// Create a bound, daemon-hosted Self perch under `owlery` with a `status` and
    /// an optional recency stamp.
    fn seed_perch(owlery: &Path, id: &str, status: &str, last_active: Option<u64>) -> PathBuf {
        let p = perch::resolve_perch_path_in(owlery, id, ParentHint::Infer);
        std::fs::create_dir_all(&p).unwrap();
        write_info(&p, &InfoJson::new(id, "t", 4242, "sid", "live_agent")).unwrap();
        set_status(&p, status).unwrap();
        if let Some(ms) = last_active {
            set_last_active(&p, ms).unwrap();
        }
        p
    }

    // [unit->REQ-UPD-4] full-auto opts out of the prompt; default gates with the
    // resolved target attached.
    #[test]
    fn decide_honors_full_auto_flag() {
        let target =
            ConsentTarget { id: "doyle".into(), perch_path: PathBuf::from("/x"), last_active_ms: 9 };
        assert_eq!(decide(true, Some(target.clone())), ConsentDecision::AutoApproved);
        assert_eq!(
            decide(false, Some(target.clone())),
            ConsentDecision::NeedsConsent(Some(target))
        );
        // Gated with no resolvable session is still NeedsConsent (never auto).
        assert_eq!(decide(false, None), ConsentDecision::NeedsConsent(None));
    }

    // [unit->REQ-UPD-4] the resolver picks the live perch with the greatest
    // last_active_ms; offline perches are excluded even if more recently active.
    #[test]
    fn resolves_most_recently_active_live_session() {
        let tmp = tempfile::tempdir().unwrap();
        let owlery = tmp.path().join("owlery");

        seed_perch(&owlery, "doyle", STATUS_ONLINE, Some(100));
        seed_perch(&owlery, "ling", STATUS_ONLINE, Some(500)); // most recent live
        // More recently active than ling, but OFFLINE → excluded.
        seed_perch(&owlery, "dunsen", STATUS_OFFLINE, Some(9_000));
        // Live but never stamped → ranks as 0, never wins over a stamped peer.
        seed_perch(&owlery, "ghost", STATUS_ONLINE, None);

        let target = most_recently_active(&owlery).expect("a live session resolves");
        assert_eq!(target.id, "ling");
        assert_eq!(target.last_active_ms, 500);
    }

    // [unit->REQ-UPD-4] no live session ⇒ no target (the gate then defers; it
    // never proceeds unprompted).
    #[test]
    fn no_live_session_resolves_to_none() {
        let tmp = tempfile::tempdir().unwrap();
        let owlery = tmp.path().join("owlery");
        // Empty owlery.
        assert!(most_recently_active(&owlery).is_none());
        // Only an offline perch → still None.
        seed_perch(&owlery, "doyle", STATUS_OFFLINE, Some(100));
        assert!(most_recently_active(&owlery).is_none());
    }
}


1	//! Reporting + boundary surface: `api boundary`, `api session-end`,
2	//! `api presence`, `api history-log`, `api emit`, plus the M2a *declarations*
3	//! for commune/signoff file-drops and adapter env aliases.
4	//!
5	//! - **boundary** (REQ-API-2) — rebind the perch to the rotated `session_id`
6	//!   while keeping the stable identity + `parent_pid` anchor (CONTEXT §api
7	//!   boundary). The Self-resume commune authoring is M2b; M2a rebinds only.
8	//! - **session-end** (REQ-HAZARD-SOFT-CLEANUP / CASCADE-WIPE-GUARD) — soft
9	//!   teardown by default (preserve perch + spool + history); `--erase`
10	//!   hard-wipes, but never a parent that still hosts live workers.
11	//! - **presence** (REQ-API-2) — record `(endpoint, ts)`; no content (CONTEXT
12	//!   §presence is privacy-preserving).
13	//! - **history-log** (REQ-API-2) — Path-B native store: append a normalized
14	//!   record to the perch's history log.
15	//! - **emit** (REQ-API-2) — Shell sensory push; a stub here (concrete Shells M5).
16	//! - **commune/signoff are file-drops, not commands** (REQ-API-3): resolved from
17	//!   the manifest's watched dirs with spt-core-fixed filenames.
18	//! - **env aliases** (REQ-START-4): the adapter declares `$SPT`/`$OWL`/`$LIVE`
19	//!   in `[env]`; spt-core resolves the injections (it supplies the subcommands).
20	//
21	// [impl->REQ-API-2]
22
23	use std::path::PathBuf;
24
25	use spt_runtime::manifest::EnvDirection;
26	use spt_runtime::Manifest;
27	use spt_store::info::{self};
28	use spt_store::perch::{self, ParentHint};
29	use spt_store::registry;
30
31	use super::worker;
32	use super::{Ctx, EXIT_REFUSED};
33
34	/// `api boundary <clear|compact> <id> --to-session-id <sid>` — rebind the perch
35	/// to the rotated session id, preserving identity + parent_pid anchor.
36	// [impl->REQ-API-2]
37	pub fn cmd_boundary(id: &str, mode: &str, to_session: &str) -> i32 {
38	    if to_session.is_empty() {
39	        eprintln!("USAGE: boundary requires a non-empty --to-session-id");
40	        return EXIT_REFUSED;
41	    }
42	    let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);
43	    let Some(mut rec) = info::read_info(&perch_path) else {
44	        eprintln!("NO_ENDPOINT:{id} has no perch to rebind");
45	        return EXIT_REFUSED;
46	    };
47	    // Keep id / started / parent_pid / cwd / state; rotate only the session id.
48	    rec.session_id = to_session.to_string();
49	    if let Err(e) = info::write_info(&perch_path, &rec) {
50	        eprintln!("BOUNDARY_FAIL:{id}: {e}");
51	        return EXIT_REFUSED;
52	    }
53	    eprintln!("BOUNDARY:{mode}:{id} -> {to_session}");
54	    0
55	}
56
57	/// `api session-end <id> [--erase]` — soft teardown by default; `--erase`
58	/// hard-wipes, guarded against cascade-wipe (6.3).
59	// [impl->REQ-HAZARD-SOFT-CLEANUP]
60	// [impl->REQ-HAZARD-CASCADE-WIPE-GUARD]
61	pub fn cmd_session_end(id: &str, erase: bool) -> i32 {
62	    let owlery = perch::owlery_dir();
63	    if !erase {
64	        // Soft (6.2): remove the ready marker + unregister; preserve spool/info.
65	        let _ = std::fs::remove_file(perch::resolve_ready_file(id, ParentHint::Infer));
66	        let _ = registry::unregister_address(id, &owlery);
67	        eprintln!("SESSION_END:{id} (soft; spool + history preserved)");
68	        return 0;
69	    }
70	    // Erase: refuse to recursively delete a parent that still hosts live
71	    // workers — soft-clean and surface their paths instead (6.3).
72	    let live = worker::live_child_paths(id);
73	    if !live.is_empty() {
74	        let _ = std::fs::remove_file(perch::resolve_ready_file(id, ParentHint::Infer));
75	        eprintln!(
76	            "CASCADE_GUARD:{id} hosts {} live child perch(es) — soft-cleaned, not erased:",
77	            live.len()
78	        );
79	        for p in live {
80	            eprintln!("  {}", p.display());
81	        }
82	        return EXIT_REFUSED;
83	    }
84	    let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);
85	    let _ = registry::unregister_address(id, &owlery);
86	    if let Err(e) = std::fs::remove_dir_all(&perch_path) {
87	        eprintln!("SESSION_END_FAIL:{id}: {e}");
88	        return EXIT_REFUSED;
89	    }
90	    eprintln!("SESSION_END:{id} (erased)");
91	    0
92	}
93
94	/// `<spt_home>/presence.json`.
95	fn presence_path() -> PathBuf {
96	    perch::spt_home().join("presence.json")
97	}
98
99	/// `api presence <id>` — record that the user interacted at `id` (endpoint +
100	/// timestamp; no content — privacy-preserving).
101	// [impl->REQ-API-2]
102	pub fn cmd_presence(id: &str) -> i32 {
103	    let datum = format!("{{\"last_active_endpoint\":\"{id}\",\"ts\":\"{}\"}}", now_stamp());
104	    if let Err(e) = std::fs::write(presence_path(), datum) {
105	        eprintln!("PRESENCE_FAIL:{id}: {e}");
106	        return EXIT_REFUSED;
107	    }
108	    eprintln!("PRESENCE:{id}");
109	    0
110	}
111
112	/// `api driven-by <id>` — report which node (if any) is remote-driving this
113	/// endpoint's session, read from the daemon-stamped `driven_by` marker on the
114	/// perch's `info.json` (REQ-REACH-1: detection is a daemon fact — the agent
115	/// queries it rather than guessing from session heuristics). Prints
116	/// `DRIVEN_BY:<id>:<node-hex>` while net-driven, `DRIVEN_BY:<id>:-` when
117	/// locally driven.
118	// [impl->REQ-REACH-1]
119	pub fn cmd_driven_by(id: &str) -> i32 {
120	    let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);
121	    let Some(rec) = info::read_info(&perch_path) else {
122	        eprintln!("NO_ENDPOINT:{id} has no perch");
123	        return EXIT_REFUSED;
124	    };
125	    println!("DRIVEN_BY:{id}:{}", rec.driven_by.as_deref().unwrap_or("-"));
126	    0
127	}
128
129	/// Append a normalized record to the perch's Path-B native history store,
130	/// returning bytes written. Thin wrapper over the store-layer primitive
131	/// ([`spt_store::history::append_record`]) — shared with the M3b digest's opt-in
132	/// persistence so the append behavior lives in exactly one place.
133	pub fn append_history(id: &str, record: &str) -> std::io::Result<usize> {
134	    let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);
135	    spt_store::history::append_record(&perch_path, record)
136	}
137
138	/// `api history-log <id>` — Path-B ingest: read a normalized record from stdin
139	/// and append it to the native store.
140	// [impl->REQ-API-2]
141	pub fn cmd_history_log(id: &str) -> i32 {
142	    use std::io::Read;
143	    let mut record = String::new();
144	    let _ = std::io::stdin().read_to_string(&mut record);
145	    if record.trim().is_empty() {
146	        eprintln!("EMPTY_RECORD: history-log read nothing on stdin");
147	        return EXIT_REFUSED;
148	    }
149	    match append_history(id, record.trim_end()) {
150	        Ok(n) => {
151	            eprintln!("HISTORY_LOGGED:{id}:{n}");
152	            0
153	        }
154	        Err(e) => {
155	            eprintln!("HISTORY_FAIL:{id}: {e}");
156	            EXIT_REFUSED
157	        }
158	    }
159	}
160
161	/// `api emit --type <t> <id>` — a Shell pushes a sensory payload to its owner.
162	/// Stubbed in M2a (concrete Shells + the sensory channel are M5); declared so
163	/// the surface is complete and adapters can bind it.
164	// [impl->REQ-API-2]
165	pub fn cmd_emit(id: &str, sensory_type: &str) -> i32 {
166	    eprintln!("EMIT_STUB:{id} type={sensory_type} (Shell sensory channel lands in M5)");
167	    0
168	}
169
170	/// The commune/signoff **file-drop** paths — proof these are file-drops, not
171	/// commands (REQ-API-3). Dir comes from the manifest's `[session]` watched
172	/// dirs; the filename is spt-core-fixed (`<id>-commune.md` / `<id>-signoff.md`).
173	/// M2a resolves them; M2b ingests them.
174	// [impl->REQ-API-3]
175	#[allow(dead_code)] // M2a declares the file-drop contract; M2b's ingest consumes it
176	pub fn resolve_filedrops(manifest: Option<&Manifest>, id: &str) -> (Option<PathBuf>, Option<PathBuf>) {
177	    let session = manifest.map(|m| &m.session);
178	    let commune = session
179	        .and_then(|s| s.commune_dir.as_ref())
180	        .map(|dir| PathBuf::from(dir).join(format!("{id}-commune.md")));
181	    let signoff = session
182	        .and_then(|s| s.signoff_dir.as_ref())
183	        .map(|dir| PathBuf::from(dir).join(format!("{id}-signoff.md")));
184	    (commune, signoff)
185	}
186
187	/// The env injections spt-core supplies from the adapter's `[env]` table — the
188	/// channel through which the adapter's `$SPT`/`$OWL`/`$LIVE` aliases reach a
189	/// session. Only `inject` directives with a value participate (REQ-START-4).
190	// [impl->REQ-START-4]
191	#[allow(dead_code)] // M2a resolves the alias injections; the spawn-env wiring is M2b
192	pub fn env_injections(manifest: Option<&Manifest>) -> Vec<(String, String)> {
193	    let Some(m) = manifest else { return Vec::new() };
194	    m.env
195	        .iter()
196	        .filter(|(_, v)| v.direction == EnvDirection::Inject)
197	        .filter_map(|(name, v)| v.value.clone().map(|val| (name.clone(), val)))
198	        .collect()
199	}
200
201	/// `api capability` — advertise the adapter's declared `hostable_types`
202	/// (resolved by adapter_name from the manifest). REQ-SEAM-CAPABILITY.
203	// [impl->REQ-SEAM-CAPABILITY]
204	pub fn cmd_capability(ctx: &Ctx) -> i32 {
205	    let Some(manifest) = &ctx.manifest else {
206	        eprintln!("NO_MANIFEST: capability needs --manifest");
207	        return EXIT_REFUSED;
208	    };
209	    for ty in &manifest.adapter.hostable_types {
210	        println!("{ty}");
211	    }
212	    0
213	}
214
215	fn now_stamp() -> String {
216	    std::time::SystemTime::now()
217	        .duration_since(std::time::UNIX_EPOCH)
218	        .map(|d| d.as_secs().to_string())
219	        .unwrap_or_else(|_| "0".to_string())
220	}
221
222	#[cfg(test)]
223	mod tests {
224	    use super::*;
225	    use crate::testutil::isolated_home;
226	    use spt_store::info::InfoJson;
227
228	    fn establish(id: &str, session: &str) {
229	        let path = perch::resolve_perch_path(id, ParentHint::Infer);
230	        std::fs::create_dir_all(&path).unwrap();
231	        let mut rec = InfoJson::new(id, "0", std::process::id(), session, "live_agent");
232	        rec.parent_pid = Some(4242);
233	        info::write_info(&path, &rec).unwrap();
234	        std::fs::write(perch::resolve_ready_file(id, ParentHint::Infer), "").unwrap();
235	    }
236
237	    #[test] // [unit->REQ-API-2] boundary rebinds the session id, keeps identity+anchor.
238	    fn boundary_rebinds_keeping_anchor() {
239	        let _h = isolated_home();
240	        establish("alice", "sid-old");
241	        assert_eq!(cmd_boundary("alice", "compact", "sid-new"), 0);
242	        let rec = info::read_info(&perch::resolve_perch_path("alice", ParentHint::Infer)).unwrap();
243	        assert_eq!(rec.session_id, "sid-new");
244	        assert_eq!(rec.parent_pid, Some(4242), "parent anchor preserved");
245	        assert_eq!(rec.id, "alice");
246	    }
247
248	    #[test] // [unit->REQ-API-2] boundary on a missing perch is refused.
249	    fn boundary_missing_perch_refused() {
250	        let _h = isolated_home();
251	        assert_eq!(cmd_boundary("ghost", "clear", "sid"), EXIT_REFUSED);
252	    }
253
254	    #[test] // [unit->REQ-HAZARD-SOFT-CLEANUP] soft session-end preserves info+spool.
255	    fn session_end_soft_preserves_state() {
256	        let _h = isolated_home();
257	        establish("alice", "sid");
258	        assert_eq!(cmd_session_end("alice", false), 0);
259	        let perch_path = perch::resolve_perch_path("alice", ParentHint::Infer);
260	        assert!(perch_path.join("info.json").exists(), "info preserved");
261	        assert!(!perch::resolve_ready_file("alice", ParentHint::Infer).exists(), "ready removed");
262	    }
263
264	    #[test] // [unit->REQ-HAZARD-SOFT-CLEANUP] --erase hard-wipes a childless perch.
265	    fn session_end_erase_wipes_childless_perch() {
266	        let _h = isolated_home();
267	        establish("alice", "sid");
268	        assert_eq!(cmd_session_end("alice", true), 0);
269	        assert!(!perch::resolve_perch_path("alice", ParentHint::Infer).exists());
270	    }
271
272	    #[test] // [unit->REQ-HAZARD-CASCADE-WIPE-GUARD] --erase refuses a parent with
273	    // live workers; soft-cleans instead.
274	    fn session_end_erase_refused_with_live_children() {
275	        let _h = isolated_home();
276	        establish("alice", "sid");
277	        worker::cmd_worker_start("alice", "alice-w1");
278	        assert_eq!(cmd_session_end("alice", true), EXIT_REFUSED);
279	        // Parent dir (and the live child) survive the refused erase.
280	        assert!(perch::resolve_perch_path("alice", ParentHint::Infer).join("info.json").exists());
281	        assert!(perch::resolve_perch_path("alice-w1", ParentHint::Infer).join("info.json").exists());
282	    }
283
284	    #[test] // [unit->REQ-API-2] history-log appends to the Path-B native store.
285	    fn history_log_appends() {
286	        let _h = isolated_home();
287	        establish("alice", "sid");
288	        append_history("alice", "{\"role\":\"user\",\"text\":\"hi\"}").unwrap();
289	        append_history("alice", "{\"role\":\"assistant\",\"text\":\"yo\"}").unwrap();
290	        let log = perch::resolve_perch_path("alice", ParentHint::Infer).join("history.log");
291	        let content = std::fs::read_to_string(log).unwrap();
292	        assert_eq!(content.lines().count(), 2);
293	        assert!(content.contains("\"hi\""));
294	    }
295
296	    #[test] // [unit->REQ-API-2] presence records the endpoint, no content.
297	    fn presence_records_endpoint() {
298	        let _h = isolated_home();
299	        assert_eq!(cmd_presence("alice"), 0);
300	        let datum = std::fs::read_to_string(presence_path()).unwrap();
301	        assert!(datum.contains("alice"));
302	        assert!(datum.contains("ts"));
303	    }
304
305	    #[test] // [unit->REQ-REACH-1] driven-by reads the daemon-stamped remote-drive
306	    // marker: "-" while locally driven, the node hex while net-driven, refused
307	    // without a perch (the agent's detection query, not a guess).
308	    fn driven_by_reads_daemon_marker() {
309	        let _h = isolated_home();
310	        assert_eq!(cmd_driven_by("ghost"), EXIT_REFUSED, "no perch → refused");
311
312	        establish("alice", "sid");
313	        assert_eq!(cmd_driven_by("alice"), 0, "locally driven reads clean");
314
315	        let perch_path = perch::resolve_perch_path("alice", ParentHint::Infer);
316	        info::set_driven_by(&perch_path, Some("cafe1234")).unwrap();
317	        assert_eq!(cmd_driven_by("alice"), 0);
318	        assert_eq!(
319	            info::read_info(&perch_path).unwrap().driven_by.as_deref(),
320	            Some("cafe1234"),
321	            "the marker the command reports"
322	        );
323	    }
324
325	    #[test] // [unit->REQ-API-3] commune/signoff resolve as file-drops with fixed names.
326	    fn filedrops_are_fixed_named_files() {
327	        let m = Manifest::from_toml_str(
328	            "[adapter]\nname=\"m\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n[session]\ncommune_dir=\".claude\"\nsignoff_dir=\".claude\"\n",
329	        )
330	        .unwrap();
331	        let (commune, signoff) = resolve_filedrops(Some(&m), "alice");
332	        assert_eq!(commune.unwrap(), PathBuf::from(".claude").join("alice-commune.md"));
333	        assert_eq!(signoff.unwrap(), PathBuf::from(".claude").join("alice-signoff.md"));
334	        // No manifest → no file-drops resolved.
335	        assert_eq!(resolve_filedrops(None, "alice"), (None, None));
336	    }
337
338	    #[test] // [unit->REQ-SEAM-CAPABILITY] capability lists the adapter's hostable_types.
339	    fn capability_lists_hostable_types() {
340	        let m = Manifest::from_toml_str(
341	            "[adapter]\nname=\"m\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\nhostable_types=[\"LiveAgent\",\"Worker\"]\n",
342	        )
343	        .unwrap();
344	        let ctx = Ctx { adapter: "m".into(), manifest: Some(m) };
345	        assert_eq!(cmd_capability(&ctx), 0);
346	        // No manifest → refused.
347	        let bare = Ctx { adapter: "m".into(), manifest: None };
348	        assert_eq!(cmd_capability(&bare), EXIT_REFUSED);
349	    }
350
351	    #[test] // [unit->REQ-START-4] env aliases are resolved from the [env] inject table.
352	    fn env_aliases_resolved_from_manifest() {
353	        let m = Manifest::from_toml_str(
354	            "[adapter]\nname=\"m\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n[env.OWL_SESSION_ID]\ndirection=\"inject\"\nvalue=\"{session_id}\"\n\n[env.READONLY]\ndirection=\"read\"\n",
355	        )
356	        .unwrap();
357	        let injections = env_injections(Some(&m));
358	        assert_eq!(injections, vec![("OWL_SESSION_ID".to_string(), "{session_id}".to_string())]);
359	    }
360	}
361

//! Reporting + boundary surface: `api boundary`, `api session-end`,
//! `api presence`, `api history-log`, `api emit`, plus the M2a *declarations*
//! for commune/signoff file-drops and adapter env aliases.
//!
//! - **boundary** (REQ-API-2) — rebind the perch to the rotated `session_id`
//!   while keeping the stable identity + `parent_pid` anchor (CONTEXT §api
//!   boundary). The Self-resume commune authoring is M2b; M2a rebinds only.
//! - **session-end** (REQ-HAZARD-SOFT-CLEANUP / CASCADE-WIPE-GUARD) — soft
//!   teardown by default (preserve perch + spool + history); `--erase`
//!   hard-wipes, but never a parent that still hosts live workers.
//! - **presence** (REQ-API-2) — record `(endpoint, ts)`; no content (CONTEXT
//!   §presence is privacy-preserving).
//! - **history-log** (REQ-API-2) — Path-B native store: append a normalized
//!   record to the perch's history log.
//! - **emit** (REQ-API-2) — Shell sensory push; a stub here (concrete Shells M5).
//! - **commune/signoff are file-drops, not commands** (REQ-API-3): resolved from
//!   the manifest's watched dirs with spt-core-fixed filenames.
//! - **env aliases** (REQ-START-4): the adapter declares `$SPT`/`$OWL`/`$LIVE`
//!   in `[env]`; spt-core resolves the injections (it supplies the subcommands).
//
// [impl->REQ-API-2]

use std::path::PathBuf;

use spt_runtime::manifest::EnvDirection;
use spt_runtime::Manifest;
use spt_store::info::{self};
use spt_store::perch::{self, ParentHint};
use spt_store::registry;

use super::worker;
use super::{Ctx, EXIT_REFUSED};

/// `api boundary <clear|compact> <id> --to-session-id <sid>` — rebind the perch
/// to the rotated session id, preserving identity + parent_pid anchor.
// [impl->REQ-API-2]
pub fn cmd_boundary(id: &str, mode: &str, to_session: &str) -> i32 {
    if to_session.is_empty() {
        eprintln!("USAGE: boundary requires a non-empty --to-session-id");
        return EXIT_REFUSED;
    }
    let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);
    let Some(mut rec) = info::read_info(&perch_path) else {
        eprintln!("NO_ENDPOINT:{id} has no perch to rebind");
        return EXIT_REFUSED;
    };
    // Keep id / started / parent_pid / cwd / state; rotate only the session id.
    rec.session_id = to_session.to_string();
    if let Err(e) = info::write_info(&perch_path, &rec) {
        eprintln!("BOUNDARY_FAIL:{id}: {e}");
        return EXIT_REFUSED;
    }
    eprintln!("BOUNDARY:{mode}:{id} -> {to_session}");
    0
}

/// `api session-end <id> [--erase]` — soft teardown by default; `--erase`
/// hard-wipes, guarded against cascade-wipe (6.3).
// [impl->REQ-HAZARD-SOFT-CLEANUP]
// [impl->REQ-HAZARD-CASCADE-WIPE-GUARD]
pub fn cmd_session_end(id: &str, erase: bool) -> i32 {
    let owlery = perch::owlery_dir();
    if !erase {
        // Soft (6.2): remove the ready marker + unregister; preserve spool/info.
        let _ = std::fs::remove_file(perch::resolve_ready_file(id, ParentHint::Infer));
        let _ = registry::unregister_address(id, &owlery);
        eprintln!("SESSION_END:{id} (soft; spool + history preserved)");
        return 0;
    }
    // Erase: refuse to recursively delete a parent that still hosts live
    // workers — soft-clean and surface their paths instead (6.3).
    let live = worker::live_child_paths(id);
    if !live.is_empty() {
        let _ = std::fs::remove_file(perch::resolve_ready_file(id, ParentHint::Infer));
        eprintln!(
            "CASCADE_GUARD:{id} hosts {} live child perch(es) — soft-cleaned, not erased:",
            live.len()
        );
        for p in live {
            eprintln!("  {}", p.display());
        }
        return EXIT_REFUSED;
    }
    let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);
    let _ = registry::unregister_address(id, &owlery);
    if let Err(e) = std::fs::remove_dir_all(&perch_path) {
        eprintln!("SESSION_END_FAIL:{id}: {e}");
        return EXIT_REFUSED;
    }
    eprintln!("SESSION_END:{id} (erased)");
    0
}

/// `<spt_home>/presence.json`.
fn presence_path() -> PathBuf {
    perch::spt_home().join("presence.json")
}

/// `api presence <id>` — record that the user interacted at `id` (endpoint +
/// timestamp; no content — privacy-preserving).
// [impl->REQ-API-2]
pub fn cmd_presence(id: &str) -> i32 {
    let datum = format!("{{\"last_active_endpoint\":\"{id}\",\"ts\":\"{}\"}}", now_stamp());
    if let Err(e) = std::fs::write(presence_path(), datum) {
        eprintln!("PRESENCE_FAIL:{id}: {e}");
        return EXIT_REFUSED;
    }
    eprintln!("PRESENCE:{id}");
    0
}

/// `api driven-by <id>` — report which node (if any) is remote-driving this
/// endpoint's session, read from the daemon-stamped `driven_by` marker on the
/// perch's `info.json` (REQ-REACH-1: detection is a daemon fact — the agent
/// queries it rather than guessing from session heuristics). Prints
/// `DRIVEN_BY:<id>:<node-hex>` while net-driven, `DRIVEN_BY:<id>:-` when
/// locally driven.
// [impl->REQ-REACH-1]
pub fn cmd_driven_by(id: &str) -> i32 {
    let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);
    let Some(rec) = info::read_info(&perch_path) else {
        eprintln!("NO_ENDPOINT:{id} has no perch");
        return EXIT_REFUSED;
    };
    println!("DRIVEN_BY:{id}:{}", rec.driven_by.as_deref().unwrap_or("-"));
    0
}

/// Append a normalized record to the perch's Path-B native history store,
/// returning bytes written. Thin wrapper over the store-layer primitive
/// ([`spt_store::history::append_record`]) — shared with the M3b digest's opt-in
/// persistence so the append behavior lives in exactly one place.
pub fn append_history(id: &str, record: &str) -> std::io::Result<usize> {
    let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);
    spt_store::history::append_record(&perch_path, record)
}

/// `api history-log <id>` — Path-B ingest: read a normalized record from stdin
/// and append it to the native store.
// [impl->REQ-API-2]
pub fn cmd_history_log(id: &str) -> i32 {
    use std::io::Read;
    let mut record = String::new();
    let _ = std::io::stdin().read_to_string(&mut record);
    if record.trim().is_empty() {
        eprintln!("EMPTY_RECORD: history-log read nothing on stdin");
        return EXIT_REFUSED;
    }
    match append_history(id, record.trim_end()) {
        Ok(n) => {
            eprintln!("HISTORY_LOGGED:{id}:{n}");
            0
        }
        Err(e) => {
            eprintln!("HISTORY_FAIL:{id}: {e}");
            EXIT_REFUSED
        }
    }
}

/// `api emit --type <t> <id>` — a Shell pushes a sensory payload to its owner.
/// Stubbed in M2a (concrete Shells + the sensory channel are M5); declared so
/// the surface is complete and adapters can bind it.
// [impl->REQ-API-2]
pub fn cmd_emit(id: &str, sensory_type: &str) -> i32 {
    eprintln!("EMIT_STUB:{id} type={sensory_type} (Shell sensory channel lands in M5)");
    0
}

/// The commune/signoff **file-drop** paths — proof these are file-drops, not
/// commands (REQ-API-3). Dir comes from the manifest's `[session]` watched
/// dirs; the filename is spt-core-fixed (`<id>-commune.md` / `<id>-signoff.md`).
/// M2a resolves them; M2b ingests them.
// [impl->REQ-API-3]
#[allow(dead_code)] // M2a declares the file-drop contract; M2b's ingest consumes it
pub fn resolve_filedrops(manifest: Option<&Manifest>, id: &str) -> (Option<PathBuf>, Option<PathBuf>) {
    let session = manifest.map(|m| &m.session);
    let commune = session
        .and_then(|s| s.commune_dir.as_ref())
        .map(|dir| PathBuf::from(dir).join(format!("{id}-commune.md")));
    let signoff = session
        .and_then(|s| s.signoff_dir.as_ref())
        .map(|dir| PathBuf::from(dir).join(format!("{id}-signoff.md")));
    (commune, signoff)
}

/// The env injections spt-core supplies from the adapter's `[env]` table — the
/// channel through which the adapter's `$SPT`/`$OWL`/`$LIVE` aliases reach a
/// session. Only `inject` directives with a value participate (REQ-START-4).
// [impl->REQ-START-4]
#[allow(dead_code)] // M2a resolves the alias injections; the spawn-env wiring is M2b
pub fn env_injections(manifest: Option<&Manifest>) -> Vec<(String, String)> {
    let Some(m) = manifest else { return Vec::new() };
    m.env
        .iter()
        .filter(|(_, v)| v.direction == EnvDirection::Inject)
        .filter_map(|(name, v)| v.value.clone().map(|val| (name.clone(), val)))
        .collect()
}

/// `api capability` — advertise the adapter's declared `hostable_types`
/// (resolved by adapter_name from the manifest). REQ-SEAM-CAPABILITY.
// [impl->REQ-SEAM-CAPABILITY]
pub fn cmd_capability(ctx: &Ctx) -> i32 {
    let Some(manifest) = &ctx.manifest else {
        eprintln!("NO_MANIFEST: capability needs --manifest");
        return EXIT_REFUSED;
    };
    for ty in &manifest.adapter.hostable_types {
        println!("{ty}");
    }
    0
}

fn now_stamp() -> String {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_secs().to_string())
        .unwrap_or_else(|_| "0".to_string())
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::testutil::isolated_home;
    use spt_store::info::InfoJson;

    fn establish(id: &str, session: &str) {
        let path = perch::resolve_perch_path(id, ParentHint::Infer);
        std::fs::create_dir_all(&path).unwrap();
        let mut rec = InfoJson::new(id, "0", std::process::id(), session, "live_agent");
        rec.parent_pid = Some(4242);
        info::write_info(&path, &rec).unwrap();
        std::fs::write(perch::resolve_ready_file(id, ParentHint::Infer), "").unwrap();
    }

    #[test] // [unit->REQ-API-2] boundary rebinds the session id, keeps identity+anchor.
    fn boundary_rebinds_keeping_anchor() {
        let _h = isolated_home();
        establish("alice", "sid-old");
        assert_eq!(cmd_boundary("alice", "compact", "sid-new"), 0);
        let rec = info::read_info(&perch::resolve_perch_path("alice", ParentHint::Infer)).unwrap();
        assert_eq!(rec.session_id, "sid-new");
        assert_eq!(rec.parent_pid, Some(4242), "parent anchor preserved");
        assert_eq!(rec.id, "alice");
    }

    #[test] // [unit->REQ-API-2] boundary on a missing perch is refused.
    fn boundary_missing_perch_refused() {
        let _h = isolated_home();
        assert_eq!(cmd_boundary("ghost", "clear", "sid"), EXIT_REFUSED);
    }

    #[test] // [unit->REQ-HAZARD-SOFT-CLEANUP] soft session-end preserves info+spool.
    fn session_end_soft_preserves_state() {
        let _h = isolated_home();
        establish("alice", "sid");
        assert_eq!(cmd_session_end("alice", false), 0);
        let perch_path = perch::resolve_perch_path("alice", ParentHint::Infer);
        assert!(perch_path.join("info.json").exists(), "info preserved");
        assert!(!perch::resolve_ready_file("alice", ParentHint::Infer).exists(), "ready removed");
    }

    #[test] // [unit->REQ-HAZARD-SOFT-CLEANUP] --erase hard-wipes a childless perch.
    fn session_end_erase_wipes_childless_perch() {
        let _h = isolated_home();
        establish("alice", "sid");
        assert_eq!(cmd_session_end("alice", true), 0);
        assert!(!perch::resolve_perch_path("alice", ParentHint::Infer).exists());
    }

    #[test] // [unit->REQ-HAZARD-CASCADE-WIPE-GUARD] --erase refuses a parent with
    // live workers; soft-cleans instead.
    fn session_end_erase_refused_with_live_children() {
        let _h = isolated_home();
        establish("alice", "sid");
        worker::cmd_worker_start("alice", "alice-w1");
        assert_eq!(cmd_session_end("alice", true), EXIT_REFUSED);
        // Parent dir (and the live child) survive the refused erase.
        assert!(perch::resolve_perch_path("alice", ParentHint::Infer).join("info.json").exists());
        assert!(perch::resolve_perch_path("alice-w1", ParentHint::Infer).join("info.json").exists());
    }

    #[test] // [unit->REQ-API-2] history-log appends to the Path-B native store.
    fn history_log_appends() {
        let _h = isolated_home();
        establish("alice", "sid");
        append_history("alice", "{\"role\":\"user\",\"text\":\"hi\"}").unwrap();
        append_history("alice", "{\"role\":\"assistant\",\"text\":\"yo\"}").unwrap();
        let log = perch::resolve_perch_path("alice", ParentHint::Infer).join("history.log");
        let content = std::fs::read_to_string(log).unwrap();
        assert_eq!(content.lines().count(), 2);
        assert!(content.contains("\"hi\""));
    }

    #[test] // [unit->REQ-API-2] presence records the endpoint, no content.
    fn presence_records_endpoint() {
        let _h = isolated_home();
        assert_eq!(cmd_presence("alice"), 0);
        let datum = std::fs::read_to_string(presence_path()).unwrap();
        assert!(datum.contains("alice"));
        assert!(datum.contains("ts"));
    }

    #[test] // [unit->REQ-REACH-1] driven-by reads the daemon-stamped remote-drive
    // marker: "-" while locally driven, the node hex while net-driven, refused
    // without a perch (the agent's detection query, not a guess).
    fn driven_by_reads_daemon_marker() {
        let _h = isolated_home();
        assert_eq!(cmd_driven_by("ghost"), EXIT_REFUSED, "no perch → refused");

        establish("alice", "sid");
        assert_eq!(cmd_driven_by("alice"), 0, "locally driven reads clean");

        let perch_path = perch::resolve_perch_path("alice", ParentHint::Infer);
        info::set_driven_by(&perch_path, Some("cafe1234")).unwrap();
        assert_eq!(cmd_driven_by("alice"), 0);
        assert_eq!(
            info::read_info(&perch_path).unwrap().driven_by.as_deref(),
            Some("cafe1234"),
            "the marker the command reports"
        );
    }

    #[test] // [unit->REQ-API-3] commune/signoff resolve as file-drops with fixed names.
    fn filedrops_are_fixed_named_files() {
        let m = Manifest::from_toml_str(
            "[adapter]\nname=\"m\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n[session]\ncommune_dir=\".claude\"\nsignoff_dir=\".claude\"\n",
        )
        .unwrap();
        let (commune, signoff) = resolve_filedrops(Some(&m), "alice");
        assert_eq!(commune.unwrap(), PathBuf::from(".claude").join("alice-commune.md"));
        assert_eq!(signoff.unwrap(), PathBuf::from(".claude").join("alice-signoff.md"));
        // No manifest → no file-drops resolved.
        assert_eq!(resolve_filedrops(None, "alice"), (None, None));
    }

    #[test] // [unit->REQ-SEAM-CAPABILITY] capability lists the adapter's hostable_types.
    fn capability_lists_hostable_types() {
        let m = Manifest::from_toml_str(
            "[adapter]\nname=\"m\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\nhostable_types=[\"LiveAgent\",\"Worker\"]\n",
        )
        .unwrap();
        let ctx = Ctx { adapter: "m".into(), manifest: Some(m) };
        assert_eq!(cmd_capability(&ctx), 0);
        // No manifest → refused.
        let bare = Ctx { adapter: "m".into(), manifest: None };
        assert_eq!(cmd_capability(&bare), EXIT_REFUSED);
    }

    #[test] // [unit->REQ-START-4] env aliases are resolved from the [env] inject table.
    fn env_aliases_resolved_from_manifest() {
        let m = Manifest::from_toml_str(
            "[adapter]\nname=\"m\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n[env.OWL_SESSION_ID]\ndirection=\"inject\"\nvalue=\"{session_id}\"\n\n[env.READONLY]\ndirection=\"read\"\n",
        )
        .unwrap();
        let injections = env_injections(Some(&m));
        assert_eq!(injections, vec![("OWL_SESSION_ID".to_string(), "{session_id}".to_string())]);
    }
}


Found 2 files
crates\spt\src\api\reporting.rs
crates\spt\src\api\mod.rs


ToolSearch

Question: select:TaskCreate,TaskUpdate


Question: select:TaskCreate,TaskUpdate


1	//! Psyche outbound relay + sanitize boundary (ADR-0012 / M4-D7.5c) — the
2	//! relay half of the daemon's parse-and-relay proxy.
3	//!
4	//! The Psyche is sandboxed; the daemon is its **sole outbound proxy**
5	//! (KNOWN-HAZARDS 7.3). [`spt_live::outbound::parse_psyche_intents`] already
6	//! made a Psyche-supplied `from=`/target *unrepresentable* (the structural
7	//! strip); this module enforces the other half of the trust boundary at relay
8	//! time:
9	//!
10	//! - **re-stamp**: every relayed envelope carries the daemon-known
11	//!   `from=<psyche_id>` — the daemon is the sole trusted author of wire
12	//!   envelopes carrying a real `from=` (ADR-0012);
13	//! - **constrained routing**: a `reply` goes **only** to the `__REPLY_TO__`
14	//!   sender of the inbound being answered (no sender ⇒ dropped, fail-closed —
15	//!   never broadcast, never guessed); a `notify` reaches **only** the agent's
16	//!   own Self perch (the user's surface today; D8 swaps this leg onto the
17	//!   notif primitive);
18	//! - **body validation**: the 4.1 codec at compose (`\n`→`<br>`, amp-last
19	//!   entities via `spt-proto`) plus a sanity cap bounding a runaway Psyche.
20	//!
21	//! Delivery itself is the existing REQ-MSG substrate ([`spt_msg::deliver`]:
22	//! TCP-first, spool-fallback) — nothing new on the wire.
23	//
24	// [impl->REQ-HAZARD-PSYCHE-OUTBOUND-PROXY]
25
26	use std::collections::BTreeMap;
27	use std::path::Path;
28	use std::time::Duration;
29
30	use spt_live::outbound::{parse_psyche_intents, PsycheIntent};
31	use spt_live::turn::run_psyche_turn;
32	use spt_live::TurnError;
33	use spt_msg::deliver::{send, SendOutcome};
34	use spt_proto::event::{compose_typed_event, EVENT_TYPE_NOTIFY};
35	use spt_runtime::AgentRuntime;
36
37	/// Sanity cap on one relayed intent body — bounds a runaway Psyche that
38	/// dumped its whole context into an envelope. Generous (the T3 chunker
39	/// handles legitimate long bodies at delivery); over-cap intents drop with a
40	/// typed outcome, never silently.
41	pub const PSYCHE_BODY_CAP: usize = 64 * 1024;
42
43	/// What the relay did with one parsed intent — loud + typed, never silent.
44	#[derive(Debug, Clone, PartialEq, Eq)]
45	pub enum RelayOutcome {
46	    /// A `reply` relayed to the inbound's `__REPLY_TO__` sender.
47	    Replied { to: String, delivery: SendOutcome },
48	    /// A `notify` relayed to the agent's own Self perch (the user surface).
49	    Notified { to: String, delivery: SendOutcome },
50	    /// A `reply` with no inbound sender to answer — dropped, fail-closed.
51	    DroppedNoReplyTarget,
52	    /// An empty/whitespace body — nothing to relay.
53	    DroppedEmptyBody,
54	    /// A body over [`PSYCHE_BODY_CAP`] — refused.
55	    DroppedOversize,
56	}
57
58	/// Relay parsed Psyche intents through the sanitize boundary (see module
59	/// docs). `psyche_id` is the daemon-known identity re-stamped as `from=` on
60	/// every relayed envelope; `reply_to` is the `__REPLY_TO__` sender of the
61	/// inbound being answered (`None` for a turn with no inbound sender — e.g.
62	/// alarm-fired); `self_id` is the agent's own Self perch, the only notify
63	/// target.
64	// [impl->REQ-HAZARD-PSYCHE-OUTBOUND-PROXY]
65	pub fn relay_psyche_outbound(
66	    psyche_id: &str,
67	    self_id: &str,
68	    reply_to: Option<&str>,
69	    intents: &[PsycheIntent],
70	    owlery: &Path,
71	) -> Vec<RelayOutcome> {
72	    intents
73	        .iter()
74	        .map(|intent| {
75	            let body = intent.body().trim();
76	            if body.is_empty() {
77	                return RelayOutcome::DroppedEmptyBody;
78	            }
79	            if body.len() > PSYCHE_BODY_CAP {
80	                return RelayOutcome::DroppedOversize;
81	            }
82	            match intent {
83	                PsycheIntent::Reply { .. } => match reply_to {
84	                    Some(to) if !to.trim().is_empty() => RelayOutcome::Replied {
85	                        to: to.to_string(),
86	                        delivery: send(to, psyche_id, body, owlery),
87	                    },
88	                    _ => RelayOutcome::DroppedNoReplyTarget,
89	                },
90	                PsycheIntent::Notify { .. } => {
91	                    // The daemon authors the wire envelope itself: typed
92	                    // `notify` with the re-stamped from. compose escapes per
93	                    // 4.1; a typed envelope passes through delivery verbatim
94	                    // (is_typed_event_envelope — never re-wrapped).
95	                    let envelope =
96	                        compose_typed_event(EVENT_TYPE_NOTIFY, &[("from", psyche_id)], body);
97	                    RelayOutcome::Notified {
98	                        to: self_id.to_string(),
99	                        delivery: send(self_id, psyche_id, &envelope, owlery),
100	                    }
101	                }
102	            }
103	        })
104	        .collect()
105	}
106
107	/// The per-turn outbound driver: one bounded, stdout-captured Psyche turn
108	/// (the D7.5a driver — never `Stdio::null()`), parsed and relayed through the
109	/// sanitize boundary. A failed turn — including `EmptyOutput`, the
110	/// null-stdout shape 7.3 guards against — relays **nothing** and surfaces the
111	/// error. The production trigger loop (when a turn fires) rides D9; this is
112	/// the per-turn mechanism it calls.
113	// [impl->REQ-HAZARD-PSYCHE-OUTBOUND-PROXY]
114	#[allow(clippy::too_many_arguments)]
115	pub fn psyche_turn_and_relay(
116	    runtime: &dyn AgentRuntime,
117	    keys: &BTreeMap<String, String>,
118	    stdin: &[u8],
119	    reply_to: Option<&str>,
120	    psyche_id: &str,
121	    self_id: &str,
122	    owlery: &Path,
123	    timeout: Duration,
124	) -> Result<Vec<RelayOutcome>, TurnError> {
125	    let stdout = run_psyche_turn(runtime, keys, stdin, timeout)?;
126	    let intents = parse_psyche_intents(&stdout);
127	    Ok(relay_psyche_outbound(psyche_id, self_id, reply_to, &intents, owlery))
128	}
129
130	#[cfg(test)]
131	mod tests {
132	    use super::*;
133	    use crate::test_home::with_home;
134	    use spt_proto::endpoint::EndpointType;
135	    use spt_runtime::{Manifest, ManifestRuntime};
136	    use spt_store::info::{write_info, InfoJson};
137	    use spt_store::perch::{self, ParentHint};
138	    use spt_store::spool;
139	    use std::path::PathBuf;
140
141	    // The stdin-echo turn command: captured stdout = the fed stdin, so a test
142	    // scripts the Psyche's stdout by feeding it as stdin (same trick as
143	    // spt-live turn.rs; dodges cmd.exe `<`/`>` redirection in echo).
144	    #[cfg(windows)]
145	    const ECHO_STDIN: &str = "cmd /C findstr x*";
146	    #[cfg(unix)]
147	    const ECHO_STDIN: &str = "sh -c cat";
148	    #[cfg(windows)]
149	    const NO_OUTPUT: &str = "cmd /C exit 0";
150	    #[cfg(unix)]
151	    const NO_OUTPUT: &str = "sh -c true";
152
153	    fn rt(cmd: &str) -> ManifestRuntime {
154	        let toml = format!(
155	            "[adapter]\nname=\"mock\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n\
156	             [session.psyche_resume]\ncommand='{cmd}'\n"
157	        );
158	        ManifestRuntime::new(Manifest::from_toml_str(&toml).unwrap())
159	    }
160
161	    /// Seed an offline perch for `id` so `send` has a spool target.
162	    fn seed_perch(id: &str) -> PathBuf {
163	        let p = perch::resolve_perch_path(id, ParentHint::Infer);
164	        std::fs::create_dir_all(&p).unwrap();
165	        write_info(&p, &InfoJson::new(id, "t0", 0, "", &EndpointType::ReadyAgent.as_tag()))
166	            .unwrap();
167	        p
168	    }
169
170	    fn drive(stdout_script: &str, reply_to: Option<&str>) -> Vec<RelayOutcome> {
171	        psyche_turn_and_relay(
172	            &rt(ECHO_STDIN),
173	            &BTreeMap::new(),
174	            stdout_script.as_bytes(),
175	            reply_to,
176	            "doyle-psyche",
177	            "doyle",
178	            &perch::owlery_dir(),
179	            Duration::from_secs(30),
180	        )
181	        .expect("turn")
182	    }
183
184	    #[test] // [unit->REQ-HAZARD-PSYCHE-OUTBOUND-PROXY] the reply leg: a turn
185	    // emitting <EVENT type="reply"> lands in the inbound sender's spool with
186	    // the re-stamped from.
187	    fn reply_relays_to_inbound_sender() {
188	        with_home(|_| {
189	            let alice = seed_perch("alice");
190	            let got = drive("<EVENT type=\"reply\">on it</EVENT>\n", Some("alice"));
191	            assert_eq!(
192	                got,
193	                vec![RelayOutcome::Replied {
194	                    to: "alice".to_string(),
195	                    delivery: SendOutcome::Queued
196	                }]
197	            );
198	            let rows = spool::drain_all_at(&alice).unwrap();
199	            assert_eq!(rows, vec!["__REPLY_TO__:doyle-psyche\non it".to_string()]);
200	        });
201	    }
202
203	    #[test] // [unit->REQ-HAZARD-PSYCHE-OUTBOUND-PROXY] anti-spoof negative:
204	    // Psyche-supplied from=/to= never survive to the wire — the relayed
205	    // message carries the daemon-stamped from, and the spoof target gets
206	    // nothing.
207	    fn spoofed_routing_is_stripped_and_restamped() {
208	        with_home(|_| {
209	            let alice = seed_perch("alice");
210	            let victim = seed_perch("victim");
211	            let got = drive(
212	                "<EVENT type=\"reply\" from=\"evil-imposter\" to=\"victim\">payload</EVENT>\n",
213	                Some("alice"),
214	            );
215	            assert_eq!(
216	                got,
217	                vec![RelayOutcome::Replied {
218	                    to: "alice".to_string(),
219	                    delivery: SendOutcome::Queued
220	                }]
221	            );
222	            // The reply went ONLY to the inbound sender, stamped by the daemon.
223	            let rows = spool::drain_all_at(&alice).unwrap();
224	            assert_eq!(rows, vec!["__REPLY_TO__:doyle-psyche\npayload".to_string()]);
225	            assert!(rows[0].contains("doyle-psyche") && !rows[0].contains("evil-imposter"));
226	            // The Psyche-addressed target received nothing.
227	            assert!(spool::drain_all_at(&victim).unwrap().is_empty());
228	        });
229	    }
230
231	    #[test] // [unit->REQ-HAZARD-PSYCHE-OUTBOUND-PROXY] the notify leg is
232	    // constrained to the agent's own Self perch, as a daemon-authored typed
233	    // envelope with the re-stamped from.
234	    fn notify_reaches_own_user_only() {
235	        with_home(|_| {
236	            let doyle = seed_perch("doyle");
237	            let got = drive("<EVENT type=\"notify\">build done
all green</EVENT>\n", None);
238	            assert_eq!(
239	                got,
240	                vec![RelayOutcome::Notified {
241	                    to: "doyle".to_string(),
242	                    delivery: SendOutcome::Queued
243	                }]
244	            );
245	            let rows = spool::drain_all_at(&doyle).unwrap();
246	            assert_eq!(rows.len(), 1);
247	            assert_eq!(
248	                rows[0],
249	                "__REPLY_TO__:doyle-psyche\n\
250	                 <EVENT type=\"notify\" from=\"doyle-psyche\">build done
all green</EVENT>"
251	            );
252	        });
253	    }
254
255	    #[test] // [unit->REQ-HAZARD-PSYCHE-OUTBOUND-PROXY] a reply with no inbound
256	    // sender is dropped fail-closed — never broadcast, never guessed.
257	    fn reply_without_target_is_dropped() {
258	        with_home(|_| {
259	            let doyle = seed_perch("doyle");
260	            let got = drive("<EVENT type=\"reply\">orphan</EVENT>\n", None);
261	            assert_eq!(got, vec![RelayOutcome::DroppedNoReplyTarget]);
262	            assert!(spool::drain_all_at(&doyle).unwrap().is_empty());
263	        });
264	    }
265
266	    #[test] // [unit->REQ-HAZARD-PSYCHE-OUTBOUND-PROXY] the null-stdout shape
267	    // fails the guard (7.3a): a no-output driver surfaces TurnError and
268	    // relays nothing.
269	    fn null_stdout_driver_fails_the_guard() {
270	        with_home(|_| {
271	            let alice = seed_perch("alice");
272	            let err = psyche_turn_and_relay(
273	                &rt(NO_OUTPUT),
274	                &BTreeMap::new(),
275	                b"<EVENT type=\"reply\">never seen</EVENT>",
276	                Some("alice"),
277	                "doyle-psyche",
278	                "doyle",
279	                &perch::owlery_dir(),
280	                Duration::from_secs(30),
281	            )
282	            .unwrap_err();
283	            assert!(matches!(err, TurnError::EmptyOutput), "got {err}");
284	            assert!(spool::drain_all_at(&alice).unwrap().is_empty());
285	        });
286	    }
287
288	    #[test] // [unit->REQ-HAZARD-PSYCHE-OUTBOUND-PROXY] a chatty Psyche that
289	    // emitted no envelope relays nothing (prose is not an intent).
290	    fn prose_only_relays_nothing() {
291	        with_home(|_| {
292	            let alice = seed_perch("alice");
293	            let got = drive("Thought hard about it.\nNothing to send.\n", Some("alice"));
294	            assert!(got.is_empty());
295	            assert!(spool::drain_all_at(&alice).unwrap().is_empty());
296	        });
297	    }
298
299	    #[test] // [unit->REQ-HAZARD-PSYCHE-OUTBOUND-PROXY] body validation: empty
300	    // bodies and over-cap bodies drop with typed outcomes, no delivery.
301	    fn empty_and_oversize_bodies_drop_typed() {
302	        with_home(|_| {
303	            let alice = seed_perch("alice");
304	            let empty = drive("<EVENT type=\"reply\"></EVENT>\n", Some("alice"));
305	            assert_eq!(empty, vec![RelayOutcome::DroppedEmptyBody]);
306
307	            let big = "x".repeat(PSYCHE_BODY_CAP + 1);
308	            let over = relay_psyche_outbound(
309	                "doyle-psyche",
310	                "doyle",
311	                Some("alice"),
312	                &[PsycheIntent::Reply { body: big }],
313	                &perch::owlery_dir(),
314	            );
315	            assert_eq!(over, vec![RelayOutcome::DroppedOversize]);
316	            assert!(spool::drain_all_at(&alice).unwrap().is_empty());
317	        });
318	    }
319	}
320

//! Psyche outbound relay + sanitize boundary (ADR-0012 / M4-D7.5c) — the
//! relay half of the daemon's parse-and-relay proxy.
//!
//! The Psyche is sandboxed; the daemon is its **sole outbound proxy**
//! (KNOWN-HAZARDS 7.3). [`spt_live::outbound::parse_psyche_intents`] already
//! made a Psyche-supplied `from=`/target *unrepresentable* (the structural
//! strip); this module enforces the other half of the trust boundary at relay
//! time:
//!
//! - **re-stamp**: every relayed envelope carries the daemon-known
//!   `from=<psyche_id>` — the daemon is the sole trusted author of wire
//!   envelopes carrying a real `from=` (ADR-0012);
//! - **constrained routing**: a `reply` goes **only** to the `__REPLY_TO__`
//!   sender of the inbound being answered (no sender ⇒ dropped, fail-closed —
//!   never broadcast, never guessed); a `notify` reaches **only** the agent's
//!   own Self perch (the user's surface today; D8 swaps this leg onto the
//!   notif primitive);
//! - **body validation**: the 4.1 codec at compose (`\n`→`<br>`, amp-last
//!   entities via `spt-proto`) plus a sanity cap bounding a runaway Psyche.
//!
//! Delivery itself is the existing REQ-MSG substrate ([`spt_msg::deliver`]:
//! TCP-first, spool-fallback) — nothing new on the wire.
//
// [impl->REQ-HAZARD-PSYCHE-OUTBOUND-PROXY]

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

use spt_live::outbound::{parse_psyche_intents, PsycheIntent};
use spt_live::turn::run_psyche_turn;
use spt_live::TurnError;
use spt_msg::deliver::{send, SendOutcome};
use spt_proto::event::{compose_typed_event, EVENT_TYPE_NOTIFY};
use spt_runtime::AgentRuntime;

/// Sanity cap on one relayed intent body — bounds a runaway Psyche that
/// dumped its whole context into an envelope. Generous (the T3 chunker
/// handles legitimate long bodies at delivery); over-cap intents drop with a
/// typed outcome, never silently.
pub const PSYCHE_BODY_CAP: usize = 64 * 1024;

/// What the relay did with one parsed intent — loud + typed, never silent.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RelayOutcome {
    /// A `reply` relayed to the inbound's `__REPLY_TO__` sender.
    Replied { to: String, delivery: SendOutcome },
    /// A `notify` relayed to the agent's own Self perch (the user surface).
    Notified { to: String, delivery: SendOutcome },
    /// A `reply` with no inbound sender to answer — dropped, fail-closed.
    DroppedNoReplyTarget,
    /// An empty/whitespace body — nothing to relay.
    DroppedEmptyBody,
    /// A body over [`PSYCHE_BODY_CAP`] — refused.
    DroppedOversize,
}

/// Relay parsed Psyche intents through the sanitize boundary (see module
/// docs). `psyche_id` is the daemon-known identity re-stamped as `from=` on
/// every relayed envelope; `reply_to` is the `__REPLY_TO__` sender of the
/// inbound being answered (`None` for a turn with no inbound sender — e.g.
/// alarm-fired); `self_id` is the agent's own Self perch, the only notify
/// target.
// [impl->REQ-HAZARD-PSYCHE-OUTBOUND-PROXY]
pub fn relay_psyche_outbound(
    psyche_id: &str,
    self_id: &str,
    reply_to: Option<&str>,
    intents: &[PsycheIntent],
    owlery: &Path,
) -> Vec<RelayOutcome> {
    intents
        .iter()
        .map(|intent| {
            let body = intent.body().trim();
            if body.is_empty() {
                return RelayOutcome::DroppedEmptyBody;
            }
            if body.len() > PSYCHE_BODY_CAP {
                return RelayOutcome::DroppedOversize;
            }
            match intent {
                PsycheIntent::Reply { .. } => match reply_to {
                    Some(to) if !to.trim().is_empty() => RelayOutcome::Replied {
                        to: to.to_string(),
                        delivery: send(to, psyche_id, body, owlery),
                    },
                    _ => RelayOutcome::DroppedNoReplyTarget,
                },
                PsycheIntent::Notify { .. } => {
                    // The daemon authors the wire envelope itself: typed
                    // `notify` with the re-stamped from. compose escapes per
                    // 4.1; a typed envelope passes through delivery verbatim
                    // (is_typed_event_envelope — never re-wrapped).
                    let envelope =
                        compose_typed_event(EVENT_TYPE_NOTIFY, &[("from", psyche_id)], body);
                    RelayOutcome::Notified {
                        to: self_id.to_string(),
                        delivery: send(self_id, psyche_id, &envelope, owlery),
                    }
                }
            }
        })
        .collect()
}

/// The per-turn outbound driver: one bounded, stdout-captured Psyche turn
/// (the D7.5a driver — never `Stdio::null()`), parsed and relayed through the
/// sanitize boundary. A failed turn — including `EmptyOutput`, the
/// null-stdout shape 7.3 guards against — relays **nothing** and surfaces the
/// error. The production trigger loop (when a turn fires) rides D9; this is
/// the per-turn mechanism it calls.
// [impl->REQ-HAZARD-PSYCHE-OUTBOUND-PROXY]
#[allow(clippy::too_many_arguments)]
pub fn psyche_turn_and_relay(
    runtime: &dyn AgentRuntime,
    keys: &BTreeMap<String, String>,
    stdin: &[u8],
    reply_to: Option<&str>,
    psyche_id: &str,
    self_id: &str,
    owlery: &Path,
    timeout: Duration,
) -> Result<Vec<RelayOutcome>, TurnError> {
    let stdout = run_psyche_turn(runtime, keys, stdin, timeout)?;
    let intents = parse_psyche_intents(&stdout);
    Ok(relay_psyche_outbound(psyche_id, self_id, reply_to, &intents, owlery))
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::test_home::with_home;
    use spt_proto::endpoint::EndpointType;
    use spt_runtime::{Manifest, ManifestRuntime};
    use spt_store::info::{write_info, InfoJson};
    use spt_store::perch::{self, ParentHint};
    use spt_store::spool;
    use std::path::PathBuf;

    // The stdin-echo turn command: captured stdout = the fed stdin, so a test
    // scripts the Psyche's stdout by feeding it as stdin (same trick as
    // spt-live turn.rs; dodges cmd.exe `<`/`>` redirection in echo).
    #[cfg(windows)]
    const ECHO_STDIN: &str = "cmd /C findstr x*";
    #[cfg(unix)]
    const ECHO_STDIN: &str = "sh -c cat";
    #[cfg(windows)]
    const NO_OUTPUT: &str = "cmd /C exit 0";
    #[cfg(unix)]
    const NO_OUTPUT: &str = "sh -c true";

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

    /// Seed an offline perch for `id` so `send` has a spool target.
    fn seed_perch(id: &str) -> PathBuf {
        let p = perch::resolve_perch_path(id, ParentHint::Infer);
        std::fs::create_dir_all(&p).unwrap();
        write_info(&p, &InfoJson::new(id, "t0", 0, "", &EndpointType::ReadyAgent.as_tag()))
            .unwrap();
        p
    }

    fn drive(stdout_script: &str, reply_to: Option<&str>) -> Vec<RelayOutcome> {
        psyche_turn_and_relay(
            &rt(ECHO_STDIN),
            &BTreeMap::new(),
            stdout_script.as_bytes(),
            reply_to,
            "doyle-psyche",
            "doyle",
            &perch::owlery_dir(),
            Duration::from_secs(30),
        )
        .expect("turn")
    }

    #[test] // [unit->REQ-HAZARD-PSYCHE-OUTBOUND-PROXY] the reply leg: a turn
    // emitting <EVENT type="reply"> lands in the inbound sender's spool with
    // the re-stamped from.
    fn reply_relays_to_inbound_sender() {
        with_home(|_| {
            let alice = seed_perch("alice");
            let got = drive("<EVENT type=\"reply\">on it</EVENT>\n", Some("alice"));
            assert_eq!(
                got,
                vec![RelayOutcome::Replied {
                    to: "alice".to_string(),
                    delivery: SendOutcome::Queued
                }]
            );
            let rows = spool::drain_all_at(&alice).unwrap();
            assert_eq!(rows, vec!["__REPLY_TO__:doyle-psyche\non it".to_string()]);
        });
    }

    #[test] // [unit->REQ-HAZARD-PSYCHE-OUTBOUND-PROXY] anti-spoof negative:
    // Psyche-supplied from=/to= never survive to the wire — the relayed
    // message carries the daemon-stamped from, and the spoof target gets
    // nothing.
    fn spoofed_routing_is_stripped_and_restamped() {
        with_home(|_| {
            let alice = seed_perch("alice");
            let victim = seed_perch("victim");
            let got = drive(
                "<EVENT type=\"reply\" from=\"evil-imposter\" to=\"victim\">payload</EVENT>\n",
                Some("alice"),
            );
            assert_eq!(
                got,
                vec![RelayOutcome::Replied {
                    to: "alice".to_string(),
                    delivery: SendOutcome::Queued
                }]
            );
            // The reply went ONLY to the inbound sender, stamped by the daemon.
            let rows = spool::drain_all_at(&alice).unwrap();
            assert_eq!(rows, vec!["__REPLY_TO__:doyle-psyche\npayload".to_string()]);
            assert!(rows[0].contains("doyle-psyche") && !rows[0].contains("evil-imposter"));
            // The Psyche-addressed target received nothing.
            assert!(spool::drain_all_at(&victim).unwrap().is_empty());
        });
    }

    #[test] // [unit->REQ-HAZARD-PSYCHE-OUTBOUND-PROXY] the notify leg is
    // constrained to the agent's own Self perch, as a daemon-authored typed
    // envelope with the re-stamped from.
    fn notify_reaches_own_user_only() {
        with_home(|_| {
            let doyle = seed_perch("doyle");
            let got = drive("<EVENT type=\"notify\">build done
all green</EVENT>\n", None);
            assert_eq!(
                got,
                vec![RelayOutcome::Notified {
                    to: "doyle".to_string(),
                    delivery: SendOutcome::Queued
                }]
            );
            let rows = spool::drain_all_at(&doyle).unwrap();
            assert_eq!(rows.len(), 1);
            assert_eq!(
                rows[0],
                "__REPLY_TO__:doyle-psyche\n\
                 <EVENT type=\"notify\" from=\"doyle-psyche\">build done
all green</EVENT>"
            );
        });
    }

    #[test] // [unit->REQ-HAZARD-PSYCHE-OUTBOUND-PROXY] a reply with no inbound
    // sender is dropped fail-closed — never broadcast, never guessed.
    fn reply_without_target_is_dropped() {
        with_home(|_| {
            let doyle = seed_perch("doyle");
            let got = drive("<EVENT type=\"reply\">orphan</EVENT>\n", None);
            assert_eq!(got, vec![RelayOutcome::DroppedNoReplyTarget]);
            assert!(spool::drain_all_at(&doyle).unwrap().is_empty());
        });
    }

    #[test] // [unit->REQ-HAZARD-PSYCHE-OUTBOUND-PROXY] the null-stdout shape
    // fails the guard (7.3a): a no-output driver surfaces TurnError and
    // relays nothing.
    fn null_stdout_driver_fails_the_guard() {
        with_home(|_| {
            let alice = seed_perch("alice");
            let err = psyche_turn_and_relay(
                &rt(NO_OUTPUT),
                &BTreeMap::new(),
                b"<EVENT type=\"reply\">never seen</EVENT>",
                Some("alice"),
                "doyle-psyche",
                "doyle",
                &perch::owlery_dir(),
                Duration::from_secs(30),
            )
            .unwrap_err();
            assert!(matches!(err, TurnError::EmptyOutput), "got {err}");
            assert!(spool::drain_all_at(&alice).unwrap().is_empty());
        });
    }

    #[test] // [unit->REQ-HAZARD-PSYCHE-OUTBOUND-PROXY] a chatty Psyche that
    // emitted no envelope relays nothing (prose is not an intent).
    fn prose_only_relays_nothing() {
        with_home(|_| {
            let alice = seed_perch("alice");
            let got = drive("Thought hard about it.\nNothing to send.\n", Some("alice"));
            assert!(got.is_empty());
            assert!(spool::drain_all_at(&alice).unwrap().is_empty());
        });
    }

    #[test] // [unit->REQ-HAZARD-PSYCHE-OUTBOUND-PROXY] body validation: empty
    // bodies and over-cap bodies drop with typed outcomes, no delivery.
    fn empty_and_oversize_bodies_drop_typed() {
        with_home(|_| {
            let alice = seed_perch("alice");
            let empty = drive("<EVENT type=\"reply\"></EVENT>\n", Some("alice"));
            assert_eq!(empty, vec![RelayOutcome::DroppedEmptyBody]);

            let big = "x".repeat(PSYCHE_BODY_CAP + 1);
            let over = relay_psyche_outbound(
                "doyle-psyche",
                "doyle",
                Some("alice"),
                &[PsycheIntent::Reply { body: big }],
                &perch::owlery_dir(),
            );
            assert_eq!(over, vec![RelayOutcome::DroppedOversize]);
            assert!(spool::drain_all_at(&alice).unwrap().is_empty());
        });
    }
}


TaskCreate

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.


Task #1 created successfully: Research D8 prerequisites (M4-PLAN §D8, D3 distribution, ADR-0007, boundaries, update/consent producers)


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.


Task #2 created successfully: Draft M4-D8-PLAN.md (slices D8a–D8d)

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


Updated task #1 status