The file C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\auth.rs has been updated successfully. (file state is current in your context — no need to Read it back)

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

    fn establish(id: &str, session_id: &str) -> String {

//! Local-api-auth — every `api` mutation proves association to its target
//! endpoint before touching on-disk state (codex red-team #13,
//! REQ-HAZARD-LOCAL-API-AUTH). Without this guard any local process could drive
//! another agent's perch by id alone.
//!
//! Two accepted proofs, checked against the target perch:
//! 1. the perch's **capability token** (minted when the perch is established,
//!    handed to the session out-of-band), or
//! 2. the **session_id** recorded in the perch's `info.json` (the harness knows
//!    its own session id; an unrelated process does not).
//!
//! A call with neither — or against a perch that doesn't exist — is refused.
//
// [impl->REQ-HAZARD-LOCAL-API-AUTH]

use spt_proto::identity::Identity;
use spt_store::info;
use spt_store::perch::{self, ParentHint};
use std::path::Path;

/// Per-perch capability-token filename within a perch dir.
const TOKEN_FILE: &str = "api.token";

/// Mint a fresh capability token: 32 hex chars from crypto-grade randomness.
/// Reuses spt-proto's Ed25519 seed generator so no extra RNG dependency is
/// pulled into the binary.
pub fn mint_token() -> String {
    let seed = Identity::generate().seed();
    seed[..16].iter().map(|b| format!("{b:02x}")).collect()
}

/// Persist a capability token into an (already-created) perch dir.
pub fn write_token(perch_path: &Path, token: &str) -> std::io::Result<()> {
    std::fs::write(perch_path.join(TOKEN_FILE), token)
}

/// Read a perch's capability token, if one was written.
pub fn read_token(perch_path: &Path) -> Option<String> {
    std::fs::read_to_string(perch_path.join(TOKEN_FILE))
        .ok()
        .map(|s| s.trim().to_string())
        .filter(|s| !s.is_empty())
}

/// Caller-supplied proof, gathered from flags with env fallback.
pub struct Proof {
    pub token: Option<String>,
    pub session_id: Option<String>,
}

impl Proof {
    /// Resolve proof from explicit flags, falling back to `$SPT_API_TOKEN` /
    /// `$OWL_SESSION_ID` (the channels a harness-hosted session carries).
    pub fn resolve(token: Option<String>, session_id: Option<String>) -> Proof {
        Proof {
            token: token
                .or_else(|| std::env::var("SPT_API_TOKEN").ok())
                .filter(|s| !s.is_empty()),
            session_id: session_id
                .or_else(|| std::env::var("OWL_SESSION_ID").ok())
                .filter(|s| !s.is_empty()),
        }
    }
}

/// Outcome of an auth check. `NoEndpoint` and `Refused` are both refusals; they
/// differ only in the diagnostic the caller prints.
#[derive(Debug, PartialEq, Eq)]
pub enum AuthResult {
    Ok,
    /// Target perch has no `info.json` — nothing to authenticate against.
    NoEndpoint,
    /// Perch exists but the proof matched neither token nor session_id.
    Refused,
}

/// Authenticate a mutation against `target_id`'s perch.
pub fn authenticate(target_id: &str, proof: &Proof) -> AuthResult {
    let perch_path = perch::resolve_perch_path(target_id, ParentHint::Infer);
    let Some(rec) = info::read_info(&perch_path) else {
        return AuthResult::NoEndpoint;
    };
    if let (Some(supplied), Some(actual)) = (&proof.token, read_token(&perch_path)) {
        if constant_eq(supplied, &actual) {
            return AuthResult::Ok;
        }
    }
    if let Some(sid) = &proof.session_id {
        if !rec.session_id.is_empty() && rec.session_id == *sid {
            return AuthResult::Ok;
        }
    }
    AuthResult::Refused
}

/// Length-then-content compare without early-out on the content loop. The token
/// is a local capability, not a network secret, but a constant-ish compare
/// costs nothing and avoids a trivially-timed oracle.
fn constant_eq(a: &str, b: &str) -> bool {
    let (a, b) = (a.as_bytes(), b.as_bytes());
    if a.len() != b.len() {
        return false;
    }
    let mut diff = 0u8;
    for i in 0..a.len() {
        diff |= a[i] ^ b[i];
    }
    diff == 0
}

#[cfg(test)]
mod tests {
    use super::*;
    use spt_store::info::InfoJson;
    use std::sync::Mutex;

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

    /// Set SPT_HOME to an isolated temp dir for the duration of a test (the
    /// owlery resolver reads it). Also clears the auth env so fallbacks don't
    /// leak between tests.
    struct Home {
        _lock: std::sync::MutexGuard<'static, ()>,
        _tmp: tempfile::TempDir,
        prev_home: Option<String>,
    }
    impl Home {
        fn set() -> Self {
            let lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
            let tmp = tempfile::tempdir().unwrap();
            let prev_home = std::env::var("SPT_HOME").ok();
            std::env::set_var("SPT_HOME", tmp.path());
            std::env::remove_var("SPT_API_TOKEN");
            std::env::remove_var("OWL_SESSION_ID");
            Home { _lock: lock, _tmp: tmp, prev_home }
        }
    }
    impl Drop for Home {
        fn drop(&mut self) {
            match &self.prev_home {
                Some(v) => std::env::set_var("SPT_HOME", v),
                None => std::env::remove_var("SPT_HOME"),
            }
        }
    }

    fn establish(id: &str, session_id: &str) -> String {
        let path = perch::resolve_perch_path(id, ParentHint::Infer);
        std::fs::create_dir_all(&path).unwrap();
        info::write_info(
            &path,
            &InfoJson::new(id, "2026-06-01T00:00:00Z", 123, session_id, "ready_agent"),
        )
        .unwrap();
        let token = mint_token();
        write_token(&path, &token).unwrap();
        token
    }

    #[test] // [unit->REQ-HAZARD-LOCAL-API-AUTH]
    fn mints_distinct_nonempty_tokens() {
        let a = mint_token();
        let b = mint_token();
        assert_eq!(a.len(), 32);
        assert_ne!(a, b, "tokens must be unpredictable");
    }

    #[test] // [unit->REQ-HAZARD-LOCAL-API-AUTH]
    fn token_proof_authenticates() {
        let _h = Home::set();
        let token = establish("alice", "sid-1");
        let proof = Proof::resolve(Some(token), None);
        assert_eq!(authenticate("alice", &proof), AuthResult::Ok);
    }

    #[test] // [unit->REQ-HAZARD-LOCAL-API-AUTH]
    fn session_id_proof_authenticates() {
        let _h = Home::set();
        establish("alice", "sid-secret");
        let proof = Proof::resolve(None, Some("sid-secret".into()));
        assert_eq!(authenticate("alice", &proof), AuthResult::Ok);
    }

    #[test] // [unit->REQ-HAZARD-LOCAL-API-AUTH]
    fn no_proof_is_refused() {
        let _h = Home::set();
        establish("alice", "sid-1");
        let proof = Proof::resolve(None, None);
        assert_eq!(authenticate("alice", &proof), AuthResult::Refused);
    }

    #[test] // [unit->REQ-HAZARD-LOCAL-API-AUTH]
    fn wrong_token_and_wrong_session_refused() {
        let _h = Home::set();
        establish("alice", "sid-1");
        let proof = Proof::resolve(Some("deadbeef".into()), Some("not-it".into()));
        assert_eq!(authenticate("alice", &proof), AuthResult::Refused);
    }

    #[test] // [unit->REQ-HAZARD-LOCAL-API-AUTH]
    fn missing_endpoint_is_no_endpoint() {
        let _h = Home::set();
        let proof = Proof::resolve(Some("whatever".into()), None);
        assert_eq!(authenticate("ghost", &proof), AuthResult::NoEndpoint);
    }
}

+    use crate::testutil::isolated_home;

-    static ENV_LOCK: Mutex<()> = Mutex::new(());

-    /// Set SPT_HOME to an isolated temp dir for the duration of a test (the

-    /// owlery resolver reads it). Also clears the auth env so fallbacks don't

-        _lock: std::sync::MutexGuard<'static, ()>,

-            let lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());

-            let tmp = tempfile::tempdir().unwrap();

-            let prev_home = std::env::var("SPT_HOME").ok();

-            std::env::set_var("SPT_HOME", tmp.path());

-            std::env::remove_var("SPT_API_TOKEN");

-            std::env::remove_var("OWL_SESSION_ID");

-            Home { _lock: lock, _tmp: tmp, prev_home }

-                Some(v) => std::env::set_var("SPT_HOME", v),

-                None => std::env::remove_var("SPT_HOME"),


Bash

cd "C:\Users\decid\Documents\projects\spt-core" && perl -0pi -e 's/let _h = Home::set\(\);/let _h = isolated_home();/g' crates/spt/src/api/auth.rs && grep -c "isolated_home()" crates/spt/src/api/auth.rs

Swap Home::set to isolated_home in auth tests


1	//! Local-api-auth — every `api` mutation proves association to its target
2	//! endpoint before touching on-disk state (codex red-team #13,
3	//! REQ-HAZARD-LOCAL-API-AUTH). Without this guard any local process could drive
4	//! another agent's perch by id alone.
5	//!
6	//! Two accepted proofs, checked against the target perch:
7	//! 1. the perch's **capability token** (minted when the perch is established,
8	//!    handed to the session out-of-band), or
9	//! 2. the **session_id** recorded in the perch's `info.json` (the harness knows
10	//!    its own session id; an unrelated process does not).
11	//!
12	//! A call with neither — or against a perch that doesn't exist — is refused.
13	//
14	// [impl->REQ-HAZARD-LOCAL-API-AUTH]
15
16	use spt_proto::identity::Identity;
17	use spt_store::info;
18	use spt_store::perch::{self, ParentHint};
19	use std::path::Path;
20
21	/// Per-perch capability-token filename within a perch dir.
22	const TOKEN_FILE: &str = "api.token";
23
24	/// Mint a fresh capability token: 32 hex chars from crypto-grade randomness.
25	/// Reuses spt-proto's Ed25519 seed generator so no extra RNG dependency is
26	/// pulled into the binary.
27	pub fn mint_token() -> String {
28	    let seed = Identity::generate().seed();
29	    seed[..16].iter().map(|b| format!("{b:02x}")).collect()
30	}
31
32	/// Persist a capability token into an (already-created) perch dir.
33	pub fn write_token(perch_path: &Path, token: &str) -> std::io::Result<()> {
34	    std::fs::write(perch_path.join(TOKEN_FILE), token)
35	}
36
37	/// Read a perch's capability token, if one was written.
38	pub fn read_token(perch_path: &Path) -> Option<String> {
39	    std::fs::read_to_string(perch_path.join(TOKEN_FILE))
40	        .ok()
41	        .map(|s| s.trim().to_string())
42	        .filter(|s| !s.is_empty())
43	}
44
45	/// Caller-supplied proof, gathered from flags with env fallback.
46	pub struct Proof {
47	    pub token: Option<String>,
48	    pub session_id: Option<String>,
49	}
50
51	impl Proof {
52	    /// Resolve proof from explicit flags, falling back to `$SPT_API_TOKEN` /
53	    /// `$OWL_SESSION_ID` (the channels a harness-hosted session carries).
54	    pub fn resolve(token: Option<String>, session_id: Option<String>) -> Proof {
55	        Proof {
56	            token: token
57	                .or_else(|| std::env::var("SPT_API_TOKEN").ok())
58	                .filter(|s| !s.is_empty()),
59	            session_id: session_id
60	                .or_else(|| std::env::var("OWL_SESSION_ID").ok())
61	                .filter(|s| !s.is_empty()),
62	        }
63	    }
64	}
65
66	/// Outcome of an auth check. `NoEndpoint` and `Refused` are both refusals; they
67	/// differ only in the diagnostic the caller prints.
68	#[derive(Debug, PartialEq, Eq)]
69	pub enum AuthResult {
70	    Ok,
71	    /// Target perch has no `info.json` — nothing to authenticate against.
72	    NoEndpoint,
73	    /// Perch exists but the proof matched neither token nor session_id.
74	    Refused,
75	}
76
77	/// Authenticate a mutation against `target_id`'s perch.
78	pub fn authenticate(target_id: &str, proof: &Proof) -> AuthResult {
79	    let perch_path = perch::resolve_perch_path(target_id, ParentHint::Infer);
80	    let Some(rec) = info::read_info(&perch_path) else {
81	        return AuthResult::NoEndpoint;
82	    };
83	    if let (Some(supplied), Some(actual)) = (&proof.token, read_token(&perch_path)) {
84	        if constant_eq(supplied, &actual) {
85	            return AuthResult::Ok;
86	        }
87	    }
88	    if let Some(sid) = &proof.session_id {
89	        if !rec.session_id.is_empty() && rec.session_id == *sid {
90	            return AuthResult::Ok;
91	        }
92	    }
93	    AuthResult::Refused
94	}
95
96	/// Length-then-content compare without early-out on the content loop. The token
97	/// is a local capability, not a network secret, but a constant-ish compare
98	/// costs nothing and avoids a trivially-timed oracle.
99	fn constant_eq(a: &str, b: &str) -> bool {
100	    let (a, b) = (a.as_bytes(), b.as_bytes());
101	    if a.len() != b.len() {
102	        return false;
103	    }
104	    let mut diff = 0u8;
105	    for i in 0..a.len() {
106	        diff |= a[i] ^ b[i];
107	    }
108	    diff == 0
109	}
110
111	#[cfg(test)]
112	mod tests {
113	    use super::*;
114	    use crate::testutil::isolated_home;
115	    use spt_store::info::InfoJson;
116
117	    fn establish(id: &str, session_id: &str) -> String {
118	        let path = perch::resolve_perch_path(id, ParentHint::Infer);
119	        std::fs::create_dir_all(&path).unwrap();
120	        info::write_info(
121	            &path,
122	            &InfoJson::new(id, "2026-06-01T00:00:00Z", 123, session_id, "ready_agent"),
123	        )
124	        .unwrap();
125	        let token = mint_token();
126	        write_token(&path, &token).unwrap();
127	        token
128	    }
129
130	    #[test] // [unit->REQ-HAZARD-LOCAL-API-AUTH]
131	    fn mints_distinct_nonempty_tokens() {
132	        let a = mint_token();
133	        let b = mint_token();
134	        assert_eq!(a.len(), 32);
135	        assert_ne!(a, b, "tokens must be unpredictable");
136	    }
137
138	    #[test] // [unit->REQ-HAZARD-LOCAL-API-AUTH]
139	    fn token_proof_authenticates() {
140	        let _h = isolated_home();
141	        let token = establish("alice", "sid-1");
142	        let proof = Proof::resolve(Some(token), None);
143	        assert_eq!(authenticate("alice", &proof), AuthResult::Ok);
144	    }
145
146	    #[test] // [unit->REQ-HAZARD-LOCAL-API-AUTH]
147	    fn session_id_proof_authenticates() {
148	        let _h = isolated_home();
149	        establish("alice", "sid-secret");
150	        let proof = Proof::resolve(None, Some("sid-secret".into()));
151	        assert_eq!(authenticate("alice", &proof), AuthResult::Ok);
152	    }
153
154	    #[test] // [unit->REQ-HAZARD-LOCAL-API-AUTH]
155	    fn no_proof_is_refused() {
156	        let _h = isolated_home();
157	        establish("alice", "sid-1");
158	        let proof = Proof::resolve(None, None);
159	        assert_eq!(authenticate("alice", &proof), AuthResult::Refused);
160	    }
161
162	    #[test] // [unit->REQ-HAZARD-LOCAL-API-AUTH]
163	    fn wrong_token_and_wrong_session_refused() {
164	        let _h = isolated_home();
165	        establish("alice", "sid-1");
166	        let proof = Proof::resolve(Some("deadbeef".into()), Some("not-it".into()));
167	        assert_eq!(authenticate("alice", &proof), AuthResult::Refused);
168	    }
169
170	    #[test] // [unit->REQ-HAZARD-LOCAL-API-AUTH]
171	    fn missing_endpoint_is_no_endpoint() {
172	        let _h = isolated_home();
173	        let proof = Proof::resolve(Some("whatever".into()), None);
174	        assert_eq!(authenticate("ghost", &proof), AuthResult::NoEndpoint);
175	    }
176	}


Now refactor startup.rs tests similarly.


Edit

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

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

    struct Home {
        _lock: std::sync::MutexGuard<'static, ()>,
        _tmp: tempfile::TempDir,
        prev: Option<String>,
    }
    impl Home {
        fn set() -> Self {
            let lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
            let tmp = tempfile::tempdir().unwrap();
            let prev = std::env::var("SPT_HOME").ok();
            std::env::set_var("SPT_HOME", tmp.path());
            Home { _lock: lock, _tmp: tmp, prev }
        }
    }
    impl Drop for Home {
        fn drop(&mut self) {
            match &self.prev {
                Some(v) => std::env::set_var("SPT_HOME", v),
                None => std::env::remove_var("SPT_HOME"),
            }
        }
    }

    fn write_seed(parent_pid: u32, session_id: &str) {

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

    fn write_seed(parent_pid: u32, session_id: &str) {


The file C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\startup.rs has been updated successfully. (file state is current in your context — no need to Read it back)

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

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

    struct Home {
        _lock: std::sync::MutexGuard<'static, ()>,
        _tmp: tempfile::TempDir,
        prev: Option<String>,
    }
    impl Home {
        fn set() -> Self {
            let lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
            let tmp = tempfile::tempdir().unwrap();
            let prev = std::env::var("SPT_HOME").ok();
            std::env::set_var("SPT_HOME", tmp.path());
            Home { _lock: lock, _tmp: tmp, prev }
        }
    }
    impl Drop for Home {
        fn drop(&mut self) {
            match &self.prev {
                Some(v) => std::env::set_var("SPT_HOME", v),
                None => std::env::remove_var("SPT_HOME"),
            }
        }
    }

    fn write_seed(parent_pid: u32, session_id: &str) {

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

    fn write_seed(parent_pid: u32, session_id: &str) {

-            let prev = std::env::var("SPT_HOME").ok();

-            Home { _lock: lock, _tmp: tmp, prev }


Bash

cd "C:\Users\decid\Documents\projects\spt-core" && perl -0pi -e 's/let _h = Home::set\(\);/let _h = isolated_home();/g' crates/spt/src/api/startup.rs && cargo test -p spt 2>&1 | tail -20

Swap guard + retest spt


test api::tests::state_value_is_validated ... ok
test api::tests::adapter_is_required ... ok
test cli::tests::invalid_usage_errors ... ok
test cli::tests::send_target_optional_with_reply_to ... ok
test api::tests::surface_parses ... ok
test cli::tests::subcommands_parse ... ok
test api::auth::tests::no_proof_is_refused ... ok
test api::auth::tests::session_id_proof_authenticates ... ok
test api::startup::tests::post_spawn_bind_establishes_perch ... ok
test api::startup::tests::stale_seed_with_dead_anchor_refused ... ok
test api::startup::tests::missing_seed_refused ... ok
test api::startup::tests::live_perch_under_other_session_conflicts ... ok
test api::auth::tests::token_proof_authenticates ... ok
test api::startup::tests::rebind_same_session_ok ... ok
test api::auth::tests::wrong_token_and_wrong_session_refused ... ok
test api::startup::tests::valid_seed_binds_live_perch ... ok
test api::startup::tests::empty_session_seed_refused ... ok

test result: ok. 20 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.06s


1	//! Harness-hosted startup (interim no-daemon): `api seed` → `api listen` → bind,
2	//! plus the post-spawn `api bind`.
3	//!
4	//! Flow (CONTEXT §Startup flows, harness-hosted):
5	//! 1. The harness's SessionStart hook fires `api seed --pid <parent_pid>
6	//!    --session-id <sid>` → an ephemeral seed file keyed by the stable parent
7	//!    pid (REQ-START-1).
8	//! 2. The agent runs its `$SPT listen <id>` alias → `api listen` self-discovers
9	//!    its parent pid, matches the seed, validates it against pid-recycle
10	//!    (KNOWN-HAZARDS 5.1), binds the perch skeleton→live, and relays.
11	//! 3. Alternatively a just-spawned binary fires `api bind <id>` to associate
12	//!    itself post-spawn (REQ-SEAM-POSTSPAWN) — establishing the live perch on
13	//!    first contact, or flipping an existing skeleton.
14	//
15	// [impl->REQ-START-1]
16	// [impl->REQ-START-2]
17	// [impl->REQ-SEAM-POSTSPAWN]
18
19	use std::time::Duration;
20
21	use spt_msg::listener::PollListener;
22	use spt_store::info::{self, InfoJson};
23	use spt_store::perch::{self, ParentHint};
24	use spt_store::proc;
25	use spt_store::seed::{self, Seed};
26	use spt_store::spool;
27
28	use super::auth;
29	use super::{Ctx, EXIT_REFUSED};
30
31	/// Poll window for the relay loop — bounds each blocking accept so the loop
32	/// stays responsive (mirrors M1's listener cadence).
33	const RELAY_POLL: Duration = Duration::from_millis(200);
34
35	/// `api seed` — record the ephemeral startup seed keyed by parent pid. No prior
36	/// perch exists, so this call is not auth-gated (it *establishes* the binding
37	/// material the gated calls later authenticate against).
38	// [impl->REQ-START-1]
39	pub fn cmd_seed(ctx: &Ctx, parent_pid: u32, session_id: String) -> i32 {
40	    if session_id.is_empty() {
41	        eprintln!("USAGE: seed requires a non-empty --session-id");
42	        return EXIT_REFUSED;
43	    }
44	    let seed = Seed {
45	        parent_pid,
46	        session_id,
47	        adapter: ctx.adapter.clone(),
48	        cwd: std::env::current_dir()
49	            .ok()
50	            .map(|p| p.to_string_lossy().replace('\\', "/")),
51	        created: now_stamp(),
52	    };
53	    match seed::write_seed(&seed) {
54	        Ok(()) => {
55	            eprintln!("SEEDED:{parent_pid}");
56	            0
57	        }
58	        Err(e) => {
59	            eprintln!("SEED_FAIL:{parent_pid}: {e}");
60	            EXIT_REFUSED
61	        }
62	    }
63	}
64
65	/// Why a seed-driven bind was refused. Each variant is a distinct diagnostic so
66	/// a stale/recycled seed never silently binds the wrong session.
67	#[derive(Debug, PartialEq, Eq)]
68	pub enum BindError {
69	    NoParentPid,
70	    NoSeed(u32),
71	    /// The seed's anchor process is dead → recycled/abandoned (5.1).
72	    StaleSeed(u32),
73	    EmptySession,
74	    /// A *live* perch already holds this id under a different session (5.1).
75	    Conflict { id: String, held: String },
76	    Io(String),
77	}
78
79	/// Consume the seed for `parent_pid` and bind/revive the perch skeleton→live.
80	/// Pure of any listener I/O so it is unit-testable without spawning processes.
81	/// Returns the perch's capability token on success.
82	// [impl->REQ-START-2]
83	// [impl->REQ-HAZARD-WINDOWS-PID-RECYCLE]
84	pub fn bind_from_seed(id: &str, parent_pid: u32) -> Result<String, BindError> {
85	    let seed = seed::read_seed(parent_pid).ok_or(BindError::NoSeed(parent_pid))?;
86	    if seed.session_id.is_empty() {
87	        return Err(BindError::EmptySession);
88	    }
89	    // Pid-recycle defense (5.1): anchor liveness on the stable parent pid. A
90	    // dead anchor means the harness that wrote the seed is gone — a recycled or
91	    // abandoned seed must never bind.
92	    if !proc::is_process_alive(seed.parent_pid) {
93	        return Err(BindError::StaleSeed(seed.parent_pid));
94	    }
95	    let token = establish_perch(id, &seed.session_id, seed.parent_pid)?;
96	    seed::remove_seed(parent_pid); // consumed; refreshed on the next SessionStart
97	    Ok(token)
98	}
99
100	/// Write the perch record (skeleton→live) + ready marker + capability token.
101	/// Reviving an existing perch keeps its token; a live perch held under a
102	/// different session id is a conflict (pid-recycle / id collision guard).
103	fn establish_perch(id: &str, session_id: &str, parent_pid: u32) -> Result<String, BindError> {
104	    let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);
105
106	    if let Some(existing) = info::read_info(&perch_path) {
107	        let owner_alive = info::read_pid(&perch_path)
108	            .map(proc::is_process_alive)
109	            .unwrap_or(false);
110	        if owner_alive
111	            && !existing.session_id.is_empty()
112	            && existing.session_id != session_id
113	        {
114	            return Err(BindError::Conflict {
115	                id: id.to_string(),
116	                held: existing.session_id,
117	            });
118	        }
119	    }
120
121	    std::fs::create_dir_all(&perch_path).map_err(|e| BindError::Io(e.to_string()))?;
122	    let mut rec = InfoJson::new(id, &now_stamp(), std::process::id(), session_id, "live_agent");
123	    rec.parent_pid = Some(parent_pid);
124	    info::write_info(&perch_path, &rec).map_err(|e| BindError::Io(e.to_string()))?;
125	    std::fs::write(perch::resolve_ready_file(id, ParentHint::Infer), "")
126	        .map_err(|e| BindError::Io(e.to_string()))?;
127
128	    // Keep an existing token across a revive; mint one for a fresh perch.
129	    let token = auth::read_token(&perch_path).unwrap_or_else(auth::mint_token);
130	    auth::write_token(&perch_path, &token).map_err(|e| BindError::Io(e.to_string()))?;
131	    Ok(token)
132	}
133
134	/// `api listen <id>` — consume the seed, bind the perch, then run the relay
135	/// loop (heir to `$LIVE start`'s Monitor-bound poll loop). `--once` drains and
136	/// exits (testability); otherwise blocks streaming messages to stdout.
137	// [impl->REQ-START-2]
138	pub fn cmd_listen(id: &str, parent_pid_override: Option<u32>, once: bool) -> i32 {
139	    let parent_pid = match parent_pid_override.or_else(proc::parent_pid) {
140	        Some(p) => p,
141	        None => {
142	            eprintln!("NO_PARENT_PID: could not resolve the parent pid anchor");
143	            return EXIT_REFUSED;
144	        }
145	    };
146	    let token = match bind_from_seed(id, parent_pid) {
147	        Ok(t) => t,
148	        Err(e) => {
149	            report_bind_error(&e);
150	            return EXIT_REFUSED;
151	        }
152	    };
153	    eprintln!("BOUND:{id} token={token}");
154
155	    let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);
156	    let listener = match PollListener::bind_blocking(id, RELAY_POLL) {
157	        Ok(l) => l,
158	        Err(e) => {
159	            eprintln!("LISTEN_FAIL:{id}: {e}");
160	            return EXIT_REFUSED;
161	        }
162	    };
163	    eprintln!("READY:{id}");
164
165	    // Drain the offline backlog first (non-deferred only; deferred rows wait
166	    // for the hook channel).
167	    for msg in spool::drain_non_deferred_at(&perch_path).unwrap_or_default() {
168	        emit(&msg);
169	    }
170	    if once {
171	        if let Some(msg) = listener.check_message_blocking() {
172	            emit(&msg);
173	        }
174	        for msg in spool::drain_non_deferred_at(&perch_path).unwrap_or_default() {
175	            emit(&msg);
176	        }
177	        return 0;
178	    }
179	    loop {
180	        if let Some(msg) = listener.check_message_blocking() {
181	            emit(&msg);
182	        }
183	        for msg in spool::drain_non_deferred_at(&perch_path).unwrap_or_default() {
184	            emit(&msg);
185	        }
186	    }
187	}
188
189	/// `api bind <id>` — post-spawn boot bind (REQ-SEAM-POSTSPAWN). Establishes the
190	/// live perch on first contact, or flips an existing skeleton, keyed to the
191	/// session id the binary reports. Auth is intrinsic: an existing *live* perch
192	/// under a different session id is refused (you can only bind your own).
193	// [impl->REQ-SEAM-POSTSPAWN]

... [144 lines truncated] ...


All 20 pass. Run workspace, then activate T4 reqs. Check pid-recycle hazard's current state.


cd "C:\Users\decid\Documents\projects\spt-core" && cargo test --workspace 2>&1 | grep -E "test result|error\[|FAILED" | head; echo "===recycle state==="; traceable-reqs check 2>&1 | grep "WINDOWS-PID-RECYCLE"

Workspace test + recycle req state


test result: ok. 20 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.06s
test result: ok. 22 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 2.24s
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.22s
test result: ok. 43 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.03s
test result: ok. 17 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.31s
test result: ok. 33 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.81s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
===recycle state===
  [OK] REQ-HAZARD-WINDOWS-PID-RECYCLE  required: [impl, unit]  stages: -doc +impl +unit -int


All green. PID-recycle already active (my tags add evidence). Activate START-1/START-2/SEAM-POSTSPAWN.


cd "C:\Users\decid\Documents\projects\spt-core" && grep -n "REQ-START-1\|REQ-START-2\|REQ-SEAM-POSTSPAWN" -A2 traceable-reqs.toml

Locate T4 req entries


67:id = "REQ-SEAM-POSTSPAWN"
68-title = "post-spawn / api bind seam with boot nonce"
69-required_stages = []
--
115:id = "REQ-START-1"
116-title = "Adapters never resolve SPT_HOME; binary on PATH; api bridging only"
117-required_stages = []
--
119:id = "REQ-START-2"
120-title = "Harness-hosted startup: api seed then listen"
121-required_stages = []


T0: Scaffold spt-runtime crate

New crate (members + deps spt-proto/store/msg, serde, toml); activation-prep. Compiles; traceable-reqs check green.

T1: Manifest schema + parse + validate

Typed structs for every docs/MANIFEST.md section + parse + validate (helpful errors). REQ-MANIFEST-1.

T2: AgentRuntime trait + ManifestRuntime

Substitution-key catalog + opaque-template fill + spawn-session seam (timeout). REQ-SEAM-SPAWN, REQ-HAZARD-SUBPROCESS-TIMEOUT.

T3: spt api dispatch group + local-api-auth

--manifest/--adapter resolution, adapter_name on every call, exit codes, local-api-auth guard. REQ-API-1, REQ-HAZARD-LOCAL-API-AUTH.

T4: Harness-hosted startup (seed/listen/bind)

api seed (interim seed-file by parent_pid) + api listen (consume seed, validate session_id, bind skeleton->live, relay loop) + api bind. REQ-START-1/2, REQ-SEAM-POSTSPAWN, REQ-API-2, REQ-HAZARD-WINDOWS-PID-RECYCLE.

T5: Delivery + activity (state/poll/inject)

api state busy|idle (+echo-gate sentinel, --no-gate, echo-gate set/clear) + api poll [--include-deferred] + inject-method selection. REQ-SEAM-ACTIVITY, REQ-SEAM-INJECT, REQ-API-2.

T6: Worker perches + cascade-wipe guard

api worker-start/worker-stop/worker-poll + cascade-wipe guard. REQ-API-2, REQ-HAZARD-CASCADE-WIPE-GUARD.

T7: Reporting + boundary surface

api boundary clear|compact + session-end (--erase) + presence + history-log + emit stub + capability hostable_types + env-alias supply. REQ-API-2/3, REQ-START-4, REQ-SEAM-CAPABILITY, REQ-HAZARD-SOFT-CLEANUP.

T8: Generic mock adapter

adapters/mock/manifest.toml + cross-platform session scripts/helper bin; zero CC conventions. REQ-MANIFEST-1, REQ-API-3.

T9: Contract E2E test

load mock manifest -> ManifestRuntime spawns session -> api bind -> api state idle -> api poll delivers spooled message. REQ-API-1/2 int, REQ-START-2 int.

T10: Activation sweep

Activate M2a reqs + hazards + REQ-ARCH-2, green check, CI green, amend ROADMAP/CONTEXT for split.


66	[[requirements]]
67	id = "REQ-SEAM-POSTSPAWN"
68	title = "post-spawn / api bind seam with boot nonce"
69	required_stages = []
70	[[requirements]]
71	id = "REQ-SEAM-PSYCHE"

[[requirements]]
id = "REQ-SEAM-POSTSPAWN"
title = "post-spawn / api bind seam with boot nonce"
required_stages = []
[[requirements]]
id = "REQ-SEAM-PSYCHE"


114	[[requirements]]
115	id = "REQ-START-1"
116	title = "Adapters never resolve SPT_HOME; binary on PATH; api bridging only"
117	required_stages = []
118	[[requirements]]
119	id = "REQ-START-2"
120	title = "Harness-hosted startup: api seed then listen"
121	required_stages = []

[[requirements]]
id = "REQ-START-1"
title = "Adapters never resolve SPT_HOME; binary on PATH; api bridging only"
required_stages = []
[[requirements]]
id = "REQ-START-2"
title = "Harness-hosted startup: api seed then listen"
required_stages = []


Edit

id = "REQ-SEAM-POSTSPAWN"
title = "post-spawn / api bind seam with boot nonce"
required_stages = []

id = "REQ-SEAM-POSTSPAWN"
title = "post-spawn / api bind seam with boot nonce"
required_stages = ["impl", "unit"]


The file C:\Users\decid\Documents\projects\spt-core\traceable-reqs.toml has been updated successfully. (file state is current in your context — no need to Read it back)

id = "REQ-SEAM-POSTSPAWN"
title = "post-spawn / api bind seam with boot nonce"
required_stages = []

id = "REQ-SEAM-POSTSPAWN"
title = "post-spawn / api bind seam with boot nonce"
required_stages = ["impl", "unit"]

title = "spawn-psyche seam (fresh + resume templates)"


id = "REQ-START-1"
title = "Adapters never resolve SPT_HOME; binary on PATH; api bridging only"
required_stages = []

id = "REQ-START-1"
title = "Adapters never resolve SPT_HOME; binary on PATH; api bridging only"
required_stages = ["impl", "unit"]


id = "REQ-START-1"
title = "Adapters never resolve SPT_HOME; binary on PATH; api bridging only"
required_stages = []

id = "REQ-START-1"
title = "Adapters never resolve SPT_HOME; binary on PATH; api bridging only"
required_stages = ["impl", "unit"]

title = "Harness-hosted startup: api seed then listen"


id = "REQ-START-2"
title = "Harness-hosted startup: api seed then listen"
required_stages = []

id = "REQ-START-2"
title = "Harness-hosted startup: api seed then listen"
required_stages = ["impl", "unit"]


id = "REQ-START-2"
title = "Harness-hosted startup: api seed then listen"
required_stages = []

id = "REQ-START-2"
title = "Harness-hosted startup: api seed then listen"
required_stages = ["impl", "unit"]

title = "spt-hosted startup: spawn-session then api bind (no file)"


Bash

cd "C:\Users\decid\Documents\projects\spt-core" && traceable-reqs check 2>&1 | grep -E "START-1|START-2|SEAM-POSTSPAWN"; traceable-reqs check >/dev/null 2>&1; echo "exit:$?"

Verify T4 coverage


  [OK] REQ-SEAM-POSTSPAWN  required: [impl, unit]  stages: -doc +impl +unit -int
  [OK] REQ-START-1  required: [impl, unit]  stages: -doc +impl +unit -int
  [OK] REQ-START-2  required: [impl, unit]  stages: -doc +impl +unit -int
exit:0


T4 covered. Commit.


cd "C:\Users\decid\Documents\projects\spt-core" && git add -A && git commit -q -F - <<'EOF'
feat(spt): M2a T4 — harness-hosted startup (seed → listen → bind)

The interim no-daemon startup topology (CONTEXT §Startup flows).

- spt-store::seed — the localized swap point: an ephemeral seed file keyed by
  the stable parent pid. `api seed --pid --session-id` writes it (not auth-gated;
  it establishes the binding material). M3 replaces the file with the daemon's
  in-memory map; nothing else in the listen path changes.
- spt-store::proc::parent_pid() — the stable session-binding anchor. Unix
  getppid; Windows NtQueryInformationProcess(InheritedFromUniqueProcessId)
  (tasklist hangs on recent builds, same reason as the OpenProcess liveness path).
- api listen — self-discovers its parent pid (override flag for tests), consumes
  the seed via bind_from_seed (pure, unit-testable), then runs the relay loop
  (--once for tests). Skeleton→live: writes info.json (live_agent + parent_pid +
  session_id), ready marker, capability token.
- Pid-recycle defense (KNOWN-HAZARDS 5.1): a dead-anchor seed is StaleSeed-
  refused; a live perch held under a different session id is a Conflict — a
  stale/recycled seed never silently binds.
- api bind — post-spawn establishing bind (REQ-SEAM-POSTSPAWN): first-contact
  establish or same-session re-bind; cross-session live perch refused.
- testutil: one process-wide home-isolation lock shared across spt test modules
  (a per-module lock let auth/startup tests clobber each other's SPT_HOME).

REQ-START-1/START-2/SEAM-POSTSPAWN activated [impl, unit]; REQ-HAZARD-WINDOWS-
PID-RECYCLE gains startup evidence. 20 spt + 3 proc + 2 seed tests green; check
green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
EOF
git log --oneline -1

Commit T4


warning: in the working copy of 'crates/spt-store/src/lib.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-store/src/proc.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt/src/api/auth.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt/src/api/mod.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt/src/main.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'traceable-reqs.toml', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-store/src/seed.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt/src/api/startup.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt/src/testutil.rs', LF will be replaced by CRLF the next time Git touches it
d6610b6 feat(spt): M2a T4 — harness-hosted startup (seed → listen → bind)


T4 done. Now T5 — delivery/activity. Check sentinel naming conventions and spool drain signatures.


cd "C:\Users\decid\Documents\projects\spt-core" && grep -rn "idle\|sentinel\|echo.gate\|echo_gate" CONTEXT.md docs/*.md | grep -i "sentinel\|\.idle\|echo.gate" | head -15

Find sentinel naming in docs


CONTEXT.md:96:**activity/idle detection** — **not** PTY-quiescence (insufficient: e.g. CC's AskUserQuestion stalls the PTY while holding stdin and needing nuanced input) and **not** a manifest-declared idle signal. Instead, the adapter calls spt-core activity/idle commands at the right moments (from its hooks); those commands manage activity/idle **sentinels inside the session perch**. The idle state lives in the perch, owned by spt-core via the commands API exposed to adapter devs.
CONTEXT.md:116:- **Hook output capability is declared per harness-event** (`can_inject`). CC's Stop hook cannot inject context — that single fact drives the echo-gate sentinel + relay fallback. The manifest expresses it so spt-core knows when to fall back.
CONTEXT.md:129:- **`api state <busy|idle>`** — adapter reports session activity; writes the activity/idle sentinel in the session perch. By default `api state idle` also writes the **echo-commune gate sentinel** (`.more-done`-equiv — modern spt couples them); `--no-gate` suppresses that coupling, and a standalone **`api echo-gate <set|clear>`** gives granular adapters explicit control over when echo communes may fire, independent of idle.
docs/KNOWN-HAZARDS.md:85:### 3.2 Stale signoff sentinel must not kill a fresh start
docs/KNOWN-HAZARDS.md:87:- **Invariant:** on every listener/daemon spawn, sweep stale signoff sentinels; signoff files are write-once per generation.
docs/MANIFEST.md:7:**SPT is not a harness.** The manifest declares only what *varies per harness*. Command templates are **opaque strings** — spt-core never parses out a model, tool list, or flag; the adapter writes the full command line and spt-core just runs it with substitution keys filled. Anything spt-core owns (sentinels, spool/registry/perch schemas, the event-block vocabulary, the version-trampoline mechanism, config knobs) is **not** in the manifest.
docs/MANIFEST.md:11:- **Sentinels** (`.idle`-equiv, echo-gate) — managed by `api state`/`api echo-gate`; the adapter only *calls* them.
docs/MANIFEST.md:34:Per harness event: the `api` command it fires, the stdin fields it maps in, and its **output capability** — `can_inject` (whether this hook can surface context to the agent). When `can_inject = false`, spt-core falls back to a sentinel + relay/poll path instead of expecting injection. This is the load-bearing harness-varying fact (CC's Stop hook can't inject).
docs/MANIFEST.md:45:can_inject = false                 # no hookSpecificOutput schema → sentinel/relay fallback
docs/MANIFEST.md:157:| 7 CC hooks | hooks.json → `owl.exe <subcmd>` | `[hooks.*]` table; `Stop.can_inject=false` drives the echo-gate fallback |


T0: Scaffold spt-runtime crate

T1: Manifest schema + parse + validate

T6: Worker perches + cascade-wipe guard

T7: Reporting + boundary surface

T8: Generic mock adapter

T9: Contract E2E test

T10: Activation sweep


Update tasks; write delivery module (T5). Sentinels: `.idle` + `.more-done` (echo-gate).


Updated task #5 status


Updated task #6 status


Write

//! Delivery + activity: `api state`, `api echo-gate`, `api poll`, and
//! inject-method selection.
//!
//! Activity state lives in the perch as **spt-core-owned sentinels** (CONTEXT
//! §activity/idle detection) — the adapter only *calls* the commands; it never
//! names or writes the sentinels (MANIFEST §What is NOT in the manifest):
//! - `.idle`      — the activity/idle sentinel (`api state busy|idle`).
//! - `.more-done` — the echo-commune gate (armed by `api state idle` unless
//!   `--no-gate`; togglable directly via `api echo-gate set|clear`). This is the
//!   `can_inject = false` fallback path: a hook that can't inject context arms
//!   the gate so a later relay/poll surfaces it instead (CONTEXT §hook output).
//!
//! `api poll` is the **short-lived hook-channel drain** (distinct from
//! `api listen`'s long-running relay): it drains the spool and prints each
//! message framed to stdout. The inject *method* (hook/relay; PTY is M3) is
//! resolved from the manifest's `[inject]` table.
//
// [impl->REQ-SEAM-ACTIVITY]
// [impl->REQ-SEAM-INJECT]
// [impl->REQ-API-2]

use spt_runtime::manifest::InjectMethod;
use spt_runtime::Manifest;
use spt_store::perch::{self, ParentHint};
use spt_store::spool;

use super::EXIT_REFUSED;

/// The activity/idle sentinel filename within a perch dir.
const IDLE_SENTINEL: &str = ".idle";
/// The echo-commune gate sentinel (`.more-done`-equiv; modern spt couples it to
/// the idle transition).
const ECHO_GATE_SENTINEL: &str = ".more-done";

fn sentinel_path(id: &str, name: &str) -> std::path::PathBuf {
    perch::resolve_perch_path(id, ParentHint::Infer).join(name)
}

/// Set/clear the `.idle` sentinel for an activity transition. Idle additionally
/// arms the echo-gate unless `no_gate` (CONTEXT §api state).
// [impl->REQ-SEAM-ACTIVITY]
pub fn cmd_state(id: &str, state: &str, no_gate: bool) -> i32 {
    let idle = sentinel_path(id, IDLE_SENTINEL);
    match state {
        "idle" => {
            if let Err(e) = std::fs::write(&idle, "") {
                eprintln!("STATE_FAIL:{id}: {e}");
                return EXIT_REFUSED;
            }
            if !no_gate {
                let _ = std::fs::write(sentinel_path(id, ECHO_GATE_SENTINEL), "");
            }
            eprintln!("IDLE:{id}{}", if no_gate { " (no-gate)" } else { " (gate-armed)" });
            0
        }
        "busy" => {
            // Removing a non-existent sentinel is fine (idempotent transition).
            let _ = std::fs::remove_file(&idle);
            eprintln!("BUSY:{id}");
            0
        }
        other => {
            eprintln!("USAGE: state must be busy|idle, got {other}");
            EXIT_REFUSED
        }
    }
}

/// `api echo-gate set|clear` — explicit control of the echo-commune gate,
/// independent of idle (CONTEXT §api state).
// [impl->REQ-SEAM-ACTIVITY]
pub fn cmd_echo_gate(id: &str, action: &str) -> i32 {
    let gate = sentinel_path(id, ECHO_GATE_SENTINEL);
    match action {
        "set" => {
            if let Err(e) = std::fs::write(&gate, "") {
                eprintln!("ECHO_GATE_FAIL:{id}: {e}");
                return EXIT_REFUSED;
            }
            eprintln!("ECHO_GATE_SET:{id}");
            0
        }
        "clear" => {
            let _ = std::fs::remove_file(&gate);
            eprintln!("ECHO_GATE_CLEAR:{id}");
            0
        }
        other => {
            eprintln!("USAGE: echo-gate must be set|clear, got {other}");
            EXIT_REFUSED
        }
    }
}

/// Whether the idle / echo-gate sentinels are currently set (readers/tests).
pub fn is_idle(id: &str) -> bool {
    sentinel_path(id, IDLE_SENTINEL).exists()
}
pub fn echo_gate_armed(id: &str) -> bool {
    sentinel_path(id, ECHO_GATE_SENTINEL).exists()
}

/// Resolve the inject methods for an activity state from the manifest's
/// `[inject]` table, **dropping `pty`** (PTY injection is M3 — `spt-term`). With
/// no manifest or no configured methods, default to `hook` (the universally-
/// available channel). REQ-SEAM-INJECT.
// [impl->REQ-SEAM-INJECT]
pub fn resolve_inject_methods(manifest: Option<&Manifest>, idle: bool) -> Vec<InjectMethod> {
    let configured = manifest.and_then(|m| m.inject.as_ref()).map(|inj| {
        if idle { &inj.idle } else { &inj.activity }
    });
    let mut methods: Vec<InjectMethod> = configured
        .map(|v| {
            v.iter()
                .copied()
                .filter(|m| *m != InjectMethod::Pty) // PTY = M3
                .collect()
        })
        .unwrap_or_default();
    if methods.is_empty() {
        methods.push(InjectMethod::Hook);
    }
    methods
}

/// `api poll <id>` — drain the spool (hook-channel delivery) and print each
/// message framed to stdout. Non-deferred only unless `include_deferred`.
// [impl->REQ-API-2]
pub fn cmd_poll(id: &str, include_deferred: bool, manifest: Option<&Manifest>) -> i32 {
    // Inject-method selection is resolved (hook/relay) even though the M2a poll
    // channel is the hook drain; PTY delivery lands with the broker in M3.
    let _methods = resolve_inject_methods(manifest, is_idle(id));
    for msg in poll_drain(id, include_deferred) {
        emit(&msg);
    }
    0
}

/// Drain the spool for `id`, returning the messages (oldest first). Separated
/// from `cmd_poll` so it is unit-testable without capturing stdout.
pub fn poll_drain(id: &str, include_deferred: bool) -> Vec<String> {
    let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);
    let drained = if include_deferred {
        spool::drain_all_at(&perch_path)
    } else {
        spool::drain_non_deferred_at(&perch_path)
    };
    drained.unwrap_or_default()
}

fn emit(msg: &str) {
    use std::io::Write;
    print!("{msg}");
    let _ = std::io::stdout().flush();
}

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

    fn establish(id: &str) {
        let path = perch::resolve_perch_path(id, ParentHint::Infer);
        std::fs::create_dir_all(&path).unwrap();
        info::write_info(
            &path,
            &InfoJson::new(id, "0", std::process::id(), "sid", "live_agent"),
        )
        .unwrap();
    }

    #[test] // [unit->REQ-SEAM-ACTIVITY] idle writes .idle + arms the gate; busy clears .idle.
    fn idle_busy_transitions_manage_sentinels() {
        let _h = isolated_home();
        establish("alice");
        assert_eq!(cmd_state("alice", "idle", false), 0);
        assert!(is_idle("alice"));
        assert!(echo_gate_armed("alice"), "idle arms the echo-gate by default");

        assert_eq!(cmd_state("alice", "busy", false), 0);
        assert!(!is_idle("alice"));
        // The gate persists across busy (cleared explicitly / on echo fire).
        assert!(echo_gate_armed("alice"));
    }

    #[test] // [unit->REQ-SEAM-ACTIVITY] --no-gate suppresses the idle↔gate coupling.
    fn idle_no_gate_does_not_arm_gate() {
        let _h = isolated_home();
        establish("alice");
        assert_eq!(cmd_state("alice", "idle", true), 0);
        assert!(is_idle("alice"));
        assert!(!echo_gate_armed("alice"));
    }

    #[test] // [unit->REQ-SEAM-ACTIVITY] echo-gate set/clear is independent of idle.
    fn echo_gate_explicit_toggle() {
        let _h = isolated_home();
        establish("alice");
        assert_eq!(cmd_echo_gate("alice", "set"), 0);
        assert!(echo_gate_armed("alice"));
        assert_eq!(cmd_echo_gate("alice", "clear"), 0);
        assert!(!echo_gate_armed("alice"));
        assert!(!is_idle("alice"), "echo-gate must not touch the idle sentinel");
    }

    #[test] // [unit->REQ-API-2] poll drains spooled messages; deferred only with the flag.
    fn poll_drains_non_deferred_by_default() {
        let _h = isolated_home();
        establish("alice");
        let perch_path = perch::resolve_perch_path("alice", ParentHint::Infer);
        spool::spool_message_at(&perch_path, "bob", "live one").unwrap();
        spool::spool_message_deferred_at(&perch_path, "bob", "deferred one").unwrap();

        let got = poll_drain("alice", false);
        assert_eq!(got.len(), 1, "deferred row must not drain without the flag");
        assert!(got[0].contains("live one"));

        // The deferred row survives and drains only with the flag.
        let got2 = poll_drain("alice", true);
        assert_eq!(got2.len(), 1);
        assert!(got2[0].contains("deferred one"));
    }

    #[test] // [unit->REQ-SEAM-INJECT] inject resolution drops PTY (M3) and defaults to hook.
    fn inject_resolution_drops_pty_defaults_hook() {
        // No manifest → default hook.
        assert_eq!(resolve_inject_methods(None, true), vec![InjectMethod::Hook]);

        let m = Manifest::from_toml_str(
            "[adapter]\nname=\"m\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n[inject]\nidle=[\"pty\",\"hook\"]\nactivity=[\"hook\"]\n",
        )
        .unwrap();
        // idle drops pty, keeps hook.
        assert_eq!(resolve_inject_methods(Some(&m), true), vec![InjectMethod::Hook]);
        assert_eq!(resolve_inject_methods(Some(&m), false), vec![InjectMethod::Hook]);
    }

    #[test] // [unit->REQ-SEAM-INJECT] an all-pty config still yields a usable channel.
    fn inject_all_pty_falls_back_to_hook() {
        let m = Manifest::from_toml_str(
            "[adapter]\nname=\"m\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n[inject]\nidle=[\"pty\"]\n",
        )
        .unwrap();
        assert_eq!(resolve_inject_methods(Some(&m), true), vec![InjectMethod::Hook]);
    }
}


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

//! Delivery + activity: `api state`, `api echo-gate`, `api poll`, and
//! inject-method selection.
//!
//! Activity state lives in the perch as **spt-core-owned sentinels** (CONTEXT
//! §activity/idle detection) — the adapter only *calls* the commands; it never
//! names or writes the sentinels (MANIFEST §What is NOT in the manifest):
//! - `.idle`      — the activity/idle sentinel (`api state busy|idle`).
//! - `.more-done` — the echo-commune gate (armed by `api state idle` unless
//!   `--no-gate`; togglable directly via `api echo-gate set|clear`). This is the
//!   `can_inject = false` fallback path: a hook that can't inject context arms
//!   the gate so a later relay/poll surfaces it instead (CONTEXT §hook output).
//!
//! `api poll` is the **short-lived hook-channel drain** (distinct from
//! `api listen`'s long-running relay): it drains the spool and prints each
//! message framed to stdout. The inject *method* (hook/relay; PTY is M3) is
//! resolved from the manifest's `[inject]` table.
//
// [impl->REQ-SEAM-ACTIVITY]
// [impl->REQ-SEAM-INJECT]
// [impl->REQ-API-2]

use spt_runtime::manifest::InjectMethod;
use spt_runtime::Manifest;
use spt_store::perch::{self, ParentHint};
use spt_store::spool;

use super::EXIT_REFUSED;

/// The activity/idle sentinel filename within a perch dir.
const IDLE_SENTINEL: &str = ".idle";
/// The echo-commune gate sentinel (`.more-done`-equiv; modern spt couples it to
/// the idle transition).
const ECHO_GATE_SENTINEL: &str = ".more-done";

fn sentinel_path(id: &str, name: &str) -> std::path::PathBuf {
    perch::resolve_perch_path(id, ParentHint::Infer).join(name)
}

/// Set/clear the `.idle` sentinel for an activity transition. Idle additionally
/// arms the echo-gate unless `no_gate` (CONTEXT §api state).
// [impl->REQ-SEAM-ACTIVITY]
pub fn cmd_state(id: &str, state: &str, no_gate: bool) -> i32 {
    let idle = sentinel_path(id, IDLE_SENTINEL);
    match state {
        "idle" => {
            if let Err(e) = std::fs::write(&idle, "") {
                eprintln!("STATE_FAIL:{id}: {e}");
                return EXIT_REFUSED;
            }
            if !no_gate {
                let _ = std::fs::write(sentinel_path(id, ECHO_GATE_SENTINEL), "");
            }
            eprintln!("IDLE:{id}{}", if no_gate { " (no-gate)" } else { " (gate-armed)" });
            0
        }
        "busy" => {
            // Removing a non-existent sentinel is fine (idempotent transition).
            let _ = std::fs::remove_file(&idle);
            eprintln!("BUSY:{id}");
            0
        }
        other => {
            eprintln!("USAGE: state must be busy|idle, got {other}");
            EXIT_REFUSED
        }
    }
}

/// `api echo-gate set|clear` — explicit control of the echo-commune gate,
/// independent of idle (CONTEXT §api state).
// [impl->REQ-SEAM-ACTIVITY]
pub fn cmd_echo_gate(id: &str, action: &str) -> i32 {
    let gate = sentinel_path(id, ECHO_GATE_SENTINEL);
    match action {
        "set" => {
            if let Err(e) = std::fs::write(&gate, "") {
                eprintln!("ECHO_GATE_FAIL:{id}: {e}");
                return EXIT_REFUSED;
            }
            eprintln!("ECHO_GATE_SET:{id}");
            0
        }
        "clear" => {
            let _ = std::fs::remove_file(&gate);
            eprintln!("ECHO_GATE_CLEAR:{id}");
            0
        }
        other => {
            eprintln!("USAGE: echo-gate must be set|clear, got {other}");
            EXIT_REFUSED
        }
    }
}

/// Whether the idle / echo-gate sentinels are currently set (readers/tests).
pub fn is_idle(id: &str) -> bool {
    sentinel_path(id, IDLE_SENTINEL).exists()
}
pub fn echo_gate_armed(id: &str) -> bool {
    sentinel_path(id, ECHO_GATE_SENTINEL).exists()
}

/// Resolve the inject methods for an activity state from the manifest's
/// `[inject]` table, **dropping `pty`** (PTY injection is M3 — `spt-term`). With
/// no manifest or no configured methods, default to `hook` (the universally-
/// available channel). REQ-SEAM-INJECT.
// [impl->REQ-SEAM-INJECT]
pub fn resolve_inject_methods(manifest: Option<&Manifest>, idle: bool) -> Vec<InjectMethod> {
    let configured = manifest.and_then(|m| m.inject.as_ref()).map(|inj| {
        if idle { &inj.idle } else { &inj.activity }
    });
    let mut methods: Vec<InjectMethod> = configured
        .map(|v| {
            v.iter()
                .copied()
                .filter(|m| *m != InjectMethod::Pty) // PTY = M3
                .collect()
        })
        .unwrap_or_default();
    if methods.is_empty() {
        methods.push(InjectMethod::Hook);
    }
    methods
}

/// `api poll <id>` — drain the spool (hook-channel delivery) and print each
/// message framed to stdout. Non-deferred only unless `include_deferred`.
// [impl->REQ-API-2]
pub fn cmd_poll(id: &str, include_deferred: bool, manifest: Option<&Manifest>) -> i32 {
    // Inject-method selection is resolved (hook/relay) even though the M2a poll
    // channel is the hook drain; PTY delivery lands with the broker in M3.
    let _methods = resolve_inject_methods(manifest, is_idle(id));
    for msg in poll_drain(id, include_deferred) {
        emit(&msg);
    }
    0
}

/// Drain the spool for `id`, returning the messages (oldest first). Separated
/// from `cmd_poll` so it is unit-testable without capturing stdout.
pub fn poll_drain(id: &str, include_deferred: bool) -> Vec<String> {
    let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);
    let drained = if include_deferred {
        spool::drain_all_at(&perch_path)
    } else {
        spool::drain_non_deferred_at(&perch_path)
    };
    drained.unwrap_or_default()
}

fn emit(msg: &str) {
    use std::io::Write;
    print!("{msg}");
    let _ = std::io::stdout().flush();
}

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

    fn establish(id: &str) {
        let path = perch::resolve_perch_path(id, ParentHint::Infer);
        std::fs::create_dir_all(&path).unwrap();
        info::write_info(
            &path,
            &InfoJson::new(id, "0", std::process::id(), "sid", "live_agent"),
        )
        .unwrap();
    }

    #[test] // [unit->REQ-SEAM-ACTIVITY] idle writes .idle + arms the gate; busy clears .idle.
    fn idle_busy_transitions_manage_sentinels() {
        let _h = isolated_home();
        establish("alice");
        assert_eq!(cmd_state("alice", "idle", false), 0);
        assert!(is_idle("alice"));
        assert!(echo_gate_armed("alice"), "idle arms the echo-gate by default");

        assert_eq!(cmd_state("alice", "busy", false), 0);
        assert!(!is_idle("alice"));
        // The gate persists across busy (cleared explicitly / on echo fire).
        assert!(echo_gate_armed("alice"));
    }

    #[test] // [unit->REQ-SEAM-ACTIVITY] --no-gate suppresses the idle↔gate coupling.
    fn idle_no_gate_does_not_arm_gate() {
        let _h = isolated_home();
        establish("alice");
        assert_eq!(cmd_state("alice", "idle", true), 0);
        assert!(is_idle("alice"));
        assert!(!echo_gate_armed("alice"));
    }

    #[test] // [unit->REQ-SEAM-ACTIVITY] echo-gate set/clear is independent of idle.
    fn echo_gate_explicit_toggle() {
        let _h = isolated_home();
        establish("alice");
        assert_eq!(cmd_echo_gate("alice", "set"), 0);
        assert!(echo_gate_armed("alice"));
        assert_eq!(cmd_echo_gate("alice", "clear"), 0);
        assert!(!echo_gate_armed("alice"));
        assert!(!is_idle("alice"), "echo-gate must not touch the idle sentinel");
    }

    #[test] // [unit->REQ-API-2] poll drains spooled messages; deferred only with the flag.
    fn poll_drains_non_deferred_by_default() {
        let _h = isolated_home();
        establish("alice");
        let perch_path = perch::resolve_perch_path("alice", ParentHint::Infer);
        spool::spool_message_at(&perch_path, "bob", "live one").unwrap();
        spool::spool_message_deferred_at(&perch_path, "bob", "deferred one").unwrap();

        let got = poll_drain("alice", false);
        assert_eq!(got.len(), 1, "deferred row must not drain without the flag");
        assert!(got[0].contains("live one"));

        // The deferred row survives and drains only with the flag.
        let got2 = poll_drain("alice", true);
        assert_eq!(got2.len(), 1);
        assert!(got2[0].contains("deferred one"));
    }

    #[test] // [unit->REQ-SEAM-INJECT] inject resolution drops PTY (M3) and defaults to hook.
    fn inject_resolution_drops_pty_defaults_hook() {
        // No manifest → default hook.
        assert_eq!(resolve_inject_methods(None, true), vec![InjectMethod::Hook]);

        let m = Manifest::from_toml_str(
            "[adapter]\nname=\"m\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n[inject]\nidle=[\"pty\",\"hook\"]\nactivity=[\"hook\"]\n",
        )
        .unwrap();
        // idle drops pty, keeps hook.
        assert_eq!(resolve_inject_methods(Some(&m), true), vec![InjectMethod::Hook]);
        assert_eq!(resolve_inject_methods(Some(&m), false), vec![InjectMethod::Hook]);
    }

    #[test] // [unit->REQ-SEAM-INJECT] an all-pty config still yields a usable channel.
    fn inject_all_pty_falls_back_to_hook() {
        let m = Manifest::from_toml_str(
            "[adapter]\nname=\"m\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n[inject]\nidle=[\"pty\"]\n",
        )
        .unwrap();
        assert_eq!(resolve_inject_methods(Some(&m), true), vec![InjectMethod::Hook]);
    }
}


Wire delivery handlers into dispatch.


Edit

pub mod auth;
pub mod delivery;
pub mod startup;


The file C:\Users\decid\Documents\projects\spt-core\crates\spt\src\api\mod.rs has been updated successfully. (file state is current in your context — no need to Read it back)

pub mod auth;
pub mod delivery;
pub mod startup;

// [impl->REQ-API-1]
pub mod auth;
+pub mod delivery;
pub mod startup;
use clap::{Args, Subcommand};


        // Mutations gated by auth — handlers land in T5–T7.
        ApiCmd::State { id, auth, .. } => gated(&id, &auth, |_| not_implemented("state")),
        ApiCmd::EchoGate { id, auth, .. } => gated(&id, &auth, |_| not_implemented("echo-gate")),
        ApiCmd::Poll { id, auth, .. } => gated(&id, &auth, |_| not_implemented("poll")),

        // Delivery + activity (T5), gated by auth.
        ApiCmd::State { state, id, no_gate, auth } => {
            gated(&id, &auth, |id| delivery::cmd_state(id, &state, no_gate))
        }
        ApiCmd::EchoGate { action, id, auth } => {
            gated(&id, &auth, |id| delivery::cmd_echo_gate(id, &action))
        }
        ApiCmd::Poll { id, include_deferred, auth } => gated(&id, &auth, |id| {
            delivery::cmd_poll(id, include_deferred, ctx.manifest.as_ref())
        }),
        // Mutations gated by auth — handlers land in T6–T7.


        // Mutations gated by auth — handlers land in T5–T7.
        ApiCmd::State { id, auth, .. } => gated(&id, &auth, |_| not_implemented("state")),
        ApiCmd::EchoGate { id, auth, .. } => gated(&id, &auth, |_| not_implemented("echo-gate")),
        ApiCmd::Poll { id, auth, .. } => gated(&id, &auth, |_| not_implemented("poll")),

        // Delivery + activity (T5), gated by auth.
        ApiCmd::State { state, id, no_gate, auth } => {
            gated(&id, &auth, |id| delivery::cmd_state(id, &state, no_gate))
        }
        ApiCmd::EchoGate { action, id, auth } => {
            gated(&id, &auth, |id| delivery::cmd_echo_gate(id, &action))
        }
        ApiCmd::Poll { id, include_deferred, auth } => gated(&id, &auth, |id| {
            delivery::cmd_poll(id, include_deferred, ctx.manifest.as_ref())
        }),
        // Mutations gated by auth — handlers land in T6–T7.

ApiCmd::Bind { id, bind_session, auth } => {

-        // Mutations gated by auth — handlers land in T5–T7.

-        ApiCmd::State { id, auth, .. } => gated(&id, &auth, |_| not_implemented("state")),

-        ApiCmd::EchoGate { id, auth, .. } => gated(&id, &auth, |_| not_implemented("echo-gate")),

-        ApiCmd::Poll { id, auth, .. } => gated(&id, &auth, |_| not_implemented("poll")),

+        // Delivery + activity (T5), gated by auth.

+        ApiCmd::State { state, id, no_gate, auth } => {

+            gated(&id, &auth, |id| delivery::cmd_state(id, &state, no_gate))

+        ApiCmd::EchoGate { action, id, auth } => {

+            gated(&id, &auth, |id| delivery::cmd_echo_gate(id, &action))

+        ApiCmd::Poll { id, include_deferred, auth } => gated(&id, &auth, |id| {

+            delivery::cmd_poll(id, include_deferred, ctx.manifest.as_ref())

+        // Mutations gated by auth — handlers land in T6–T7.

ApiCmd::WorkerStart { parent, auth, .. } => {

gated(&parent, &auth, |_| not_implemented("worker-start"))


Bash

cd "C:\Users\decid\Documents\projects\spt-core" && cargo test -p spt 2>&1 | tail -16

Test spt with delivery


test api::startup::tests::missing_seed_refused ... ok
test api::auth::tests::no_proof_is_refused ... ok
test api::auth::tests::wrong_token_and_wrong_session_refused ... ok
test api::auth::tests::session_id_proof_authenticates ... ok
test api::delivery::tests::idle_no_gate_does_not_arm_gate ... ok
test api::delivery::tests::poll_drains_non_deferred_by_default ... ok
test api::startup::tests::empty_session_seed_refused ... ok
test api::startup::tests::live_perch_under_other_session_conflicts ... ok
test api::delivery::tests::idle_busy_transitions_manage_sentinels ... ok
test api::startup::tests::post_spawn_bind_establishes_perch ... ok
test api::startup::tests::rebind_same_session_ok ... ok
test api::startup::tests::stale_seed_with_dead_anchor_refused ... ok
test api::startup::tests::valid_seed_binds_live_perch ... ok

test result: ok. 26 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.12s


26 pass. Activate REQ-SEAM-ACTIVITY + REQ-SEAM-INJECT (REQ-API-2 deferred to T9 per plan).


cd "C:\Users\decid\Documents\projects\spt-core" && grep -n "REQ-SEAM-ACTIVITY\|REQ-SEAM-INJECT" -A2 traceable-reqs.toml

Locate seam reqs


79:id = "REQ-SEAM-ACTIVITY"
80-title = "Activity/idle reported via api sentinels, not PTY quiescence"
81-required_stages = []
--
83:id = "REQ-SEAM-INJECT"
84-title = "inject-input methods configurable per activity-state"
85-required_stages = []


78	[[requirements]]
79	id = "REQ-SEAM-ACTIVITY"
80	title = "Activity/idle reported via api sentinels, not PTY quiescence"
81	required_stages = []
82	[[requirements]]
83	id = "REQ-SEAM-INJECT"
84	title = "inject-input methods configurable per activity-state"
85	required_stages = []

[[requirements]]
id = "REQ-SEAM-ACTIVITY"
title = "Activity/idle reported via api sentinels, not PTY quiescence"
required_stages = []
[[requirements]]
id = "REQ-SEAM-INJECT"
title = "inject-input methods configurable per activity-state"
required_stages = []


Edit

id = "REQ-SEAM-ACTIVITY"
title = "Activity/idle reported via api sentinels, not PTY quiescence"
required_stages = []

id = "REQ-SEAM-ACTIVITY"
title = "Activity/idle reported via api sentinels, not PTY quiescence"
required_stages = ["impl", "unit"]


id = "REQ-SEAM-ACTIVITY"
title = "Activity/idle reported via api sentinels, not PTY quiescence"
required_stages = []

id = "REQ-SEAM-ACTIVITY"
title = "Activity/idle reported via api sentinels, not PTY quiescence"
required_stages = ["impl", "unit"]

title = "inject-input methods configurable per activity-state"


id = "REQ-SEAM-INJECT"
title = "inject-input methods configurable per activity-state"
required_stages = []

id = "REQ-SEAM-INJECT"
title = "inject-input methods configurable per activity-state"
required_stages = ["impl", "unit"]


id = "REQ-SEAM-INJECT"
title = "inject-input methods configurable per activity-state"
required_stages = []

id = "REQ-SEAM-INJECT"
title = "inject-input methods configurable per activity-state"
required_stages = ["impl", "unit"]

title = "resume-session seam (fresh-with-preload / continue-existing)"


Bash

cd "C:\Users\decid\Documents\projects\spt-core" && traceable-reqs check >/dev/null 2>&1; echo "exit:$?" && git add -A && git commit -q -F - <<'EOF'
feat(spt): M2a T5 — delivery + activity (state / echo-gate / poll / inject)

Activity state lives in the perch as spt-core-owned sentinels (the adapter
only calls the commands; it never names them — MANIFEST §not-in-manifest):
- api state busy|idle — writes/clears the `.idle` sentinel; idle also arms the
  `.more-done` echo-commune gate unless --no-gate (CONTEXT §api state, the
  can_inject=false fallback path).
- api echo-gate set|clear — explicit gate control, independent of idle.
- api poll [--include-deferred] — the short-lived hook-channel drain (vs
  listen's long relay): drains the spool, prints each message framed.
  Non-deferred only unless the flag.
- resolve_inject_methods — reads the manifest [inject] table, drops `pty`
  (PTY injection is M3/spt-term), defaults to `hook` (REQ-SEAM-INJECT).

REQ-SEAM-ACTIVITY + REQ-SEAM-INJECT activated [impl, unit]; REQ-API-2 carries
impl+unit evidence now, activates with `int` at T9. 26 spt tests green; check
green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
EOF
git log --oneline -1

Commit T5


exit:0
warning: in the working copy of 'crates/spt/src/api/mod.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'traceable-reqs.toml', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt/src/api/delivery.rs', LF will be replaced by CRLF the next time Git touches it
bae7124 feat(spt): M2a T5 — delivery + activity (state / echo-gate / poll / inject)


T5 done. T6 — worker perches + cascade-wipe guard. Check the hazard.


Updated task #7 status


cd "C:\Users\decid\Documents\projects\spt-core" && grep -n "CASCADE-WIPE\|cascade" -i traceable-reqs.toml docs/KNOWN-HAZARDS.md | head; echo "===hazard text==="; sed -n '/cascade/I,/Sister cite/p' docs/KNOWN-HAZARDS.md | head -20

Find cascade-wipe hazard


traceable-reqs.toml:428:id = "REQ-HAZARD-CASCADE-WIPE-GUARD"
docs/KNOWN-HAZARDS.md:169:- **Failure:** mixed flat + nested perch layouts confuse which perch is live; cascade-wipe risk.
docs/KNOWN-HAZARDS.md:180:### 6.3 Cascade-wipe guard: never delete a parent hosting non-empty children
===hazard text===
- **Failure:** mixed flat + nested perch layouts confuse which perch is live; cascade-wipe risk.
- **Invariant:** one path resolver; never create divergent siblings.
- **spt-core mapping:** clean greenfield layout from day one (no migration window) — pick one structure, route everything through the registry. Storage layout deferred to design phase but this single-source-of-truth rule is binding.
- **Sister cite:** `src/common/perch_path.rs`; CHANGELOG Phase 25.4.
### 6.3 Cascade-wipe guard: never delete a parent hosting non-empty children
- **Failure:** `doctor --fix` deletes a top-level perch that still hosts in-flight nested Worker/Psyche perches.
- **Invariant:** before hard-delete, check for non-empty nested children; if present, soft-clean only and surface the path.
- **spt-core mapping:** any destructive maintenance command must check for live child instances first.
- **Sister cite:** CHANGELOG v1.11.20 Phase 35.1.


Cascade-wipe (6.3): refuse hard-delete of a parent with live nested children; soft-clean instead. Child liveness via the ready marker (deterministic in the interim no-process-worker model). Write worker module.


Write

//! Worker perches: `api worker-start` / `worker-stop` / `worker-poll`, plus the
//! **cascade-wipe guard** (KNOWN-HAZARDS 6.3).
//!
//! A worker is a nested child perch under a parent agent
//! (`owlery/<parent>/nested/<worker>/`), addressable like any perch and routed
//! through the *single* path resolver (REQ-HAZARD-SINGLE-PATH-SOURCE / WORKER-
//! PATH — no divergent sibling layouts).
//!
//! ## Cascade-wipe guard (6.3, REQ-HAZARD-CASCADE-WIPE-GUARD)
//! A destructive parent teardown must never hard-delete a parent that still
//! hosts **live** children. "Live" here = the child still carries its `ready`
//! marker (the soft-cleanup signal; `worker-stop` removes it). When live
//! children exist, the caller soft-cleans and surfaces their paths instead of
//! recursively deleting in-flight work. [`has_live_children`] /
//! [`live_child_paths`] are the guard primitives T7's `session-end` consults.
//
// [impl->REQ-API-2]
// [impl->REQ-HAZARD-CASCADE-WIPE-GUARD]

use spt_store::info::{self, InfoJson};
use spt_store::perch::{self, ParentHint};
use spt_store::spool;

use super::auth;
use super::EXIT_REFUSED;

/// `api worker-start <parent> <id>` — create a nested worker perch under
/// `parent`. Resolves with an Explicit parent so the layout is unambiguous even
/// if the id's own suffix would infer a different one.
// [impl->REQ-API-2]
pub fn cmd_worker_start(parent: &str, id: &str) -> i32 {
    let perch_path = perch::resolve_perch_path(id, ParentHint::Explicit(parent));
    if let Err(e) = std::fs::create_dir_all(&perch_path) {
        eprintln!("WORKER_START_FAIL:{id}: {e}");
        return EXIT_REFUSED;
    }
    let rec = InfoJson::new(id, &now_stamp(), std::process::id(), "", "worker");
    if let Err(e) = info::write_info(&perch_path, &rec) {
        eprintln!("WORKER_START_FAIL:{id}: {e}");
        return EXIT_REFUSED;
    }
    // ready marker = the live-child signal the cascade-wipe guard checks.
    let _ = std::fs::write(
        perch::resolve_ready_file(id, ParentHint::Explicit(parent)),
        "",
    );
    let token = auth::mint_token();
    let _ = auth::write_token(&perch_path, &token);
    eprintln!("WORKER_STARTED:{id} under {parent} token={token}");
    0
}

/// `api worker-stop <id>` — soft-clean a worker (REQ-HAZARD-SOFT-CLEANUP):
/// remove its `ready` marker so it no longer counts as a live child; the
/// `info.json` + spool are preserved. Idempotent.
// [impl->REQ-API-2]
pub fn cmd_worker_stop(id: &str) -> i32 {
    let removed = std::fs::remove_file(perch::resolve_ready_file(id, ParentHint::Infer)).is_ok();
    if removed {
        eprintln!("WORKER_STOPPED:{id}");
    } else {
        eprintln!("WORKER_STOPPED:{id} (no ready marker)");
    }
    0
}

/// `api worker-poll <id>` — drain a worker perch's spool (oldest first), framed
/// to stdout. Non-deferred only unless `include_deferred`.
// [impl->REQ-API-2]
pub fn cmd_worker_poll(id: &str, include_deferred: bool) -> i32 {
    let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);
    let drained = if include_deferred {
        spool::drain_all_at(&perch_path)
    } else {
        spool::drain_non_deferred_at(&perch_path)
    };
    for msg in drained.unwrap_or_default() {
        use std::io::Write;
        print!("{msg}");
        let _ = std::io::stdout().flush();
    }
    0
}

/// The paths of `parent_id`'s **live** nested children (those still carrying a
/// `ready` marker). Empty ⇒ the parent is safe to hard-delete.
// [impl->REQ-HAZARD-CASCADE-WIPE-GUARD]
pub fn live_child_paths(parent_id: &str) -> Vec<std::path::PathBuf> {
    let nested = perch::resolve_perch_path(parent_id, ParentHint::Infer).join("nested");
    let mut live = Vec::new();
    let Ok(entries) = std::fs::read_dir(&nested) else {
        return live;
    };
    for entry in entries.flatten() {
        let child = entry.path();
        if !child.is_dir() {
            continue;
        }
        // A child is live while its ready marker exists (info.json present =
        // it's a real perch; ready = not yet soft-cleaned).
        if info::read_info(&child).is_some() && child.join("ready").exists() {
            live.push(child);
        }
    }
    live
}

/// True if `parent_id` hosts at least one live nested child.
// [impl->REQ-HAZARD-CASCADE-WIPE-GUARD]
pub fn has_live_children(parent_id: &str) -> bool {
    !live_child_paths(parent_id).is_empty()
}

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;

    fn establish_parent(id: &str) {
        let path = perch::resolve_perch_path(id, ParentHint::Infer);
        std::fs::create_dir_all(&path).unwrap();
        info::write_info(
            &path,
            &InfoJson::new(id, "0", std::process::id(), "sid", "live_agent"),
        )
        .unwrap();
    }

    #[test] // [unit->REQ-API-2] a worker nests under its parent and is addressable.
    fn worker_start_nests_under_parent() {
        let _h = isolated_home();
        establish_parent("alice");
        assert_eq!(cmd_worker_start("alice", "alice-w1"), 0);

        let expected = perch::resolve_perch_path("alice", ParentHint::Infer)
            .join("nested")
            .join("alice-w1");
        assert!(expected.join("info.json").exists());
        assert!(expected.join("ready").exists());
        // Inferred resolution agrees with the explicit nesting.
        assert_eq!(perch::resolve_perch_path("alice-w1", ParentHint::Infer), expected);
    }

    #[test] // [unit->REQ-HAZARD-CASCADE-WIPE-GUARD] a parent with a live worker
    // reports children; stopping the worker clears the guard.
    fn cascade_guard_tracks_live_children() {
        let _h = isolated_home();
        establish_parent("alice");
        assert!(!has_live_children("alice"), "no children yet");

        cmd_worker_start("alice", "alice-w1");
        assert!(has_live_children("alice"), "live worker must be seen");
        assert_eq!(live_child_paths("alice").len(), 1);

        // Soft-stop the worker → ready marker gone → no longer a live child.
        assert_eq!(cmd_worker_stop("alice-w1"), 0);
        assert!(!has_live_children("alice"), "soft-stopped worker is not live");
        // The record is preserved (soft cleanup), only the ready marker removed.
        let child = perch::resolve_perch_path("alice-w1", ParentHint::Infer);
        assert!(child.join("info.json").exists());
        assert!(!child.join("ready").exists());
    }

    #[test] // [unit->REQ-API-2] worker-poll drains the worker's own spool.
    fn worker_poll_drains_spool() {
        let _h = isolated_home();
        establish_parent("alice");
        cmd_worker_start("alice", "alice-w1");
        let child = perch::resolve_perch_path("alice-w1", ParentHint::Infer);
        spool::spool_message_at(&child, "alice", "task result").unwrap();

        let perch_path = perch::resolve_perch_path("alice-w1", ParentHint::Infer);
        let got = spool::drain_non_deferred_at(&perch_path).unwrap();
        assert_eq!(got.len(), 1);
        // And the command path returns 0.
        cmd_worker_start("alice", "alice-w2");
        assert_eq!(cmd_worker_poll("alice-w2", false), 0);
    }

    #[test] // [unit->REQ-HAZARD-CASCADE-WIPE-GUARD] two workers: guard holds until
    // the last live child is stopped.
    fn guard_clears_only_when_all_children_stopped() {
        let _h = isolated_home();
        establish_parent("alice");
        cmd_worker_start("alice", "alice-w1");
        cmd_worker_start("alice", "alice-w2");
        assert_eq!(live_child_paths("alice").len(), 2);
        cmd_worker_stop("alice-w1");
        assert!(has_live_children("alice"), "one child still live");
        cmd_worker_stop("alice-w2");
        assert!(!has_live_children("alice"));
    }
}


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

//! Worker perches: `api worker-start` / `worker-stop` / `worker-poll`, plus the
//! **cascade-wipe guard** (KNOWN-HAZARDS 6.3).
//!
//! A worker is a nested child perch under a parent agent
//! (`owlery/<parent>/nested/<worker>/`), addressable like any perch and routed
//! through the *single* path resolver (REQ-HAZARD-SINGLE-PATH-SOURCE / WORKER-
//! PATH — no divergent sibling layouts).
//!
//! ## Cascade-wipe guard (6.3, REQ-HAZARD-CASCADE-WIPE-GUARD)
//! A destructive parent teardown must never hard-delete a parent that still
//! hosts **live** children. "Live" here = the child still carries its `ready`
//! marker (the soft-cleanup signal; `worker-stop` removes it). When live
//! children exist, the caller soft-cleans and surfaces their paths instead of
//! recursively deleting in-flight work. [`has_live_children`] /
//! [`live_child_paths`] are the guard primitives T7's `session-end` consults.
//
// [impl->REQ-API-2]
// [impl->REQ-HAZARD-CASCADE-WIPE-GUARD]

use spt_store::info::{self, InfoJson};
use spt_store::perch::{self, ParentHint};
use spt_store::spool;

use super::auth;
use super::EXIT_REFUSED;

/// `api worker-start <parent> <id>` — create a nested worker perch under
/// `parent`. Resolves with an Explicit parent so the layout is unambiguous even
/// if the id's own suffix would infer a different one.
// [impl->REQ-API-2]
pub fn cmd_worker_start(parent: &str, id: &str) -> i32 {
    let perch_path = perch::resolve_perch_path(id, ParentHint::Explicit(parent));
    if let Err(e) = std::fs::create_dir_all(&perch_path) {
        eprintln!("WORKER_START_FAIL:{id}: {e}");
        return EXIT_REFUSED;
    }
    let rec = InfoJson::new(id, &now_stamp(), std::process::id(), "", "worker");
    if let Err(e) = info::write_info(&perch_path, &rec) {
        eprintln!("WORKER_START_FAIL:{id}: {e}");
        return EXIT_REFUSED;
    }
    // ready marker = the live-child signal the cascade-wipe guard checks.
    let _ = std::fs::write(
        perch::resolve_ready_file(id, ParentHint::Explicit(parent)),
        "",
    );
    let token = auth::mint_token();
    let _ = auth::write_token(&perch_path, &token);
    eprintln!("WORKER_STARTED:{id} under {parent} token={token}");
    0
}

/// `api worker-stop <id>` — soft-clean a worker (REQ-HAZARD-SOFT-CLEANUP):
/// remove its `ready` marker so it no longer counts as a live child; the
/// `info.json` + spool are preserved. Idempotent.
// [impl->REQ-API-2]
pub fn cmd_worker_stop(id: &str) -> i32 {
    let removed = std::fs::remove_file(perch::resolve_ready_file(id, ParentHint::Infer)).is_ok();
    if removed {
        eprintln!("WORKER_STOPPED:{id}");
    } else {
        eprintln!("WORKER_STOPPED:{id} (no ready marker)");
    }
    0
}

/// `api worker-poll <id>` — drain a worker perch's spool (oldest first), framed
/// to stdout. Non-deferred only unless `include_deferred`.
// [impl->REQ-API-2]
pub fn cmd_worker_poll(id: &str, include_deferred: bool) -> i32 {
    let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);
    let drained = if include_deferred {
        spool::drain_all_at(&perch_path)
    } else {
        spool::drain_non_deferred_at(&perch_path)
    };
    for msg in drained.unwrap_or_default() {
        use std::io::Write;
        print!("{msg}");
        let _ = std::io::stdout().flush();
    }
    0
}

/// The paths of `parent_id`'s **live** nested children (those still carrying a
/// `ready` marker). Empty ⇒ the parent is safe to hard-delete.
// [impl->REQ-HAZARD-CASCADE-WIPE-GUARD]
pub fn live_child_paths(parent_id: &str) -> Vec<std::path::PathBuf> {
    let nested = perch::resolve_perch_path(parent_id, ParentHint::Infer).join("nested");
    let mut live = Vec::new();
    let Ok(entries) = std::fs::read_dir(&nested) else {
        return live;
    };
    for entry in entries.flatten() {
        let child = entry.path();
        if !child.is_dir() {
            continue;
        }
        // A child is live while its ready marker exists (info.json present =
        // it's a real perch; ready = not yet soft-cleaned).
        if info::read_info(&child).is_some() && child.join("ready").exists() {
            live.push(child);
        }
    }
    live
}

/// True if `parent_id` hosts at least one live nested child.
// [impl->REQ-HAZARD-CASCADE-WIPE-GUARD]
pub fn has_live_children(parent_id: &str) -> bool {
    !live_child_paths(parent_id).is_empty()
}

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;

    fn establish_parent(id: &str) {
        let path = perch::resolve_perch_path(id, ParentHint::Infer);
        std::fs::create_dir_all(&path).unwrap();
        info::write_info(
            &path,
            &InfoJson::new(id, "0", std::process::id(), "sid", "live_agent"),
        )
        .unwrap();
    }

    #[test] // [unit->REQ-API-2] a worker nests under its parent and is addressable.
    fn worker_start_nests_under_parent() {
        let _h = isolated_home();
        establish_parent("alice");
        assert_eq!(cmd_worker_start("alice", "alice-w1"), 0);

        let expected = perch::resolve_perch_path("alice", ParentHint::Infer)
            .join("nested")
            .join("alice-w1");
        assert!(expected.join("info.json").exists());
        assert!(expected.join("ready").exists());
        // Inferred resolution agrees with the explicit nesting.
        assert_eq!(perch::resolve_perch_path("alice-w1", ParentHint::Infer), expected);
    }

    #[test] // [unit->REQ-HAZARD-CASCADE-WIPE-GUARD] a parent with a live worker
    // reports children; stopping the worker clears the guard.
    fn cascade_guard_tracks_live_children() {
        let _h = isolated_home();
        establish_parent("alice");
        assert!(!has_live_children("alice"), "no children yet");

        cmd_worker_start("alice", "alice-w1");
        assert!(has_live_children("alice"), "live worker must be seen");
        assert_eq!(live_child_paths("alice").len(), 1);

        // Soft-stop the worker → ready marker gone → no longer a live child.
        assert_eq!(cmd_worker_stop("alice-w1"), 0);
        assert!(!has_live_children("alice"), "soft-stopped worker is not live");
        // The record is preserved (soft cleanup), only the ready marker removed.
        let child = perch::resolve_perch_path("alice-w1", ParentHint::Infer);
        assert!(child.join("info.json").exists());
        assert!(!child.join("ready").exists());
    }

    #[test] // [unit->REQ-API-2] worker-poll drains the worker's own spool.
    fn worker_poll_drains_spool() {
        let _h = isolated_home();
        establish_parent("alice");
        cmd_worker_start("alice", "alice-w1");
        let child = perch::resolve_perch_path("alice-w1", ParentHint::Infer);
        spool::spool_message_at(&child, "alice", "task result").unwrap();

        let perch_path = perch::resolve_perch_path("alice-w1", ParentHint::Infer);
        let got = spool::drain_non_deferred_at(&perch_path).unwrap();
        assert_eq!(got.len(), 1);
        // And the command path returns 0.
        cmd_worker_start("alice", "alice-w2");
        assert_eq!(cmd_worker_poll("alice-w2", false), 0);
    }

    #[test] // [unit->REQ-HAZARD-CASCADE-WIPE-GUARD] two workers: guard holds until
    // the last live child is stopped.
    fn guard_clears_only_when_all_children_stopped() {
        let _h = isolated_home();
        establish_parent("alice");
        cmd_worker_start("alice", "alice-w1");
        cmd_worker_start("alice", "alice-w2");
        assert_eq!(live_child_paths("alice").len(), 2);
        cmd_worker_stop("alice-w1");
        assert!(has_live_children("alice"), "one child still live");
        cmd_worker_stop("alice-w2");
        assert!(!has_live_children("alice"));
    }
}


Edit

pub mod auth;
pub mod delivery;
pub mod startup;

pub mod auth;
pub mod delivery;
pub mod startup;
pub mod worker;


pub mod auth;
pub mod delivery;
pub mod startup;
pub mod worker;

pub mod auth;
pub mod delivery;
pub mod startup;
+pub mod worker;
use clap::{Args, Subcommand};
use std::path::PathBuf;


        // Mutations gated by auth — handlers land in T6–T7.
        ApiCmd::WorkerStart { parent, auth, .. } => {
            gated(&parent, &auth, |_| not_implemented("worker-start"))
        }
        ApiCmd::WorkerStop { id, auth, .. } => gated(&id, &auth, |_| not_implemented("worker-stop")),
        ApiCmd::WorkerPoll { id, auth, .. } => gated(&id, &auth, |_| not_implemented("worker-poll")),

        // Worker perches (T6). worker-start authenticates against the parent.
        ApiCmd::WorkerStart { parent, id, auth } => {
            gated(&parent, &auth, |parent| worker::cmd_worker_start(parent, &id))
        }
        ApiCmd::WorkerStop { id, auth } => gated(&id, &auth, |id| worker::cmd_worker_stop(id)),
        ApiCmd::WorkerPoll { id, auth } => {
            gated(&id, &auth, |id| worker::cmd_worker_poll(id, false))
        }
        // Mutations gated by auth — handlers land in T7.


        // Mutations gated by auth — handlers land in T6–T7.
        ApiCmd::WorkerStart { parent, auth, .. } => {
            gated(&parent, &auth, |_| not_implemented("worker-start"))
        }
        ApiCmd::WorkerStop { id, auth, .. } => gated(&id, &auth, |_| not_implemented("worker-stop")),
        ApiCmd::WorkerPoll { id, auth, .. } => gated(&id, &auth, |_| not_implemented("worker-poll")),

        // Worker perches (T6). worker-start authenticates against the parent.
        ApiCmd::WorkerStart { parent, id, auth } => {
            gated(&parent, &auth, |parent| worker::cmd_worker_start(parent, &id))
        }
        ApiCmd::WorkerStop { id, auth } => gated(&id, &auth, |id| worker::cmd_worker_stop(id)),
        ApiCmd::WorkerPoll { id, auth } => {
            gated(&id, &auth, |id| worker::cmd_worker_poll(id, false))
        }
        // Mutations gated by auth — handlers land in T7.

ApiCmd::Poll { id, include_deferred, auth } => gated(&id, &auth, |id| {

-        // Mutations gated by auth — handlers land in T6–T7.

-        ApiCmd::WorkerStart { parent, auth, .. } => {

-            gated(&parent, &auth, |_| not_implemented("worker-start"))

+        // Worker perches (T6). worker-start authenticates against the parent.

+        ApiCmd::WorkerStart { parent, id, auth } => {

+            gated(&parent, &auth, |parent| worker::cmd_worker_start(parent, &id))

-        ApiCmd::WorkerStop { id, auth, .. } => gated(&id, &auth, |_| not_implemented("worker-stop")),

-        ApiCmd::WorkerPoll { id, auth, .. } => gated(&id, &auth, |_| not_implemented("worker-poll")),

+        ApiCmd::WorkerStop { id, auth } => gated(&id, &auth, |id| worker::cmd_worker_stop(id)),

+        ApiCmd::WorkerPoll { id, auth } => {

+            gated(&id, &auth, |id| worker::cmd_worker_poll(id, false))

+        // Mutations gated by auth — handlers land in T7.

ApiCmd::Boundary { id, auth, .. } => gated(&id, &auth, |_| not_implemented("boundary")),

ApiCmd::SessionEnd { id, auth, .. } => gated(&id, &auth, |_| not_implemented("session-end")),

ApiCmd::Presence { id, auth, .. } => gated(&id, &auth, |_| not_implemented("presence")),


Bash

cd "C:\Users\decid\Documents\projects\spt-core" && cargo test -p spt 2>&1 | tail -12

Test spt with worker module


test api::startup::tests::live_perch_under_other_session_conflicts ... ok
test api::startup::tests::post_spawn_bind_establishes_perch ... ok
test api::startup::tests::rebind_same_session_ok ... ok
test api::startup::tests::stale_seed_with_dead_anchor_refused ... ok
test api::startup::tests::valid_seed_binds_live_perch ... ok
test api::worker::tests::cascade_guard_tracks_live_children ... ok
test api::worker::tests::guard_clears_only_when_all_children_stopped ... ok
test api::worker::tests::worker_poll_drains_spool ... ok
test api::worker::tests::worker_start_nests_under_parent ... ok

test result: ok. 30 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.19s


30 pass. Activate REQ-HAZARD-CASCADE-WIPE-GUARD.


427	[[requirements]]
428	id = "REQ-HAZARD-CASCADE-WIPE-GUARD"
429	title = "No hard-delete of a parent hosting non-empty children (6.3)"
430	required_stages = []

[[requirements]]
id = "REQ-HAZARD-CASCADE-WIPE-GUARD"
title = "No hard-delete of a parent hosting non-empty children (6.3)"
required_stages = []


Edit

id = "REQ-HAZARD-CASCADE-WIPE-GUARD"
title = "No hard-delete of a parent hosting non-empty children (6.3)"
required_stages = []

id = "REQ-HAZARD-CASCADE-WIPE-GUARD"
title = "No hard-delete of a parent hosting non-empty children (6.3)"
required_stages = ["impl", "unit"]


The file C:\Users\decid\Documents\projects\spt-core\traceable-reqs.toml has been updated successfully. (file state is current in your context — no need to Read it back)

id = "REQ-HAZARD-CASCADE-WIPE-GUARD"
title = "No hard-delete of a parent hosting non-empty children (6.3)"
required_stages = []

id = "REQ-HAZARD-CASCADE-WIPE-GUARD"
title = "No hard-delete of a parent hosting non-empty children (6.3)"
required_stages = ["impl", "unit"]

title = "Drop files are daemon-owned single-writer (6.4)"


Bash

cd "C:\Users\decid\Documents\projects\spt-core" && traceable-reqs check >/dev/null 2>&1; echo "exit:$?" && git add -A && git commit -q -F - <<'EOF'
feat(spt): M2a T6 — worker perches + cascade-wipe guard

- api worker-start <parent> <id> — create a nested worker perch
  (owlery/<parent>/nested/<id>/) via the single path resolver (Explicit parent,
  no divergent layouts). Writes info.json (state=worker), ready marker, token.
- api worker-stop <id> — soft-clean (REQ-HAZARD-SOFT-CLEANUP): drop the ready
  marker; info.json + spool preserved. Idempotent.
- api worker-poll <id> — drain the worker's own spool.
- Cascade-wipe guard (KNOWN-HAZARDS 6.3): live_child_paths / has_live_children
  enumerate a parent's live nested children (liveness = ready marker present, the
  soft-cleanup signal). T7's session-end consults this so a destructive parent
  teardown never recursively deletes in-flight workers — it soft-cleans and
  surfaces their paths instead.

REQ-HAZARD-CASCADE-WIPE-GUARD activated [impl, unit]; REQ-API-2 gains worker
evidence (activates with int at T9). 30 spt tests green; check green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
EOF
git log --oneline -1

Commit T6


exit:0
warning: in the working copy of 'crates/spt/src/api/mod.rs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'traceable-reqs.toml', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt/src/api/worker.rs', LF will be replaced by CRLF the next time Git touches it
42af360 feat(spt): M2a T6 — worker perches + cascade-wipe guard


T0: Scaffold spt-runtime crate

T1: Manifest schema + parse + validate

T6: Worker perches + cascade-wipe guard

T7: Reporting + boundary surface

T8: Generic mock adapter

T9: Contract E2E test

T10: Activation sweep


T6 done. T7 is the largest remaining surface. Check the req titles to align evidence.


cd "C:\Users\decid\Documents\projects\spt-core" && grep -n "REQ-API-3\|REQ-START-4\|REQ-SEAM-CAPABILITY\|REQ-HAZARD-SOFT-CLEANUP\|REQ-API-2\b" -A2 traceable-reqs.toml; echo "===CONTEXT api cmds==="; grep -n "boundary\|session-end\|presence\|history-log\|env.alias\|hostable_types\|PresenceChannel" CONTEXT.md | head

Check T7 req titles + CONTEXT cmds


91:id = "REQ-SEAM-CAPABILITY"
92-title = "Hostable endpoint-types capability declaration"
93-required_stages = []
--
105:id = "REQ-API-2"
106-title = "The api subcommand surface (bind/listen/poll/state/worker/boundary/...)"
107-required_stages = []
--
109:id = "REQ-API-3"
110-title = "commune/signoff are file-drops, not commands"
111-required_stages = []
--
127:id = "REQ-START-4"
128-title = "Adapter-injected env aliases (SPT/OWL/LIVE)"
129-required_stages = []
--
424:id = "REQ-HAZARD-SOFT-CLEANUP"
425-title = "Soft-cleanup preserves state, removes only the ready marker (6.2)"
426-required_stages = ["impl", "unit"]  # activated M1 T3: soft_cleanup removes ready marker only, preserves spool + info.json
===CONTEXT api cmds===
92:- **Path B — spt-core-native history store.** spt-core exposes a `history-log` command/API; the adapter writes its logs to spt-core in the native format and spt-core stores them. Rationale: spt-core needs its own log store for Shells anyway, and this simplifies integration for flexible/DIY harnesses.
132:- **`api boundary <clear|compact>`** — context-boundary report; **carries the new `session_id`** (it rotates on `/clear` or `/compact`), so the daemon rebinds the perch to the new session id while keeping the stable identity + `parent_pid` anchor. Authors a **Self-resume commune** (resume the Self session → commune file-drop) rather than a background echo — strong live-context signal at the boundary (see `docs/CONTEXT-MEMORY.md`).
133:- **`api session-end`** — session stop/crash report → soft teardown by default (preserve perch + spool + tracked history for recovery — KNOWN-HAZARDS 6.2). **`--erase`** instead hard-wipes the perch and tracked history (for ephemeral/secondary adapters that act as robust agent-spawned-agent surfaces).
134:- **`api history-log`** — Path B: ingest normalized records into spt-core's native history store.
135:- **`api presence`** — adapter reports user interaction → updates the presence datum `(last_active_node, last_active_endpoint, ts)`. In the spt-hosted topology, presence is **also** updated by the broker *detecting* (sensing, not watching/logging) user input on a held PTY — privacy-preserving (it notes that input occurred, records no content).
160:**Env-var aliases:** adapters inject clean env-var aliases for in-session invocation (heirs to today's `$OWL`/`$LIVE`), e.g. **`$SPT` = `spt api`** so a Monitor-bound call reads `$SPT listen <id>`. spt-core supplies the subcommands; the adapter supplies the env aliases (manifest philosophy).
181:**PresenceChannel** (broker endpoint — concept locked, impl deferred past v1):
182:A *broker* endpoint, not an interaction surface. Job: (1) **presence resolution** — track which node + endpoint the user most recently interacted with; (2) **shell brokering** — locate/instantiate the right Shell on that node and relay between the agent and the user. An agent "just knows how to reach the user" by firing at its PresenceChannel; the channel figures out the rest. Also a durable, **shell-agnostic 2-way thread**: messages persist in the channel, not in any one Shell, so the user can be sent a message via a phone messaging-Shell and surface/continue that same agent conversation later at a GameRobot Shell. Shells are interchangeable I/O windows onto the channel's thread.
199:**Shell** (first-class concept — model locked, concrete types deferred past v1): a *driven surface*, not an agent. Nothing intelligent runs at a Shell; a remote agent (on another node) drives it. A Shell is a "self-documenting" endpoint that advertises a **typed capability toolset** and a 2-way interaction relationship with the user. It may live behind a node on a platform with zero conventional-harness compatibility (e.g. a gaming handheld). Examples (all deferred): `GameRobot` (a 2D-world avatar — move, gesture, alert-symbol, request-screenshot, preload-message), an OS-notification target, an in-session-inject target. The earlier "presence blueprint" idea collapses into this: a blueprint *is* a Shell type + its capabilities.
206:All concrete Shells, the PresenceChannel implementation, and presence gossip are deferred.


All commands clear. Write `reporting.rs` (boundary/session-end/presence/history-log/emit/filedrops/env-aliases).