1	//! Per-endpoint **session ledger** — `<perch>/sessions.log` (ADR-0019 /
2	//! REQ-TERM-6), heir to the sister project's `psyches/tracked/agents/<id>/
3	//! sessions.log`.
4	//!
5	//! A live agent that `/clear`s or `/compact`s keeps its perch identity but
6	//! rotates its harness `session_id` (a new transcript file). The single
7	//! `{session_id}` a `[history]`/`[digest]` source resolves therefore *follows*
8	//! the thread but cannot *span* it. This ledger records each rotation so the
9	//! digest can enumerate the **last K sessions** and bridge a boundary
10	//! (ADR-0019 §thread-spanning).
11	//!
12	//! One JSONL row per rotation: `{ts, session_id, trigger}`. The ledger is
13	//! **bounded** ([`MAX_LEDGER`]) — the sister project's seal-on-boot failure grew
14	//! its equivalent unboundedly (DIGEST-MILESTONE-PLAN risk #2); appending rotates
15	//! the oldest rows out. A re-bind of the *same* session is not a new boundary and
16	//! is deduped.
17	// [impl->REQ-TERM-6]
18
19	use std::io;
20	use std::path::Path;
21
22	use serde::{Deserialize, Serialize};
23
24	use crate::atomic::atomic_write_string;
25
26	/// The ledger filename under a perch.
27	pub const LEDGER_FILE: &str = "sessions.log";
28
29	/// On-disk row cap. The ledger is a rolling tail — older rows rotate out on
30	/// append so the file can never grow without bound (risk #2).
31	pub const MAX_LEDGER: usize = 64;
32
33	/// Why a session became active.
34	#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
35	#[serde(rename_all = "lowercase")]
36	pub enum SessionTrigger {
37	    /// First bind of the perch (`api listen` / revive).
38	    Boot,
39	    /// A `/clear` boundary (`api boundary clear`).
40	    Clear,
41	    /// A `/compact` boundary (`api boundary compact`).
42	    Compact,
43	}
44
45	impl SessionTrigger {
46	    /// The lowercase token (matches the serde wire form) — used as the boundary
47	    /// marker `kind` in the spanned digest.
48	    pub fn token(self) -> &'static str {
49	        match self {
50	            SessionTrigger::Boot => "boot",
51	            SessionTrigger::Clear => "clear",
52	            SessionTrigger::Compact => "compact",
53	        }
54	    }
55	}
56
57	/// One ledger row: a `session_id` became active at `ts` via `trigger`.
58	#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
59	pub struct SessionEntry {
60	    /// RFC3339-UTC instant (see [`crate::timefmt`]).
61	    pub ts: String,
62	    /// The harness session id that became active.
63	    pub session_id: String,
64	    /// What caused the rotation.
65	    pub trigger: SessionTrigger,
66	    /// The project working directory the session was active in (the W3
67	    /// `info.cwd` set at bind, or the spawn cwd). **Additive serde-default**: a
68	    /// pre-migration row missing `cwd` deserializes as `None`. A harness resolves
69	    /// a transcript by `session_id` + `cwd`, so picker Resume-from-history
70	    /// (cross-project rows) restores the right dir from here.
71	    // [impl->REQ-SESSION-RESUME-TEMPLATE]
72	    #[serde(default, skip_serializing_if = "Option::is_none")]
73	    pub cwd: Option<String>,
74	}
75
76	/// Every ledger row in order (oldest→newest). Tolerant: a blank or unparseable
77	/// line is skipped, the rest still load (the degrade-don't-crash store stance).
78	pub fn read_all(perch: &Path) -> Vec<SessionEntry> {
79	    let Ok(s) = std::fs::read_to_string(perch.join(LEDGER_FILE)) else {
80	        return Vec::new();
81	    };
82	    s.lines()
83	        .filter(|l| !l.trim().is_empty())
84	        .filter_map(|l| serde_json::from_str(l).ok())
85	        .collect()
86	}
87
88	/// The last `k` rows (oldest→newest within the tail). `k == 0` ⇒ empty.
89	pub fn last_k(perch: &Path, k: usize) -> Vec<SessionEntry> {
90	    if k == 0 {
91	        return Vec::new();
92	    }
93	    let all = read_all(perch);
94	    let start = all.len().saturating_sub(k);
95	    all[start..].to_vec()
96	}
97
98	/// Append `entry`, deduping an immediate repeat of the same `session_id` (a
99	/// re-bind of the live session is not a new boundary) and bounding the ledger to
100	/// [`MAX_LEDGER`] (oldest rows rotate out). The whole bounded ledger is rewritten
101	/// atomically (it is tiny — ≤ `MAX_LEDGER` short rows — so a full rewrite avoids
102	/// partial-line corruption). Returns `true` if a row was written, `false` on a
103	/// same-session dedup.
104	// [impl->REQ-TERM-6]
105	pub fn append(perch: &Path, entry: &SessionEntry) -> io::Result<bool> {
106	    let mut entries = read_all(perch);
107	    if entries.last().map(|e| e.session_id.as_str()) == Some(entry.session_id.as_str()) {
108	        return Ok(false); // same session re-bound — not a new boundary
109	    }
110	    entries.push(entry.clone());
111	    let start = entries.len().saturating_sub(MAX_LEDGER);
112	    let body: String = entries[start..]
113	        .iter()
114	        .map(|e| serde_json::to_string(e).unwrap_or_default())
115	        .map(|l| l + "\n")
116	        .collect();
117	    std::fs::create_dir_all(perch)?;
118	    atomic_write_string(&perch.join(LEDGER_FILE), &body)?;
119	    Ok(true)
120	}
121
122	#[cfg(test)]
123	mod tests {
124	    use super::*;
125
126	    fn entry(sid: &str, trigger: SessionTrigger) -> SessionEntry {
127	        SessionEntry {
128	            ts: crate::timefmt::rfc3339_utc(1_700_000_000),
129	            session_id: sid.to_string(),
130	            trigger,
131	            cwd: None,
132	        }
133	    }
134
135	    // [unit->REQ-TERM-6] rows append in order and read back oldest→newest.
136	    #[test]
137	    fn appends_and_reads_in_order() {
138	        let dir = tempfile::tempdir().unwrap();
139	        let perch = dir.path().join("perch");
140	        assert!(append(&perch, &entry("s1", SessionTrigger::Boot)).unwrap());
141	        assert!(append(&perch, &entry("s2", SessionTrigger::Clear)).unwrap());
142	        let all = read_all(&perch);
143	        assert_eq!(all.len(), 2);
144	        assert_eq!(all[0].session_id, "s1");
145	        assert_eq!(all[0].trigger, SessionTrigger::Boot);
146	        assert_eq!(all[1].session_id, "s2");
147	        assert_eq!(all[1].trigger, SessionTrigger::Clear);
148	    }
149
150	    // [unit->REQ-TERM-6] a re-bind of the same session is deduped (not a new
151	    // boundary); a different session appends.
152	    #[test]
153	    fn same_session_rebind_is_deduped() {
154	        let dir = tempfile::tempdir().unwrap();
155	        let perch = dir.path().join("perch");
156	        assert!(append(&perch, &entry("s1", SessionTrigger::Boot)).unwrap());
157	        assert!(
158	            !append(&perch, &entry("s1", SessionTrigger::Boot)).unwrap(),
159	            "same session re-bind → no new row"
160	        );
161	        assert_eq!(read_all(&perch).len(), 1);
162	        assert!(append(&perch, &entry("s2", SessionTrigger::Clear)).unwrap());
163	        assert_eq!(read_all(&perch).len(), 2);
164	    }
165
166	    // [unit->REQ-TERM-6] last_k returns the newest K rows; the ledger is bounded so
167	    // it never grows past MAX_LEDGER.
168	    #[test]
169	    fn last_k_and_bounding() {
170	        let dir = tempfile::tempdir().unwrap();
171	        let perch = dir.path().join("perch");
172	        for i in 0..(MAX_LEDGER + 10) {
173	            append(&perch, &entry(&format!("s{i}"), SessionTrigger::Clear)).unwrap();
174	        }
175	        assert_eq!(read_all(&perch).len(), MAX_LEDGER, "ledger is bounded");
176	        let last3 = last_k(&perch, 3);
177	        assert_eq!(last3.len(), 3);
178	        let n = MAX_LEDGER + 10;
179	        assert_eq!(last3[2].session_id, format!("s{}", n - 1), "newest last");
180	        assert_eq!(last3[0].session_id, format!("s{}", n - 3));
181	        assert!(last_k(&perch, 0).is_empty());
182	    }
183
184	    // [unit->REQ-TERM-6] a blank/garbage line is skipped, the valid rows still load.
185	    #[test]
186	    fn tolerant_of_garbage_lines() {
187	        let dir = tempfile::tempdir().unwrap();
188	        let perch = dir.path().join("perch");
189	        std::fs::create_dir_all(&perch).unwrap();
190	        std::fs::write(
191	            perch.join(LEDGER_FILE),
192	            "not json\n\n{\"ts\":\"t\",\"session_id\":\"good\",\"trigger\":\"boot\"}\n",
193	        )
194	        .unwrap();
195	        let all = read_all(&perch);
196	        assert_eq!(all.len(), 1);
197	        assert_eq!(all[0].session_id, "good");
198	    }
199	}
200

//! Per-endpoint **session ledger** — `<perch>/sessions.log` (ADR-0019 /
//! REQ-TERM-6), heir to the sister project's `psyches/tracked/agents/<id>/
//! sessions.log`.
//!
//! A live agent that `/clear`s or `/compact`s keeps its perch identity but
//! rotates its harness `session_id` (a new transcript file). The single
//! `{session_id}` a `[history]`/`[digest]` source resolves therefore *follows*
//! the thread but cannot *span* it. This ledger records each rotation so the
//! digest can enumerate the **last K sessions** and bridge a boundary
//! (ADR-0019 §thread-spanning).
//!
//! One JSONL row per rotation: `{ts, session_id, trigger}`. The ledger is
//! **bounded** ([`MAX_LEDGER`]) — the sister project's seal-on-boot failure grew
//! its equivalent unboundedly (DIGEST-MILESTONE-PLAN risk #2); appending rotates
//! the oldest rows out. A re-bind of the *same* session is not a new boundary and
//! is deduped.
// [impl->REQ-TERM-6]

use std::io;
use std::path::Path;

use serde::{Deserialize, Serialize};

use crate::atomic::atomic_write_string;

/// The ledger filename under a perch.
pub const LEDGER_FILE: &str = "sessions.log";

/// On-disk row cap. The ledger is a rolling tail — older rows rotate out on
/// append so the file can never grow without bound (risk #2).
pub const MAX_LEDGER: usize = 64;

/// Why a session became active.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SessionTrigger {
    /// First bind of the perch (`api listen` / revive).
    Boot,
    /// A `/clear` boundary (`api boundary clear`).
    Clear,
    /// A `/compact` boundary (`api boundary compact`).
    Compact,
}

impl SessionTrigger {
    /// The lowercase token (matches the serde wire form) — used as the boundary
    /// marker `kind` in the spanned digest.
    pub fn token(self) -> &'static str {
        match self {
            SessionTrigger::Boot => "boot",
            SessionTrigger::Clear => "clear",
            SessionTrigger::Compact => "compact",
        }
    }
}

/// One ledger row: a `session_id` became active at `ts` via `trigger`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SessionEntry {
    /// RFC3339-UTC instant (see [`crate::timefmt`]).
    pub ts: String,
    /// The harness session id that became active.
    pub session_id: String,
    /// What caused the rotation.
    pub trigger: SessionTrigger,
    /// The project working directory the session was active in (the W3
    /// `info.cwd` set at bind, or the spawn cwd). **Additive serde-default**: a
    /// pre-migration row missing `cwd` deserializes as `None`. A harness resolves
    /// a transcript by `session_id` + `cwd`, so picker Resume-from-history
    /// (cross-project rows) restores the right dir from here.
    // [impl->REQ-SESSION-RESUME-TEMPLATE]
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub cwd: Option<String>,
}

/// Every ledger row in order (oldest→newest). Tolerant: a blank or unparseable
/// line is skipped, the rest still load (the degrade-don't-crash store stance).
pub fn read_all(perch: &Path) -> Vec<SessionEntry> {
    let Ok(s) = std::fs::read_to_string(perch.join(LEDGER_FILE)) else {
        return Vec::new();
    };
    s.lines()
        .filter(|l| !l.trim().is_empty())
        .filter_map(|l| serde_json::from_str(l).ok())
        .collect()
}

/// The last `k` rows (oldest→newest within the tail). `k == 0` ⇒ empty.
pub fn last_k(perch: &Path, k: usize) -> Vec<SessionEntry> {
    if k == 0 {
        return Vec::new();
    }
    let all = read_all(perch);
    let start = all.len().saturating_sub(k);
    all[start..].to_vec()
}

/// Append `entry`, deduping an immediate repeat of the same `session_id` (a
/// re-bind of the live session is not a new boundary) and bounding the ledger to
/// [`MAX_LEDGER`] (oldest rows rotate out). The whole bounded ledger is rewritten
/// atomically (it is tiny — ≤ `MAX_LEDGER` short rows — so a full rewrite avoids
/// partial-line corruption). Returns `true` if a row was written, `false` on a
/// same-session dedup.
// [impl->REQ-TERM-6]
pub fn append(perch: &Path, entry: &SessionEntry) -> io::Result<bool> {
    let mut entries = read_all(perch);
    if entries.last().map(|e| e.session_id.as_str()) == Some(entry.session_id.as_str()) {
        return Ok(false); // same session re-bound — not a new boundary
    }
    entries.push(entry.clone());
    let start = entries.len().saturating_sub(MAX_LEDGER);
    let body: String = entries[start..]
        .iter()
        .map(|e| serde_json::to_string(e).unwrap_or_default())
        .map(|l| l + "\n")
        .collect();
    std::fs::create_dir_all(perch)?;
    atomic_write_string(&perch.join(LEDGER_FILE), &body)?;
    Ok(true)
}

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

    fn entry(sid: &str, trigger: SessionTrigger) -> SessionEntry {
        SessionEntry {
            ts: crate::timefmt::rfc3339_utc(1_700_000_000),
            session_id: sid.to_string(),
            trigger,
            cwd: None,
        }
    }

    // [unit->REQ-TERM-6] rows append in order and read back oldest→newest.
    #[test]
    fn appends_and_reads_in_order() {
        let dir = tempfile::tempdir().unwrap();
        let perch = dir.path().join("perch");
        assert!(append(&perch, &entry("s1", SessionTrigger::Boot)).unwrap());
        assert!(append(&perch, &entry("s2", SessionTrigger::Clear)).unwrap());
        let all = read_all(&perch);
        assert_eq!(all.len(), 2);
        assert_eq!(all[0].session_id, "s1");
        assert_eq!(all[0].trigger, SessionTrigger::Boot);
        assert_eq!(all[1].session_id, "s2");
        assert_eq!(all[1].trigger, SessionTrigger::Clear);
    }

    // [unit->REQ-TERM-6] a re-bind of the same session is deduped (not a new
    // boundary); a different session appends.
    #[test]
    fn same_session_rebind_is_deduped() {
        let dir = tempfile::tempdir().unwrap();
        let perch = dir.path().join("perch");
        assert!(append(&perch, &entry("s1", SessionTrigger::Boot)).unwrap());
        assert!(
            !append(&perch, &entry("s1", SessionTrigger::Boot)).unwrap(),
            "same session re-bind → no new row"
        );
        assert_eq!(read_all(&perch).len(), 1);
        assert!(append(&perch, &entry("s2", SessionTrigger::Clear)).unwrap());
        assert_eq!(read_all(&perch).len(), 2);
    }

    // [unit->REQ-TERM-6] last_k returns the newest K rows; the ledger is bounded so
    // it never grows past MAX_LEDGER.
    #[test]
    fn last_k_and_bounding() {
        let dir = tempfile::tempdir().unwrap();
        let perch = dir.path().join("perch");
        for i in 0..(MAX_LEDGER + 10) {
            append(&perch, &entry(&format!("s{i}"), SessionTrigger::Clear)).unwrap();
        }
        assert_eq!(read_all(&perch).len(), MAX_LEDGER, "ledger is bounded");
        let last3 = last_k(&perch, 3);
        assert_eq!(last3.len(), 3);
        let n = MAX_LEDGER + 10;
        assert_eq!(last3[2].session_id, format!("s{}", n - 1), "newest last");
        assert_eq!(last3[0].session_id, format!("s{}", n - 3));
        assert!(last_k(&perch, 0).is_empty());
    }

    // [unit->REQ-TERM-6] a blank/garbage line is skipped, the valid rows still load.
    #[test]
    fn tolerant_of_garbage_lines() {
        let dir = tempfile::tempdir().unwrap();
        let perch = dir.path().join("perch");
        std::fs::create_dir_all(&perch).unwrap();
        std::fs::write(
            perch.join(LEDGER_FILE),
            "not json\n\n{\"ts\":\"t\",\"session_id\":\"good\",\"trigger\":\"boot\"}\n",
        )
        .unwrap();
        let all = read_all(&perch);
        assert_eq!(all.len(), 1);
        assert_eq!(all[0].session_id, "good");
    }
}


1	//! Harness-instance hosting (M12-W1 — spt-hosted harness bringup, REQ-HOST-RUN-1):
2	//! the spt-HOSTED counterpart to harness-hosted launch.
3	//!
4	//! **Who spawns the harness.** Today a harness is *harness-hosted*: an external
5	//! launcher runs the adapter's `[session.self]` command and the harness binds its
6	//! own perch on startup (`api listen`/`api bind` → `establish_perch`). spt-hosting
7	//! changes *only who owns the PTY*: the daemon spawns `[session.self]` into a
8	//! **broker-held PTY** ([`launch_harness_brokered_in`]). The harness still
9	//! self-registers its perch on bind — there is no daemon pre-register step.
10	//!
11	//! **Why broker-held.** The broker owns the PTY, so the harness survives a
12	//! `spt rc` detach — PTY ownership stays with the broker, no termination on
13	//! detach (the KNOWN-HAZARDS attach-lifecycle invariant, satisfied by
14	//! construction). The broker session is labeled with the endpoint id so
15	//! `spt rc <id>` resolves exactly this PTY.
16	//!
17	//! **Id plumbing.** The endpoint id reaches the harness two ways, both filled
18	//! from the SAME `{key}` catalog: argv via `{id}` substitution into the opaque
19	//! `[session.self]` command (the harness parses `--id`), AND the manifest
20	//! `[env.<VAR>]` direction = inject values (e.g. `SPT_ENDPOINT_ID = "{id}"`),
21	//! which are `{key}`-substituted and passed in `SpawnReq.env` so the broker sets
22	//! them on the spawned child (REQ-HAZARD-ENV-SUBST). A flagless harness whose
23	//! `[session.self]` has no argv slot for `{id}` (e.g. bare `claude`) relies on
24	//! the env inject — which previously arrived EMPTY (the `[env]` value was never
25	//! substituted nor applied), so the harness seeded-by-PPID instead of binding →
26	//! a zero-perch `NO_PERCH`. This mirrors [`crate::shellhost::launch_shell_brokered_in`]
27	//! but feeds `[session.self]` (not `[shell].spawn`), uses real terminal PTY dims,
28	//! and mints no link token (a harness binds via `api`, not the shell link
29	//! handshake).
30	// [impl->REQ-HOST-RUN-1]
31
32	use std::collections::BTreeMap;
33
34	use spt_runtime::manifest::{AdapterKind, EnvDirection, Manifest};
35
36	use crate::brain::{now_ms, Brain};
37	use crate::msg::SpawnReq;
38
39	/// Default PTY geometry for a freshly brought-up harness, before an attach
40	/// resizes it to the operator's real terminal. A harness *is* a terminal (unlike
41	/// a shell's wide anti-wrap stdin conduit), so a conventional 80×24 is right.
42	pub const DEFAULT_HARNESS_ROWS: u16 = 24;
43	pub const DEFAULT_HARNESS_COLS: u16 = 80;
44
45	/// Outcome of an spt-hosted harness bringup: the broker session id (the attach
46	/// handle) plus the OS pid of the spawned harness (`None` if the broker backend
47	/// exposed none).
48	#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49	pub struct HarnessSpawned {
50	    pub session_id: u64,
51	    pub pid: Option<u32>,
52	}
53
54	/// Mint a provisional spawn-time session id (16 hex chars). The *real* session id
55	/// is reported post-spawn by the harness (`identity.session_id_source =
56	/// "post_spawn"`); this only fills the `{session_id}` placeholder at spawn so the
57	/// command template resolves. A fresh create mints one; `--resume` supplies the
58	/// prior session id instead.
59	pub fn mint_session_id() -> String {
60	    let seed = spt_proto::identity::Identity::generate().seed();
61	    seed[..8].iter().map(|b| format!("{b:02x}")).collect()
62	}
63
64	/// Validate the adapter and fill its session command for one endpoint, returning
65	/// the tokenized `program + args`. Pure (no spawn) — the broker call in
66	/// [`launch_harness_brokered_in`] is the only side effect. Fails closed: a
67	/// non-harness adapter, a missing role command, an unresolved `{placeholder}`,
68	/// or an empty command all error before anything is spawned.
69	///
70	/// `is_resume`: select the NATIVE-resume role over the fresh one — when `true`
71	/// AND the adapter declares `[session.resume]`, the resume template is filled
72	/// (the harness runs e.g. `claude -r {session_id}` instead of a fresh
73	/// `claude --session-id {session_id}`, which would start a blank transcript). A
74	/// `true` with NO `[session.resume]` declared FALLS BACK to `[session.self]`
75	/// (full back-compat); a `false` always uses `[session.self]`.
76	// [impl->REQ-HOST-RUN-1]
77	// [impl->REQ-SESSION-RESUME-TEMPLATE]
78	pub fn prepare_harness_spawn(
79	    endpoint_id: &str,
80	    adapter_name: &str,
81	    session_id: &str,
82	    manifest: &Manifest,
83	    is_resume: bool,
84	) -> Result<PreparedSpawn, String> {
85	    if manifest.adapter.kind != AdapterKind::Harness {
86	        return Err(format!(
87	            "adapter '{adapter_name}' is kind={:?}, not a harness — \
88	             `spt endpoint run` brings up harness endpoints",
89	            manifest.adapter.kind
90	        ));
91	    }
92	    // Resume selects `[session.resume]` when declared, else falls back to
93	    // `[session.self]` (an adapter with no resume sibling behaves exactly as
94	    // today). A fresh bringup always uses `[session.self]`.
95	    let role = if is_resume {
96	        manifest
97	            .session
98	            .resume
99	            .as_ref()
100	            .or(manifest.session.self_.as_ref())
101	    } else {
102	        manifest.session.self_.as_ref()
103	    }
104	    .ok_or_else(|| {
105	        format!("adapter '{adapter_name}' declares no [session.self] — nothing to bring up")
106	    })?;
107	    // The guaranteed-fill catalog for a self-spawn. `id` rides argv so the harness
108	    // binds to exactly this endpoint; `session_name` defaults to the id. The SAME
109	    // catalog fills the [env] inject values (REQ-HAZARD-ENV-SUBST).
110	    let keys = BTreeMap::from([
111	        ("id".to_string(), endpoint_id.to_string()),
112	        ("adapter_name".to_string(), adapter_name.to_string()),
113	        ("session_id".to_string(), session_id.to_string()),
114	        ("session_name".to_string(), endpoint_id.to_string()),
115	    ]);
116	    // [impl->REQ-HAZARD-TEMPLATE-ARGV-FILL] tokenize the template then fill each
117	    // token so a multi-word/quote/semicolon {key} value is one argv element.
118	    let tokens =
119	        spt_runtime::runtime::fill_template_tokens(&role.command, &keys).map_err(|e| e.to_string())?;
120	    if tokens.is_empty() {
121	        return Err("empty session command".into());
122	    }
123	    // [impl->REQ-HAZARD-ENV-SUBST] every [env.<VAR>] direction=inject value is
124	    // {key}-substituted from the SAME catalog and passed to the broker for the
125	    // spawned child's environment — the schema's "Value to inject (with
126	    // substitution)" promise, previously unfulfilled (the value arrived empty, so
127	    // a flagless harness seeded-by-PPID instead of binding → zero perch).
128	    let mut env = BTreeMap::new();
129	    for (name, var) in &manifest.env {
130	        if var.direction == EnvDirection::Inject {
131	            // Validation guarantees an inject directive has a value; fill it.
132	            if let Some(template) = &var.value {
133	                let filled = spt_runtime::runtime::fill_template(template, &keys)
134	                    .map_err(|e| e.to_string())?;
135	                env.insert(name.clone(), filled);
136	            }
137	        }
138	    }
139	    // ADR-0022 / W2: carry the adapter's idle-delivery translation-binary path
140	    // (if declared) so the broker can lifecycle-manage it alongside this session.
141	    // The path is opaque (resolved at spawn like every adapter command).
142	    let translation_binary = manifest
143	        .message_idle_translation_binary
144	        .as_ref()
145	        .map(|b| b.path.clone());
146	    Ok(PreparedSpawn {
147	        tokens,
148	        env,
149	        translation_binary,
150	    })
151	}
152
153	/// The filled spawn inputs for an spt-hosted harness: the tokenized argv, the
154	/// `[env]`-inject map (both filled from the same `{key}` catalog —
155	/// REQ-HAZARD-ENV-SUBST), and the optional idle-delivery translation-binary path
156	/// (ADR-0022) the broker lifecycle-manages.
157	#[derive(Debug, Clone, PartialEq, Eq)]
158	pub struct PreparedSpawn {
159	    pub tokens: Vec<String>,
160	    pub env: BTreeMap<String, String>,
161	    /// `[message-idle-translation-binary].path` if the adapter declares one.
162	    pub translation_binary: Option<String>,
163	}
164
165	/// Spawn an adapter's `[session.self]` harness into a broker-held PTY (the
166	/// spt-hosted bringup). The broker session is labeled `endpoint_id` so
167	/// `spt rc <id>` resolves it; the harness self-registers its perch on bind. The
168	/// broker outlives this call — the session keeps running after it returns.
169	///
170	/// `session_id`: the spawn-time `{session_id}` fill — a provisional [`mint_session_id`]
171	/// for a fresh create, or the prior session for `--resume`.
172	///
173	/// `cwd`: the project-scoped working directory the harness PTY lands in
174	/// (REQ-HOST-RUN-2). `None` inherits the broker's cwd (the W1 bringup shortcut);
175	/// `Some(dir)` is what a project-scoped launcher (`cc <id>` at a project root)
176	/// passes so the harness reads the RIGHT `.claude`/session history/digest source.
177	///
178	/// `is_resume`: forwarded to [`prepare_harness_spawn`] — `true` selects the
179	/// adapter's `[session.resume]` native-resume command (REQ-SESSION-RESUME-TEMPLATE)
180	/// when declared, else falls back to `[session.self]`.
181	// [impl->REQ-HOST-RUN-1]
182	// [impl->REQ-HOST-RUN-2]
183	// [impl->REQ-SESSION-RESUME-TEMPLATE]
184	pub fn launch_harness_brokered_in(
185	    broker_name: &str,
186	    endpoint_id: &str,
187	    adapter_name: &str,
188	    session_id: &str,
189	    manifest: &Manifest,
190	    cwd: Option<&str>,
191	    is_resume: bool,
192	) -> Result<HarnessSpawned, String> {
193	    let PreparedSpawn {
194	        tokens,
195	        env,
196	        translation_binary,
197	    } = prepare_harness_spawn(endpoint_id, adapter_name, session_id, manifest, is_resume)?;
198	    let (program, args) = tokens.split_first().expect("non-empty checked");
199	    let mut brain =
200	        Brain::cold_start(broker_name, now_ms()).map_err(|e| format!("broker connect: {e}"))?;
201	    let (session_id, pid) = brain
202	        .spawn_session_pid(SpawnReq {
203	            program: program.clone(),
204	            args: args.to_vec(),
205	            rows: DEFAULT_HARNESS_ROWS,
206	            cols: DEFAULT_HARNESS_COLS,
207	            endpoint: endpoint_id.to_string(),
208	            cwd: cwd.map(str::to_string),
209	            env,
210	            translation_binary,
211	        })
212	        .map_err(|e| format!("broker spawn: {e}"))?;
213	    Ok(HarnessSpawned { session_id, pid })
214	}
215
216	#[cfg(test)]
217	mod tests {
218	    use super::*;
219
220	    fn harness_manifest(self_command: &str) -> Manifest {
221	        let toml_src = format!(
222	            "[adapter]\nname = \"mock\"\nkind = \"harness\"\nversion = \"1\"\n\
223	             min_spt_core_version = \"0\"\n\n[session.self]\ncommand = '{self_command}'\n\
224	             keys = [\"id\", \"session_id\"]\n"
225	        );
226	        spt_runtime::Manifest::from_toml_str(&toml_src).unwrap()
227	    }
228
229	    // [unit->REQ-HOST-RUN-1] the spt-hosted bringup is template-driven: the
230	    // endpoint id and the spawn-time session id ride argv via {id}/{session_id}
231	    // substitution into [session.self] (so the harness binds to exactly this
232	    // endpoint), tokenized program-first.
233	    #[test]
234	    fn prepare_fills_id_and_session_into_self_command() {
235	        let m = harness_manifest("mock-session --id {id} --session-id {session_id}");
236	        let prepared = prepare_harness_spawn("doyle", "mock", "sess-abc", &m, false).unwrap();
237	        assert_eq!(
238	            prepared.tokens,
239	            vec![
240	                "mock-session".to_string(),
241	                "--id".to_string(),
242	                "doyle".to_string(),
243	                "--session-id".to_string(),
244	                "sess-abc".to_string(),
245	            ]
246	        );
247	        assert!(prepared.env.is_empty(), "no [env] declared → no inject");
248	    }
249
250	    // [unit->REQ-HAZARD-ENV-SUBST] a manifest [env.<VAR>] direction=inject value
251	    // is {key}-substituted from the same catalog and returned for the spawn — so a
252	    // flagless harness gets SPT_ENDPOINT_ID populated (not empty → seed-by-PPID).
253	    #[test]
254	    fn prepare_fills_env_inject_values_with_substitution() {
255	        let m = spt_runtime::Manifest::from_toml_str(
256	            "[adapter]\nname = \"mock\"\nkind = \"harness\"\nversion = \"1\"\n\
257	             min_spt_core_version = \"0\"\n\n[session.self]\ncommand = 'claude'\n\n\
258	             [env.SPT_ENDPOINT_ID]\ndirection = \"inject\"\nvalue = \"{id}\"\n\n\
259	             [env.SPT_SESSION]\ndirection = \"inject\"\nvalue = \"sess-{session_id}\"\n\n\
260	             [env.SPT_READ_ONLY]\ndirection = \"read\"\n",
261	        )
262	        .unwrap();
263	        let prepared = prepare_harness_spawn("wall-b", "mock", "abc", &m, false).unwrap();
264	        assert_eq!(prepared.env.get("SPT_ENDPOINT_ID").map(String::as_str), Some("wall-b"));
265	        assert_eq!(prepared.env.get("SPT_SESSION").map(String::as_str), Some("sess-abc"));
266	        // A `read` directive injects nothing.
267	        assert!(!prepared.env.contains_key("SPT_READ_ONLY"));
268	    }
269
270	    // [unit->REQ-MSG-IDLE-TRANSLATION-BINARY] a declared
271	    // `[message-idle-translation-binary].path` is threaded into the prepared spawn
272	    // (so the broker can lifecycle-manage it, ADR-0022 / W2); an adapter that
273	    // declares none yields `translation_binary = None` (the degenerate raw-inject /
274	    // spool path).
275	    #[test]
276	    fn prepare_threads_translation_binary_path_when_declared() {
277	        // declared → carried.
278	        let with = spt_runtime::Manifest::from_toml_str(
279	            "[adapter]\nname = \"cc\"\nkind = \"harness\"\nversion = \"1\"\n\
280	             min_spt_core_version = \"0\"\n\n[session.self]\ncommand = 'claude'\n\n\
281	             [message-idle-translation-binary]\npath = \"cc-spt-idle-translate\"\n",
282	        )
283	        .unwrap();
284	        let prepared = prepare_harness_spawn("wall-a", "cc", "s", &with, false).unwrap();
285	        assert_eq!(
286	            prepared.translation_binary.as_deref(),
287	            Some("cc-spt-idle-translate")
288	        );
289
290	        // absent → None.
291	        let without = harness_manifest("claude");
292	        let prepared = prepare_harness_spawn("wall-a", "mock", "s", &without, false).unwrap();
293	        assert!(prepared.translation_binary.is_none());
294	    }
295
296	    // [unit->REQ-HOST-RUN-1] fails closed before any spawn: a shell-kind adapter
297	    // is refused (endpoint run hosts harnesses), a manifest with no [session.self]
298	    // is refused (nothing to bring up), and an unresolved {placeholder} errs
299	    // naming the missing key.
300	    #[test]
301	    fn prepare_fails_closed_on_wrong_kind_missing_role_and_bad_template() {
302	        // Shell-kind adapter refused.
303	        let shell = spt_runtime::Manifest::from_toml_str(
304	            "[adapter]\nname = \"sh\"\nkind = \"shell\"\nversion = \"1\"\n\
305	             min_spt_core_version = \"0\"\n\n[shell]\nspawn = 'sh'\n",
306	        )
307	        .unwrap();
308	        assert!(prepare_harness_spawn("e", "sh", "s", &shell, false)
309	            .unwrap_err()
310	            .contains("not a harness"));
311
312	        // Harness with no [session.self] refused.
313	        let no_self = spt_runtime::Manifest::from_toml_str(
314	            "[adapter]\nname = \"h\"\nkind = \"harness\"\nversion = \"1\"\n\
315	             min_spt_core_version = \"0\"\n",
316	        )
317	        .unwrap();
318	        assert!(prepare_harness_spawn("e", "h", "s", &no_self, false)
319	            .unwrap_err()
320	            .contains("no [session.self]"));
321
322	        // Unknown {placeholder} errs naming the key.
323	        let bad = harness_manifest("mock-session --id {id} --boom {not_a_key}");
324	        assert!(prepare_harness_spawn("e", "mock", "s", &bad, false)
325	            .unwrap_err()
326	            .contains("not_a_key"));
327	    }
328
329	    // [unit->REQ-HOST-RUN-1] a provisional session id is fresh, hex, and unique
330	    // per mint (it only fills the spawn-time {session_id}; the harness reports the
331	    // real one post-spawn).
332	    #[test]
333	    fn mint_session_id_is_fresh_hex() {
334	        let a = mint_session_id();
335	        let b = mint_session_id();
336	        assert_eq!(a.len(), 16);
337	        assert!(a.chars().all(|c| c.is_ascii_hexdigit()));
338	        assert_ne!(a, b, "per-mint distinct");
339	    }
340	}
341

//! Harness-instance hosting (M12-W1 — spt-hosted harness bringup, REQ-HOST-RUN-1):
//! the spt-HOSTED counterpart to harness-hosted launch.
//!
//! **Who spawns the harness.** Today a harness is *harness-hosted*: an external
//! launcher runs the adapter's `[session.self]` command and the harness binds its
//! own perch on startup (`api listen`/`api bind` → `establish_perch`). spt-hosting
//! changes *only who owns the PTY*: the daemon spawns `[session.self]` into a
//! **broker-held PTY** ([`launch_harness_brokered_in`]). The harness still
//! self-registers its perch on bind — there is no daemon pre-register step.
//!
//! **Why broker-held.** The broker owns the PTY, so the harness survives a
//! `spt rc` detach — PTY ownership stays with the broker, no termination on
//! detach (the KNOWN-HAZARDS attach-lifecycle invariant, satisfied by
//! construction). The broker session is labeled with the endpoint id so
//! `spt rc <id>` resolves exactly this PTY.
//!
//! **Id plumbing.** The endpoint id reaches the harness two ways, both filled
//! from the SAME `{key}` catalog: argv via `{id}` substitution into the opaque
//! `[session.self]` command (the harness parses `--id`), AND the manifest
//! `[env.<VAR>]` direction = inject values (e.g. `SPT_ENDPOINT_ID = "{id}"`),
//! which are `{key}`-substituted and passed in `SpawnReq.env` so the broker sets
//! them on the spawned child (REQ-HAZARD-ENV-SUBST). A flagless harness whose
//! `[session.self]` has no argv slot for `{id}` (e.g. bare `claude`) relies on
//! the env inject — which previously arrived EMPTY (the `[env]` value was never
//! substituted nor applied), so the harness seeded-by-PPID instead of binding →
//! a zero-perch `NO_PERCH`. This mirrors [`crate::shellhost::launch_shell_brokered_in`]
//! but feeds `[session.self]` (not `[shell].spawn`), uses real terminal PTY dims,
//! and mints no link token (a harness binds via `api`, not the shell link
//! handshake).
// [impl->REQ-HOST-RUN-1]

use std::collections::BTreeMap;

use spt_runtime::manifest::{AdapterKind, EnvDirection, Manifest};

use crate::brain::{now_ms, Brain};
use crate::msg::SpawnReq;

/// Default PTY geometry for a freshly brought-up harness, before an attach
/// resizes it to the operator's real terminal. A harness *is* a terminal (unlike
/// a shell's wide anti-wrap stdin conduit), so a conventional 80×24 is right.
pub const DEFAULT_HARNESS_ROWS: u16 = 24;
pub const DEFAULT_HARNESS_COLS: u16 = 80;

/// Outcome of an spt-hosted harness bringup: the broker session id (the attach
/// handle) plus the OS pid of the spawned harness (`None` if the broker backend
/// exposed none).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct HarnessSpawned {
    pub session_id: u64,
    pub pid: Option<u32>,
}

/// Mint a provisional spawn-time session id (16 hex chars). The *real* session id
/// is reported post-spawn by the harness (`identity.session_id_source =
/// "post_spawn"`); this only fills the `{session_id}` placeholder at spawn so the
/// command template resolves. A fresh create mints one; `--resume` supplies the
/// prior session id instead.
pub fn mint_session_id() -> String {
    let seed = spt_proto::identity::Identity::generate().seed();
    seed[..8].iter().map(|b| format!("{b:02x}")).collect()
}

/// Validate the adapter and fill its session command for one endpoint, returning
/// the tokenized `program + args`. Pure (no spawn) — the broker call in
/// [`launch_harness_brokered_in`] is the only side effect. Fails closed: a
/// non-harness adapter, a missing role command, an unresolved `{placeholder}`,
/// or an empty command all error before anything is spawned.
///
/// `is_resume`: select the NATIVE-resume role over the fresh one — when `true`
/// AND the adapter declares `[session.resume]`, the resume template is filled
/// (the harness runs e.g. `claude -r {session_id}` instead of a fresh
/// `claude --session-id {session_id}`, which would start a blank transcript). A
/// `true` with NO `[session.resume]` declared FALLS BACK to `[session.self]`
/// (full back-compat); a `false` always uses `[session.self]`.
// [impl->REQ-HOST-RUN-1]
// [impl->REQ-SESSION-RESUME-TEMPLATE]
pub fn prepare_harness_spawn(
    endpoint_id: &str,
    adapter_name: &str,
    session_id: &str,
    manifest: &Manifest,
    is_resume: bool,
) -> Result<PreparedSpawn, String> {
    if manifest.adapter.kind != AdapterKind::Harness {
        return Err(format!(
            "adapter '{adapter_name}' is kind={:?}, not a harness — \
             `spt endpoint run` brings up harness endpoints",
            manifest.adapter.kind
        ));
    }
    // Resume selects `[session.resume]` when declared, else falls back to
    // `[session.self]` (an adapter with no resume sibling behaves exactly as
    // today). A fresh bringup always uses `[session.self]`.
    let role = if is_resume {
        manifest
            .session
            .resume
            .as_ref()
            .or(manifest.session.self_.as_ref())
    } else {
        manifest.session.self_.as_ref()
    }
    .ok_or_else(|| {
        format!("adapter '{adapter_name}' declares no [session.self] — nothing to bring up")
    })?;
    // The guaranteed-fill catalog for a self-spawn. `id` rides argv so the harness
    // binds to exactly this endpoint; `session_name` defaults to the id. The SAME
    // catalog fills the [env] inject values (REQ-HAZARD-ENV-SUBST).
    let keys = BTreeMap::from([
        ("id".to_string(), endpoint_id.to_string()),
        ("adapter_name".to_string(), adapter_name.to_string()),
        ("session_id".to_string(), session_id.to_string()),
        ("session_name".to_string(), endpoint_id.to_string()),
    ]);
    // [impl->REQ-HAZARD-TEMPLATE-ARGV-FILL] tokenize the template then fill each
    // token so a multi-word/quote/semicolon {key} value is one argv element.
    let tokens =
        spt_runtime::runtime::fill_template_tokens(&role.command, &keys).map_err(|e| e.to_string())?;
    if tokens.is_empty() {
        return Err("empty session command".into());
    }
    // [impl->REQ-HAZARD-ENV-SUBST] every [env.<VAR>] direction=inject value is
    // {key}-substituted from the SAME catalog and passed to the broker for the
    // spawned child's environment — the schema's "Value to inject (with
    // substitution)" promise, previously unfulfilled (the value arrived empty, so
    // a flagless harness seeded-by-PPID instead of binding → zero perch).
    let mut env = BTreeMap::new();
    for (name, var) in &manifest.env {
        if var.direction == EnvDirection::Inject {
            // Validation guarantees an inject directive has a value; fill it.
            if let Some(template) = &var.value {
                let filled = spt_runtime::runtime::fill_template(template, &keys)
                    .map_err(|e| e.to_string())?;
                env.insert(name.clone(), filled);
            }
        }
    }
    // ADR-0022 / W2: carry the adapter's idle-delivery translation-binary path
    // (if declared) so the broker can lifecycle-manage it alongside this session.
    // The path is opaque (resolved at spawn like every adapter command).
    let translation_binary = manifest
        .message_idle_translation_binary
        .as_ref()
        .map(|b| b.path.clone());
    Ok(PreparedSpawn {
        tokens,
        env,
        translation_binary,
    })
}

/// The filled spawn inputs for an spt-hosted harness: the tokenized argv, the
/// `[env]`-inject map (both filled from the same `{key}` catalog —
/// REQ-HAZARD-ENV-SUBST), and the optional idle-delivery translation-binary path
/// (ADR-0022) the broker lifecycle-manages.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PreparedSpawn {
    pub tokens: Vec<String>,
    pub env: BTreeMap<String, String>,
    /// `[message-idle-translation-binary].path` if the adapter declares one.
    pub translation_binary: Option<String>,
}

/// Spawn an adapter's `[session.self]` harness into a broker-held PTY (the
/// spt-hosted bringup). The broker session is labeled `endpoint_id` so
/// `spt rc <id>` resolves it; the harness self-registers its perch on bind. The
/// broker outlives this call — the session keeps running after it returns.
///
/// `session_id`: the spawn-time `{session_id}` fill — a provisional [`mint_session_id`]
/// for a fresh create, or the prior session for `--resume`.
///
/// `cwd`: the project-scoped working directory the harness PTY lands in
/// (REQ-HOST-RUN-2). `None` inherits the broker's cwd (the W1 bringup shortcut);
/// `Some(dir)` is what a project-scoped launcher (`cc <id>` at a project root)
/// passes so the harness reads the RIGHT `.claude`/session history/digest source.
///
/// `is_resume`: forwarded to [`prepare_harness_spawn`] — `true` selects the
/// adapter's `[session.resume]` native-resume command (REQ-SESSION-RESUME-TEMPLATE)
/// when declared, else falls back to `[session.self]`.
// [impl->REQ-HOST-RUN-1]
// [impl->REQ-HOST-RUN-2]
// [impl->REQ-SESSION-RESUME-TEMPLATE]
pub fn launch_harness_brokered_in(
    broker_name: &str,
    endpoint_id: &str,
    adapter_name: &str,
    session_id: &str,
    manifest: &Manifest,
    cwd: Option<&str>,
    is_resume: bool,
) -> Result<HarnessSpawned, String> {
    let PreparedSpawn {
        tokens,
        env,
        translation_binary,
    } = prepare_harness_spawn(endpoint_id, adapter_name, session_id, manifest, is_resume)?;
    let (program, args) = tokens.split_first().expect("non-empty checked");
    let mut brain =
        Brain::cold_start(broker_name, now_ms()).map_err(|e| format!("broker connect: {e}"))?;
    let (session_id, pid) = brain
        .spawn_session_pid(SpawnReq {
            program: program.clone(),
            args: args.to_vec(),
            rows: DEFAULT_HARNESS_ROWS,
            cols: DEFAULT_HARNESS_COLS,
            endpoint: endpoint_id.to_string(),
            cwd: cwd.map(str::to_string),
            env,
            translation_binary,
        })
        .map_err(|e| format!("broker spawn: {e}"))?;
    Ok(HarnessSpawned { session_id, pid })
}

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

    fn harness_manifest(self_command: &str) -> Manifest {
        let toml_src = format!(
            "[adapter]\nname = \"mock\"\nkind = \"harness\"\nversion = \"1\"\n\
             min_spt_core_version = \"0\"\n\n[session.self]\ncommand = '{self_command}'\n\
             keys = [\"id\", \"session_id\"]\n"
        );
        spt_runtime::Manifest::from_toml_str(&toml_src).unwrap()
    }

    // [unit->REQ-HOST-RUN-1] the spt-hosted bringup is template-driven: the
    // endpoint id and the spawn-time session id ride argv via {id}/{session_id}
    // substitution into [session.self] (so the harness binds to exactly this
    // endpoint), tokenized program-first.
    #[test]
    fn prepare_fills_id_and_session_into_self_command() {
        let m = harness_manifest("mock-session --id {id} --session-id {session_id}");
        let prepared = prepare_harness_spawn("doyle", "mock", "sess-abc", &m, false).unwrap();
        assert_eq!(
            prepared.tokens,
            vec![
                "mock-session".to_string(),
                "--id".to_string(),
                "doyle".to_string(),
                "--session-id".to_string(),
                "sess-abc".to_string(),
            ]
        );
        assert!(prepared.env.is_empty(), "no [env] declared → no inject");
    }

    // [unit->REQ-HAZARD-ENV-SUBST] a manifest [env.<VAR>] direction=inject value
    // is {key}-substituted from the same catalog and returned for the spawn — so a
    // flagless harness gets SPT_ENDPOINT_ID populated (not empty → seed-by-PPID).
    #[test]
    fn prepare_fills_env_inject_values_with_substitution() {
        let m = spt_runtime::Manifest::from_toml_str(
            "[adapter]\nname = \"mock\"\nkind = \"harness\"\nversion = \"1\"\n\
             min_spt_core_version = \"0\"\n\n[session.self]\ncommand = 'claude'\n\n\
             [env.SPT_ENDPOINT_ID]\ndirection = \"inject\"\nvalue = \"{id}\"\n\n\
             [env.SPT_SESSION]\ndirection = \"inject\"\nvalue = \"sess-{session_id}\"\n\n\
             [env.SPT_READ_ONLY]\ndirection = \"read\"\n",
        )
        .unwrap();
        let prepared = prepare_harness_spawn("wall-b", "mock", "abc", &m, false).unwrap();
        assert_eq!(prepared.env.get("SPT_ENDPOINT_ID").map(String::as_str), Some("wall-b"));
        assert_eq!(prepared.env.get("SPT_SESSION").map(String::as_str), Some("sess-abc"));
        // A `read` directive injects nothing.
        assert!(!prepared.env.contains_key("SPT_READ_ONLY"));
    }

    // [unit->REQ-MSG-IDLE-TRANSLATION-BINARY] a declared
    // `[message-idle-translation-binary].path` is threaded into the prepared spawn
    // (so the broker can lifecycle-manage it, ADR-0022 / W2); an adapter that
    // declares none yields `translation_binary = None` (the degenerate raw-inject /
    // spool path).
    #[test]
    fn prepare_threads_translation_binary_path_when_declared() {
        // declared → carried.
        let with = spt_runtime::Manifest::from_toml_str(
            "[adapter]\nname = \"cc\"\nkind = \"harness\"\nversion = \"1\"\n\
             min_spt_core_version = \"0\"\n\n[session.self]\ncommand = 'claude'\n\n\
             [message-idle-translation-binary]\npath = \"cc-spt-idle-translate\"\n",
        )
        .unwrap();
        let prepared = prepare_harness_spawn("wall-a", "cc", "s", &with, false).unwrap();
        assert_eq!(
            prepared.translation_binary.as_deref(),
            Some("cc-spt-idle-translate")
        );

        // absent → None.
        let without = harness_manifest("claude");
        let prepared = prepare_harness_spawn("wall-a", "mock", "s", &without, false).unwrap();
        assert!(prepared.translation_binary.is_none());
    }

    // [unit->REQ-HOST-RUN-1] fails closed before any spawn: a shell-kind adapter
    // is refused (endpoint run hosts harnesses), a manifest with no [session.self]
    // is refused (nothing to bring up), and an unresolved {placeholder} errs
    // naming the missing key.
    #[test]
    fn prepare_fails_closed_on_wrong_kind_missing_role_and_bad_template() {
        // Shell-kind adapter refused.
        let shell = spt_runtime::Manifest::from_toml_str(
            "[adapter]\nname = \"sh\"\nkind = \"shell\"\nversion = \"1\"\n\
             min_spt_core_version = \"0\"\n\n[shell]\nspawn = 'sh'\n",
        )
        .unwrap();
        assert!(prepare_harness_spawn("e", "sh", "s", &shell, false)
            .unwrap_err()
            .contains("not a harness"));

        // Harness with no [session.self] refused.
        let no_self = spt_runtime::Manifest::from_toml_str(
            "[adapter]\nname = \"h\"\nkind = \"harness\"\nversion = \"1\"\n\
             min_spt_core_version = \"0\"\n",
        )
        .unwrap();
        assert!(prepare_harness_spawn("e", "h", "s", &no_self, false)
            .unwrap_err()
            .contains("no [session.self]"));

        // Unknown {placeholder} errs naming the key.
        let bad = harness_manifest("mock-session --id {id} --boom {not_a_key}");
        assert!(prepare_harness_spawn("e", "mock", "s", &bad, false)
            .unwrap_err()
            .contains("not_a_key"));
    }

    // [unit->REQ-HOST-RUN-1] a provisional session id is fresh, hex, and unique
    // per mint (it only fills the spawn-time {session_id}; the harness reports the
    // real one post-spawn).
    #[test]
    fn mint_session_id_is_fresh_hex() {
        let a = mint_session_id();
        let b = mint_session_id();
        assert_eq!(a.len(), 16);
        assert!(a.chars().all(|c| c.is_ascii_hexdigit()));
        assert_ne!(a, b, "per-mint distinct");
    }
}


I have a clear picture of all three lib-crate anchors and the existing test patterns. Now let me look at the existing manifest roundtrip test pattern (further in manifest.rs), the existing e2e int test, and the mock adapter.


1183	    // TABLE with a non-empty `path` parses + round-trips under its kebab key; an
1184	    // EMPTY path is refused at validate; an absent table leaves the field None and
1185	    // emits no section; an UNKNOWN future key is IGNORED, not a hard failure
1186	    // (forward-compat — no deny_unknown_fields on a lifecycle-binary contract).
1187	    #[test]
1188	    fn message_idle_translation_binary_opt_in() {
1189	        let base =
1190	            "[adapter]\nname=\"cc\"\nkind=\"harness\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n";
1191
1192	        // declared + non-empty path → parses, preserved, round-trips.
1193	        let with = format!(
1194	            "{base}\n[message-idle-translation-binary]\npath = \"cc-spt-idle-translate\"\n"
1195	        );
1196	        let m = Manifest::from_toml_str(&with).expect("translation binary table parses");
1197	        assert_eq!(
1198	            m.message_idle_translation_binary.as_ref().map(|b| b.path.as_str()),
1199	            Some("cc-spt-idle-translate")
1200	        );
1201	        let toml = m.to_toml_string().unwrap();
1202	        assert!(
1203	            toml.contains("[message-idle-translation-binary]")
1204	                && toml.contains("path = \"cc-spt-idle-translate\""),
1205	            "serializes under the kebab table key: {toml}"
1206	        );
1207	        let reparsed = Manifest::from_toml_str(&toml).unwrap();
1208	        assert_eq!(
1209	            m.message_idle_translation_binary,
1210	            reparsed.message_idle_translation_binary
1211	        );
1212
1213	        // absent → None, and emits no section.
1214	        let m0 = Manifest::from_toml_str(base).expect("no table parses");
1215	        assert!(m0.message_idle_translation_binary.is_none());
1216	        assert!(!m0
1217	            .to_toml_string()
1218	            .unwrap()
1219	            .contains("message-idle-translation-binary"));
1220
1221	        // declared with an empty path → refused at validate.
1222	        let empty =
1223	            format!("{base}\n[message-idle-translation-binary]\npath = \"\"\n");
1224	        let err = Manifest::from_toml_str(&empty).unwrap_err().to_string();
1225	        assert!(
1226	            err.contains("message-idle-translation-binary") && err.contains("non-empty"),
1227	            "empty path must be refused: {err}"
1228	        );
1229
1230	        // an UNKNOWN future key alongside `path` is ignored (forward-compat),
1231	        // never a hard parse failure.
1232	        let future = format!(
1233	            "{base}\n[message-idle-translation-binary]\npath = \"cc-translate\"\n\
1234	             spawn_timeout_ms = 2000\n"
1235	        );
1236	        let mf = Manifest::from_toml_str(&future)
1237	            .expect("an unknown forward-compat key must not hard-fail the manifest");
1238	        assert_eq!(
1239	            mf.message_idle_translation_binary.unwrap().path,
1240	            "cc-translate"
1241	        );
1242	    }
1243
1244	    #[test] // [unit->REQ-MANIFEST-1]
1245	    fn shell_fixture_parses() {
1246	        let m = Manifest::from_toml_str(SHELL_FIXTURE).expect("parse");
1247	        assert_eq!(m.adapter.kind, AdapterKind::Shell);
1248	        let shell = m.shell.as_ref().unwrap();
1249	        assert_eq!(shell.broadcast, Some(Broadcast::Subnet));
1250	        assert_eq!(shell.command_receipt, Some(CommandReceipt::Http));
1251	        assert_eq!(shell.capabilities["move"].args, ["direction", "distance"]);
1252	        assert!(shell.capabilities["screenshot"].args.is_empty());
1253	        assert_eq!(shell.sensory.types, ["image", "sound", "event"]);
1254	    }
1255
1256	    #[test] // [unit->REQ-MANIFEST-1]
1257	    fn round_trips_through_toml() {
1258	        let m = Manifest::from_toml_str(HARNESS_FIXTURE).expect("parse");
1259	        let serialized = m.to_toml_string().expect("serialize");
1260	        let reparsed = Manifest::from_toml_str(&serialized).expect("re-parse");
1261	        assert_eq!(
1262	            m, reparsed,
1263	            "manifest must survive a serialize→parse round-trip"
1264	        );
1265	    }
1266
1267	    #[test] // [unit->REQ-MANIFEST-1]
1268	    fn unknown_enum_variant_is_an_error_not_a_panic() {
1269	        let bad = r#"
1270	[adapter]
1271	name = "x"
1272	version = "1.0.0"
1273	min_spt_core_version = "1.0.0"
1274
1275	[history]
1276	strategy = "telepathy"
1277	"#;
1278	        let e = Manifest::from_toml_str(bad).unwrap_err();
1279	        assert!(matches!(e, ManifestError::Parse(_)), "got {e}");
1280	        // message names the offending value
1281	        assert!(e.to_string().contains("telepathy") || e.to_string().contains("strategy"));
1282	    }
1283
1284	    #[test] // [unit->REQ-MANIFEST-1]
1285	    fn shell_kind_without_shell_section_fails_validation() {
1286	        let bad = r#"
1287	[adapter]
1288	name = "x"
1289	kind = "shell"
1290	version = "1.0.0"
1291	min_spt_core_version = "1.0.0"
1292	"#;
1293	        let e = Manifest::from_toml_str(bad).unwrap_err();
1294	        assert!(matches!(e, ManifestError::Validation(_)));
1295	        assert!(e.to_string().contains("[shell]"));
1296	    }
1297
1298	    #[test] // [unit->REQ-MANIFEST-1]
1299	    fn harness_kind_with_shell_section_fails_validation() {
1300	        let bad = r#"
1301	[adapter]
1302	name = "x"
1303	version = "1.0.0"
1304	min_spt_core_version = "1.0.0"
1305
1306	[shell]
1307	spawn = "x"
1308	"#;
1309	        let e = Manifest::from_toml_str(bad).unwrap_err();
1310	        assert!(matches!(e, ManifestError::Validation(_)));
1311	    }
1312
1313	    // [unit->REQ-CONSENT-3] a capability carries its own act-gate: the
1314	    // `require_approval` enum + an optional `class_key` parse onto the op, and a
1315	    // `class_key` without a gated mode refuses at register time (a class key
1316	    // only scopes a grant — meaningless ungated).
1317	    #[test]
1318	    fn per_capability_act_gate_parses_and_validates() {
1319	        let src = r#"
1320	[adapter]
1321	name = "usbip"
1322	kind = "shell"
1323	version = "1.0.0"
1324	min_spt_core_version = "1.0.0"
1325
1326	[shell]
1327	spawn = "usbip {link_token}"
1328
1329	[shell.capabilities.attach]
1330	args = ["busid"]
1331	require_approval = "remembered"
1332	class_key = "hid"
1333
1334	[shell.capabilities.list]
1335	"#;
1336	        let shell = Manifest::from_toml_str(src).unwrap().shell.unwrap();
1337	        let attach = &shell.capabilities["attach"];
1338	        assert_eq!(attach.require_approval, ShellApproval::Remembered);
1339	        assert_eq!(attach.class_key.as_deref(), Some("hid"));
1340	        // An ungated op defaults clean (no gate, no class).
1341	        let list = &shell.capabilities["list"];
1342	        assert!(list.require_approval.is_default());
1343	        assert!(list.class_key.is_none());
1344
1345	        // class_key without a gate is a register-time contradiction.
1346	        let bad = r#"
1347	[adapter]
1348	name = "usbip"
1349	kind = "shell"
1350	version = "1.0.0"
1351	min_spt_core_version = "1.0.0"
1352
1353	[shell]
1354	spawn = "usbip {link_token}"
1355
1356	[shell.capabilities.attach]
1357	class_key = "hid"
1358	"#;
1359	        let e = Manifest::from_toml_str(bad).unwrap_err();
1360	        assert!(matches!(e, ManifestError::Validation(_)));
1361	        assert!(e.to_string().contains("class_key"), "{e}");
1362	    }
1363
1364	    // [unit->REQ-MANIFEST-4] keyword matching: literal is case-insensitive
1365	    // substring; regex opt-in compiles the pattern; an invalid regex silences
1366	    // its hint (no match, no panic); first matching keyword is the one returned.
1367	    #[test]
1368	    fn hint_keyword_matching() {
1369	        let literal = Hint {
1370	            keywords: vec!["SPT send".into(), "owl".into()],
1371	            text: "use $OWL send".into(),
1372	            regex: false,
1373	        };
1374	        // Case-insensitive substring; returns the matched keyword.
1375	        assert_eq!(literal.matched_keyword("how do i spt send a msg"), Some("SPT send"));
1376	        assert_eq!(literal.matched_keyword("the OWL hoots"), Some("owl"));
1377	        assert_eq!(literal.matched_keyword("nothing here"), None);
1378
1379	        // Regex opt-in.
1380	        let re = Hint {
1381	            keywords: vec![r"spt\s+\w+".into()],
1382	            text: "t".into(),
1383	            regex: true,
1384	        };
1385	        assert_eq!(re.matched_keyword("run spt send now"), Some(r"spt\s+\w+"));
1386	        assert!(re.matched_keyword("sptsend").is_none(), "regex needs the whitespace");
1387
1388	        // An invalid regex matches nothing (best-effort, never panics).
1389	        let bad = Hint {
1390	            keywords: vec!["(unclosed".into()],
1391	            text: "t".into(),
1392	            regex: true,
1393	        };
1394	        assert!(bad.matched_keyword("(unclosed literal present").is_none());
1395	    }
1396
1397	    // [unit->REQ-MANIFEST-4] a profile overlays [[hints]] by leaf-replace: the
1398	    // array is replaced wholesale, not spliced.
1399	    #[test]
1400	    fn profile_overlays_hints_wholesale() {
1401	        let parent = Manifest::from_toml_str(
1402	            "[adapter]\nname = \"a\"\nkind = \"harness\"\nversion = \"1\"\n\
1403	             min_spt_core_version = \"0\"\n\n\
1404	             [[hints]]\nkeywords = [\"base\"]\ntext = \"base hint\"\n\n\
1405	             [profiles.p]\n[[profiles.p.hints]]\nkeywords = [\"prof\"]\ntext = \"profile hint\"\n",
1406	        )
1407	        .expect("parses");
1408	        assert_eq!(parent.hints.len(), 1);
1409	        let merged = crate::profile::resolve(&parent, "p").expect("resolves");
1410	        assert_eq!(merged.hints.len(), 1, "array replaced wholesale, not spliced");
1411	        assert_eq!(merged.hints[0].text, "profile hint");
1412	    }
1413
1414	    #[test] // [unit->REQ-MANIFEST-1]
1415	    fn inject_env_without_value_fails_validation() {
1416	        let bad = r#"
1417	[adapter]
1418	name = "x"
1419	version = "1.0.0"
1420	min_spt_core_version = "1.0.0"
1421
1422	[env.FOO]
1423	direction = "inject"
1424	"#;
1425	        let e = Manifest::from_toml_str(bad).unwrap_err();
1426	        assert!(e.to_string().contains("FOO"));
1427	    }
1428
1429	    #[test] // [unit->REQ-MANIFEST-1]
1430	    fn fetcher_strategy_requires_fetcher_field() {
1431	        let bad = r#"
1432	[adapter]
1433	name = "x"
1434	version = "1.0.0"
1435	min_spt_core_version = "1.0.0"
1436
1437	[history]
1438	strategy = "fetcher"
1439	"#;
1440	        let e = Manifest::from_toml_str(bad).unwrap_err();
1441	        assert!(matches!(e, ManifestError::Validation(_)));
1442	    }
1443
1444	    // [unit->REQ-TERM-5] the [digest] seam: an extractor reusing [history]'s
1445	    // locate_template needs no own `source`; an own-source [digest] needs no
1446	    // [history] at all; both round-trip through TOML.
1447	    #[test]
1448	    fn digest_section_source_resolution() {
1449	        // Reuse [history] locate_template (no own source).
1450	        let reuse = r#"
1451	[adapter]
1452	name = "x"
1453	version = "1.0.0"
1454	min_spt_core_version = "1.0.0"
1455
1456	[history]
1457	strategy = "locate_normalize"
1458	locate_template = "~/.h/{session_id}.jsonl"
1459	normalize_command = "norm"
1460
1461	[digest]
1462	extractor = "extract --in {source}"
1463	"#;
1464	        let m = Manifest::from_toml_str(reuse).expect("reuse-history parses");
1465	        assert!(m.digest.as_ref().unwrap().source.is_none());
1466	        let rt = Manifest::from_toml_str(&m.to_toml_string().unwrap()).unwrap();
1467	        assert_eq!(m.digest, rt.digest, "[digest] round-trips");
1468
1469	        // Own source, no [history] section at all.
1470	        let own = r#"
1471	[adapter]
1472	name = "x"
1473	version = "1.0.0"
1474	min_spt_core_version = "1.0.0"
1475
1476	[digest]
1477	extractor = "extract --in {source}"
1478	source = "~/logs/{session_id}.jsonl"
1479	"#;
1480	        let m = Manifest::from_toml_str(own).expect("own-source parses");
1481	        assert_eq!(
1482	            m.digest.unwrap().source.as_deref(),
1483	            Some("~/logs/{session_id}.jsonl")
1484	        );
1485	    }
1486
1487	    // [unit->REQ-TERM-5] [digest] validation: an empty extractor, no resolvable
1488	    // source, and a zero window are each rejected with an author-facing message.
1489	    #[test]
1490	    fn digest_section_validation_errors() {
1491	        // Empty extractor.
1492	        let empty = r#"
1493	[adapter]
1494	name = "x"
1495	version = "1.0.0"
1496	min_spt_core_version = "1.0.0"
1497
1498	[digest]
1499	extractor = "  "
1500	source = "s"
1501	"#;
1502	        let e = Manifest::from_toml_str(empty).unwrap_err();
1503	        assert!(matches!(e, ManifestError::Validation(_)));
1504	        assert!(e.to_string().contains("extractor"), "{e}");
1505
1506	        // No own source and no [history] locate_template → unresolvable.
1507	        let no_src = r#"
1508	[adapter]
1509	name = "x"
1510	version = "1.0.0"
1511	min_spt_core_version = "1.0.0"
1512
1513	[history]
1514	strategy = "fetcher"
1515	fetcher = "f"
1516
1517	[digest]
1518	extractor = "extract"
1519	"#;
1520	        let e = Manifest::from_toml_str(no_src).unwrap_err();
1521	        assert!(matches!(e, ManifestError::Validation(_)));
1522	        assert!(e.to_string().contains("source") || e.to_string().contains("locate_template"), "{e}");
1523
1524	        // Zero window.
1525	        let zero = r#"
1526	[adapter]
1527	name = "x"
1528	version = "1.0.0"
1529	min_spt_core_version = "1.0.0"
1530
1531	[digest]
1532	extractor = "extract"
1533	source = "s"
1534	window_turns = 0
1535	"#;
1536	        let e = Manifest::from_toml_str(zero).unwrap_err();
1537	        assert!(e.to_string().contains("window_turns"), "{e}");
1538	    }
1539
1540	    #[test] // [unit->REQ-MANIFEST-1]
1541	    fn minimal_header_only_manifest_is_valid() {
1542	        let m = Manifest::from_toml_str(
1543	            "[adapter]\nname = \"min\"\nversion = \"1.0.0\"\nmin_spt_core_version = \"1.0.0\"\n",
1544	        )
1545	        .expect("parse");
1546	        assert_eq!(m.adapter.name, "min");
1547	        assert!(m.session.is_empty());
1548	        assert!(m.hooks.is_empty());
1549	    }
1550
1551	    #[test] // [unit->REQ-MANIFEST-1]
1552	    fn syntactically_broken_toml_is_an_error_not_a_panic() {
1553	        let e = Manifest::from_toml_str("this is not = = toml").unwrap_err();
1554	        assert!(matches!(e, ManifestError::Parse(_)));
1555	    }
1556
1557	    // [unit->REQ-MANIFEST-7] the optional adapter-declared shortcut basename:
1558	    // absent ⇒ None (picker falls back to "spt" → spt-<id>); declared ⇒ read
1559	    // verbatim (an adapter brands its shortcut, e.g. cc → cc-<id>); omitted from
1560	    // serialization when absent (additive + N-1-safe).
1561	    #[test]
1562	    fn shortcut_basename_optional_with_default() {
1563	        let m = Manifest::from_toml_str(HARNESS_FIXTURE).expect("parse");
1564	        assert_eq!(m.adapter.shortcut_basename, None, "absent ⇒ None");
1565
1566	        let branded = HARNESS_FIXTURE.replace("[adapter]", "[adapter]\nshortcut_basename = \"cc\"");
1567	        let m2 = Manifest::from_toml_str(&branded).expect("parse branded");
1568	        assert_eq!(m2.adapter.shortcut_basename.as_deref(), Some("cc"));
1569
1570	        // N-1-safe serialization: the field is omitted when absent, present when set.
1571	        assert!(!m.to_toml_string().unwrap().contains("shortcut_basename"));
1572	        assert!(m2.to_toml_string().unwrap().contains("shortcut_basename"));
1573	    }
1574
1575	    // [unit->REQ-MANIFEST-8] `[adapter] host_binaries` is the bind-time match-key:
1576	    // additive + N-1-safe (an old manifest without it parses to an empty list, and
1577	    // an empty list is omitted on serialization, like shortcut_basename); a
1578	    // declared list round-trips.
1579	    #[test]
1580	    fn host_binaries_optional_and_n1_safe() {
1581	        // Absent ⇒ empty (an old manifest parses clean).
1582	        let m = Manifest::from_toml_str(HARNESS_FIXTURE).expect("parse");
1583	        assert!(m.adapter.host_binaries.is_empty(), "absent ⇒ empty");
1584	        assert!(
1585	            !m.to_toml_string().unwrap().contains("host_binaries"),
1586	            "empty list omitted on serialization"
1587	        );
1588
1589	        // Declared ⇒ round-trips and serializes.
1590	        let hosted = HARNESS_FIXTURE.replace(
1591	            "[adapter]",
1592	            "[adapter]\nhost_binaries = [\"claude\", \"claude-cli\"]",
1593	        );
1594	        let m2 = Manifest::from_toml_str(&hosted).expect("parse hosted");
1595	        assert_eq!(m2.adapter.host_binaries, vec!["claude", "claude-cli"]);
1596	        assert!(m2.to_toml_string().unwrap().contains("host_binaries"));
1597	    }
1598
1599	    // ---- M6-D3: the published manifest JSON Schema ----
1600
1601	    #[test] // [unit->REQ-DOCS-4] the generated schema carries the stable $id at
1602	            // the canonical Pages URL (ADR-0014), the adapter header's requireds, and
1603	            // the closed enum vocabularies (the things adapter authors validate against).
1604	    fn manifest_schema_shape() {
1605	        let s = manifest_json_schema();
1606	        let v: serde_json::Value = serde_json::from_str(&s).expect("schema is valid JSON");
1607	        assert_eq!(
1608	            v["$id"],
1609	            "https://sabermage.github.io/spt-releases/manifest.schema.json"
1610	        );
1611	        assert_eq!(v["title"], "spt-core runtime manifest");
1612	        // [adapter] is the only required top-level section.
1613	        let req: Vec<&str> = v["required"]
1614	            .as_array()
1615	            .unwrap()
1616	            .iter()
1617	            .map(|x| x.as_str().unwrap())
1618	            .collect();
1619	        assert_eq!(req, ["adapter"], "only the header is mandatory");
1620	        // The header's own requireds include the compat gate.
1621	        let adapter_req = v["$defs"]["Adapter"]["required"].as_array().unwrap();
1622	        assert!(adapter_req.iter().any(|x| x == "min_spt_core_version"));
1623	        // Closed vocabularies surface as enums (e.g. the update avenues).
1624	        let avenues = serde_json::to_string(&v["$defs"]["UpdateAvenue"]).unwrap();
1625	        assert!(avenues.contains("file_pull") && avenues.contains("delegated"));
1626	        // Doc comments ride along as descriptions (the schema IS documentation).
1627	        assert!(
1628	            s.contains("opaque"),
1629	            "descriptions must carry the contract's language"
1630	        );
1631	    }
1632
1633	    /// The drift gate (M6-D3): the checked-in `manifest.schema.json` — the
1634	    /// copy that ships to the docs site root and the release assets — must
1635	    /// equal what the current derives generate. CI runs this in the normal
1636	    /// sweep on every push, so the published schema can never lag the parser.
1637	    /// Regenerate: `SPT_BLESS=1 cargo test -p spt-runtime manifest_schema`.
1638	    // [int->REQ-DOCS-5]
1639	    #[test]
1640	    fn checked_in_schema_is_current() {
1641	        let want = manifest_json_schema();
1642	        let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("manifest.schema.json");
1643	        if std::env::var("SPT_BLESS").ok().as_deref() == Some("1") {
1644	            std::fs::write(&path, &want).expect("bless schema");
1645	            return;
1646	        }
1647	        let got = std::fs::read_to_string(&path).expect(
1648	            "checked-in manifest.schema.json (regenerate: SPT_BLESS=1 cargo test -p spt-runtime)",
1649	        );
1650	        assert_eq!(
1651	            got.replace("\r\n", "\n"),
1652	            want,
1653	            "manifest.schema.json drifted from the derives — regenerate with \
1654	             SPT_BLESS=1 cargo test -p spt-runtime"
1655	        );
1656	    }
1657	}
1658

    // TABLE with a non-empty `path` parses + round-trips under its kebab key; an
    // EMPTY path is refused at validate; an absent table leaves the field None and
    // emits no section; an UNKNOWN future key is IGNORED, not a hard failure
    // (forward-compat — no deny_unknown_fields on a lifecycle-binary contract).
    #[test]
    fn message_idle_translation_binary_opt_in() {
        let base =
            "[adapter]\nname=\"cc\"\nkind=\"harness\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n";

        // declared + non-empty path → parses, preserved, round-trips.
        let with = format!(
            "{base}\n[message-idle-translation-binary]\npath = \"cc-spt-idle-translate\"\n"
        );
        let m = Manifest::from_toml_str(&with).expect("translation binary table parses");
        assert_eq!(
            m.message_idle_translation_binary.as_ref().map(|b| b.path.as_str()),
            Some("cc-spt-idle-translate")
        );
        let toml = m.to_toml_string().unwrap();
        assert!(
            toml.contains("[message-idle-translation-binary]")
                && toml.contains("path = \"cc-spt-idle-translate\""),
            "serializes under the kebab table key: {toml}"
        );
        let reparsed = Manifest::from_toml_str(&toml).unwrap();
        assert_eq!(
            m.message_idle_translation_binary,
            reparsed.message_idle_translation_binary
        );

        // absent → None, and emits no section.
        let m0 = Manifest::from_toml_str(base).expect("no table parses");
        assert!(m0.message_idle_translation_binary.is_none());
        assert!(!m0
            .to_toml_string()
            .unwrap()
            .contains("message-idle-translation-binary"));

        // declared with an empty path → refused at validate.
        let empty =
            format!("{base}\n[message-idle-translation-binary]\npath = \"\"\n");
        let err = Manifest::from_toml_str(&empty).unwrap_err().to_string();
        assert!(
            err.contains("message-idle-translation-binary") && err.contains("non-empty"),
            "empty path must be refused: {err}"
        );

        // an UNKNOWN future key alongside `path` is ignored (forward-compat),
        // never a hard parse failure.
        let future = format!(
            "{base}\n[message-idle-translation-binary]\npath = \"cc-translate\"\n\
             spawn_timeout_ms = 2000\n"
        );
        let mf = Manifest::from_toml_str(&future)
            .expect("an unknown forward-compat key must not hard-fail the manifest");
        assert_eq!(
            mf.message_idle_translation_binary.unwrap().path,
            "cc-translate"
        );
    }

    #[test] // [unit->REQ-MANIFEST-1]
    fn shell_fixture_parses() {
        let m = Manifest::from_toml_str(SHELL_FIXTURE).expect("parse");
        assert_eq!(m.adapter.kind, AdapterKind::Shell);
        let shell = m.shell.as_ref().unwrap();
        assert_eq!(shell.broadcast, Some(Broadcast::Subnet));
        assert_eq!(shell.command_receipt, Some(CommandReceipt::Http));
        assert_eq!(shell.capabilities["move"].args, ["direction", "distance"]);
        assert!(shell.capabilities["screenshot"].args.is_empty());
        assert_eq!(shell.sensory.types, ["image", "sound", "event"]);
    }

    #[test] // [unit->REQ-MANIFEST-1]
    fn round_trips_through_toml() {
        let m = Manifest::from_toml_str(HARNESS_FIXTURE).expect("parse");
        let serialized = m.to_toml_string().expect("serialize");
        let reparsed = Manifest::from_toml_str(&serialized).expect("re-parse");
        assert_eq!(
            m, reparsed,
            "manifest must survive a serialize→parse round-trip"
        );
    }

    #[test] // [unit->REQ-MANIFEST-1]
    fn unknown_enum_variant_is_an_error_not_a_panic() {
        let bad = r#"
[adapter]
name = "x"
version = "1.0.0"
min_spt_core_version = "1.0.0"

[history]
strategy = "telepathy"
"#;
        let e = Manifest::from_toml_str(bad).unwrap_err();
        assert!(matches!(e, ManifestError::Parse(_)), "got {e}");
        // message names the offending value
        assert!(e.to_string().contains("telepathy") || e.to_string().contains("strategy"));
    }

    #[test] // [unit->REQ-MANIFEST-1]
    fn shell_kind_without_shell_section_fails_validation() {
        let bad = r#"
[adapter]
name = "x"
kind = "shell"
version = "1.0.0"
min_spt_core_version = "1.0.0"
"#;
        let e = Manifest::from_toml_str(bad).unwrap_err();
        assert!(matches!(e, ManifestError::Validation(_)));
        assert!(e.to_string().contains("[shell]"));
    }

    #[test] // [unit->REQ-MANIFEST-1]
    fn harness_kind_with_shell_section_fails_validation() {
        let bad = r#"
[adapter]
name = "x"
version = "1.0.0"
min_spt_core_version = "1.0.0"

[shell]
spawn = "x"
"#;
        let e = Manifest::from_toml_str(bad).unwrap_err();
        assert!(matches!(e, ManifestError::Validation(_)));
    }

    // [unit->REQ-CONSENT-3] a capability carries its own act-gate: the
    // `require_approval` enum + an optional `class_key` parse onto the op, and a
    // `class_key` without a gated mode refuses at register time (a class key
    // only scopes a grant — meaningless ungated).
    #[test]
    fn per_capability_act_gate_parses_and_validates() {
        let src = r#"
[adapter]
name = "usbip"
kind = "shell"
version = "1.0.0"
min_spt_core_version = "1.0.0"

[shell]
spawn = "usbip {link_token}"

[shell.capabilities.attach]
args = ["busid"]
require_approval = "remembered"
class_key = "hid"

[shell.capabilities.list]
"#;
        let shell = Manifest::from_toml_str(src).unwrap().shell.unwrap();
        let attach = &shell.capabilities["attach"];
        assert_eq!(attach.require_approval, ShellApproval::Remembered);
        assert_eq!(attach.class_key.as_deref(), Some("hid"));
        // An ungated op defaults clean (no gate, no class).
        let list = &shell.capabilities["list"];
        assert!(list.require_approval.is_default());
        assert!(list.class_key.is_none());

        // class_key without a gate is a register-time contradiction.
        let bad = r#"
[adapter]
name = "usbip"
kind = "shell"
version = "1.0.0"
min_spt_core_version = "1.0.0"

[shell]
spawn = "usbip {link_token}"

[shell.capabilities.attach]
class_key = "hid"
"#;
        let e = Manifest::from_toml_str(bad).unwrap_err();
        assert!(matches!(e, ManifestError::Validation(_)));
        assert!(e.to_string().contains("class_key"), "{e}");
    }

    // [unit->REQ-MANIFEST-4] keyword matching: literal is case-insensitive
    // substring; regex opt-in compiles the pattern; an invalid regex silences
    // its hint (no match, no panic); first matching keyword is the one returned.
    #[test]
    fn hint_keyword_matching() {
        let literal = Hint {
            keywords: vec!["SPT send".into(), "owl".into()],
            text: "use $OWL send".into(),
            regex: false,
        };
        // Case-insensitive substring; returns the matched keyword.
        assert_eq!(literal.matched_keyword("how do i spt send a msg"), Some("SPT send"));
        assert_eq!(literal.matched_keyword("the OWL hoots"), Some("owl"));
        assert_eq!(literal.matched_keyword("nothing here"), None);

        // Regex opt-in.
        let re = Hint {
            keywords: vec![r"spt\s+\w+".into()],
            text: "t".into(),
            regex: true,
        };
        assert_eq!(re.matched_keyword("run spt send now"), Some(r"spt\s+\w+"));
        assert!(re.matched_keyword("sptsend").is_none(), "regex needs the whitespace");

        // An invalid regex matches nothing (best-effort, never panics).
        let bad = Hint {
            keywords: vec!["(unclosed".into()],
            text: "t".into(),
            regex: true,
        };
        assert!(bad.matched_keyword("(unclosed literal present").is_none());
    }

    // [unit->REQ-MANIFEST-4] a profile overlays [[hints]] by leaf-replace: the
    // array is replaced wholesale, not spliced.
    #[test]
    fn profile_overlays_hints_wholesale() {
        let parent = Manifest::from_toml_str(
            "[adapter]\nname = \"a\"\nkind = \"harness\"\nversion = \"1\"\n\
             min_spt_core_version = \"0\"\n\n\
             [[hints]]\nkeywords = [\"base\"]\ntext = \"base hint\"\n\n\
             [profiles.p]\n[[profiles.p.hints]]\nkeywords = [\"prof\"]\ntext = \"profile hint\"\n",
        )
        .expect("parses");
        assert_eq!(parent.hints.len(), 1);
        let merged = crate::profile::resolve(&parent, "p").expect("resolves");
        assert_eq!(merged.hints.len(), 1, "array replaced wholesale, not spliced");
        assert_eq!(merged.hints[0].text, "profile hint");
    }

    #[test] // [unit->REQ-MANIFEST-1]
    fn inject_env_without_value_fails_validation() {
        let bad = r#"
[adapter]
name = "x"
version = "1.0.0"
min_spt_core_version = "1.0.0"

[env.FOO]
direction = "inject"
"#;
        let e = Manifest::from_toml_str(bad).unwrap_err();
        assert!(e.to_string().contains("FOO"));
    }

    #[test] // [unit->REQ-MANIFEST-1]
    fn fetcher_strategy_requires_fetcher_field() {
        let bad = r#"
[adapter]
name = "x"
version = "1.0.0"
min_spt_core_version = "1.0.0"

[history]
strategy = "fetcher"
"#;
        let e = Manifest::from_toml_str(bad).unwrap_err();
        assert!(matches!(e, ManifestError::Validation(_)));
    }

    // [unit->REQ-TERM-5] the [digest] seam: an extractor reusing [history]'s
    // locate_template needs no own `source`; an own-source [digest] needs no
    // [history] at all; both round-trip through TOML.
    #[test]
    fn digest_section_source_resolution() {
        // Reuse [history] locate_template (no own source).
        let reuse = r#"
[adapter]
name = "x"
version = "1.0.0"
min_spt_core_version = "1.0.0"

[history]
strategy = "locate_normalize"
locate_template = "~/.h/{session_id}.jsonl"
normalize_command = "norm"

[digest]
extractor = "extract --in {source}"
"#;
        let m = Manifest::from_toml_str(reuse).expect("reuse-history parses");
        assert!(m.digest.as_ref().unwrap().source.is_none());
        let rt = Manifest::from_toml_str(&m.to_toml_string().unwrap()).unwrap();
        assert_eq!(m.digest, rt.digest, "[digest] round-trips");

        // Own source, no [history] section at all.
        let own = r#"
[adapter]
name = "x"
version = "1.0.0"
min_spt_core_version = "1.0.0"

[digest]
extractor = "extract --in {source}"
source = "~/logs/{session_id}.jsonl"
"#;
        let m = Manifest::from_toml_str(own).expect("own-source parses");
        assert_eq!(
            m.digest.unwrap().source.as_deref(),
            Some("~/logs/{session_id}.jsonl")
        );
    }

    // [unit->REQ-TERM-5] [digest] validation: an empty extractor, no resolvable
    // source, and a zero window are each rejected with an author-facing message.
    #[test]
    fn digest_section_validation_errors() {
        // Empty extractor.
        let empty = r#"
[adapter]
name = "x"
version = "1.0.0"
min_spt_core_version = "1.0.0"

[digest]
extractor = "  "
source = "s"
"#;
        let e = Manifest::from_toml_str(empty).unwrap_err();
        assert!(matches!(e, ManifestError::Validation(_)));
        assert!(e.to_string().contains("extractor"), "{e}");

        // No own source and no [history] locate_template → unresolvable.
        let no_src = r#"
[adapter]
name = "x"
version = "1.0.0"
min_spt_core_version = "1.0.0"

[history]
strategy = "fetcher"
fetcher = "f"

[digest]
extractor = "extract"
"#;
        let e = Manifest::from_toml_str(no_src).unwrap_err();
        assert!(matches!(e, ManifestError::Validation(_)));
        assert!(e.to_string().contains("source") || e.to_string().contains("locate_template"), "{e}");

        // Zero window.
        let zero = r#"
[adapter]
name = "x"
version = "1.0.0"
min_spt_core_version = "1.0.0"

[digest]
extractor = "extract"
source = "s"
window_turns = 0
"#;
        let e = Manifest::from_toml_str(zero).unwrap_err();
        assert!(e.to_string().contains("window_turns"), "{e}");
    }

    #[test] // [unit->REQ-MANIFEST-1]
    fn minimal_header_only_manifest_is_valid() {
        let m = Manifest::from_toml_str(
            "[adapter]\nname = \"min\"\nversion = \"1.0.0\"\nmin_spt_core_version = \"1.0.0\"\n",
        )
        .expect("parse");
        assert_eq!(m.adapter.name, "min");
        assert!(m.session.is_empty());
        assert!(m.hooks.is_empty());
    }

    #[test] // [unit->REQ-MANIFEST-1]
    fn syntactically_broken_toml_is_an_error_not_a_panic() {
        let e = Manifest::from_toml_str("this is not = = toml").unwrap_err();
        assert!(matches!(e, ManifestError::Parse(_)));
    }

    // [unit->REQ-MANIFEST-7] the optional adapter-declared shortcut basename:
    // absent ⇒ None (picker falls back to "spt" → spt-<id>); declared ⇒ read
    // verbatim (an adapter brands its shortcut, e.g. cc → cc-<id>); omitted from
    // serialization when absent (additive + N-1-safe).
    #[test]
    fn shortcut_basename_optional_with_default() {
        let m = Manifest::from_toml_str(HARNESS_FIXTURE).expect("parse");
        assert_eq!(m.adapter.shortcut_basename, None, "absent ⇒ None");

        let branded = HARNESS_FIXTURE.replace("[adapter]", "[adapter]\nshortcut_basename = \"cc\"");
        let m2 = Manifest::from_toml_str(&branded).expect("parse branded");
        assert_eq!(m2.adapter.shortcut_basename.as_deref(), Some("cc"));

        // N-1-safe serialization: the field is omitted when absent, present when set.
        assert!(!m.to_toml_string().unwrap().contains("shortcut_basename"));
        assert!(m2.to_toml_string().unwrap().contains("shortcut_basename"));
    }

    // [unit->REQ-MANIFEST-8] `[adapter] host_binaries` is the bind-time match-key:
    // additive + N-1-safe (an old manifest without it parses to an empty list, and
    // an empty list is omitted on serialization, like shortcut_basename); a
    // declared list round-trips.
    #[test]
    fn host_binaries_optional_and_n1_safe() {
        // Absent ⇒ empty (an old manifest parses clean).
        let m = Manifest::from_toml_str(HARNESS_FIXTURE).expect("parse");
        assert!(m.adapter.host_binaries.is_empty(), "absent ⇒ empty");
        assert!(
            !m.to_toml_string().unwrap().contains("host_binaries"),
            "empty list omitted on serialization"
        );

        // Declared ⇒ round-trips and serializes.
        let hosted = HARNESS_FIXTURE.replace(
            "[adapter]",
            "[adapter]\nhost_binaries = [\"claude\", \"claude-cli\"]",
        );
        let m2 = Manifest::from_toml_str(&hosted).expect("parse hosted");
        assert_eq!(m2.adapter.host_binaries, vec!["claude", "claude-cli"]);
        assert!(m2.to_toml_string().unwrap().contains("host_binaries"));
    }

    // ---- M6-D3: the published manifest JSON Schema ----

    #[test] // [unit->REQ-DOCS-4] the generated schema carries the stable $id at
            // the canonical Pages URL (ADR-0014), the adapter header's requireds, and
            // the closed enum vocabularies (the things adapter authors validate against).
    fn manifest_schema_shape() {
        let s = manifest_json_schema();
        let v: serde_json::Value = serde_json::from_str(&s).expect("schema is valid JSON");
        assert_eq!(
            v["$id"],
            "https://sabermage.github.io/spt-releases/manifest.schema.json"
        );
        assert_eq!(v["title"], "spt-core runtime manifest");
        // [adapter] is the only required top-level section.
        let req: Vec<&str> = v["required"]
            .as_array()
            .unwrap()
            .iter()
            .map(|x| x.as_str().unwrap())
            .collect();
        assert_eq!(req, ["adapter"], "only the header is mandatory");
        // The header's own requireds include the compat gate.
        let adapter_req = v["$defs"]["Adapter"]["required"].as_array().unwrap();
        assert!(adapter_req.iter().any(|x| x == "min_spt_core_version"));
        // Closed vocabularies surface as enums (e.g. the update avenues).
        let avenues = serde_json::to_string(&v["$defs"]["UpdateAvenue"]).unwrap();
        assert!(avenues.contains("file_pull") && avenues.contains("delegated"));
        // Doc comments ride along as descriptions (the schema IS documentation).
        assert!(
            s.contains("opaque"),
            "descriptions must carry the contract's language"
        );
    }

    /// The drift gate (M6-D3): the checked-in `manifest.schema.json` — the
    /// copy that ships to the docs site root and the release assets — must
    /// equal what the current derives generate. CI runs this in the normal
    /// sweep on every push, so the published schema can never lag the parser.
    /// Regenerate: `SPT_BLESS=1 cargo test -p spt-runtime manifest_schema`.
    // [int->REQ-DOCS-5]
    #[test]
    fn checked_in_schema_is_current() {
        let want = manifest_json_schema();
        let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("manifest.schema.json");
        if std::env::var("SPT_BLESS").ok().as_deref() == Some("1") {
            std::fs::write(&path, &want).expect("bless schema");
            return;
        }
        let got = std::fs::read_to_string(&path).expect(
            "checked-in manifest.schema.json (regenerate: SPT_BLESS=1 cargo test -p spt-runtime)",
        );
        assert_eq!(
            got.replace("\r\n", "\n"),
            want,
            "manifest.schema.json drifted from the derives — regenerate with \
             SPT_BLESS=1 cargo test -p spt-runtime"
        );
    }
}