1	//! Perch layout — the **single source of truth** for where a perch lives on
2	//! disk. Every reader and writer of a perch path routes through
3	//! [`resolve_perch_path`]; nothing composes perch paths by hand. If the layout
4	//! ever changes, exactly this one module changes (KNOWN-HAZARDS 6.1 / 1.5).
5	//!
6	//! Adapted from sister `perch_path.rs` + `owlery.rs` path primitives
7	//! (ADR-0001), with two clean-room changes for the spt-core model:
8	//! - **Taxonomy:** the sister's Spine/Touch perch kinds are gone (CONTEXT
9	//!   endpoint types are ReadyAgent/LiveAgent/Psyche/Worker/SptNode). A top-level
10	//!   agent perch (ReadyAgent or a LiveAgent's Self) classifies as [`PerchKind::Self_`].
11	//! - **Purity:** path composition performs NO filesystem I/O — every function
12	//!   returns a `PathBuf`. Directory creation is the writer's job (e.g. the spool
13	//!   `create_dir_all`s on open).
14	//!
15	//! Layout invariant:
16	//! - `Self_`            → flat: `owlery/<id>/`
17	//! - `Psyche` / `Worker`→ nested under parent: `owlery/<parent>/nested/<child>/`
18	//!
19	//! The single `nested/` namespace holds both Psyche and Worker children — no
20	//! divergent sibling layouts (the hazard this module exists to prevent).
21
22	use std::path::{Path, PathBuf};
23
24	/// Root of all spt-core on-disk state. Honors `$SPT_HOME`, else the platform
25	/// default (`%LOCALAPPDATA%\spt-core` on Windows, `$HOME/.spt-core` on Unix).
26	/// **Pure** — reads env, composes a path, creates nothing.
27	///
28	/// The default lives under `spt-core/`, **deliberately distinct from the modern
29	/// spt runtime's `spt/` home**, so a dev build never collides with a running
30	/// spt-plugin during the parity build-out. Once spt-core + the spt-plugin reach
31	/// full parity with modern spt this may simply stay `spt-core/` for good; the
32	/// override (`$SPT_HOME`) is the relocation knob either way.
33	// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
34	pub fn spt_home() -> PathBuf {
35	    if let Ok(over) = std::env::var("SPT_HOME") {
36	        if !over.is_empty() {
37	            return PathBuf::from(over);
38	        }
39	    }
40	    platform_default_spt_home()
41	}
42
43	#[cfg(windows)]
44	fn platform_default_spt_home() -> PathBuf {
45	    if let Ok(local) = std::env::var("LOCALAPPDATA") {
46	        if !local.is_empty() {
47	            return PathBuf::from(local).join("spt-core");
48	        }
49	    }
50	    let up = std::env::var("USERPROFILE").expect("LOCALAPPDATA or USERPROFILE must be set on Windows");
51	    PathBuf::from(up).join("AppData").join("Local").join("spt-core")
52	}
53
54	#[cfg(unix)]
55	fn platform_default_spt_home() -> PathBuf {
56	    let home = std::env::var("HOME").expect("HOME must be set on Unix");
57	    PathBuf::from(home).join(".spt-core")
58	}
59
60	/// The owlery directory holding all perches: `<spt_home>/owlery/`.
61	// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
62	pub fn owlery_dir() -> PathBuf {
63	    spt_home().join("owlery")
64	}
65
66	/// Classification of a perch id by its naming convention. Stable, pure, no I/O.
67	///
68	/// - `<x>-psyche` → [`PerchKind::Psyche`]
69	/// - `<x>-w{N}`   → [`PerchKind::Worker`] (`{N}` = one-or-more ASCII digits, `<x>` non-empty)
70	/// - anything else → [`PerchKind::Self_`]
71	#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72	pub enum PerchKind {
73	    /// Top-level agent perch (a ReadyAgent, or a LiveAgent's Self).
74	    Self_,
75	    /// A LiveAgent's Psyche companion perch.
76	    Psyche,
77	    /// A subagent perch under a parent LiveAgent.
78	    Worker,
79	}
80
81	impl PerchKind {
82	    /// Classify an id by suffix. Worker is checked FIRST (rightmost `-w{digits}`)
83	    /// so `dunsen-psyche-w1` classifies as `Worker`.
84	    // [impl->REQ-HAZARD-WORKER-PATH]
85	    pub fn classify(id: &str) -> PerchKind {
86	        if is_worker_id(id) {
87	            return PerchKind::Worker;
88	        }
89	        if id.ends_with("-psyche") && id.len() > "-psyche".len() {
90	            return PerchKind::Psyche;
91	        }
92	        PerchKind::Self_
93	    }
94
95	    /// True for kinds nested under a parent (Psyche, Worker); false for Self_.
96	    pub fn nests_under_parent(self) -> bool {
97	        matches!(self, PerchKind::Psyche | PerchKind::Worker)
98	    }
99	}
100
101	/// Worker-id rule: rightmost `-w` whose suffix is one-or-more digits, with a
102	/// non-empty name before it.
103	fn is_worker_id(id: &str) -> bool {
104	    if let Some(pos) = id.rfind("-w") {
105	        if pos == 0 {
106	            return false;
107	        }
108	        let suffix = &id[pos + 2..];
109	        !suffix.is_empty() && suffix.bytes().all(|b| b.is_ascii_digit())
110	    } else {
111	        false
112	    }
113	}
114
115	/// How the resolver determines the parent id for nested kinds.
116	/// `Explicit` is preferred (caller knows the parent); `Infer` derives it by
117	/// stripping the kind suffix from the child id (legacy reader call sites).
118	#[derive(Debug, Clone)]
119	pub enum ParentHint<'a> {
120	    Explicit(&'a str),
121	    Infer,
122	}
123
124	/// **The** perch-directory resolver. Self_ → flat `owlery/<id>/`;
125	/// Psyche/Worker → `owlery/<parent>/nested/<child>/`. Panic-free: if `Infer`
126	/// cannot derive a parent (id shape doesn't match its kind — should not happen
127	/// since classify+infer agree), falls back to flat for safety.
128	// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
129	// [impl->REQ-HAZARD-WORKER-PATH]
130	pub fn resolve_perch_path(child_id: &str, hint: ParentHint<'_>) -> PathBuf {
131	    let kind = PerchKind::classify(child_id);
132	    if !kind.nests_under_parent() {
133	        return owlery_dir().join(child_id);
134	    }
135	    let parent = match hint {
136	        ParentHint::Explicit(p) => p.to_string(),
137	        ParentHint::Infer => match infer_parent(child_id, kind) {
138	            Some(p) => p,
139	            None => return owlery_dir().join(child_id), // defensive flat fallback
140	        },
141	    };
142	    owlery_dir().join(parent).join("nested").join(child_id)
143	}
144
145	/// Derive the parent id of a nested child by stripping its kind suffix.
146	fn infer_parent(id: &str, kind: PerchKind) -> Option<String> {
147	    match kind {
148	        PerchKind::Psyche => id.strip_suffix("-psyche").map(str::to_string),
149	        PerchKind::Worker => {
150	            let pos = id.rfind("-w")?;
151	            if pos == 0 {
152	                return None;
153	            }
154	            let suffix = &id[pos + 2..];
155	            if suffix.is_empty() || !suffix.bytes().all(|b| b.is_ascii_digit()) {
156	                return None;
157	            }
158	            Some(id[..pos].to_string())
159	        }
160	        PerchKind::Self_ => None,
161	    }
162	}
163
164	/// `<resolved_perch>/info.json`.
165	pub fn resolve_info_file(child_id: &str, hint: ParentHint<'_>) -> PathBuf {
166	    info_file_at(&resolve_perch_path(child_id, hint))
167	}
168
169	/// `<resolved_perch>/spool.db` — the spool the path-aware spool surface opens.
170	pub fn resolve_spool_db(child_id: &str, hint: ParentHint<'_>) -> PathBuf {
171	    resolve_perch_path(child_id, hint).join("spool.db")
172	}
173
174	/// `<resolved_perch>/ready`.
175	pub fn resolve_ready_file(child_id: &str, hint: ParentHint<'_>) -> PathBuf {
176	    resolve_perch_path(child_id, hint).join("ready")
177	}
178
179	/// `<resolved_perch>/inbox`.
180	pub fn resolve_inbox_dir(child_id: &str, hint: ParentHint<'_>) -> PathBuf {
181	    resolve_perch_path(child_id, hint).join("inbox")
182	}
183
184	/// `<perch_path>/info.json` from an already-resolved path.
185	pub fn info_file_at(perch_path: &Path) -> PathBuf {
186	    perch_path.join("info.json")
187	}
188
189	#[cfg(test)]
190	mod tests {
191	    use super::*;
192	    use std::sync::Mutex;
193
194	    static ENV_LOCK: Mutex<()> = Mutex::new(());
195
196	    struct EnvGuard {
197	        _lock: std::sync::MutexGuard<'static, ()>,
198	        prev: Option<String>,
199	    }
200	    impl EnvGuard {
201	        fn set(tmp: &Path) -> Self {
202	            let lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
203	            let prev = std::env::var("SPT_HOME").ok();
204	            std::env::set_var("SPT_HOME", tmp);
205	            EnvGuard { _lock: lock, prev }
206	        }
207	    }
208	    impl Drop for EnvGuard {
209	        fn drop(&mut self) {
210	            match &self.prev {
211	                Some(v) => std::env::set_var("SPT_HOME", v),
212	                None => std::env::remove_var("SPT_HOME"),
213	            }
214	        }
215	    }
216
217	    // ---- classification ----
218
219	    // [unit->REQ-HAZARD-WORKER-PATH]
220	    #[test]
221	    fn classification_matrix() {
222	        assert_eq!(PerchKind::classify("dunsen"), PerchKind::Self_);
223	        assert_eq!(PerchKind::classify("dunsen-psyche"), PerchKind::Psyche);
224	        assert_eq!(PerchKind::classify("dunsen-w1"), PerchKind::Worker);
225	        assert_eq!(PerchKind::classify("dunsen-w42"), PerchKind::Worker);
226	        // Worker rule edges → Self_.
227	        assert_eq!(PerchKind::classify("-w1"), PerchKind::Self_, "no parent name");
228	        assert_eq!(PerchKind::classify("dunsen-w"), PerchKind::Self_, "no digits");
229	        assert_eq!(PerchKind::classify("dunsen-wa"), PerchKind::Self_, "non-digit");
230	        // Rightmost -w wins.
231	        assert_eq!(PerchKind::classify("dunsen-psyche-w1"), PerchKind::Worker);
232	        // Dropped sister kinds are now plain Self_ (no Spine/Touch).
233	        assert_eq!(PerchKind::classify("dunsen-spine"), PerchKind::Self_);
234	        assert_eq!(PerchKind::classify("dunsen-touch"), PerchKind::Self_);
235	    }
236
237	    // ---- resolution: one resolver, no divergent layouts ----
238
239	    // [unit->REQ-HAZARD-SINGLE-PATH-SOURCE] Self_ is flat.
240	    #[test]
241	    fn resolve_self_is_flat() {
242	        let tmp = tempfile::tempdir().unwrap();
243	        let _g = EnvGuard::set(tmp.path());
244	        let owlery = tmp.path().join("owlery");
245	        assert_eq!(resolve_perch_path("dunsen", ParentHint::Infer), owlery.join("dunsen"));
246	    }
247
248	    // [unit->REQ-HAZARD-WORKER-PATH] Psyche + Worker nest under the single
249	    // `nested/` namespace, via both Explicit and Infer.
250	    #[test]
251	    fn resolve_psyche_and_worker_nest_under_parent() {
252	        let tmp = tempfile::tempdir().unwrap();
253	        let _g = EnvGuard::set(tmp.path());
254	        let owlery = tmp.path().join("owlery");
255
256	        let psyche_nested = owlery.join("dunsen").join("nested").join("dunsen-psyche");
257	        assert_eq!(resolve_perch_path("dunsen-psyche", ParentHint::Infer), psyche_nested);
258	        assert_eq!(resolve_perch_path("dunsen-psyche", ParentHint::Explicit("dunsen")), psyche_nested);
259
260	        let worker_nested = owlery.join("dunsen").join("nested").join("dunsen-w42");
261	        assert_eq!(resolve_perch_path("dunsen-w42", ParentHint::Infer), worker_nested);
262	        assert_eq!(resolve_perch_path("dunsen-w42", ParentHint::Explicit("dunsen")), worker_nested);
263	    }
264
265	    // [unit->REQ-HAZARD-SINGLE-PATH-SOURCE] Explicit parent is trusted
266	    // mechanically (overrides what Infer would derive).
267	    #[test]
268	    fn explicit_parent_wins_over_infer() {
269	        let tmp = tempfile::tempdir().unwrap();
270	        let _g = EnvGuard::set(tmp.path());
271	        let owlery = tmp.path().join("owlery");
272	        assert_eq!(
273	            resolve_perch_path("dunsen-psyche", ParentHint::Explicit("doyle")),
274	            owlery.join("doyle").join("nested").join("dunsen-psyche")
275	        );
276	    }
277
278	    // [unit->REQ-HAZARD-SINGLE-PATH-SOURCE] child-file helpers all hang off the
279	    // one resolver — spool.db / info.json / ready / inbox share the perch root.
280	    #[test]
281	    fn child_files_route_through_one_resolver() {
282	        let tmp = tempfile::tempdir().unwrap();
283	        let _g = EnvGuard::set(tmp.path());
284	        let root = resolve_perch_path("dunsen-w7", ParentHint::Explicit("dunsen"));
285	        assert_eq!(resolve_spool_db("dunsen-w7", ParentHint::Explicit("dunsen")), root.join("spool.db"));
286	        assert_eq!(resolve_info_file("dunsen-w7", ParentHint::Explicit("dunsen")), root.join("info.json"));
287	        assert_eq!(resolve_ready_file("dunsen-w7", ParentHint::Explicit("dunsen")), root.join("ready"));
288	        assert_eq!(resolve_inbox_dir("dunsen-w7", ParentHint::Explicit("dunsen")), root.join("inbox"));
289	    }
290
291	    // [unit->REQ-HAZARD-SINGLE-PATH-SOURCE] spt_home is pure: honors SPT_HOME,
292	    // creates nothing.
293	    #[test]
294	    fn spt_home_is_pure_and_honors_env() {
295	        let tmp = tempfile::tempdir().unwrap();
296	        let target = tmp.path().join("does-not-exist-yet");
297	        let _g = EnvGuard::set(&target);
298	        assert_eq!(spt_home(), target);
299	        assert!(!target.exists(), "spt_home must not create the dir");
300	    }
301	}
302

//! Perch layout — the **single source of truth** for where a perch lives on
//! disk. Every reader and writer of a perch path routes through
//! [`resolve_perch_path`]; nothing composes perch paths by hand. If the layout
//! ever changes, exactly this one module changes (KNOWN-HAZARDS 6.1 / 1.5).
//!
//! Adapted from sister `perch_path.rs` + `owlery.rs` path primitives
//! (ADR-0001), with two clean-room changes for the spt-core model:
//! - **Taxonomy:** the sister's Spine/Touch perch kinds are gone (CONTEXT
//!   endpoint types are ReadyAgent/LiveAgent/Psyche/Worker/SptNode). A top-level
//!   agent perch (ReadyAgent or a LiveAgent's Self) classifies as [`PerchKind::Self_`].
//! - **Purity:** path composition performs NO filesystem I/O — every function
//!   returns a `PathBuf`. Directory creation is the writer's job (e.g. the spool
//!   `create_dir_all`s on open).
//!
//! Layout invariant:
//! - `Self_`            → flat: `owlery/<id>/`
//! - `Psyche` / `Worker`→ nested under parent: `owlery/<parent>/nested/<child>/`
//!
//! The single `nested/` namespace holds both Psyche and Worker children — no
//! divergent sibling layouts (the hazard this module exists to prevent).

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

/// Root of all spt-core on-disk state. Honors `$SPT_HOME`, else the platform
/// default (`%LOCALAPPDATA%\spt-core` on Windows, `$HOME/.spt-core` on Unix).
/// **Pure** — reads env, composes a path, creates nothing.
///
/// The default lives under `spt-core/`, **deliberately distinct from the modern
/// spt runtime's `spt/` home**, so a dev build never collides with a running
/// spt-plugin during the parity build-out. Once spt-core + the spt-plugin reach
/// full parity with modern spt this may simply stay `spt-core/` for good; the
/// override (`$SPT_HOME`) is the relocation knob either way.
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
pub fn spt_home() -> PathBuf {
    if let Ok(over) = std::env::var("SPT_HOME") {
        if !over.is_empty() {
            return PathBuf::from(over);
        }
    }
    platform_default_spt_home()
}

#[cfg(windows)]
fn platform_default_spt_home() -> PathBuf {
    if let Ok(local) = std::env::var("LOCALAPPDATA") {
        if !local.is_empty() {
            return PathBuf::from(local).join("spt-core");
        }
    }
    let up = std::env::var("USERPROFILE").expect("LOCALAPPDATA or USERPROFILE must be set on Windows");
    PathBuf::from(up).join("AppData").join("Local").join("spt-core")
}

#[cfg(unix)]
fn platform_default_spt_home() -> PathBuf {
    let home = std::env::var("HOME").expect("HOME must be set on Unix");
    PathBuf::from(home).join(".spt-core")
}

/// The owlery directory holding all perches: `<spt_home>/owlery/`.
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
pub fn owlery_dir() -> PathBuf {
    spt_home().join("owlery")
}

/// Classification of a perch id by its naming convention. Stable, pure, no I/O.
///
/// - `<x>-psyche` → [`PerchKind::Psyche`]
/// - `<x>-w{N}`   → [`PerchKind::Worker`] (`{N}` = one-or-more ASCII digits, `<x>` non-empty)
/// - anything else → [`PerchKind::Self_`]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PerchKind {
    /// Top-level agent perch (a ReadyAgent, or a LiveAgent's Self).
    Self_,
    /// A LiveAgent's Psyche companion perch.
    Psyche,
    /// A subagent perch under a parent LiveAgent.
    Worker,
}

impl PerchKind {
    /// Classify an id by suffix. Worker is checked FIRST (rightmost `-w{digits}`)
    /// so `dunsen-psyche-w1` classifies as `Worker`.
    // [impl->REQ-HAZARD-WORKER-PATH]
    pub fn classify(id: &str) -> PerchKind {
        if is_worker_id(id) {
            return PerchKind::Worker;
        }
        if id.ends_with("-psyche") && id.len() > "-psyche".len() {
            return PerchKind::Psyche;
        }
        PerchKind::Self_
    }

    /// True for kinds nested under a parent (Psyche, Worker); false for Self_.
    pub fn nests_under_parent(self) -> bool {
        matches!(self, PerchKind::Psyche | PerchKind::Worker)
    }
}

/// Worker-id rule: rightmost `-w` whose suffix is one-or-more digits, with a
/// non-empty name before it.
fn is_worker_id(id: &str) -> bool {
    if let Some(pos) = id.rfind("-w") {
        if pos == 0 {
            return false;
        }
        let suffix = &id[pos + 2..];
        !suffix.is_empty() && suffix.bytes().all(|b| b.is_ascii_digit())
    } else {
        false
    }
}

/// How the resolver determines the parent id for nested kinds.
/// `Explicit` is preferred (caller knows the parent); `Infer` derives it by
/// stripping the kind suffix from the child id (legacy reader call sites).
#[derive(Debug, Clone)]
pub enum ParentHint<'a> {
    Explicit(&'a str),
    Infer,
}

/// **The** perch-directory resolver. Self_ → flat `owlery/<id>/`;
/// Psyche/Worker → `owlery/<parent>/nested/<child>/`. Panic-free: if `Infer`
/// cannot derive a parent (id shape doesn't match its kind — should not happen
/// since classify+infer agree), falls back to flat for safety.
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
// [impl->REQ-HAZARD-WORKER-PATH]
pub fn resolve_perch_path(child_id: &str, hint: ParentHint<'_>) -> PathBuf {
    let kind = PerchKind::classify(child_id);
    if !kind.nests_under_parent() {
        return owlery_dir().join(child_id);
    }
    let parent = match hint {
        ParentHint::Explicit(p) => p.to_string(),
        ParentHint::Infer => match infer_parent(child_id, kind) {
            Some(p) => p,
            None => return owlery_dir().join(child_id), // defensive flat fallback
        },
    };
    owlery_dir().join(parent).join("nested").join(child_id)
}

/// Derive the parent id of a nested child by stripping its kind suffix.
fn infer_parent(id: &str, kind: PerchKind) -> Option<String> {
    match kind {
        PerchKind::Psyche => id.strip_suffix("-psyche").map(str::to_string),
        PerchKind::Worker => {
            let pos = id.rfind("-w")?;
            if pos == 0 {
                return None;
            }
            let suffix = &id[pos + 2..];
            if suffix.is_empty() || !suffix.bytes().all(|b| b.is_ascii_digit()) {
                return None;
            }
            Some(id[..pos].to_string())
        }
        PerchKind::Self_ => None,
    }
}

/// `<resolved_perch>/info.json`.
pub fn resolve_info_file(child_id: &str, hint: ParentHint<'_>) -> PathBuf {
    info_file_at(&resolve_perch_path(child_id, hint))
}

/// `<resolved_perch>/spool.db` — the spool the path-aware spool surface opens.
pub fn resolve_spool_db(child_id: &str, hint: ParentHint<'_>) -> PathBuf {
    resolve_perch_path(child_id, hint).join("spool.db")
}

/// `<resolved_perch>/ready`.
pub fn resolve_ready_file(child_id: &str, hint: ParentHint<'_>) -> PathBuf {
    resolve_perch_path(child_id, hint).join("ready")
}

/// `<resolved_perch>/inbox`.
pub fn resolve_inbox_dir(child_id: &str, hint: ParentHint<'_>) -> PathBuf {
    resolve_perch_path(child_id, hint).join("inbox")
}

/// `<perch_path>/info.json` from an already-resolved path.
pub fn info_file_at(perch_path: &Path) -> PathBuf {
    perch_path.join("info.json")
}

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

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

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

    // ---- classification ----

    // [unit->REQ-HAZARD-WORKER-PATH]
    #[test]
    fn classification_matrix() {
        assert_eq!(PerchKind::classify("dunsen"), PerchKind::Self_);
        assert_eq!(PerchKind::classify("dunsen-psyche"), PerchKind::Psyche);
        assert_eq!(PerchKind::classify("dunsen-w1"), PerchKind::Worker);
        assert_eq!(PerchKind::classify("dunsen-w42"), PerchKind::Worker);
        // Worker rule edges → Self_.
        assert_eq!(PerchKind::classify("-w1"), PerchKind::Self_, "no parent name");
        assert_eq!(PerchKind::classify("dunsen-w"), PerchKind::Self_, "no digits");
        assert_eq!(PerchKind::classify("dunsen-wa"), PerchKind::Self_, "non-digit");
        // Rightmost -w wins.
        assert_eq!(PerchKind::classify("dunsen-psyche-w1"), PerchKind::Worker);
        // Dropped sister kinds are now plain Self_ (no Spine/Touch).
        assert_eq!(PerchKind::classify("dunsen-spine"), PerchKind::Self_);
        assert_eq!(PerchKind::classify("dunsen-touch"), PerchKind::Self_);
    }

    // ---- resolution: one resolver, no divergent layouts ----

    // [unit->REQ-HAZARD-SINGLE-PATH-SOURCE] Self_ is flat.
    #[test]
    fn resolve_self_is_flat() {
        let tmp = tempfile::tempdir().unwrap();
        let _g = EnvGuard::set(tmp.path());
        let owlery = tmp.path().join("owlery");
        assert_eq!(resolve_perch_path("dunsen", ParentHint::Infer), owlery.join("dunsen"));
    }

    // [unit->REQ-HAZARD-WORKER-PATH] Psyche + Worker nest under the single
    // `nested/` namespace, via both Explicit and Infer.
    #[test]
    fn resolve_psyche_and_worker_nest_under_parent() {
        let tmp = tempfile::tempdir().unwrap();
        let _g = EnvGuard::set(tmp.path());
        let owlery = tmp.path().join("owlery");

        let psyche_nested = owlery.join("dunsen").join("nested").join("dunsen-psyche");
        assert_eq!(resolve_perch_path("dunsen-psyche", ParentHint::Infer), psyche_nested);
        assert_eq!(resolve_perch_path("dunsen-psyche", ParentHint::Explicit("dunsen")), psyche_nested);

        let worker_nested = owlery.join("dunsen").join("nested").join("dunsen-w42");
        assert_eq!(resolve_perch_path("dunsen-w42", ParentHint::Infer), worker_nested);
        assert_eq!(resolve_perch_path("dunsen-w42", ParentHint::Explicit("dunsen")), worker_nested);
    }

    // [unit->REQ-HAZARD-SINGLE-PATH-SOURCE] Explicit parent is trusted
    // mechanically (overrides what Infer would derive).
    #[test]
    fn explicit_parent_wins_over_infer() {
        let tmp = tempfile::tempdir().unwrap();
        let _g = EnvGuard::set(tmp.path());
        let owlery = tmp.path().join("owlery");
        assert_eq!(
            resolve_perch_path("dunsen-psyche", ParentHint::Explicit("doyle")),
            owlery.join("doyle").join("nested").join("dunsen-psyche")
        );
    }

    // [unit->REQ-HAZARD-SINGLE-PATH-SOURCE] child-file helpers all hang off the
    // one resolver — spool.db / info.json / ready / inbox share the perch root.
    #[test]
    fn child_files_route_through_one_resolver() {
        let tmp = tempfile::tempdir().unwrap();
        let _g = EnvGuard::set(tmp.path());
        let root = resolve_perch_path("dunsen-w7", ParentHint::Explicit("dunsen"));
        assert_eq!(resolve_spool_db("dunsen-w7", ParentHint::Explicit("dunsen")), root.join("spool.db"));
        assert_eq!(resolve_info_file("dunsen-w7", ParentHint::Explicit("dunsen")), root.join("info.json"));
        assert_eq!(resolve_ready_file("dunsen-w7", ParentHint::Explicit("dunsen")), root.join("ready"));
        assert_eq!(resolve_inbox_dir("dunsen-w7", ParentHint::Explicit("dunsen")), root.join("inbox"));
    }

    // [unit->REQ-HAZARD-SINGLE-PATH-SOURCE] spt_home is pure: honors SPT_HOME,
    // creates nothing.
    #[test]
    fn spt_home_is_pure_and_honors_env() {
        let tmp = tempfile::tempdir().unwrap();
        let target = tmp.path().join("does-not-exist-yet");
        let _g = EnvGuard::set(&target);
        assert_eq!(spt_home(), target);
        assert!(!target.exists(), "spt_home must not create the dir");
    }
}


Bash

cd "C:\Users\decid\Documents\projects\spt-core" && sed -n '1,80p' crates/spt-msg/src/ready.rs

Read ReadyAgent::start


//! Ready-agent lifecycle: register a perch, listen, drain backlog, tear down.
//!
//! Clean-room re-expression of the sister `owl/poll.rs` startup + `setup.rs`
//! against the spt-store API, scoped to the M1 loopback model. The harness
//! coupling that bloats the sister `poll.rs` (stream EVENT emission, EVENT-PART
//! chunking, alarms, handoff, Psyche) is **not** M1 — it lands in M2/M3. M1's
//! ready agent is the minimal participant: a perch + a TCP listener + a spool
//! drain.
//!
//! Startup ([`ReadyAgent::start`]) is the REQ-MSG-3 contract:
//! 1. Create the perch dir; write `info.json` (`ready_agent` endpoint) + the
//!    `ready` marker.
//! 2. Bind a loopback listener and register its address (via [`PollListener`]).
//! 3. Drain the **non-deferred** spool backlog so messages that arrived while
//!    the agent was offline are delivered on startup. Deferred rows are left
//!    for the hook channel (REQ-HAZARD-DEFERRED-SURVIVE-DRAIN, covered M0).
//!
//! Teardown ([`ReadyAgent::soft_cleanup`]) honors REQ-HAZARD-SOFT-CLEANUP (6.2):
//! it removes **only** the `ready` marker and unregisters the address. The
//! spool and `info.json` are preserved so a restart resumes its backlog.
// [impl->REQ-MSG-3]

use spt_store::info::{self, InfoJson};
use spt_store::perch::{self, ParentHint};
use spt_store::spool;
use std::fs;
use std::path::PathBuf;
use std::time::Duration;

use crate::listener::PollListener;

/// Default poll window for [`ReadyAgent::recv`]; bounds each blocking accept so
/// the caller stays responsive (and can re-check spool / shut down between calls).
const RECV_POLL: Duration = Duration::from_millis(200);

/// A running ready agent: an owned perch + a registered loopback listener.
pub struct ReadyAgent {
    id: String,
    listener: PollListener,
    perch_path: PathBuf,
    closed: bool,
}

impl ReadyAgent {
    /// Register the perch (info.json + ready marker + listener address) and
    /// drain the offline backlog. Returns the agent and any backlog messages
    /// (oldest first) that were spooled while it was down.
    // [impl->REQ-MSG-3]
    pub fn start(id: &str) -> Result<(ReadyAgent, Vec<String>), String> {
        let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);
        fs::create_dir_all(&perch_path)
            .map_err(|e| format!("Failed to create perch dir: {}", e))?;

        // info.json first, so the record exists by the time the address is live.
        let rec = InfoJson::new(id, &now_stamp(), std::process::id(), &session_id(), "ready_agent");
        info::write_info(&perch_path, &rec)
            .map_err(|e| format!("Failed to write info.json: {}", e))?;

        // ready marker — the liveness signal senders check.
        fs::write(perch::resolve_ready_file(id, ParentHint::Infer), "")
            .map_err(|e| format!("Failed to write ready marker: {}", e))?;

        // Bind + register the listener address. Blocking with a short poll so
        // recv() stays responsive.
        let listener = PollListener::bind_blocking(id, RECV_POLL)?;

        // Drain the offline backlog (non-deferred only; deferred rows survive
        // for the hook channel).
        let backlog = spool::drain_non_deferred_at(&perch_path)
            .map_err(|e| format!("Failed to drain spool backlog: {}", e))?;

        Ok((
            ReadyAgent {
                id: id.to_string(),
                listener,
                perch_path,
                closed: false,
            },
            backlog,
        ))


cd "C:\Users\decid\Documents\projects\spt-core" && sed -n '50,140p' crates/spt-store/src/info.rs; echo "===PROC rest==="; sed -n '30,120p' crates/spt-store/src/proc.rs

Read InfoJson API + proc parent-pid


    pub parent_pid: Option<u32>,
    /// Working directory the perch was created in (forward-slash normalized).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub cwd: Option<String>,
}

impl InfoJson {
    /// Construct a numeric-pid record.
    pub fn new(id: &str, started: &str, pid: u32, session_id: &str, state: &str) -> InfoJson {
        InfoJson {
            id: id.to_string(),
            started: started.to_string(),
            pid: PidValue::Numeric(pid),
            session_id: session_id.to_string(),
            state: state.to_string(),
            parent_pid: None,
            cwd: None,
        }
    }
}

/// Number of read attempts before giving up (torn-read retry).
const READ_ATTEMPTS: u32 = 5;

/// Atomically write `info` to `<perch_path>/info.json` (tmp + rename, EBUSY
/// retry via the atomic layer). The perch directory must already exist.
// [impl->REQ-HAZARD-INFO-JSON-TORN-READ]
pub fn write_info(perch_path: &Path, info: &InfoJson) -> std::io::Result<()> {
    let json = serde_json::to_string(info)
        .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
    atomic_write_string(&perch_path.join("info.json"), &json)
}

/// Read and parse `<perch_path>/info.json`. Tolerant: a transient read/parse
/// failure (e.g. catching the file mid-replace on a filesystem where that is
/// observable) is retried up to [`READ_ATTEMPTS`] times; returns `None` on
/// persistent absence/corruption — never panics.
// [impl->REQ-HAZARD-INFO-JSON-TORN-READ]
pub fn read_info(perch_path: &Path) -> Option<InfoJson> {
    let path = perch_path.join("info.json");
    for _ in 0..READ_ATTEMPTS {
        if let Ok(content) = std::fs::read_to_string(&path) {
            if let Ok(info) = serde_json::from_str::<InfoJson>(&content) {
                return Some(info);
            }
        }
    }
    None
}

/// Read just the numeric pid, tolerantly. Routed through `serde_json::Value` so
/// a schema-minimal `{"pid": N}` (as wrapper-owned listeners write) parses
/// without the full `InfoJson` shape. Returns `None` for a missing file, the
/// `"BUSY"` sentinel, or any non-numeric pid. Copied semantics from sister
/// `get_pid_from_info`.
// [impl->REQ-HAZARD-INFO-JSON-TORN-READ]
pub fn read_pid(perch_path: &Path) -> Option<u32> {
    let path = perch_path.join("info.json");
    for _ in 0..READ_ATTEMPTS {
        if let Ok(content) = std::fs::read_to_string(&path) {
            if let Ok(val) = serde_json::from_str::<serde_json::Value>(&content) {
                // `as_u64` is None for the "BUSY" string sentinel → None pid.
                return val.get("pid").and_then(|v| v.as_u64()).map(|n| n as u32);
            }
        }
    }
    None
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::sync::atomic::{AtomicBool, Ordering};
    use std::sync::Arc;

    fn perch() -> tempfile::TempDir {
        tempfile::tempdir().unwrap()
    }

    // [unit->REQ-HAZARD-INFO-JSON-TORN-READ] write → read round-trip; optional
    // fields omitted when None.
    #[test]
    fn write_read_round_trip() {
        let d = perch();
        let mut info = InfoJson::new("doyle", "2026-05-31T00:00:00Z", 4242, "sid-1", "live_agent");
        info.cwd = Some("C:/repo/spt-core".to_string());
        write_info(d.path(), &info).unwrap();

        let got = read_info(d.path()).expect("reads back");
        assert_eq!(got, info);

===PROC rest===
            }
            true
        }
    }
}

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

    // [unit->REQ-HAZARD-REGISTRY-STALE-CLEAN] the current process is alive.
    #[test]
    fn current_process_is_alive() {
        assert!(is_process_alive(std::process::id()));
    }

    // [unit->REQ-HAZARD-REGISTRY-STALE-CLEAN] a pid far above any real
    // allocation reads as dead. Chosen deterministically (above Linux pid_max
    // ~4.2M and Windows ranges; positive as i32 so the Unix kill-group
    // semantics don't trigger) to avoid the pid-reuse flakiness of
    // spawn-then-reap under parallel test execution.
    #[test]
    fn unassigned_pid_is_dead() {
        const DEAD_PID: u32 = 2_000_000_000;
        assert!(!is_process_alive(DEAD_PID), "unassigned pid must read as dead");
    }
}


cd "C:\Users\decid\Documents\projects\spt-core" && cat crates/spt-proto/src/lib.rs | head -40; echo "===identity exports==="; grep -rn "pub fn\|pub struct" crates/spt-proto/src/*.rs | grep -i "rand\|gen\|new\|token\|id" | head

Check spt-proto for reusable random/id helpers


//! `spt-proto` — the wire-protocol commodity layer for spt-core.
//!
//! Per ADR-0001, the stable wire formats are **copied verbatim** from the
//! sister project (`claude_skill_owl`); only architecture/lifecycle is
//! clean-room. This crate owns the envelope grammar and is part of the public,
//! semver-committed SDK (R-ARCH-2), carrying its own wire-protocol version
//! independent of crate semver (R-ARCH-3).
//!
// spt-proto is the bottom layer of the acyclically-layered crate stack
// (R-ARCH-1): it depends on no other workspace crate, so nothing it exposes can
// form a cycle. Every higher crate (spt-store, …) layers above it one-way.
// [impl->REQ-ARCH-1]

pub mod chunk;
pub mod endpoint;
pub mod envelope;
pub mod event;
pub mod identity;
pub mod payload;
pub mod version;
===identity exports===
crates/spt-proto/src/endpoint.rs:132:    pub fn is_agent_endpoint(&self) -> bool {
crates/spt-proto/src/identity.rs:54:pub struct Identity {
crates/spt-proto/src/identity.rs:61:    pub fn generate() -> Identity {
crates/spt-proto/src/identity.rs:69:    pub fn from_seed(seed: &[u8; SEED_LEN]) -> Identity {
crates/spt-proto/src/identity.rs:76:    pub fn seed(&self) -> [u8; SEED_LEN] {
crates/spt-proto/src/identity.rs:81:    pub fn public_key(&self) -> PublicKey {
crates/spt-proto/src/identity.rs:87:    pub fn sign(&self, msg: &[u8]) -> [u8; SIGNATURE_LEN] {
crates/spt-proto/src/identity.rs:103:pub struct PublicKey(VerifyingKey);
crates/spt-proto/src/identity.rs:107:    pub fn to_bytes(&self) -> [u8; PUBLIC_KEY_LEN] {
crates/spt-proto/src/identity.rs:113:    pub fn from_bytes(bytes: &[u8; PUBLIC_KEY_LEN]) -> Result<PublicKey, IdentityError> {


`Identity::generate().seed()` gives crypto-grade random for tokens. Build the `api` module (T3). Add dep first.


Edit

spt-proto = { path = "../spt-proto" }
spt-store = { path = "../spt-store" }
spt-msg = { path = "../spt-msg" }
clap = { version = "4", features = ["derive"] }

spt-proto = { path = "../spt-proto" }
spt-store = { path = "../spt-store" }
spt-msg = { path = "../spt-msg" }
spt-runtime = { path = "../spt-runtime" }
clap = { version = "4", features = ["derive"] }

[dev-dependencies]
tempfile = "3"


<tool_use_error>File has not been read yet. Read it first before writing to it.</tool_use_error>

Error: File has not been read yet. Read it first before writing to it.


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.


1	[package]
2	name = "spt"
3	version = "0.0.0"
4	edition = "2021"
5	publish = false
6	description = "spt-core CLI: the single `spt` binary. Thin command layer over spt-msg (send/ring/ready/poll/list/stop/whoami)."
7
8	[[bin]]
9	name = "spt"
10	path = "src/main.rs"
11
12	[dependencies]
13	spt-proto = { path = "../spt-proto" }
14	spt-store = { path = "../spt-store" }
15	spt-msg = { path = "../spt-msg" }
16	clap = { version = "4", features = ["derive"] }
17

[package]
name = "spt"
version = "0.0.0"
edition = "2021"
publish = false
description = "spt-core CLI: the single `spt` binary. Thin command layer over spt-msg (send/ring/ready/poll/list/stop/whoami)."

[[bin]]
name = "spt"
path = "src/main.rs"

[dependencies]
spt-proto = { path = "../spt-proto" }
spt-store = { path = "../spt-store" }
spt-msg = { path = "../spt-msg" }
clap = { version = "4", features = ["derive"] }


description = "spt-core CLI: the single `spt` binary. Thin command layer over spt-msg (send/ring/ready/poll/list/stop/whoami)."

[[bin]]
name = "spt"
path = "src/main.rs"

[dependencies]
spt-proto = { path = "../spt-proto" }
spt-store = { path = "../spt-store" }
spt-msg = { path = "../spt-msg" }
clap = { version = "4", features = ["derive"] }

description = "spt-core CLI: the single `spt` binary. Thin command layer over spt-msg (send/ring/ready/poll/list/stop/whoami) + the harness-contract `api` surface (spt-runtime)."

[[bin]]
name = "spt"
path = "src/main.rs"

[dependencies]
spt-proto = { path = "../spt-proto" }
spt-store = { path = "../spt-store" }
spt-msg = { path = "../spt-msg" }
spt-runtime = { path = "../spt-runtime" }
clap = { version = "4", features = ["derive"] }

[dev-dependencies]
tempfile = "3"


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

description = "spt-core CLI: the single `spt` binary. Thin command layer over spt-msg (send/ring/ready/poll/list/stop/whoami) + the harness-contract `api` surface (spt-runtime)."

[[bin]]
name = "spt"
path = "src/main.rs"

[dependencies]
spt-proto = { path = "../spt-proto" }
spt-store = { path = "../spt-store" }
spt-msg = { path = "../spt-msg" }
spt-runtime = { path = "../spt-runtime" }
clap = { version = "4", features = ["derive"] }

[dev-dependencies]
tempfile = "3"

[package]
name = "spt"
version = "0.0.0"
edition = "2021"
publish = false
description = "spt-core CLI: the single `spt` binary. Thin command layer over spt-msg (send/ring/ready/poll/list/stop/whoami)."

[[bin]]
name = "spt"
path = "src/main.rs"

[dependencies]
spt-proto = { path = "../spt-proto" }
spt-store = { path = "../spt-store" }
spt-msg = { path = "../spt-msg" }
clap = { version = "4", features = ["derive"] }

-description = "spt-core CLI: the single `spt` binary. Thin command layer over spt-msg (send/ring/ready/poll/list/stop/whoami)."

+description = "spt-core CLI: the single `spt` binary. Thin command layer over spt-msg (send/ring/ready/poll/list/stop/whoami) + the harness-contract `api` surface (spt-runtime)."

+spt-runtime = { path = "../spt-runtime" }


Write the auth guard + api dispatch group.


Write

//! 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);
    }
}


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

//! 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);
    }
}


//! The inbound half of the harness contract: the `spt api <cmd>` surface a
//! harness's hooks fire to mutate on-disk SPT state. Every call carries
//! `--adapter <name>` (the `adapter_name`), and every *mutation* is gated by
//! [`auth`] (REQ-HAZARD-LOCAL-API-AUTH).
//!
//! Interim no-daemon model (decision 2026-06-01): these commands mutate the
//! perch/spool/registry directly via spt-store/spt-msg, mirroring M1's
//! standalone listener. M3's `spt-daemon` swaps the on-disk seed + standalone
//! `listen` for an in-memory daemon record — the surface here stays thin so
//! that is a move, not a rewrite.
//!
//! Exit codes (superset of the messaging surface): `0` ok · `1` runtime refusal
//! (auth refused, no endpoint) · `2` usage (clap) · `3` not-yet-built in this
//! M2a slice.
//
// [impl->REQ-API-1]

pub mod auth;

use clap::{Args, Subcommand};
use std::path::PathBuf;

use spt_runtime::Manifest;

use auth::{authenticate, AuthResult, Proof};

/// Exit code for an `api` subcommand whose handler lands in a later M2a task.
const EXIT_NOT_IMPLEMENTED: i32 = 3;
/// Exit code for a runtime refusal (auth/no-endpoint).
pub const EXIT_REFUSED: i32 = 1;

/// The `api` command group. `--adapter` is required on every invocation; an
/// optional `--manifest` is parsed for commands that need the adapter contract
/// (spawn roles, capability advertisement).
#[derive(Args)]
pub struct ApiArgs {
    /// adapter_name — the calling harness adapter. Required on every `api` call.
    #[arg(long)]
    adapter: String,
    /// Path to the adapter's runtime manifest (when the command needs it).
    #[arg(long)]
    manifest: Option<PathBuf>,
    #[command(subcommand)]
    cmd: ApiCmd,
}

/// The inbound surface. Lifecycle-execution commands (psyche/commune) are M2b;
/// these are the M2a harness-contract entry points.
#[derive(Subcommand)]
pub enum ApiCmd {
    /// Harness-hosted startup: record an ephemeral seed keyed by parent pid (T4).
    Seed {
        #[arg(long)]
        pid: u32,
        #[arg(long)]
        session_id: String,
    },
    /// Consume a seed and hold the perch + relay loop (interim long-runner, T4).
    Listen {
        id: String,
    },
    /// Post-spawn bind of a session to its perch (T4).
    Bind {
        id: String,
        #[arg(long)]
        session_id: Option<String>,
        #[command(flatten)]
        auth: AuthFlags,
    },
    /// Set activity state busy|idle (+ echo-gate sentinel) (T5).
    State {
        #[arg(value_parser = ["busy", "idle"])]
        state: String,
        id: String,
        #[arg(long)]
        no_gate: bool,
        #[command(flatten)]
        auth: AuthFlags,
    },
    /// Manage the echo-gate sentinel directly (T5).
    EchoGate {
        #[arg(value_parser = ["set", "clear"])]
        action: String,
        id: String,
        #[command(flatten)]
        auth: AuthFlags,
    },
    /// Drain delivered messages (hook channel) (T5).
    Poll {
        id: String,
        #[arg(long)]
        include_deferred: bool,
        #[command(flatten)]
        auth: AuthFlags,
    },
    /// Create a nested worker perch under a parent (T6).
    WorkerStart {
        parent: String,
        id: String,
        #[command(flatten)]
        auth: AuthFlags,
    },
    /// Tear down a worker perch (T6).
    WorkerStop {
        id: String,
        #[command(flatten)]
        auth: AuthFlags,
    },
    /// Drain a worker perch's messages (T6).
    WorkerPoll {
        id: String,
        #[command(flatten)]
        auth: AuthFlags,
    },
    /// Rebind the perch to a new session_id, preserving identity (T7).
    Boundary {
        #[arg(value_parser = ["clear", "compact"])]
        mode: String,
        id: String,
        #[arg(long)]
        session_id: String,
        #[command(flatten)]
        auth: AuthFlags,
    },
    /// Soft teardown (spool/history preserved); `--erase` hard-wipes (T7).
    SessionEnd {
        id: String,
        #[arg(long)]
        erase: bool,
        #[command(flatten)]
        auth: AuthFlags,
    },
    /// Report presence (T7).
    Presence {
        id: String,
        #[command(flatten)]
        auth: AuthFlags,
    },
    /// Append to the native history store (Path-B) (T7).
    HistoryLog {
        id: String,
        #[command(flatten)]
        auth: AuthFlags,
    },
    /// Emit a Shell sensory payload (stub) (T7).
    Emit {
        id: String,
        #[arg(long)]
        r#type: String,
        #[command(flatten)]
        auth: AuthFlags,
    },
    /// Print the adapter's declared capability (`hostable_types`) (T7).
    Capability,
}

/// Auth proof flags shared by every mutating subcommand.
#[derive(Args)]
pub struct AuthFlags {
    /// Capability token proving association to the target perch.
    #[arg(long)]
    token: Option<String>,
    /// Session id proving association (matches the perch's info.json).
    #[arg(long)]
    session_id: Option<String>,
}

impl AuthFlags {
    fn proof(&self) -> Proof {
        Proof::resolve(self.token.clone(), self.session_id.clone())
    }
}

/// Resolved per-call context shared by handlers.
pub struct Ctx {
    /// The `adapter_name` carried on this call.
    pub adapter: String,
    /// Parsed manifest, when `--manifest` was supplied (and valid).
    pub manifest: Option<Manifest>,
}

/// Parse + dispatch the `api` group. Returns the process exit code.
pub fn run(args: ApiArgs) -> i32 {
    // Resolve the manifest up front so a bad path/content fails fast and
    // uniformly, before any state mutation.
    let manifest = match &args.manifest {
        Some(path) => match load_manifest(path, &args.adapter) {
            Ok(m) => Some(m),
            Err(code) => return code,
        },
        None => None,
    };
    let ctx = Ctx { adapter: args.adapter, manifest };

    match args.cmd {
        ApiCmd::Capability => cmd_capability(&ctx),
        // Mutations gated by auth — handlers land in T4–T7.
        ApiCmd::Bind { id, auth, .. } => gated(&id, &auth, |_| not_implemented("bind")),
        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")),
        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")),
        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")),
        ApiCmd::HistoryLog { id, auth, .. } => gated(&id, &auth, |_| not_implemented("history-log")),
        ApiCmd::Emit { id, auth, .. } => gated(&id, &auth, |_| not_implemented("emit")),
        // Startup commands establish their own auth (seed has no prior perch).
        ApiCmd::Seed { .. } => not_implemented("seed"),
        ApiCmd::Listen { .. } => not_implemented("listen"),
    }
}

/// Load + validate a manifest, checking it matches the declared adapter_name.
/// Returns an exit code on failure (already reported to stderr).
fn load_manifest(path: &PathBuf, adapter: &str) -> Result<Manifest, i32> {
    let text = std::fs::read_to_string(path).map_err(|e| {
        eprintln!("MANIFEST_READ:{}: {e}", path.display());
        EXIT_REFUSED
    })?;
    let manifest = Manifest::from_toml_str(&text).map_err(|e| {
        eprintln!("MANIFEST_INVALID:{e}");
        EXIT_REFUSED
    })?;
    if manifest.adapter.name != adapter {
        eprintln!(
            "ADAPTER_MISMATCH: --adapter {adapter} but manifest declares {}",
            manifest.adapter.name
        );
        return Err(EXIT_REFUSED);
    }
    Ok(manifest)
}

/// Run `f` only if the proof authenticates against `target_id`; otherwise print
/// the refusal and return [`EXIT_REFUSED`].
// [impl->REQ-HAZARD-LOCAL-API-AUTH]
fn gated<F: FnOnce(&str) -> i32>(target_id: &str, auth: &AuthFlags, f: F) -> i32 {
    match authenticate(target_id, &auth.proof()) {
        AuthResult::Ok => f(target_id),
        AuthResult::NoEndpoint => {
            eprintln!("NO_ENDPOINT:{target_id} has no perch to authenticate against");
            EXIT_REFUSED
        }
        AuthResult::Refused => {
            eprintln!("AUTH_REFUSED:{target_id} (need --token or matching --session-id)");
            EXIT_REFUSED
        }
    }
}

/// `api capability` — advertise the adapter's `hostable_types`. Requires a
/// manifest (the capability lives there, resolved by adapter_name).
fn cmd_capability(ctx: &Ctx) -> i32 {
    let Some(manifest) = &ctx.manifest else {
        eprintln!("NO_MANIFEST: capability needs --manifest");
        return EXIT_REFUSED;
    };
    for ty in &manifest.adapter.hostable_types {
        println!("{ty}");
    }
    0
}

fn not_implemented(cmd: &str) -> i32 {
    eprintln!("NOT_IMPLEMENTED:api {cmd} (lands in a later M2a task)");
    EXIT_NOT_IMPLEMENTED
}

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

    /// A tiny parent Cli wrapping just the api group, for parse tests.
    #[derive(Parser)]
    struct Probe {
        #[command(flatten)]
        api: ApiArgs,
    }

    fn parse(args: &[&str]) -> Result<Probe, clap::Error> {
        Probe::try_parse_from(args)
    }

    #[test] // [unit->REQ-API-1] adapter_name is mandatory on every api call.
    fn adapter_is_required() {
        assert!(parse(&["spt", "capability"]).is_err());
        assert!(parse(&["spt", "--adapter", "mock", "capability"]).is_ok());
    }

    #[test] // [unit->REQ-API-1] the surface parses its documented subcommands.
    fn surface_parses() {
        assert!(parse(&["spt", "--adapter", "m", "seed", "--pid", "10", "--session-id", "s"]).is_ok());
        assert!(parse(&["spt", "--adapter", "m", "listen", "alice"]).is_ok());
        assert!(parse(&["spt", "--adapter", "m", "state", "idle", "alice"]).is_ok());
        assert!(parse(&["spt", "--adapter", "m", "poll", "alice", "--include-deferred"]).is_ok());
        assert!(parse(&["spt", "--adapter", "m", "worker-start", "alice", "alice-w1"]).is_ok());
        assert!(parse(&["spt", "--adapter", "m", "boundary", "compact", "alice", "--session-id", "s2"]).is_ok());
        assert!(parse(&["spt", "--adapter", "m", "session-end", "alice", "--erase"]).is_ok());
    }

    #[test] // [unit->REQ-API-1] closed-vocabulary args reject bad values.
    fn state_value_is_validated() {
        assert!(parse(&["spt", "--adapter", "m", "state", "sideways", "alice"]).is_err());
        assert!(parse(&["spt", "--adapter", "m", "boundary", "explode", "alice", "--session-id", "s"]).is_err());
    }
}


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

//! The inbound half of the harness contract: the `spt api <cmd>` surface a
//! harness's hooks fire to mutate on-disk SPT state. Every call carries
//! `--adapter <name>` (the `adapter_name`), and every *mutation* is gated by
//! [`auth`] (REQ-HAZARD-LOCAL-API-AUTH).
//!
//! Interim no-daemon model (decision 2026-06-01): these commands mutate the
//! perch/spool/registry directly via spt-store/spt-msg, mirroring M1's
//! standalone listener. M3's `spt-daemon` swaps the on-disk seed + standalone
//! `listen` for an in-memory daemon record — the surface here stays thin so
//! that is a move, not a rewrite.
//!
//! Exit codes (superset of the messaging surface): `0` ok · `1` runtime refusal
//! (auth refused, no endpoint) · `2` usage (clap) · `3` not-yet-built in this
//! M2a slice.
//
// [impl->REQ-API-1]

pub mod auth;

use clap::{Args, Subcommand};
use std::path::PathBuf;

use spt_runtime::Manifest;

use auth::{authenticate, AuthResult, Proof};

/// Exit code for an `api` subcommand whose handler lands in a later M2a task.
const EXIT_NOT_IMPLEMENTED: i32 = 3;
/// Exit code for a runtime refusal (auth/no-endpoint).
pub const EXIT_REFUSED: i32 = 1;

/// The `api` command group. `--adapter` is required on every invocation; an
/// optional `--manifest` is parsed for commands that need the adapter contract
/// (spawn roles, capability advertisement).
#[derive(Args)]
pub struct ApiArgs {
    /// adapter_name — the calling harness adapter. Required on every `api` call.
    #[arg(long)]
    adapter: String,
    /// Path to the adapter's runtime manifest (when the command needs it).
    #[arg(long)]
    manifest: Option<PathBuf>,
    #[command(subcommand)]
    cmd: ApiCmd,
}

/// The inbound surface. Lifecycle-execution commands (psyche/commune) are M2b;
/// these are the M2a harness-contract entry points.
#[derive(Subcommand)]
pub enum ApiCmd {
    /// Harness-hosted startup: record an ephemeral seed keyed by parent pid (T4).
    Seed {
        #[arg(long)]
        pid: u32,
        #[arg(long)]
        session_id: String,
    },
    /// Consume a seed and hold the perch + relay loop (interim long-runner, T4).
    Listen {
        id: String,
    },
    /// Post-spawn bind of a session to its perch (T4).
    Bind {
        id: String,
        #[arg(long)]
        session_id: Option<String>,
        #[command(flatten)]
        auth: AuthFlags,
    },
    /// Set activity state busy|idle (+ echo-gate sentinel) (T5).
    State {
        #[arg(value_parser = ["busy", "idle"])]
        state: String,
        id: String,
        #[arg(long)]
        no_gate: bool,
        #[command(flatten)]
        auth: AuthFlags,
    },
    /// Manage the echo-gate sentinel directly (T5).
    EchoGate {
        #[arg(value_parser = ["set", "clear"])]
        action: String,
        id: String,
        #[command(flatten)]
        auth: AuthFlags,
    },
    /// Drain delivered messages (hook channel) (T5).
    Poll {
        id: String,
        #[arg(long)]
        include_deferred: bool,
        #[command(flatten)]
        auth: AuthFlags,
    },
    /// Create a nested worker perch under a parent (T6).
    WorkerStart {
        parent: String,
        id: String,
        #[command(flatten)]
        auth: AuthFlags,
    },
    /// Tear down a worker perch (T6).
    WorkerStop {
        id: String,
        #[command(flatten)]
        auth: AuthFlags,
    },
    /// Drain a worker perch's messages (T6).
    WorkerPoll {
        id: String,
        #[command(flatten)]
        auth: AuthFlags,
    },
    /// Rebind the perch to a new session_id, preserving identity (T7).
    Boundary {
        #[arg(value_parser = ["clear", "compact"])]
        mode: String,
        id: String,
        #[arg(long)]
        session_id: String,
        #[command(flatten)]
        auth: AuthFlags,
    },
    /// Soft teardown (spool/history preserved); `--erase` hard-wipes (T7).
    SessionEnd {
        id: String,
        #[arg(long)]
        erase: bool,
        #[command(flatten)]
        auth: AuthFlags,
    },
    /// Report presence (T7).
    Presence {
        id: String,
        #[command(flatten)]
        auth: AuthFlags,
    },
    /// Append to the native history store (Path-B) (T7).
    HistoryLog {
        id: String,
        #[command(flatten)]
        auth: AuthFlags,
    },
    /// Emit a Shell sensory payload (stub) (T7).
    Emit {
        id: String,
        #[arg(long)]
        r#type: String,
        #[command(flatten)]
        auth: AuthFlags,
    },
    /// Print the adapter's declared capability (`hostable_types`) (T7).
    Capability,
}

/// Auth proof flags shared by every mutating subcommand.
#[derive(Args)]
pub struct AuthFlags {
    /// Capability token proving association to the target perch.
    #[arg(long)]
    token: Option<String>,
    /// Session id proving association (matches the perch's info.json).
    #[arg(long)]
    session_id: Option<String>,
}

impl AuthFlags {
    fn proof(&self) -> Proof {
        Proof::resolve(self.token.clone(), self.session_id.clone())
    }
}

/// Resolved per-call context shared by handlers.
pub struct Ctx {
    /// The `adapter_name` carried on this call.
    pub adapter: String,
    /// Parsed manifest, when `--manifest` was supplied (and valid).
    pub manifest: Option<Manifest>,
}

/// Parse + dispatch the `api` group. Returns the process exit code.
pub fn run(args: ApiArgs) -> i32 {
    // Resolve the manifest up front so a bad path/content fails fast and
    // uniformly, before any state mutation.
    let manifest = match &args.manifest {
        Some(path) => match load_manifest(path, &args.adapter) {
            Ok(m) => Some(m),
            Err(code) => return code,
        },
        None => None,
    };
    let ctx = Ctx { adapter: args.adapter, manifest };

    match args.cmd {
        ApiCmd::Capability => cmd_capability(&ctx),
        // Mutations gated by auth — handlers land in T4–T7.
        ApiCmd::Bind { id, auth, .. } => gated(&id, &auth, |_| not_implemented("bind")),
        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")),
        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")),
        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")),
        ApiCmd::HistoryLog { id, auth, .. } => gated(&id, &auth, |_| not_implemented("history-log")),
        ApiCmd::Emit { id, auth, .. } => gated(&id, &auth, |_| not_implemented("emit")),
        // Startup commands establish their own auth (seed has no prior perch).
        ApiCmd::Seed { .. } => not_implemented("seed"),
        ApiCmd::Listen { .. } => not_implemented("listen"),
    }
}

/// Load + validate a manifest, checking it matches the declared adapter_name.
/// Returns an exit code on failure (already reported to stderr).
fn load_manifest(path: &PathBuf, adapter: &str) -> Result<Manifest, i32> {
    let text = std::fs::read_to_string(path).map_err(|e| {
        eprintln!("MANIFEST_READ:{}: {e}", path.display());
        EXIT_REFUSED
    })?;
    let manifest = Manifest::from_toml_str(&text).map_err(|e| {
        eprintln!("MANIFEST_INVALID:{e}");
        EXIT_REFUSED
    })?;
    if manifest.adapter.name != adapter {
        eprintln!(
            "ADAPTER_MISMATCH: --adapter {adapter} but manifest declares {}",
            manifest.adapter.name
        );
        return Err(EXIT_REFUSED);
    }
    Ok(manifest)
}

/// Run `f` only if the proof authenticates against `target_id`; otherwise print
/// the refusal and return [`EXIT_REFUSED`].
// [impl->REQ-HAZARD-LOCAL-API-AUTH]
fn gated<F: FnOnce(&str) -> i32>(target_id: &str, auth: &AuthFlags, f: F) -> i32 {
    match authenticate(target_id, &auth.proof()) {
        AuthResult::Ok => f(target_id),
        AuthResult::NoEndpoint => {
            eprintln!("NO_ENDPOINT:{target_id} has no perch to authenticate against");
            EXIT_REFUSED
        }
        AuthResult::Refused => {
            eprintln!("AUTH_REFUSED:{target_id} (need --token or matching --session-id)");
            EXIT_REFUSED
        }
    }
}

/// `api capability` — advertise the adapter's `hostable_types`. Requires a
/// manifest (the capability lives there, resolved by adapter_name).
fn cmd_capability(ctx: &Ctx) -> i32 {
    let Some(manifest) = &ctx.manifest else {
        eprintln!("NO_MANIFEST: capability needs --manifest");
        return EXIT_REFUSED;
    };
    for ty in &manifest.adapter.hostable_types {
        println!("{ty}");
    }
    0
}

fn not_implemented(cmd: &str) -> i32 {
    eprintln!("NOT_IMPLEMENTED:api {cmd} (lands in a later M2a task)");
    EXIT_NOT_IMPLEMENTED
}

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

    /// A tiny parent Cli wrapping just the api group, for parse tests.
    #[derive(Parser)]
    struct Probe {
        #[command(flatten)]
        api: ApiArgs,
    }

    fn parse(args: &[&str]) -> Result<Probe, clap::Error> {
        Probe::try_parse_from(args)
    }

    #[test] // [unit->REQ-API-1] adapter_name is mandatory on every api call.
    fn adapter_is_required() {
        assert!(parse(&["spt", "capability"]).is_err());
        assert!(parse(&["spt", "--adapter", "mock", "capability"]).is_ok());
    }

    #[test] // [unit->REQ-API-1] the surface parses its documented subcommands.
    fn surface_parses() {
        assert!(parse(&["spt", "--adapter", "m", "seed", "--pid", "10", "--session-id", "s"]).is_ok());
        assert!(parse(&["spt", "--adapter", "m", "listen", "alice"]).is_ok());
        assert!(parse(&["spt", "--adapter", "m", "state", "idle", "alice"]).is_ok());
        assert!(parse(&["spt", "--adapter", "m", "poll", "alice", "--include-deferred"]).is_ok());
        assert!(parse(&["spt", "--adapter", "m", "worker-start", "alice", "alice-w1"]).is_ok());
        assert!(parse(&["spt", "--adapter", "m", "boundary", "compact", "alice", "--session-id", "s2"]).is_ok());
        assert!(parse(&["spt", "--adapter", "m", "session-end", "alice", "--erase"]).is_ok());
    }

    #[test] // [unit->REQ-API-1] closed-vocabulary args reject bad values.
    fn state_value_is_validated() {
        assert!(parse(&["spt", "--adapter", "m", "state", "sideways", "alice"]).is_err());
        assert!(parse(&["spt", "--adapter", "m", "boundary", "explode", "alice", "--session-id", "s"]).is_err());
    }
}


Wire `api` into the top-level CLI + main module list.


Edit

//! Subcommands (REQ-MSG-2 stable surface): `send` · `ring` · `ready` · `poll` ·
//! `list` · `stop` · `whoami`.
// [impl->REQ-MSG-2]

mod cli;
mod roster;

//! Subcommands (REQ-MSG-2 stable surface): `send` · `ring` · `ready` · `poll` ·
//! `list` · `stop` · `whoami`. The harness-contract inbound surface lives under
//! the `api` group (REQ-API-1), dispatched from [`api`].
// [impl->REQ-MSG-2]

mod api;
mod cli;
mod roster;


1	//! `spt` — the single spt-core binary. A **thin** command layer over `spt-msg`:
2	//! it owns I/O (stdin/stdout/stderr) and process exit codes; all messaging
3	//! logic lives in the crate (clean-room separation per the M1 plan).
4	//!
5	//! Subcommands (REQ-MSG-2 stable surface): `send` · `ring` · `ready` · `poll` ·
6	//! `list` · `stop` · `whoami`.
7	// [impl->REQ-MSG-2]
8
9	mod cli;
10	mod roster;
11
12	fn main() {
13	    let code = cli::run();
14	    std::process::exit(code);
15	}
16

//! `spt` — the single spt-core binary. A **thin** command layer over `spt-msg`:
//! it owns I/O (stdin/stdout/stderr) and process exit codes; all messaging
//! logic lives in the crate (clean-room separation per the M1 plan).
//!
//! Subcommands (REQ-MSG-2 stable surface): `send` · `ring` · `ready` · `poll` ·
//! `list` · `stop` · `whoami`.
// [impl->REQ-MSG-2]

mod cli;
mod roster;

fn main() {
    let code = cli::run();
    std::process::exit(code);
}


//! Subcommands (REQ-MSG-2 stable surface): `send` · `ring` · `ready` · `poll` ·
//! `list` · `stop` · `whoami`.
// [impl->REQ-MSG-2]

mod cli;
mod roster;

//! Subcommands (REQ-MSG-2 stable surface): `send` · `ring` · `ready` · `poll` ·
//! `list` · `stop` · `whoami`. The harness-contract inbound surface lives under
//! the `api` group (REQ-API-1), dispatched from [`api`].
// [impl->REQ-MSG-2]

mod api;
mod cli;
mod roster;


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

//! Subcommands (REQ-MSG-2 stable surface): `send` · `ring` · `ready` · `poll` ·
//! `list` · `stop` · `whoami`. The harness-contract inbound surface lives under
//! the `api` group (REQ-API-1), dispatched from [`api`].
// [impl->REQ-MSG-2]

mod api;
mod cli;
mod roster;

//! `spt` — the single spt-core binary. A **thin** command layer over `spt-msg`:
//! it owns I/O (stdin/stdout/stderr) and process exit codes; all messaging
//! logic lives in the crate (clean-room separation per the M1 plan).
//!
//! Subcommands (REQ-MSG-2 stable surface): `send` · `ring` · `ready` · `poll` ·
//! `list` · `stop` · `whoami`.
// [impl->REQ-MSG-2]

mod cli;
mod roster;

fn main() {
    let code = cli::run();
    std::process::exit(code);
}

+//! `list` · `stop` · `whoami`. The harness-contract inbound surface lives under

+//! the `api` group (REQ-API-1), dispatched from [`api`].


Now wire the `Api` variant into cli.rs.


    /// Print this session's own perch id (from $OWL_SESSION_ID / $SPT_AGENT_ID).
    Whoami,
}

    /// Print this session's own perch id (from $OWL_SESSION_ID / $SPT_AGENT_ID).
    Whoami,
    /// Harness-contract inbound surface (REQ-API-1): the entry points a
    /// harness's hooks fire to mutate on-disk SPT state.
    Api(crate::api::ApiArgs),
}


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

    /// Print this session's own perch id (from $OWL_SESSION_ID / $SPT_AGENT_ID).
    Whoami,
}

    /// Print this session's own perch id (from $OWL_SESSION_ID / $SPT_AGENT_ID).
    Whoami,
    /// Harness-contract inbound surface (REQ-API-1): the entry points a
    /// harness's hooks fire to mutate on-disk SPT state.
    Api(crate::api::ApiArgs),
}

+    /// Harness-contract inbound surface (REQ-API-1): the entry points a

+    /// harness's hooks fire to mutate on-disk SPT state.

/// Parse args and dispatch. Returns the process exit code.


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


        Cmd::Stop { id } => cmd_stop(&id),
        Cmd::Whoami => cmd_whoami(),
    }
}

        Cmd::Stop { id } => cmd_stop(&id),
        Cmd::Whoami => cmd_whoami(),
        Cmd::Api(args) => crate::api::run(args),
    }
}


        Cmd::Stop { id } => cmd_stop(&id),
        Cmd::Whoami => cmd_whoami(),
    }
}

        Cmd::Stop { id } => cmd_stop(&id),
        Cmd::Whoami => cmd_whoami(),
        Cmd::Api(args) => crate::api::run(args),
    }
}

+        Cmd::Api(args) => crate::api::run(args),


Bash

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

Build + test spt binary


test api::tests::state_value_is_validated ... FAILED
test api::tests::surface_parses ... FAILED
test cli::tests::subcommands_parse ... ok
test api::auth::tests::session_id_proof_authenticates ... ok
test api::auth::tests::missing_endpoint_is_no_endpoint ... ok
test api::auth::tests::no_proof_is_refused ... ok
test api::auth::tests::token_proof_authenticates ... ok
test api::auth::tests::wrong_token_and_wrong_session_refused ... ok

failures:

---- api::tests::state_value_is_validated stdout ----

thread 'api::tests::state_value_is_validated' (52652) panicked at C:\Users\decid\.cargo\registry\src\index.crates.io-1949cf8c6b5b557f\clap_builder-4.6.0\src\builder\debug_asserts.rs:99:13:
Command boundary: Argument names must be unique, but 'session_id' is in use by more than one argument or group
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

---- api::tests::surface_parses stdout ----

thread 'api::tests::surface_parses' (2644) panicked at C:\Users\decid\.cargo\registry\src\index.crates.io-1949cf8c6b5b557f\clap_builder-4.6.0\src\builder\debug_asserts.rs:99:13:
Command boundary: Argument names must be unique, but 'session_id' is in use by more than one argument or group

failures:
    api::tests::state_value_is_validated
    api::tests::surface_parses

test result: FAILED. 11 passed; 2 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s

error: test failed, to rerun pass `-p spt --bin spt`


Clap arg-name collision (`session_id` in both AuthFlags and Bind/Boundary). Rename the command-specific ones.


Edit

    /// Post-spawn bind of a session to its perch (T4).
    Bind {
        id: String,
        #[arg(long)]
        session_id: Option<String>,
        #[command(flatten)]
        auth: AuthFlags,
    },

    /// Post-spawn bind of a session to its perch (T4).
    Bind {
        id: String,
        /// The session id discovered post-spawn, written into the perch record.
        #[arg(long = "set-session-id")]
        session_id: Option<String>,
        #[command(flatten)]
        auth: AuthFlags,
    },


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)

    /// Post-spawn bind of a session to its perch (T4).
    Bind {
        id: String,
        #[arg(long)]
        session_id: Option<String>,
        #[command(flatten)]
        auth: AuthFlags,
    },

    /// Post-spawn bind of a session to its perch (T4).
    Bind {
        id: String,
        /// The session id discovered post-spawn, written into the perch record.
        #[arg(long = "set-session-id")]
        session_id: Option<String>,
        #[command(flatten)]
        auth: AuthFlags,
    },

+        /// The session id discovered post-spawn, written into the perch record.

+        #[arg(long = "set-session-id")]


    /// Rebind the perch to a new session_id, preserving identity (T7).
    Boundary {
        #[arg(value_parser = ["clear", "compact"])]
        mode: String,
        id: String,
        #[arg(long)]
        session_id: String,
        #[command(flatten)]
        auth: AuthFlags,
    },

    /// Rebind the perch to a new session_id, preserving identity (T7).
    Boundary {
        #[arg(value_parser = ["clear", "compact"])]
        mode: String,
        id: String,
        /// The new session id to rebind the perch to.
        #[arg(long = "to-session-id")]
        session_id: String,
        #[command(flatten)]
        auth: AuthFlags,
    },


    /// Rebind the perch to a new session_id, preserving identity (T7).
    Boundary {
        #[arg(value_parser = ["clear", "compact"])]
        mode: String,
        id: String,
        #[arg(long)]
        session_id: String,
        #[command(flatten)]
        auth: AuthFlags,
    },

    /// Rebind the perch to a new session_id, preserving identity (T7).
    Boundary {
        #[arg(value_parser = ["clear", "compact"])]
        mode: String,
        id: String,
        /// The new session id to rebind the perch to.
        #[arg(long = "to-session-id")]
        session_id: String,
        #[command(flatten)]
        auth: AuthFlags,
    },

+        /// The new session id to rebind the perch to.


assert!(parse(&["spt", "--adapter", "m", "boundary", "compact", "alice", "--session-id", "s2"]).is_ok());

assert!(parse(&["spt", "--adapter", "m", "boundary", "compact", "alice", "--to-session-id", "s2"]).is_ok());


assert!(parse(&["spt", "--adapter", "m", "state", "idle", "alice"]).is_ok());

assert!(parse(&["spt", "--adapter", "m", "poll", "alice", "--include-deferred"]).is_ok());

assert!(parse(&["spt", "--adapter", "m", "worker-start", "alice", "alice-w1"]).is_ok());

-        assert!(parse(&["spt", "--adapter", "m", "boundary", "compact", "alice", "--session-id", "s2"]).is_ok());

+        assert!(parse(&["spt", "--adapter", "m", "boundary", "compact", "alice", "--to-session-id", "s2"]).is_ok());

assert!(parse(&["spt", "--adapter", "m", "session-end", "alice", "--erase"]).is_ok());


assert!(parse(&["spt", "--adapter", "m", "boundary", "explode", "alice", "--session-id", "s"]).is_err());

assert!(parse(&["spt", "--adapter", "m", "boundary", "explode", "alice", "--to-session-id", "s"]).is_err());


#[test] // [unit->REQ-API-1] closed-vocabulary args reject bad values.

assert!(parse(&["spt", "--adapter", "m", "state", "sideways", "alice"]).is_err());

-        assert!(parse(&["spt", "--adapter", "m", "boundary", "explode", "alice", "--session-id", "s"]).is_err());

+        assert!(parse(&["spt", "--adapter", "m", "boundary", "explode", "alice", "--to-session-id", "s"]).is_err());


Bash

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

Re-test spt binary


note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

---- api::tests::surface_parses stdout ----

thread 'api::tests::surface_parses' (51156) panicked at C:\Users\decid\.cargo\registry\src\index.crates.io-1949cf8c6b5b557f\clap_builder-4.6.0\src\builder\debug_asserts.rs:99:13:
Command boundary: Argument names must be unique, but 'session_id' is in use by more than one argument or group

failures:
    api::tests::state_value_is_validated
    api::tests::surface_parses

test result: FAILED. 11 passed; 2 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.03s

error: test failed, to rerun pass `-p spt --bin spt`


Clap derives arg id from field name, not `long`. Rename the fields themselves.


Edit

        /// The session id discovered post-spawn, written into the perch record.
        #[arg(long = "set-session-id")]
        session_id: Option<String>,
        #[command(flatten)]
        auth: AuthFlags,
    },
    /// Set activity state busy|idle (+ echo-gate sentinel) (T5).

        /// The session id discovered post-spawn, written into the perch record.
        #[arg(long = "set-session-id")]
        bind_session: Option<String>,
        #[command(flatten)]
        auth: AuthFlags,
    },
    /// Set activity state busy|idle (+ echo-gate sentinel) (T5).


        /// The session id discovered post-spawn, written into the perch record.
        #[arg(long = "set-session-id")]
        session_id: Option<String>,
        #[command(flatten)]
        auth: AuthFlags,
    },
    /// Set activity state busy|idle (+ echo-gate sentinel) (T5).

        /// The session id discovered post-spawn, written into the perch record.
        #[arg(long = "set-session-id")]
        bind_session: Option<String>,
        #[command(flatten)]
        auth: AuthFlags,
    },
    /// Set activity state busy|idle (+ echo-gate sentinel) (T5).


        /// The new session id to rebind the perch to.
        #[arg(long = "to-session-id")]
        session_id: String,

        /// The new session id to rebind the perch to.
        #[arg(long = "to-session-id")]
        to_session: String,


        /// The new session id to rebind the perch to.
        #[arg(long = "to-session-id")]
        session_id: String,

        /// The new session id to rebind the perch to.
        #[arg(long = "to-session-id")]
        to_session: String,


Bash

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

Re-test spt binary


test api::tests::adapter_is_required ... ok
test cli::tests::invalid_usage_errors ... ok
test api::tests::state_value_is_validated ... ok
test api::tests::surface_parses ... ok
test cli::tests::subcommands_parse ... ok
test api::auth::tests::wrong_token_and_wrong_session_refused ... ok
test api::auth::tests::session_id_proof_authenticates ... ok
test api::auth::tests::token_proof_authenticates ... ok
test api::auth::tests::no_proof_is_refused ... ok

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


All 13 green. Activate REQ-API-1 + REQ-HAZARD-LOCAL-API-AUTH.


cd "C:\Users\decid\Documents\projects\spt-core" && grep -n "REQ-API-1\b\|LOCAL-API-AUTH" -A2 traceable-reqs.toml

Locate req entries


101:id = "REQ-API-1"
102-title = "api prefix and adapter_name on every machinery invocation"
103-required_stages = []
--
445:id = "REQ-HAZARD-LOCAL-API-AUTH"
446-title = "Every local `api` mutation authenticated to an endpoint/session (codex #13)"
447-required_stages = []


100	[[requirements]]
101	id = "REQ-API-1"
102	title = "api prefix and adapter_name on every machinery invocation"
103	required_stages = []

[[requirements]]
id = "REQ-API-1"
title = "api prefix and adapter_name on every machinery invocation"
required_stages = []


444	[[requirements]]
445	id = "REQ-HAZARD-LOCAL-API-AUTH"
446	title = "Every local `api` mutation authenticated to an endpoint/session (codex #13)"
447	required_stages = []

[[requirements]]
id = "REQ-HAZARD-LOCAL-API-AUTH"
title = "Every local `api` mutation authenticated to an endpoint/session (codex #13)"
required_stages = []


Edit

id = "REQ-API-1"
title = "api prefix and adapter_name on every machinery invocation"
required_stages = []

id = "REQ-API-1"
title = "api prefix and adapter_name on every machinery invocation"
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-API-1"
title = "api prefix and adapter_name on every machinery invocation"
required_stages = []

id = "REQ-API-1"
title = "api prefix and adapter_name on every machinery invocation"
required_stages = ["impl", "unit"]

title = "The api subcommand surface (bind/listen/poll/state/worker/boundary/...)"


id = "REQ-HAZARD-LOCAL-API-AUTH"
title = "Every local `api` mutation authenticated to an endpoint/session (codex #13)"
required_stages = []

id = "REQ-HAZARD-LOCAL-API-AUTH"
title = "Every local `api` mutation authenticated to an endpoint/session (codex #13)"
required_stages = ["impl", "unit"]


id = "REQ-HAZARD-LOCAL-API-AUTH"
title = "Every local `api` mutation authenticated to an endpoint/session (codex #13)"
required_stages = []

id = "REQ-HAZARD-LOCAL-API-AUTH"
title = "Every local `api` mutation authenticated to an endpoint/session (codex #13)"
required_stages = ["impl", "unit"]

title = "Idempotent/exactly-once delivery across brain restart at every broker boundary (codex #14)"


Bash

cd "C:\Users\decid\Documents\projects\spt-core" && traceable-reqs check 2>&1 | grep -i "API-1\|LOCAL-API-AUTH"; traceable-reqs check >/dev/null 2>&1; echo "exit:$?"

Verify coverage


  [OK] REQ-API-1  required: [impl, unit]  stages: -doc +impl +unit -int
  [OK] REQ-HAZARD-LOCAL-API-AUTH  required: [impl, unit]  stages: -doc +impl +unit -int
exit:0


Both covered. Commit T3.


cd "C:\Users\decid\Documents\projects\spt-core" && git add -A && git commit -q -F - <<'EOF'
feat(spt): M2a T3 — `spt api` dispatch group + local-api-auth

The inbound half of the harness contract opens as the `api` subcommand group.

- ApiArgs: `--adapter <name>` mandatory on every call (adapter_name threaded
  through Ctx); optional `--manifest` loaded+validated up front (fails fast,
  adapter-name match enforced) so a bad contract never reaches state mutation.
- Full surface declared so `api --help` lists it (seed/listen/bind/state/
  echo-gate/poll/worker-*/boundary/session-end/presence/history-log/emit/
  capability); closed-vocab args (state, boundary mode) reject bad values.
  Handlers for T4-T7 land per task; until then they return exit 3.
- auth.rs (REQ-HAZARD-LOCAL-API-AUTH, codex #13): every mutation routes through
  gated() → authenticate(), which accepts a per-perch capability token (minted
  from spt-proto's Ed25519 RNG, constant-time compared) OR the session_id in
  the perch's info.json. No proof / no endpoint ⇒ refused, exit 1.

REQ-API-1 + REQ-HAZARD-LOCAL-API-AUTH activated [impl, unit]. 13 spt tests
(6 auth, parse/required-adapter/closed-vocab). cargo test green; check green.

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

Commit T3


warning: in the working copy of 'Cargo.lock', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt/Cargo.toml', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt/src/cli.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/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
281eb8d feat(spt): M2a T3 — `spt api` dispatch group + local-api-auth


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


T3 done. T4 = startup flows — need CONTEXT's authoritative model. Read it.


cd "C:\Users\decid\Documents\projects\spt-core" && grep -n "seed\|listen\|bind\|Startup\|startup\|parent_pid\|harness-hosted\|post.spawn\|post_spawn" CONTEXT.md | head -40

Find startup-flow sections in CONTEXT