<system-reminder>[Truncated: PARTIAL view — showing lines 1-1182 of 1658 total (29793 tokens, cap 25000). Call Read with offset=1183 limit=1182 for the next page, or Grep to find a specific section. Do NOT answer from this page alone if the answer may be further in the file.]</system-reminder>

1	//! Runtime-manifest schema — the typed form of [`docs/MANIFEST.md`].
2	//!
3	//! A manifest declares only what *varies per harness*; command templates are
4	//! **opaque strings** spt-core never parses (MANIFEST §Principle). This module
5	//! is the parse+validate front door: [`Manifest::from_toml_str`] deserializes
6	//! the TOML and runs [`Manifest::validate`], returning a [`ManifestError`] with
7	//! a human-readable message instead of panicking on bad adapter input.
8	//!
9	//! Closed vocabularies (adapter kind, history strategy, env direction, inject
10	//! method, identity source, update avenue, shell broadcast/receipt) are modeled
11	//! as enums so an unknown variant is a parse error pointing at the bad value —
12	//! the "helpful errors for adapter authors" open sub-item (MANIFEST §Open).
13	//!
14	//! Scope (M2a/T1): every section is *declared and parsed*. Executing the
15	//! lifecycle sections (`[session.psyche_*]`, commune/signoff dirs, `[history]`
16	//! source, `[update]` conduct) is M2b/M3 — here they only round-trip.
17	//
18	// [impl->REQ-MANIFEST-1]
19
20	use schemars::JsonSchema;
21	use serde::{Deserialize, Serialize};
22	use std::collections::BTreeMap;
23	use std::fmt;
24
25	/// A fully-parsed runtime manifest. Top-level sections map 1:1 to the
26	/// `docs/MANIFEST.md` tables; all but `[adapter]` are optional so a minimal
27	/// adapter declares only its header.
28	#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
29	pub struct Manifest {
30	    pub adapter: Adapter,
31	    /// `[hooks.<event>]` — inbound hook table, keyed by harness event name.
32	    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
33	    pub hooks: BTreeMap<String, Hook>,
34	    /// `[session]` — watched-dir keys plus the `[session.<role>]` templates.
35	    #[serde(default, skip_serializing_if = "Session::is_empty")]
36	    pub session: Session,
37	    /// `[env.<VAR>]` — env-var inject/read table.
38	    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
39	    pub env: BTreeMap<String, EnvVar>,
40	    #[serde(default, skip_serializing_if = "Option::is_none")]
41	    pub history: Option<History>,
42	    /// `[digest]` — the adapter-declared session-digest extractor seam (ADR-0019).
43	    #[serde(default, skip_serializing_if = "Option::is_none")]
44	    pub digest: Option<Digest>,
45	    #[serde(default, skip_serializing_if = "Option::is_none")]
46	    pub inject: Option<Inject>,
47	    /// `[message-idle-translation-binary]` — opt-in adapter idle-delivery
48	    /// translation binary (ADR-0022 / REQ-MSG-IDLE-TRANSLATION-BINARY). A TABLE
49	    /// carrying a `path` scalar (modeled as a table, not a bare top-level scalar, so
50	    /// an author who writes it after another section cannot have it silently
51	    /// absorbed — and so it stays N+1 extensible). The binary is a pure
52	    /// stdin→stdout JSON-lines filter: spt-core feeds it `init`/`event`/`input`
53	    /// lines and reads back `{key}`/`{delay_ms}`/`{text}` keystroke-commands, which
54	    /// spt-core applies to the broker-held PTY atomically (spt-core owns every PTY
55	    /// write). spt-core LIFECYCLE-manages it (spawn when the spt-hosted endpoint
56	    /// comes up, terminate when it goes down). A NEW manifest primitive — NOT
57	    /// collapsed into `[inject]`/`notif_command` — though it shares the poll-feed
58	    /// substrate. Absent ⇒ no translation binary (delivery falls back to the
59	    /// v0.11.0 degenerate raw-inject / spool).
60	    // [impl->REQ-MSG-IDLE-TRANSLATION-BINARY]
61	    #[serde(
62	        default,
63	        rename = "message-idle-translation-binary",
64	        skip_serializing_if = "Option::is_none"
65	    )]
66	    pub message_idle_translation_binary: Option<IdleTranslationBinary>,
67	    #[serde(default, skip_serializing_if = "Option::is_none")]
68	    pub identity: Option<Identity>,
69	    #[serde(default, skip_serializing_if = "Option::is_none")]
70	    pub update: Option<Update>,
71	    /// `[shell]` body — present iff `adapter.kind = "shell"` (validated).
72	    #[serde(default, skip_serializing_if = "Option::is_none")]
73	    pub shell: Option<Shell>,
74	    /// `[profiles.<name>]` — **shipped** profile overlays: sparse leaf-replace
75	    /// overlays declared by the adapter dev inside the parent manifest, updating
76	    /// as one unit with it. Stored raw ([`toml::Value`]); [`crate::profile::resolve`]
77	    /// merges one onto the base and re-validates the complete manifest. A bare
78	    /// `adapter_name` ignores these (parent unmodified); the composite
79	    /// `<adapter>:<profile>` selects one. **Local** (node-local, user-authored)
80	    /// profiles live beside the adapter in the registry, never here.
81	    /// (CONTEXT.md §adapter profile.)
82	    // [impl->REQ-MANIFEST-2]
83	    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
84	    #[schemars(with = "BTreeMap<String, serde_json::Value>")]
85	    pub profiles: BTreeMap<String, toml::Value>,
86	    /// `[strings]` — an adapter-authored KV tree of **opaque data** (spt-core
87	    /// never executes a string; command templates live in their own sections
88	    /// behind registration). Dot-path-readable via `spt adapter get-string`, and
89	    /// it rides the same leaf-replace profile overlay as the rest of the manifest
90	    /// (a shipped or local profile may override base strings). Node-local; no
91	    /// cross-node sync. (CONTEXT.md §adapter strings.)
92	    // [impl->REQ-MANIFEST-3]
93	    #[serde(default, skip_serializing_if = "toml::Table::is_empty")]
94	    #[schemars(with = "BTreeMap<String, serde_json::Value>")]
95	    pub strings: toml::Table,
96	    /// `[[hints]]` — once-per-session keyword hints (CONTEXT.md §keyword hints).
97	    /// **Order is significant** (first match wins). A profile overlays this by
98	    /// leaf-replace like any section — the array is replaced wholesale, never
99	    /// spliced (override/extend = re-declare).
100	    // [impl->REQ-MANIFEST-4]
101	    #[serde(default, skip_serializing_if = "Vec::is_empty")]
102	    pub hints: Vec<Hint>,
103	}
104
105	/// `[adapter]` — the manifest header, readable before any update (compat gate).
106	#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
107	pub struct Adapter {
108	    pub name: String,
109	    #[serde(default)]
110	    pub kind: AdapterKind,
111	    pub version: String,
112	    /// Lowest spt-core version this adapter tolerates (compat gate).
113	    pub min_spt_core_version: String,
114	    /// Endpoint types this adapter can host (`LiveAgent`, `Worker`, …).
115	    #[serde(default, skip_serializing_if = "Vec::is_empty")]
116	    pub hostable_types: Vec<String>,
117	    /// Optional basename for the `spt endpoint run` picker's `<basename>-<id>`
118	    /// launcher shortcut (REQ-MANIFEST-7). Absent ⇒ the harness-agnostic default
119	    /// `spt` (→ `spt-<id>`); an adapter sets this to brand its shortcuts
120	    /// (spt-claude-code → `cc`, giving `cc-<id>`). Additive + N-1-safe (omitted
121	    /// from serialization when absent). The picker reads it from the RESOLVED
122	    /// manifest of the selected adapter.
123	    #[serde(default, skip_serializing_if = "Option::is_none")]
124	    pub shortcut_basename: Option<String>,
125	    /// The harness executable basenames this `kind="harness"` adapter hosts
126	    /// agents inside (e.g. `host_binaries = ["claude"]`). The bind-time
127	    /// adapter-resolution match-key (REQ-MANIFEST-8 / ADR-0021): a harness
128	    /// session's parent pid → its exe basename selects the candidate adapters
129	    /// whose `host_binaries` contains it (case-insensitive, `.exe`-stripped), so
130	    /// `listen`/`poll` resolve the owning adapter at bind with no mandatory
131	    /// `--adapter`. Additive + N-1-safe (omitted from serialization when empty,
132	    /// like `shortcut_basename`); an empty list is harmless (the adapter is never
133	    /// a bind-time candidate, only reachable via the explicit `--adapter`).
134	    #[serde(default, skip_serializing_if = "Vec::is_empty")]
135	    pub host_binaries: Vec<String>,
136	}
137
138	/// The two adapter kinds. A `harness` hosts agents; a `shell` provides a driven
139	/// surface (MANIFEST §Shell adapters).
140	#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)]
141	#[serde(rename_all = "snake_case")]
142	pub enum AdapterKind {
143	    #[default]
144	    Harness,
145	    Shell,
146	}
147
148	/// `[hooks.<event>]` — one harness event → the `api` command it fires, the
149	/// stdin fields it maps, and whether it can surface context to the agent.
150	#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
151	pub struct Hook {
152	    /// Opaque `api …` command line the harness invokes for this event.
153	    pub fires: String,
154	    #[serde(default, skip_serializing_if = "Vec::is_empty")]
155	    pub reads: Vec<String>,
156	    /// Whether this hook can inject context (false ⇒ sentinel/relay fallback).
157	    #[serde(default)]
158	    pub can_inject: bool,
159	}
160
161	/// `[[hints]]` — one once-per-session keyword hint (CONTEXT.md §keyword hints).
162	/// The adapter's user-prompt hook pipes the full user message to `spt api hint`;
163	/// a matching keyword surfaces `text` to the agent's context channel, at most
164	/// once per session and once per message.
165	#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
166	pub struct Hint {
167	    /// Keywords that fire the hint — literal **case-insensitive substrings** by
168	    /// default; compiled as **regex** patterns when `regex = true`.
169	    #[serde(default, skip_serializing_if = "Vec::is_empty")]
170	    pub keywords: Vec<String>,
171	    /// The hint text surfaced when a keyword matches.
172	    pub text: String,
173	    /// Treat `keywords` as regex patterns instead of literal substrings.
174	    #[serde(default)]
175	    pub regex: bool,
176	}
177
178	impl Hint {
179	    /// The first keyword that fires against `message` (literal case-insensitive
180	    /// substring, or regex when `regex = true`); `None` if none match. An invalid
181	    /// regex never matches — a bad pattern silences its own hint, never panics
182	    /// (best-effort, the opaque-template stance).
183	    // [impl->REQ-MANIFEST-4]
184	    pub fn matched_keyword(&self, message: &str) -> Option<&str> {
185	        let lowered = message.to_lowercase();
186	        self.keywords
187	            .iter()
188	            .find(|kw| {
189	                if self.regex {
190	                    regex::Regex::new(kw)
191	                        .map(|re| re.is_match(message))
192	                        .unwrap_or(false)
193	                } else {
194	                    lowered.contains(&kw.to_lowercase())
195	                }
196	            })
197	            .map(String::as_str)
198	    }
199	}
200
201	/// `[session]` — the watched-dir keys (`commune_dir`/`signoff_dir`) co-located
202	/// with the fixed set of `[session.<role>]` command templates.
203	#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema)]
204	pub struct Session {
205	    // Watched dirs (direct `[session]` keys). Filenames are spt-core-fixed;
206	    // only the directory is adapter-declared (MANIFEST §watched dirs).
207	    #[serde(default, skip_serializing_if = "Option::is_none")]
208	    pub commune_dir: Option<String>,
209	    #[serde(default, skip_serializing_if = "Option::is_none")]
210	    pub signoff_dir: Option<String>,
211	    // Role templates (`[session.<role>]`). `self` is the agent's own session;
212	    // the `psyche_*`/`echo_commune`/`signoff` roles are parsed here but only
213	    // *executed* in M2b.
214	    #[serde(rename = "self", default, skip_serializing_if = "Option::is_none")]
215	    pub self_: Option<SessionRole>,
216	    /// `[session.resume]` — the agent's OWN-session NATIVE resume (the `self_`
217	    /// sibling, mirroring `psyche_init`→`psyche_resume`). Selected over `self_`
218	    /// only when a bringup carries `--resume <session>` AND this role is declared;
219	    /// absent ⇒ fall back to `[session.self]` (full back-compat). Keys spt-core
220	    /// fills are the SAME catalog as `self`: `{id}`, `{session_id}` (the resumed
221	    /// id), `{session_name}`, `{adapter_name}` (REQ-SESSION-RESUME-TEMPLATE).
222	    // [impl->REQ-SESSION-RESUME-TEMPLATE]
223	    #[serde(default, skip_serializing_if = "Option::is_none")]
224	    pub resume: Option<SessionRole>,
225	    #[serde(default, skip_serializing_if = "Option::is_none")]
226	    pub psyche_init: Option<SessionRole>,
227	    #[serde(default, skip_serializing_if = "Option::is_none")]
228	    pub psyche_resume: Option<SessionRole>,
229	    #[serde(default, skip_serializing_if = "Option::is_none")]
230	    pub echo_commune: Option<SessionRole>,
231	    #[serde(default, skip_serializing_if = "Option::is_none")]
232	    pub signoff: Option<SessionRole>,
233	    /// `[session.notif]` — the endpoint-native notification render
234	    /// (ADR-0007's `notif_command` seam, REQ-NOTIF-2): an OS toast, a
235	    /// GameRobot `alert-symbol`, anything the adapter can run. Spawned
236	    /// detached when a notif surfaces at this endpoint, combinable with the
237	    /// agent-surface delivery. Keys spt-core fills: `{notif_id}`,
238	    /// `{notif_from}`, `{notif_subnet}`, `{notif_body}`.
239	    // [impl->REQ-NOTIF-2]
240	    #[serde(default, skip_serializing_if = "Option::is_none")]
241	    pub notif: Option<SessionRole>,
242	}
243
244	impl Session {
245	    /// True when no dirs and no roles are declared (skip-serialize helper).
246	    pub fn is_empty(&self) -> bool {
247	        self.commune_dir.is_none()
248	            && self.signoff_dir.is_none()
249	            && self.self_.is_none()
250	            && self.resume.is_none()
251	            && self.psyche_init.is_none()
252	            && self.psyche_resume.is_none()
253	            && self.echo_commune.is_none()
254	            && self.signoff.is_none()
255	            && self.notif.is_none()
256	    }
257
258	    /// All declared role templates, paired with their role name. Used by the
259	    /// runtime to enumerate spawnable roles.
260	    pub fn roles(&self) -> Vec<(&'static str, &SessionRole)> {
261	        let mut out = Vec::new();
262	        for (name, role) in [
263	            ("self", &self.self_),
264	            ("resume", &self.resume),
265	            ("psyche_init", &self.psyche_init),
266	            ("psyche_resume", &self.psyche_resume),
267	            ("echo_commune", &self.echo_commune),
268	            ("signoff", &self.signoff),
269	            ("notif", &self.notif),
270	        ] {
271	            if let Some(r) = role {
272	                out.push((name, r));
273	            }
274	        }
275	        out
276	    }
277	}
278
279	/// `[session.<role>]` — one opaque outbound command template plus its spawn
280	/// context. Model/tools/flags all live inside `command`, never as fields
281	/// (MANIFEST §session roles). No nested tables here (keeps TOML round-trip
282	/// emission scalar-before-table clean).
283	#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
284	pub struct SessionRole {
285	    /// Opaque command line, with `{key}` substitution placeholders.
286	    pub command: String,
287	    #[serde(default, skip_serializing_if = "Option::is_none")]
288	    pub cwd: Option<String>,
289	    /// Env var set on summarizer children so their own hooks bail (recursion
290	    /// guard).
291	    #[serde(default, skip_serializing_if = "Option::is_none")]
292	    pub recursion_guard_env: Option<String>,
293	    #[serde(default)]
294	    pub detach: bool,
295	    /// Env vars to strip from the child's inherited environment.
296	    #[serde(default, skip_serializing_if = "Vec::is_empty")]
297	    pub env_remove: Vec<String>,
298	    /// Substitution keys spt-core guarantees to fill for this role.
299	    #[serde(default, skip_serializing_if = "Vec::is_empty")]
300	    pub keys: Vec<String>,
301	}
302
303	/// `[env.<VAR>]` — a single env-var directive.
304	#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
305	pub struct EnvVar {
306	    pub direction: EnvDirection,
307	    /// Value to inject (with substitution); required for `inject`.
308	    #[serde(default, skip_serializing_if = "Option::is_none")]
309	    pub value: Option<String>,
310	    /// Harness-hosted injection channel (spt-hosted inherits from the broker).
311	    #[serde(default, skip_serializing_if = "Option::is_none")]
312	    pub channel: Option<String>,
313	}
314
315	#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
316	#[serde(rename_all = "snake_case")]
317	pub enum EnvDirection {
318	    Inject,
319	    Read,
320	}
321
322	/// `[history]` — transcript access strategy.
323	#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
324	pub struct History {
325	    pub strategy: HistoryStrategy,
326	    /// `fetcher` strategy: adapter binary emitting normalized history.
327	    #[serde(default, skip_serializing_if = "Option::is_none")]
328	    pub fetcher: Option<String>,
329	    /// `locate_normalize` strategy: where the raw transcript lives.
330	    #[serde(default, skip_serializing_if = "Option::is_none")]
331	    pub locate_template: Option<String>,
332	    /// `locate_normalize` strategy: command normalizing the raw transcript.
333	    #[serde(default, skip_serializing_if = "Option::is_none")]
334	    pub normalize_command: Option<String>,
335	}
336
337	#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
338	#[serde(rename_all = "snake_case")]
339	pub enum HistoryStrategy {
340	    /// spt-core asks the adapter (pull, adapter binary emits normalized).
341	    Fetcher,
342	    /// spt-core locates the raw transcript then normalizes it.
343	    LocateNormalize,
344	    /// Adapter pushes via `api history-log`; spt-core stores (Path-B).
345	    Native,
346	}
347
348	/// `[digest]` — the session-digest extractor seam (ADR-0019). Reverses M9's
349	/// "no manifest seam" stance: the digest gets its **own** adapter-declared
350	/// extractor, distinct from `[history]` (which stays opaque + single-session and
351	/// feeds the echo-commune verbatim). The extractor maps the harness's **native**
352	/// log → the published `{role, text, tool, ts}` digest-record contract
353	/// ([`spt_term::DigestRecord`]).
354	///
355	/// **Imperative, not a DSL** (ADR-0019 §Decision): real harness logs are nested
356	/// (one line → many entries, mixed block lists, types to filter) — a flat
357	/// declarative map cannot express them, and a map powerful enough is a reinvented
358	/// language. So the extractor is an opaque command spt-core never parses, exactly
359	/// like every other manifest template.
360	///
361	/// **Source.** By default the extractor reads the **same files as `[history]`**
362	/// (the `locate_template`; DRY). An adapter may override with `source` (the
363	/// own-source escape hatch). `api digest-entry` push remains the always-available
364	/// fallback for a log-less adapter (which declares no `[digest]` at all).
365	///
366	/// **Presentation.** `window_turns`, `arg_truncation`, and `sprint_collapse` are
367	/// adapter-declared **defaults** any consumer may override at pull/subscribe;
368	/// spt-core ships fallback defaults ([`spt_term::DigestConfig`]) when absent. The
369	/// fixed "~3 turns" is no longer an spt-core requirement (ADR-0019).
370	#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
371	pub struct Digest {
372	    /// Opaque extractor command: native harness log → the `{role,text,tool,ts}`
373	    /// contract (one JSON record per output line). `{key}` substitution applies
374	    /// (e.g. `{session_id}`, and `{source}` for the resolved log path).
375	    pub extractor: String,
376	    /// Own-source escape hatch: a `locate_template` for the log file(s) the
377	    /// extractor reads. Absent ⇒ reuse `[history].locate_template` (DRY).
378	    #[serde(default, skip_serializing_if = "Option::is_none")]
379	    pub source: Option<String>,
380	    /// Adapter-default window depth (user turns kept). Absent ⇒ spt-core fallback.
381	    #[serde(default, skip_serializing_if = "Option::is_none")]
382	    pub window_turns: Option<usize>,
383	    /// Adapter-default tool-arg truncation width. Absent ⇒ spt-core fallback.
384	    #[serde(default, skip_serializing_if = "Option::is_none")]
385	    pub arg_truncation: Option<usize>,
386	    /// Adapter-default for collapsing consecutive tool records into one sprint.
387	    /// Absent ⇒ spt-core fallback (collapse on).
388	    #[serde(default, skip_serializing_if = "Option::is_none")]
389	    pub sprint_collapse: Option<bool>,
390	}
391
392	/// `[inject]` — inject-input methods per activity state.
393	#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema)]
394	pub struct Inject {
395	    #[serde(default, skip_serializing_if = "Vec::is_empty")]
396	    pub activity: Vec<InjectMethod>,
397	    #[serde(default, skip_serializing_if = "Vec::is_empty")]
398	    pub idle: Vec<InjectMethod>,
399	}
400
401	#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
402	#[serde(rename_all = "snake_case")]
403	pub enum InjectMethod {
404	    Pty,
405	    Hook,
406	    Relay,
407	    Http,
408	}
409
410	/// `[message-idle-translation-binary]` — the opt-in idle-delivery translation
411	/// binary (ADR-0022). A table so the contract degrades gracefully: spt-core does
412	/// NOT `deny_unknown_fields`, so a newer adapter declaring a future key (e.g. a
413	/// spawn timeout) against an older spt-core parses fine — the unknown key is
414	/// ignored, never a hard manifest failure (a lifecycle-binary contract perri
415	/// builds blind from docs must be forward-compatible). Known keys today: `path`.
416	#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
417	pub struct IdleTranslationBinary {
418	    /// The binary PATH spt-core spawns and lifecycle-manages. Opaque (resolved at
419	    /// endpoint bringup like every other adapter command); validated non-empty.
420	    pub path: String,
421	}
422
423	/// `[identity]` — how the harness's session id is obtained.
424	#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
425	pub struct Identity {
426	    pub session_id_source: SessionIdSource,
427	    /// Process-tree anchor name when `session_id` is absent.
428	    #[serde(default, skip_serializing_if = "Option::is_none")]
429	    pub parent_ancestor_name: Option<String>,
430	}
431
432	#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
433	#[serde(rename_all = "snake_case")]
434	pub enum SessionIdSource {
435	    /// Discovered after spawn (process-tree / wrapper handoff).
436	    PostSpawn,
437	    /// Injected as a UUID the harness echoes back.
438	    UuidInject,
439	}
440
441	/// `[update]` — adapter self-update directive (parsed in M2a; conducted in M3).
442	#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
443	pub struct Update {
444	    pub avenue: UpdateAvenue,
445	    /// `delegated` avenue: the command spt-core delegates to.
446	    #[serde(default, skip_serializing_if = "Option::is_none")]
447	    pub command: Option<String>,
448	    /// `file_pull` / `gh_release` avenue: source repo. For `gh_release` this is
449	    /// the `user/repo` whose **GitHub releases** the adapter ships updates from.
450	    #[serde(default, skip_serializing_if = "Option::is_none")]
451	    pub repo: Option<String>,
452	    /// `file_pull` avenue: path selector.
453	    #[serde(default, skip_serializing_if = "Option::is_none")]
454	    pub path_regex: Option<String>,
455	    /// `gh_release` avenue: the release **asset** name to fetch (the adapter
456	    /// `.spt` archive). Absent ⇒ the default `adapter.spt`, matching the
457	    /// `spt adapter add --release` acquisition primitive (REQ-INSTALL-9). Not
458	    /// applicable to `delegated` / `file_pull`.
459	    // [impl->REQ-UPD-9]
460	    #[serde(default, skip_serializing_if = "Option::is_none")]
461	    pub asset: Option<String>,
462	    /// The adapter's Ed25519 **content-signing public key** (64 hex chars / 32
463	    /// bytes). spt-core verifies a pulled payload against this per-adapter key
464	    /// before applying it (REQ-UPD-5 adapter content signing, ADR-0004 §D) — the
465	    /// adapter author signs their own releases; spt-core's release key stays
466	    /// scoped to spt-core. **Required for `file_pull`** (there are bytes to
467	    /// verify); **optional for `gh_release`** (absent ⇒ HTTPS+GitHub
468	    /// first-acquisition trust, the same trust `spt adapter add --release` and
469	    /// the installer first-fetch place; present ⇒ the fetched `.spt` is verified
470	    /// fail-closed against this key, REQ-UPD-9); not applicable to `delegated`
471	    /// (opaque updater).
472	    #[serde(default, skip_serializing_if = "Option::is_none")]
473	    pub signing_key: Option<String>,
474	    /// `delegated` avenue: the adapter attests its own updater verifies the
475	    /// content it installs (e.g. `claude.exe plugin update` checks its own
476	    /// signatures). spt-core cannot see a delegated updater's bytes, so it
477	    /// delegates the trust **only** when this is set; an unattested delegated
478	    /// update is skipped as unverifiable (REQ-UPD-5).
479	    #[serde(default)]
480	    pub self_verifies: bool,
481	    /// Verify spt-core satisfies `min_spt_core_version` before/after.
482	    #[serde(default)]
483	    pub version_check: bool,
484	    /// Optional inverse of install — run by `spt adapter remove` once the adapter
485	    /// is quiesced (the mirror of `spt adapter add`, which reuses this section as
486	    /// the install mechanism). Absent ⇒ spt-core's default cleanup. (Modeled in
487	    /// M2a; conducted with adapter-registration later.)
488	    #[serde(default, skip_serializing_if = "Option::is_none")]
489	    pub uninstall: Option<String>,
490	}
491
492	#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
493	#[serde(rename_all = "snake_case")]
494	pub enum UpdateAvenue {
495	    /// Delegate to the adapter's own updater (e.g. `claude plugin update`).
496	    Delegated,
497	    /// spt-core pulls files from a repo.
498	    FilePull,
499	    /// spt-core ships updates from the adapter's own **GitHub releases**
500	    /// (REQ-UPD-9): compare the repo's latest release version against the
501	    /// installed adapter version and, when newer, fetch the release `.spt`
502	    /// archive (the REQ-INSTALL-9 `--release` primitive), verify it against an
503	    /// optional `signing_key` (else HTTPS+GitHub trust), and re-register. No
504	    /// signing tooling or plugin coupling required of the adapter author.
505	    // [impl->REQ-UPD-9]
506	    GhRelease,
507	}
508
509	/// `[shell]` — the body of a `kind = "shell"` adapter (a driven surface).
510	#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
511	pub struct Shell {
512	    /// Broker-launched opaque spawn command.
513	    pub spawn: String,
514	    /// Ephemeral ⇒ no offline perch + no history retention.
515	    #[serde(default)]
516	    pub ephemeral: bool,
517	    #[serde(default, skip_serializing_if = "Option::is_none")]
518	    pub broadcast: Option<Broadcast>,
519	    /// How the shell receives agent commands.
520	    #[serde(default, skip_serializing_if = "Option::is_none")]
521	    pub command_receipt: Option<CommandReceipt>,
522	    /// Instruction sent to the binary on link-break.
523	    #[serde(default, skip_serializing_if = "Option::is_none")]
524	    pub pre_close: Option<String>,
525	    /// Graceful-termination window before force-close.
526	    #[serde(default, skip_serializing_if = "Option::is_none")]
527	    pub close_timeout_ms: Option<u64>,
528	    /// Auto-online whenever the owner endpoint is online.
529	    #[serde(default)]
530	    pub persistent: bool,
531	    /// Long-running wake-watcher run WHILE offline; exit ⇒ revive.
532	    #[serde(default, skip_serializing_if = "Option::is_none")]
533	    pub wake_command: Option<String>,
534	    /// Whether the shell may fire `api owner-shutdown` to suspend its owner.
535	    #[serde(default)]
536	    pub can_shutdown: bool,
537	    /// Per-spawn user approval gate (floor; a node/endpoint setting may tighten).
538	    /// Absent ⇒ `none`. (Modeled now; conducted when shells land.)
539	    #[serde(default, skip_serializing_if = "ShellApproval::is_default")]
540	    pub require_approval: ShellApproval,
541	    /// Optional ceiling on concurrent-existing instances per owner endpoint
542	    /// (online + offline both count). Absent ⇒ unlimited.
543	    #[serde(default, skip_serializing_if = "Option::is_none")]
544	    pub max_instances_per_owner: Option<u32>,
545	    /// What happens at the cap: `reject` (default) or `approve` (per-spawn
546	    /// approval beyond the cap; does not raise it). Only meaningful with a cap.
547	    #[serde(default, skip_serializing_if = "OverCap::is_default")]
548	    pub over_cap: OverCap,
549	    /// `[shell.capabilities]` — the command vocabulary (agent→shell).
550	    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
551	    pub capabilities: BTreeMap<String, ShellCapability>,
552	    /// `[shell.sensory]` — the sensory vocabulary (shell→agent).
553	    #[serde(default, skip_serializing_if = "Sensory::is_empty")]
554	    pub sensory: Sensory,
555	    /// `[shell.drive]` — the drive vocabulary (agent→shell, REST-only ephemeral
556	    /// control; M11-W2, REQ-SHELL-3). The owner→shell mirror of `[shell.sensory]`.
557	    #[serde(default, skip_serializing_if = "Drive::is_empty")]
558	    pub drive: Drive,
559	    /// `[shell.tunnel]` — opt-in for the opaque reliable-ordered byte tunnel
560	    /// (M11-W3, REQ-SHELL-4). Absent ⇒ no tunnel.
561	    #[serde(default, skip_serializing_if = "Tunnel::is_default")]
562	    pub tunnel: Tunnel,
563	}
564
565	/// One entry in `[shell.capabilities]` — a command and its argument names.
566	///
567	/// A capability may carry its own **act-gate** (M11, REQ-CONSENT-3): the same
568	/// `require_approval` enum the spawn gate uses, riding the individual op so the
569	/// *dangerous act* is gated, not just the spawn. An optional `class_key` scopes
570	/// the written grant finer than the op id — granted per `(owner × class × node)`
571	/// (the usbip `attach`'s device class), so a remembered HID-class grant never
572	/// authorizes a storage-class act. Spawn gates govern existence; capability
573	/// gates govern acts (CONTEXT §"per-capability approval gates").
574	// [impl->REQ-CONSENT-3]
575	#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema)]
576	pub struct ShellCapability {
577	    #[serde(default, skip_serializing_if = "Vec::is_empty")]
578	    pub args: Vec<String>,
579	    /// Per-act approval gate (floor; a node/endpoint setting may tighten).
580	    /// Absent ⇒ `none` (ungated).
581	    #[serde(default, skip_serializing_if = "ShellApproval::is_default")]
582	    pub require_approval: ShellApproval,
583	    /// Optional grant-qualifier class so a grant scopes finer than the op id
584	    /// (`(owner × class × node)`). Only meaningful with a gated `require_approval`.
585	    #[serde(default, skip_serializing_if = "Option::is_none")]
586	    pub class_key: Option<String>,
587	}
588
589	/// `[shell.sensory]` — the sensory payload types a shell may emit.
590	#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema)]
591	pub struct Sensory {
592	    #[serde(default, skip_serializing_if = "Vec::is_empty")]
593	    pub types: Vec<String>,
594	}
595
596	impl Sensory {
597	    pub fn is_empty(&self) -> bool {
598	        self.types.is_empty()
599	    }
600	}
601
602	/// `[shell.drive]` — the drive payload types an agent may push to a shell
603	/// (M11-W2, REQ-SHELL-3). The owner→shell mirror of [`Sensory`]: REST-only,
604	/// ephemeral latest-wins, never spooled.
605	// [impl->REQ-SHELL-3]
606	#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema)]
607	pub struct Drive {
608	    #[serde(default, skip_serializing_if = "Vec::is_empty")]
609	    pub types: Vec<String>,
610	}
611
612	impl Drive {
613	    pub fn is_empty(&self) -> bool {
614	        self.types.is_empty()
615	    }
616	}
617
618	/// `[shell.tunnel]` — the opt-in for the opaque reliable-ordered byte tunnel
619	/// (M11-W3, REQ-SHELL-4): a dedicated QUIC stream pair bound to the owner↔shell
620	/// link, carrying wire protocol traffic the channel taxonomy must NOT reinterpret
621	/// (first consumer: USB/IP URB traffic). Not enveloped, not MAC-framed, not
622	/// spooled; the link lifecycle governs it (a link-break closes the tunnel).
623	/// Reliable-ordered ⇒ congestion surfaces as lag never loss ⇒ on-LAN posture.
624	// [impl->REQ-SHELL-4]
625	#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema)]
626	pub struct Tunnel {
627	    /// Whether this shell opens the opaque tunnel on link-up.
628	    #[serde(default)]
629	    pub enable: bool,
630	    /// Optional diagnostic label for the opaque wire protocol (e.g. `usbip-urb`).
631	    /// Informational only — the substrate never interprets tunnel bytes.
632	    #[serde(default, skip_serializing_if = "Option::is_none")]
633	    pub protocol: Option<String>,
634	}
635
636	impl Tunnel {
637	    /// Default ⇒ disabled with no label (the `skip_serializing_if` predicate so an
638	    /// untunnel'd shell never emits a `[shell.tunnel]` table).
639	    pub fn is_default(&self) -> bool {
640	        !self.enable && self.protocol.is_none()
641	    }
642	}
643
644	#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
645	pub enum Broadcast {
646	    #[serde(rename = "subnet")]
647	    Subnet,
648	    #[serde(rename = "same-node")]
649	    SameNode,
650	    #[serde(rename = "none")]
651	    None,
652	}
653
654	#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
655	#[serde(rename_all = "snake_case")]
656	pub enum CommandReceipt {
657	    Http,
658	    Stdin,
659	    Relay,
660	}
661
662	/// Per-shell instantiation-approval mode (`require_approval`). Reuses the consent
663	/// plumbing: `remembered` lets allow-always write a persistent grant; `always`
664	/// suppresses allow-always (prompt every spawn).
665	#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
666	#[serde(rename_all = "snake_case")]
667	pub enum ShellApproval {
668	    /// No approval (default — matches the system's everything-opt-in posture).
669	    #[default]
670	    None,
671	    /// Prompt; allow-always persists a grant, later spawns auto-allow.
672	    Remembered,
673	    /// Prompt on every spawn; allow-always suppressed (no persistent grant).
674	    Always,
675	}
676
677	impl ShellApproval {
678	    /// Whether this is the default (`none`) — for `skip_serializing_if`.
679	    pub fn is_default(&self) -> bool {
680	        matches!(self, ShellApproval::None)
681	    }
682	}
683
684	/// What happens when an owner is at its `max_instances_per_owner` cap.
685	#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
686	#[serde(rename_all = "snake_case")]
687	pub enum OverCap {
688	    /// Refuse the spawn outright (default).
689	    #[default]
690	    Reject,
691	    /// Require per-spawn approval beyond the cap (does not raise the cap).
692	    Approve,
693	}
694
695	impl OverCap {
696	    /// Whether this is the default (`reject`) — for `skip_serializing_if`.
697	    pub fn is_default(&self) -> bool {
698	        matches!(self, OverCap::Reject)
699	    }
700	}
701
702	/// Parse/validation failure — carries a human-readable message for the adapter
703	/// author rather than aborting.
704	#[derive(Debug)]
705	pub enum ManifestError {
706	    /// TOML deserialization failed (syntax or an unknown enum variant).
707	    Parse(toml::de::Error),
708	    /// Parsed cleanly but violated a cross-field invariant.
709	    Validation(String),
710	    /// A requested profile name is not declared on the (merged) adapter manifest.
711	    UnknownProfile(String),
712	}
713
714	impl fmt::Display for ManifestError {
715	    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
716	        match self {
717	            ManifestError::Parse(e) => write!(f, "manifest parse error: {e}"),
718	            ManifestError::Validation(msg) => write!(f, "manifest invalid: {msg}"),
719	            ManifestError::UnknownProfile(name) => {
720	                write!(f, "unknown adapter profile '{name}'")
721	            }
722	        }
723	    }
724	}
725
726	impl std::error::Error for ManifestError {}
727
728	impl From<toml::de::Error> for ManifestError {
729	    fn from(e: toml::de::Error) -> Self {
730	        ManifestError::Parse(e)
731	    }
732	}
733
734	/// The published manifest JSON Schema (M6-D3, REQ-DOCS-4): generated from the
735	/// **same derives that parse adapter manifests**, so the published contract
736	/// structurally cannot drift from the parser. Carries a stable `$id` at the
737	/// canonical Pages URL (ADR-0014). The checked-in copy
738	/// (`crates/spt-runtime/manifest.schema.json`) is the site/release asset;
739	/// the `checked_in_schema_is_current` test is the drift gate (regenerate with
740	/// `SPT_BLESS=1 cargo test -p spt-runtime`).
741	///
742	/// The schema documents the *data model*; the on-disk format is TOML (JSON
743	/// Schema validates the equivalent JSON document — what `spt adapter add`'s
744	/// validate-first step checks after TOML parse). Cross-field invariants
745	/// ([`Manifest::validate`]) are enforced by spt-core, not expressible here.
746	// [impl->REQ-DOCS-4]
747	// [impl->REQ-DOCS-5]
748	pub fn manifest_json_schema() -> String {
749	    let schema = schemars::schema_for!(Manifest);
750	    let mut v = serde_json::to_value(&schema).expect("schema is JSON");
751	    let obj = v.as_object_mut().expect("root schema is an object");
752	    obj.insert(
753	        "$id".into(),
754	        "https://sabermage.github.io/spt-releases/manifest.schema.json".into(),
755	    );
756	    obj.insert("title".into(), "spt-core runtime manifest".into());
757	    obj.insert(
758	        "description".into(),
759	        "Per-adapter runtime manifest for the spt-core harness contract. \
760	         Authored as TOML (this schema describes the equivalent data model). \
761	         A manifest declares only what varies per harness/shell; command \
762	         templates are opaque strings spt-core never parses. Cross-field \
763	         invariants (kind<->[shell] agreement, strategy/avenue field \
764	         requirements) are enforced by spt-core's validate step beyond this \
765	         schema."
766	            .into(),
767	    );
768	    let mut s = serde_json::to_string_pretty(&v).expect("schema serializes");
769	    s.push('\n');
770	    s
771	}
772
773	impl Manifest {
774	    /// Parse a manifest from TOML text, then validate its cross-field
775	    /// invariants. Never panics on adapter input — malformed TOML, an unknown
776	    /// enum variant, or a broken invariant all return [`ManifestError`].
777	    pub fn from_toml_str(s: &str) -> Result<Manifest, ManifestError> {
778	        let manifest: Manifest = toml::from_str(s)?;
779	        manifest.validate()?;
780	        Ok(manifest)
781	    }
782
783	    /// Serialize back to TOML. Section structs are ordered scalars-before-tables
784	    /// so the emitter never hits "values must precede tables".
785	    pub fn to_toml_string(&self) -> Result<String, toml::ser::Error> {
786	        toml::to_string(self)
787	    }
788
789	    /// Check the cross-field invariants the type system can't: kind↔body
790	    /// agreement, strategy↔field presence, inject completeness.
791	    pub fn validate(&self) -> Result<(), ManifestError> {
792	        let err = |m: String| Err(ManifestError::Validation(m));
793
794	        if self.adapter.name.trim().is_empty() {
795	            return err("adapter.name must not be empty".into());
796	        }
797	        if self.adapter.version.trim().is_empty() {
798	            return err(format!(
799	                "adapter '{}': version must not be empty",
800	                self.adapter.name
801	            ));
802	        }
803
804	        // kind ↔ [shell] body agreement.
805	        match self.adapter.kind {
806	            AdapterKind::Shell if self.shell.is_none() => {
807	                return err(format!(
808	                    "shell adapter '{}' is missing its [shell] section",
809	                    self.adapter.name
810	                ));
811	            }
812	            AdapterKind::Harness if self.shell.is_some() => {
813	                return err(format!(
814	                    "harness adapter '{}' must not declare a [shell] section (set kind = \"shell\")",
815	                    self.adapter.name
816	                ));
817	            }
818	            _ => {}
819	        }
820
821	        // [shell.capabilities] act-gates (M11, REQ-CONSENT-3): a `class_key`
822	        // only scopes a grant, so it is meaningless without a gated
823	        // `require_approval` — refuse the contradiction at register time.
824	        // [impl->REQ-CONSENT-3]
825	        if let Some(shell) = &self.shell {
826	            // [shell.tunnel] (M11-W3, REQ-SHELL-4): the diagnostic protocol label
827	            // describes the tunnel's opaque wire — meaningless without the tunnel
828	            // enabled. Refuse the contradiction at register time.
829	            // [impl->REQ-SHELL-4]
830	            if shell.tunnel.protocol.is_some() && !shell.tunnel.enable {
831	                return err(
832	                    "[shell.tunnel]: `protocol` label needs `enable = true` — the label only \
833	                     describes an enabled tunnel's opaque wire"
834	                        .into(),
835	                );
836	            }
837	            for (op, cap) in &shell.capabilities {
838	                if cap.class_key.is_some() && cap.require_approval.is_default() {
839	                    return err(format!(
840	                        "[shell.capabilities.{op}]: `class_key` needs a gated `require_approval` \
841	                         (remembered|always) — a class key only scopes a grant"
842	                    ));
843	                }
844	            }
845	        }
846
847	        // history strategy ↔ required fields.
848	        if let Some(h) = &self.history {
849	            match h.strategy {
850	                HistoryStrategy::Fetcher if h.fetcher.is_none() => {
851	                    return err("[history] strategy = \"fetcher\" requires `fetcher`".into());
852	                }
853	                HistoryStrategy::LocateNormalize
854	                    if h.locate_template.is_none() || h.normalize_command.is_none() =>
855	                {
856	                    return err("[history] strategy = \"locate_normalize\" requires both `locate_template` and `normalize_command`".into());
857	                }
858	                _ => {}
859	            }
860	        }
861
862	        // [digest] (ADR-0019): the extractor must be present, the log source must
863	        // be resolvable (own `source`, or a [history] locate_template to reuse),
864	        // and a declared window must be non-zero.
865	        if let Some(d) = &self.digest {
866	            if d.extractor.trim().is_empty() {
867	                return err("[digest] requires a non-empty `extractor` command".into());
868	            }
869	            let has_history_locate = self
870	                .history
871	                .as_ref()
872	                .is_some_and(|h| h.locate_template.is_some());
873	            if d.source.is_none() && !has_history_locate {
874	                return err(
875	                    "[digest] needs `source` (own-source) or a [history] `locate_template` to read from \
876	                     (a log-less adapter pushes via `api digest-entry` and declares no [digest])"
877	                        .into(),
878	                );
879	            }
880	            if d.window_turns == Some(0) {
881	                return err("[digest] `window_turns` must be greater than 0".into());
882	            }
883	        }
884
885	        // message-idle-translation-binary (ADR-0022): when declared it must be a
886	        // non-empty path — an empty value is an adapter authoring mistake (spt-core
887	        // would try to spawn nothing). The path itself is opaque (resolved at
888	        // endpoint bringup, like every other adapter command).
889	        // [impl->REQ-MSG-IDLE-TRANSLATION-BINARY]
890	        if let Some(bin) = &self.message_idle_translation_binary {
891	            if bin.path.trim().is_empty() {
892	                return err(
893	                    "[message-idle-translation-binary] `path` must be non-empty (omit the table \
894	                     entirely for no translation binary)"
895	                        .into(),
896	                );
897	            }
898	        }
899
900	        // env: an inject directive needs a value.
901	        for (name, var) in &self.env {
902	            if var.direction == EnvDirection::Inject && var.value.is_none() {
903	                return err(format!(
904	                    "[env.{name}] direction = \"inject\" requires a `value`"
905	                ));
906	            }
907	        }
908
909	        // update avenue ↔ required fields.
910	        if let Some(u) = &self.update {
911	            match u.avenue {
912	                UpdateAvenue::Delegated if u.command.is_none() => {
913	                    return err("[update] avenue = \"delegated\" requires `command`".into());
914	                }
915	                UpdateAvenue::FilePull if u.repo.is_none() => {
916	                    return err("[update] avenue = \"file_pull\" requires `repo`".into());
917	                }
918	                UpdateAvenue::FilePull if u.signing_key.is_none() => {
919	                    // Adapter content signing (REQ-UPD-5): a pulled payload must be
920	                    // verifiable, so a file_pull adapter must declare its key.
921	                    return err(
922	                        "[update] avenue = \"file_pull\" requires `signing_key` (adapter content signing)".into(),
923	                    );
924	                }
925	                // gh_release needs the repo whose releases ship updates; `asset`
926	                // defaults to `adapter.spt` and `signing_key` is OPTIONAL (absent
927	                // ⇒ HTTPS+GitHub acquisition trust, present ⇒ fail-closed verify).
928	                // [impl->REQ-UPD-9]
929	                UpdateAvenue::GhRelease if u.repo.is_none() => {
930	                    return err(
931	                        "[update] avenue = \"gh_release\" requires `repo` (the user/repo whose GitHub releases ship updates)".into(),
932	                    );
933	                }
934	                _ => {}
935	            }
936	        }
937
938	        Ok(())
939	    }
940	}
941
942	#[cfg(test)]
943	mod tests {
944	    use super::*;
945
946	    /// A full harness manifest exercising every section (the worked
947	    /// `claude-spt` example from MANIFEST.md, trimmed to the parsed surface).
948	    const HARNESS_FIXTURE: &str = r#"
949	[adapter]
950	name = "claude-spt"
951	version = "2.0.0"
952	min_spt_core_version = "1.0.0"
953	hostable_types = ["LiveAgent", "ReadyAgent", "Psyche", "Worker"]
954
955	[hooks.SessionStart]
956	fires = "api seed --pid {parent_pid} --session-id {session_id} --adapter {adapter_name}"
957	reads = ["session_id", "source", "agent_type"]
958	can_inject = true
959
960	[hooks.Stop]
961	fires = "api state idle"
962	can_inject = false
963
964	[session]
965	commune_dir = ".claude"
966	signoff_dir = ".claude"
967
968	[session.psyche_init]
969	command = "claude -p --agent owl-psyche --name {session_name} --model sonnet"
970	cwd = "{psyche_dir}"
971	env_remove = ["OWL_SESSION_ID"]
972	recursion_guard_env = "SPT_ECHO_COMMUNE"
973	detach = true
974	keys = ["session_name", "psyche_dir"]
975
976	[env.OWL_SESSION_ID]
977	direction = "inject"
978	value = "{session_id}"
979	channel = "CLAUDE_ENV_FILE"
980
981	[history]
982	strategy = "fetcher"
983	fetcher = "claude-spt-history --session {session_id}"
984
985	[digest]
986	extractor = "claude-spt-digest --session {session_id} --in {source}"
987	source = "~/.claude/projects/{project}/{session_id}.jsonl"
988	window_turns = 5
989	arg_truncation = 40
990	sprint_collapse = true
991
992	[inject]
993	activity = ["hook"]
994	idle = ["pty", "hook"]
995
996	[identity]
997	session_id_source = "post_spawn"
998	parent_ancestor_name = "claude"
999
1000	[update]
1001	avenue = "delegated"
1002	command = "claude plugin update spt"
1003	version_check = true
1004	"#;
1005
1006	    const SHELL_FIXTURE: &str = r#"
1007	[adapter]
1008	name = "GameRobot"
1009	kind = "shell"
1010	version = "1.0.0"
1011	min_spt_core_version = "1.0.0"
1012
1013	[shell]
1014	spawn = "gamerobot-shell --link {link_token}"
1015	ephemeral = false
1016	broadcast = "subnet"
1017	command_receipt = "http"
1018	persistent = true
1019	can_shutdown = false
1020
1021	[shell.capabilities]
1022	move = { args = ["direction", "distance"] }
1023	screenshot = {}
1024
1025	[shell.sensory]
1026	types = ["image", "sound", "event"]
1027	"#;
1028
1029	    #[test] // [unit->REQ-MANIFEST-1]
1030	    fn harness_fixture_parses_all_sections() {
1031	        let m = Manifest::from_toml_str(HARNESS_FIXTURE).expect("parse");
1032	        assert_eq!(m.adapter.name, "claude-spt");
1033	        assert_eq!(m.adapter.kind, AdapterKind::Harness);
1034	        assert_eq!(m.adapter.hostable_types.len(), 4);
1035	        // hooks
1036	        assert!(m.hooks["SessionStart"].can_inject);
1037	        assert!(!m.hooks["Stop"].can_inject);
1038	        assert_eq!(
1039	            m.hooks["SessionStart"].reads,
1040	            ["session_id", "source", "agent_type"]
1041	        );
1042	        // session: dirs + one role
1043	        assert_eq!(m.session.commune_dir.as_deref(), Some(".claude"));
1044	        let role = m.session.psyche_init.as_ref().unwrap();
1045	        assert!(role.detach);
1046	        assert_eq!(role.env_remove, ["OWL_SESSION_ID"]);
1047	        assert_eq!(
1048	            role.recursion_guard_env.as_deref(),
1049	            Some("SPT_ECHO_COMMUNE")
1050	        );
1051	        assert_eq!(m.session.roles().len(), 1);
1052	        // env
1053	        assert_eq!(m.env["OWL_SESSION_ID"].direction, EnvDirection::Inject);
1054	        // digest: the extractor seam with own source + presentation defaults
1055	        let dig = m.digest.as_ref().unwrap();
1056	        assert_eq!(dig.extractor, "claude-spt-digest --session {session_id} --in {source}");
1057	        assert_eq!(dig.window_turns, Some(5));
1058	        assert_eq!(dig.arg_truncation, Some(40));
1059	        assert_eq!(dig.sprint_collapse, Some(true));
1060	        // history / inject / identity / update enums
1061	        assert_eq!(m.history.unwrap().strategy, HistoryStrategy::Fetcher);
1062	        let inject = m.inject.unwrap();
1063	        assert_eq!(inject.idle, [InjectMethod::Pty, InjectMethod::Hook]);
1064	        assert_eq!(
1065	            m.identity.unwrap().session_id_source,
1066	            SessionIdSource::PostSpawn
1067	        );
1068	        assert_eq!(m.update.unwrap().avenue, UpdateAvenue::Delegated);
1069	    }
1070
1071	    // [unit->REQ-UPD-5] adapter content signing: a `file_pull` avenue must declare
1072	    // both `repo` and a `signing_key` (so a pulled payload is verifiable); a
1073	    // `delegated` avenue carries the `self_verifies` attest instead. Both shapes
1074	    // round-trip through TOML.
1075	    #[test]
1076	    fn update_avenue_content_signing_fields() {
1077	        // file_pull without signing_key → rejected.
1078	        let no_key = "[adapter]\nname=\"a\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n\
1079	                      [update]\navenue=\"file_pull\"\nrepo=\"https://x/y\"\n";
1080	        let err = Manifest::from_toml_str(no_key).unwrap_err().to_string();
1081	        assert!(
1082	            err.contains("signing_key"),
1083	            "file_pull must require a content key: {err}"
1084	        );
1085
1086	        // file_pull with repo + signing_key → ok, fields preserved + round-trip.
1087	        let ok = "[adapter]\nname=\"a\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n\
1088	                  [update]\navenue=\"file_pull\"\nrepo=\"https://x/y\"\nsigning_key=\"deadbeef\"\n";
1089	        let m = Manifest::from_toml_str(ok).expect("file_pull with key parses");
1090	        let u = m.update.clone().unwrap();
1091	        assert_eq!(u.avenue, UpdateAvenue::FilePull);
1092	        assert_eq!(u.signing_key.as_deref(), Some("deadbeef"));
1093	        assert!(!u.self_verifies);
1094	        let reparsed = Manifest::from_toml_str(&m.to_toml_string().unwrap()).unwrap();
1095	        assert_eq!(m.update, reparsed.update);
1096
1097	        // delegated with self_verifies attest → ok (no signing_key needed).
1098	        let deleg = "[adapter]\nname=\"a\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n\
1099	                     [update]\navenue=\"delegated\"\ncommand=\"claude plugin update\"\nself_verifies=true\n";
1100	        let md = Manifest::from_toml_str(deleg).expect("delegated parses");
1101	        assert!(
1102	            md.update.unwrap().self_verifies,
1103	            "delegated attest preserved"
1104	        );
1105	    }
1106
1107	    // [unit->REQ-UPD-9] the gh_release avenue: a manifest declaring
1108	    // `avenue = "gh_release"` + `repo` validates (with `asset`/`signing_key`
1109	    // optional, round-tripping); a gh_release manifest MISSING `repo` is
1110	    // rejected at validation.
1111	    #[test]
1112	    fn update_avenue_gh_release_fields() {
1113	        // gh_release with repo only → ok (asset defaults, signing_key optional).
1114	        let bare = "[adapter]\nname=\"a\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n\
1115	                    [update]\navenue=\"gh_release\"\nrepo=\"user/repo\"\n";
1116	        let m = Manifest::from_toml_str(bare).expect("gh_release with repo parses");
1117	        let u = m.update.clone().unwrap();
1118	        assert_eq!(u.avenue, UpdateAvenue::GhRelease);
1119	        assert_eq!(u.repo.as_deref(), Some("user/repo"));
1120	        assert!(u.asset.is_none(), "asset is optional (defaults to adapter.spt)");
1121	        assert!(u.signing_key.is_none(), "signing_key is optional for gh_release");
1122	        let reparsed = Manifest::from_toml_str(&m.to_toml_string().unwrap()).unwrap();
1123	        assert_eq!(m.update, reparsed.update, "gh_release round-trips");
1124
1125	        // gh_release with an explicit asset + optional signing_key → preserved.
1126	        let full = "[adapter]\nname=\"a\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n\
1127	                    [update]\navenue=\"gh_release\"\nrepo=\"user/repo\"\n\
1128	                    asset=\"my-adapter.spt\"\nsigning_key=\"deadbeef\"\n";
1129	        let mf = Manifest::from_toml_str(full).expect("gh_release with asset+key parses");
1130	        let uf = mf.update.unwrap();
1131	        assert_eq!(uf.asset.as_deref(), Some("my-adapter.spt"));
1132	        assert_eq!(uf.signing_key.as_deref(), Some("deadbeef"));
1133
1134	        // gh_release MISSING repo → rejected at validation.
1135	        let no_repo = "[adapter]\nname=\"a\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n\
1136	                       [update]\navenue=\"gh_release\"\n";
1137	        let err = Manifest::from_toml_str(no_repo).unwrap_err();
1138	        assert!(matches!(err, ManifestError::Validation(_)));
1139	        assert!(
1140	            err.to_string().contains("repo"),
1141	            "gh_release must require repo: {err}"
1142	        );
1143	    }
1144
1145	    // [unit->REQ-SHELL-4] [shell.tunnel] opt-in: an enabled tunnel with a
1146	    // diagnostic protocol label parses + round-trips; a default (absent) tunnel
1147	    // serializes no `[shell.tunnel]` table; a `protocol` label without
1148	    // `enable = true` is refused at validate (the label only describes an enabled
1149	    // tunnel's opaque wire).
1150	    #[test]
1151	    fn shell_tunnel_opt_in() {
1152	        let base = "[adapter]\nname=\"u\"\nkind=\"shell\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n\
1153	                    [shell]\nspawn=\"usbip-shell --link {link_token}\"\n";
1154
1155	        // enabled + labelled → parses, fields preserved, round-trips.
1156	        let enabled = format!("{base}\n[shell.tunnel]\nenable = true\nprotocol = \"usbip-urb\"\n");
1157	        let m = Manifest::from_toml_str(&enabled).expect("enabled tunnel parses");
1158	        let t = &m.shell.as_ref().unwrap().tunnel;
1159	        assert!(t.enable);
1160	        assert_eq!(t.protocol.as_deref(), Some("usbip-urb"));
1161	        let reparsed = Manifest::from_toml_str(&m.to_toml_string().unwrap()).unwrap();
1162	        assert_eq!(m.shell.unwrap().tunnel, reparsed.shell.unwrap().tunnel);
1163
1164	        // absent tunnel → default, and serializes no [shell.tunnel] table.
1165	        let m0 = Manifest::from_toml_str(base).expect("no tunnel parses");
1166	        assert!(m0.shell.as_ref().unwrap().tunnel.is_default());
1167	        assert!(
1168	            !m0.to_toml_string().unwrap().contains("[shell.tunnel]"),
1169	            "a default tunnel must not emit a [shell.tunnel] table"
1170	        );
1171
1172	        // protocol label without enable → validate refuses the contradiction.
1173	        let bad = format!("{base}\n[shell.tunnel]\nprotocol = \"usbip-urb\"\n");
1174	        let err = Manifest::from_toml_str(&bad).unwrap_err().to_string();
1175	        assert!(
1176	            err.contains("enable = true"),
1177	            "label-without-enable must be refused: {err}"
1178	        );
1179	    }
1180
1181	    // [unit->REQ-MSG-IDLE-TRANSLATION-BINARY] the opt-in idle-delivery translation
1182	    // binary (ADR-0022, doyle OPT-B ruling): a `[message-idle-translation-binary]`

//! Runtime-manifest schema — the typed form of [`docs/MANIFEST.md`].
//!
//! A manifest declares only what *varies per harness*; command templates are
//! **opaque strings** spt-core never parses (MANIFEST §Principle). This module
//! is the parse+validate front door: [`Manifest::from_toml_str`] deserializes
//! the TOML and runs [`Manifest::validate`], returning a [`ManifestError`] with
//! a human-readable message instead of panicking on bad adapter input.
//!
//! Closed vocabularies (adapter kind, history strategy, env direction, inject
//! method, identity source, update avenue, shell broadcast/receipt) are modeled
//! as enums so an unknown variant is a parse error pointing at the bad value —
//! the "helpful errors for adapter authors" open sub-item (MANIFEST §Open).
//!
//! Scope (M2a/T1): every section is *declared and parsed*. Executing the
//! lifecycle sections (`[session.psyche_*]`, commune/signoff dirs, `[history]`
//! source, `[update]` conduct) is M2b/M3 — here they only round-trip.
//
// [impl->REQ-MANIFEST-1]

use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::fmt;

/// A fully-parsed runtime manifest. Top-level sections map 1:1 to the
/// `docs/MANIFEST.md` tables; all but `[adapter]` are optional so a minimal
/// adapter declares only its header.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct Manifest {
    pub adapter: Adapter,
    /// `[hooks.<event>]` — inbound hook table, keyed by harness event name.
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
    pub hooks: BTreeMap<String, Hook>,
    /// `[session]` — watched-dir keys plus the `[session.<role>]` templates.
    #[serde(default, skip_serializing_if = "Session::is_empty")]
    pub session: Session,
    /// `[env.<VAR>]` — env-var inject/read table.
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
    pub env: BTreeMap<String, EnvVar>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub history: Option<History>,
    /// `[digest]` — the adapter-declared session-digest extractor seam (ADR-0019).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub digest: Option<Digest>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub inject: Option<Inject>,
    /// `[message-idle-translation-binary]` — opt-in adapter idle-delivery
    /// translation binary (ADR-0022 / REQ-MSG-IDLE-TRANSLATION-BINARY). A TABLE
    /// carrying a `path` scalar (modeled as a table, not a bare top-level scalar, so
    /// an author who writes it after another section cannot have it silently
    /// absorbed — and so it stays N+1 extensible). The binary is a pure
    /// stdin→stdout JSON-lines filter: spt-core feeds it `init`/`event`/`input`
    /// lines and reads back `{key}`/`{delay_ms}`/`{text}` keystroke-commands, which
    /// spt-core applies to the broker-held PTY atomically (spt-core owns every PTY
    /// write). spt-core LIFECYCLE-manages it (spawn when the spt-hosted endpoint
    /// comes up, terminate when it goes down). A NEW manifest primitive — NOT
    /// collapsed into `[inject]`/`notif_command` — though it shares the poll-feed
    /// substrate. Absent ⇒ no translation binary (delivery falls back to the
    /// v0.11.0 degenerate raw-inject / spool).
    // [impl->REQ-MSG-IDLE-TRANSLATION-BINARY]
    #[serde(
        default,
        rename = "message-idle-translation-binary",
        skip_serializing_if = "Option::is_none"
    )]
    pub message_idle_translation_binary: Option<IdleTranslationBinary>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub identity: Option<Identity>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub update: Option<Update>,
    /// `[shell]` body — present iff `adapter.kind = "shell"` (validated).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub shell: Option<Shell>,
    /// `[profiles.<name>]` — **shipped** profile overlays: sparse leaf-replace
    /// overlays declared by the adapter dev inside the parent manifest, updating
    /// as one unit with it. Stored raw ([`toml::Value`]); [`crate::profile::resolve`]
    /// merges one onto the base and re-validates the complete manifest. A bare
    /// `adapter_name` ignores these (parent unmodified); the composite
    /// `<adapter>:<profile>` selects one. **Local** (node-local, user-authored)
    /// profiles live beside the adapter in the registry, never here.
    /// (CONTEXT.md §adapter profile.)
    // [impl->REQ-MANIFEST-2]
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
    #[schemars(with = "BTreeMap<String, serde_json::Value>")]
    pub profiles: BTreeMap<String, toml::Value>,
    /// `[strings]` — an adapter-authored KV tree of **opaque data** (spt-core
    /// never executes a string; command templates live in their own sections
    /// behind registration). Dot-path-readable via `spt adapter get-string`, and
    /// it rides the same leaf-replace profile overlay as the rest of the manifest
    /// (a shipped or local profile may override base strings). Node-local; no
    /// cross-node sync. (CONTEXT.md §adapter strings.)
    // [impl->REQ-MANIFEST-3]
    #[serde(default, skip_serializing_if = "toml::Table::is_empty")]
    #[schemars(with = "BTreeMap<String, serde_json::Value>")]
    pub strings: toml::Table,
    /// `[[hints]]` — once-per-session keyword hints (CONTEXT.md §keyword hints).
    /// **Order is significant** (first match wins). A profile overlays this by
    /// leaf-replace like any section — the array is replaced wholesale, never
    /// spliced (override/extend = re-declare).
    // [impl->REQ-MANIFEST-4]
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub hints: Vec<Hint>,
}

/// `[adapter]` — the manifest header, readable before any update (compat gate).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct Adapter {
    pub name: String,
    #[serde(default)]
    pub kind: AdapterKind,
    pub version: String,
    /// Lowest spt-core version this adapter tolerates (compat gate).
    pub min_spt_core_version: String,
    /// Endpoint types this adapter can host (`LiveAgent`, `Worker`, …).
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub hostable_types: Vec<String>,
    /// Optional basename for the `spt endpoint run` picker's `<basename>-<id>`
    /// launcher shortcut (REQ-MANIFEST-7). Absent ⇒ the harness-agnostic default
    /// `spt` (→ `spt-<id>`); an adapter sets this to brand its shortcuts
    /// (spt-claude-code → `cc`, giving `cc-<id>`). Additive + N-1-safe (omitted
    /// from serialization when absent). The picker reads it from the RESOLVED
    /// manifest of the selected adapter.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub shortcut_basename: Option<String>,
    /// The harness executable basenames this `kind="harness"` adapter hosts
    /// agents inside (e.g. `host_binaries = ["claude"]`). The bind-time
    /// adapter-resolution match-key (REQ-MANIFEST-8 / ADR-0021): a harness
    /// session's parent pid → its exe basename selects the candidate adapters
    /// whose `host_binaries` contains it (case-insensitive, `.exe`-stripped), so
    /// `listen`/`poll` resolve the owning adapter at bind with no mandatory
    /// `--adapter`. Additive + N-1-safe (omitted from serialization when empty,
    /// like `shortcut_basename`); an empty list is harmless (the adapter is never
    /// a bind-time candidate, only reachable via the explicit `--adapter`).
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub host_binaries: Vec<String>,
}

/// The two adapter kinds. A `harness` hosts agents; a `shell` provides a driven
/// surface (MANIFEST §Shell adapters).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum AdapterKind {
    #[default]
    Harness,
    Shell,
}

/// `[hooks.<event>]` — one harness event → the `api` command it fires, the
/// stdin fields it maps, and whether it can surface context to the agent.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct Hook {
    /// Opaque `api …` command line the harness invokes for this event.
    pub fires: String,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub reads: Vec<String>,
    /// Whether this hook can inject context (false ⇒ sentinel/relay fallback).
    #[serde(default)]
    pub can_inject: bool,
}

/// `[[hints]]` — one once-per-session keyword hint (CONTEXT.md §keyword hints).
/// The adapter's user-prompt hook pipes the full user message to `spt api hint`;
/// a matching keyword surfaces `text` to the agent's context channel, at most
/// once per session and once per message.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct Hint {
    /// Keywords that fire the hint — literal **case-insensitive substrings** by
    /// default; compiled as **regex** patterns when `regex = true`.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub keywords: Vec<String>,
    /// The hint text surfaced when a keyword matches.
    pub text: String,
    /// Treat `keywords` as regex patterns instead of literal substrings.
    #[serde(default)]
    pub regex: bool,
}

impl Hint {
    /// The first keyword that fires against `message` (literal case-insensitive
    /// substring, or regex when `regex = true`); `None` if none match. An invalid
    /// regex never matches — a bad pattern silences its own hint, never panics
    /// (best-effort, the opaque-template stance).
    // [impl->REQ-MANIFEST-4]
    pub fn matched_keyword(&self, message: &str) -> Option<&str> {
        let lowered = message.to_lowercase();
        self.keywords
            .iter()
            .find(|kw| {
                if self.regex {
                    regex::Regex::new(kw)
                        .map(|re| re.is_match(message))
                        .unwrap_or(false)
                } else {
                    lowered.contains(&kw.to_lowercase())
                }
            })
            .map(String::as_str)
    }
}

/// `[session]` — the watched-dir keys (`commune_dir`/`signoff_dir`) co-located
/// with the fixed set of `[session.<role>]` command templates.
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema)]
pub struct Session {
    // Watched dirs (direct `[session]` keys). Filenames are spt-core-fixed;
    // only the directory is adapter-declared (MANIFEST §watched dirs).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub commune_dir: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub signoff_dir: Option<String>,
    // Role templates (`[session.<role>]`). `self` is the agent's own session;
    // the `psyche_*`/`echo_commune`/`signoff` roles are parsed here but only
    // *executed* in M2b.
    #[serde(rename = "self", default, skip_serializing_if = "Option::is_none")]
    pub self_: Option<SessionRole>,
    /// `[session.resume]` — the agent's OWN-session NATIVE resume (the `self_`
    /// sibling, mirroring `psyche_init`→`psyche_resume`). Selected over `self_`
    /// only when a bringup carries `--resume <session>` AND this role is declared;
    /// absent ⇒ fall back to `[session.self]` (full back-compat). Keys spt-core
    /// fills are the SAME catalog as `self`: `{id}`, `{session_id}` (the resumed
    /// id), `{session_name}`, `{adapter_name}` (REQ-SESSION-RESUME-TEMPLATE).
    // [impl->REQ-SESSION-RESUME-TEMPLATE]
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub resume: Option<SessionRole>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub psyche_init: Option<SessionRole>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub psyche_resume: Option<SessionRole>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub echo_commune: Option<SessionRole>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub signoff: Option<SessionRole>,
    /// `[session.notif]` — the endpoint-native notification render
    /// (ADR-0007's `notif_command` seam, REQ-NOTIF-2): an OS toast, a
    /// GameRobot `alert-symbol`, anything the adapter can run. Spawned
    /// detached when a notif surfaces at this endpoint, combinable with the
    /// agent-surface delivery. Keys spt-core fills: `{notif_id}`,
    /// `{notif_from}`, `{notif_subnet}`, `{notif_body}`.
    // [impl->REQ-NOTIF-2]
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub notif: Option<SessionRole>,
}

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

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

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

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

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum EnvDirection {
    Inject,
    Read,
}

/// `[history]` — transcript access strategy.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct History {
    pub strategy: HistoryStrategy,
    /// `fetcher` strategy: adapter binary emitting normalized history.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub fetcher: Option<String>,
    /// `locate_normalize` strategy: where the raw transcript lives.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub locate_template: Option<String>,
    /// `locate_normalize` strategy: command normalizing the raw transcript.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub normalize_command: Option<String>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum HistoryStrategy {
    /// spt-core asks the adapter (pull, adapter binary emits normalized).
    Fetcher,
    /// spt-core locates the raw transcript then normalizes it.
    LocateNormalize,
    /// Adapter pushes via `api history-log`; spt-core stores (Path-B).
    Native,
}

/// `[digest]` — the session-digest extractor seam (ADR-0019). Reverses M9's
/// "no manifest seam" stance: the digest gets its **own** adapter-declared
/// extractor, distinct from `[history]` (which stays opaque + single-session and
/// feeds the echo-commune verbatim). The extractor maps the harness's **native**
/// log → the published `{role, text, tool, ts}` digest-record contract
/// ([`spt_term::DigestRecord`]).
///
/// **Imperative, not a DSL** (ADR-0019 §Decision): real harness logs are nested
/// (one line → many entries, mixed block lists, types to filter) — a flat
/// declarative map cannot express them, and a map powerful enough is a reinvented
/// language. So the extractor is an opaque command spt-core never parses, exactly
/// like every other manifest template.
///
/// **Source.** By default the extractor reads the **same files as `[history]`**
/// (the `locate_template`; DRY). An adapter may override with `source` (the
/// own-source escape hatch). `api digest-entry` push remains the always-available
/// fallback for a log-less adapter (which declares no `[digest]` at all).
///
/// **Presentation.** `window_turns`, `arg_truncation`, and `sprint_collapse` are
/// adapter-declared **defaults** any consumer may override at pull/subscribe;
/// spt-core ships fallback defaults ([`spt_term::DigestConfig`]) when absent. The
/// fixed "~3 turns" is no longer an spt-core requirement (ADR-0019).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct Digest {
    /// Opaque extractor command: native harness log → the `{role,text,tool,ts}`
    /// contract (one JSON record per output line). `{key}` substitution applies
    /// (e.g. `{session_id}`, and `{source}` for the resolved log path).
    pub extractor: String,
    /// Own-source escape hatch: a `locate_template` for the log file(s) the
    /// extractor reads. Absent ⇒ reuse `[history].locate_template` (DRY).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub source: Option<String>,
    /// Adapter-default window depth (user turns kept). Absent ⇒ spt-core fallback.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub window_turns: Option<usize>,
    /// Adapter-default tool-arg truncation width. Absent ⇒ spt-core fallback.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub arg_truncation: Option<usize>,
    /// Adapter-default for collapsing consecutive tool records into one sprint.
    /// Absent ⇒ spt-core fallback (collapse on).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub sprint_collapse: Option<bool>,
}

/// `[inject]` — inject-input methods per activity state.
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema)]
pub struct Inject {
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub activity: Vec<InjectMethod>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub idle: Vec<InjectMethod>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum InjectMethod {
    Pty,
    Hook,
    Relay,
    Http,
}

/// `[message-idle-translation-binary]` — the opt-in idle-delivery translation
/// binary (ADR-0022). A table so the contract degrades gracefully: spt-core does
/// NOT `deny_unknown_fields`, so a newer adapter declaring a future key (e.g. a
/// spawn timeout) against an older spt-core parses fine — the unknown key is
/// ignored, never a hard manifest failure (a lifecycle-binary contract perri
/// builds blind from docs must be forward-compatible). Known keys today: `path`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct IdleTranslationBinary {
    /// The binary PATH spt-core spawns and lifecycle-manages. Opaque (resolved at
    /// endpoint bringup like every other adapter command); validated non-empty.
    pub path: String,
}

/// `[identity]` — how the harness's session id is obtained.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct Identity {
    pub session_id_source: SessionIdSource,
    /// Process-tree anchor name when `session_id` is absent.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub parent_ancestor_name: Option<String>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum SessionIdSource {
    /// Discovered after spawn (process-tree / wrapper handoff).
    PostSpawn,
    /// Injected as a UUID the harness echoes back.
    UuidInject,
}

/// `[update]` — adapter self-update directive (parsed in M2a; conducted in M3).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct Update {
    pub avenue: UpdateAvenue,
    /// `delegated` avenue: the command spt-core delegates to.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub command: Option<String>,
    /// `file_pull` / `gh_release` avenue: source repo. For `gh_release` this is
    /// the `user/repo` whose **GitHub releases** the adapter ships updates from.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub repo: Option<String>,
    /// `file_pull` avenue: path selector.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub path_regex: Option<String>,
    /// `gh_release` avenue: the release **asset** name to fetch (the adapter
    /// `.spt` archive). Absent ⇒ the default `adapter.spt`, matching the
    /// `spt adapter add --release` acquisition primitive (REQ-INSTALL-9). Not
    /// applicable to `delegated` / `file_pull`.
    // [impl->REQ-UPD-9]
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub asset: Option<String>,
    /// The adapter's Ed25519 **content-signing public key** (64 hex chars / 32
    /// bytes). spt-core verifies a pulled payload against this per-adapter key
    /// before applying it (REQ-UPD-5 adapter content signing, ADR-0004 §D) — the
    /// adapter author signs their own releases; spt-core's release key stays
    /// scoped to spt-core. **Required for `file_pull`** (there are bytes to
    /// verify); **optional for `gh_release`** (absent ⇒ HTTPS+GitHub
    /// first-acquisition trust, the same trust `spt adapter add --release` and
    /// the installer first-fetch place; present ⇒ the fetched `.spt` is verified
    /// fail-closed against this key, REQ-UPD-9); not applicable to `delegated`
    /// (opaque updater).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub signing_key: Option<String>,
    /// `delegated` avenue: the adapter attests its own updater verifies the
    /// content it installs (e.g. `claude.exe plugin update` checks its own
    /// signatures). spt-core cannot see a delegated updater's bytes, so it
    /// delegates the trust **only** when this is set; an unattested delegated
    /// update is skipped as unverifiable (REQ-UPD-5).
    #[serde(default)]
    pub self_verifies: bool,
    /// Verify spt-core satisfies `min_spt_core_version` before/after.
    #[serde(default)]
    pub version_check: bool,
    /// Optional inverse of install — run by `spt adapter remove` once the adapter
    /// is quiesced (the mirror of `spt adapter add`, which reuses this section as
    /// the install mechanism). Absent ⇒ spt-core's default cleanup. (Modeled in
    /// M2a; conducted with adapter-registration later.)
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub uninstall: Option<String>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum UpdateAvenue {
    /// Delegate to the adapter's own updater (e.g. `claude plugin update`).
    Delegated,
    /// spt-core pulls files from a repo.
    FilePull,
    /// spt-core ships updates from the adapter's own **GitHub releases**
    /// (REQ-UPD-9): compare the repo's latest release version against the
    /// installed adapter version and, when newer, fetch the release `.spt`
    /// archive (the REQ-INSTALL-9 `--release` primitive), verify it against an
    /// optional `signing_key` (else HTTPS+GitHub trust), and re-register. No
    /// signing tooling or plugin coupling required of the adapter author.
    // [impl->REQ-UPD-9]
    GhRelease,
}

/// `[shell]` — the body of a `kind = "shell"` adapter (a driven surface).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct Shell {
    /// Broker-launched opaque spawn command.
    pub spawn: String,
    /// Ephemeral ⇒ no offline perch + no history retention.
    #[serde(default)]
    pub ephemeral: bool,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub broadcast: Option<Broadcast>,
    /// How the shell receives agent commands.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub command_receipt: Option<CommandReceipt>,
    /// Instruction sent to the binary on link-break.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub pre_close: Option<String>,
    /// Graceful-termination window before force-close.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub close_timeout_ms: Option<u64>,
    /// Auto-online whenever the owner endpoint is online.
    #[serde(default)]
    pub persistent: bool,
    /// Long-running wake-watcher run WHILE offline; exit ⇒ revive.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub wake_command: Option<String>,
    /// Whether the shell may fire `api owner-shutdown` to suspend its owner.
    #[serde(default)]
    pub can_shutdown: bool,
    /// Per-spawn user approval gate (floor; a node/endpoint setting may tighten).
    /// Absent ⇒ `none`. (Modeled now; conducted when shells land.)
    #[serde(default, skip_serializing_if = "ShellApproval::is_default")]
    pub require_approval: ShellApproval,
    /// Optional ceiling on concurrent-existing instances per owner endpoint
    /// (online + offline both count). Absent ⇒ unlimited.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub max_instances_per_owner: Option<u32>,
    /// What happens at the cap: `reject` (default) or `approve` (per-spawn
    /// approval beyond the cap; does not raise it). Only meaningful with a cap.
    #[serde(default, skip_serializing_if = "OverCap::is_default")]
    pub over_cap: OverCap,
    /// `[shell.capabilities]` — the command vocabulary (agent→shell).
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
    pub capabilities: BTreeMap<String, ShellCapability>,
    /// `[shell.sensory]` — the sensory vocabulary (shell→agent).
    #[serde(default, skip_serializing_if = "Sensory::is_empty")]
    pub sensory: Sensory,
    /// `[shell.drive]` — the drive vocabulary (agent→shell, REST-only ephemeral
    /// control; M11-W2, REQ-SHELL-3). The owner→shell mirror of `[shell.sensory]`.
    #[serde(default, skip_serializing_if = "Drive::is_empty")]
    pub drive: Drive,
    /// `[shell.tunnel]` — opt-in for the opaque reliable-ordered byte tunnel
    /// (M11-W3, REQ-SHELL-4). Absent ⇒ no tunnel.
    #[serde(default, skip_serializing_if = "Tunnel::is_default")]
    pub tunnel: Tunnel,
}

/// One entry in `[shell.capabilities]` — a command and its argument names.
///
/// A capability may carry its own **act-gate** (M11, REQ-CONSENT-3): the same
/// `require_approval` enum the spawn gate uses, riding the individual op so the
/// *dangerous act* is gated, not just the spawn. An optional `class_key` scopes
/// the written grant finer than the op id — granted per `(owner × class × node)`
/// (the usbip `attach`'s device class), so a remembered HID-class grant never
/// authorizes a storage-class act. Spawn gates govern existence; capability
/// gates govern acts (CONTEXT §"per-capability approval gates").
// [impl->REQ-CONSENT-3]
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema)]
pub struct ShellCapability {
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub args: Vec<String>,
    /// Per-act approval gate (floor; a node/endpoint setting may tighten).
    /// Absent ⇒ `none` (ungated).
    #[serde(default, skip_serializing_if = "ShellApproval::is_default")]
    pub require_approval: ShellApproval,
    /// Optional grant-qualifier class so a grant scopes finer than the op id
    /// (`(owner × class × node)`). Only meaningful with a gated `require_approval`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub class_key: Option<String>,
}

/// `[shell.sensory]` — the sensory payload types a shell may emit.
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema)]
pub struct Sensory {
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub types: Vec<String>,
}

impl Sensory {
    pub fn is_empty(&self) -> bool {
        self.types.is_empty()
    }
}

/// `[shell.drive]` — the drive payload types an agent may push to a shell
/// (M11-W2, REQ-SHELL-3). The owner→shell mirror of [`Sensory`]: REST-only,
/// ephemeral latest-wins, never spooled.
// [impl->REQ-SHELL-3]
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema)]
pub struct Drive {
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub types: Vec<String>,
}

impl Drive {
    pub fn is_empty(&self) -> bool {
        self.types.is_empty()
    }
}

/// `[shell.tunnel]` — the opt-in for the opaque reliable-ordered byte tunnel
/// (M11-W3, REQ-SHELL-4): a dedicated QUIC stream pair bound to the owner↔shell
/// link, carrying wire protocol traffic the channel taxonomy must NOT reinterpret
/// (first consumer: USB/IP URB traffic). Not enveloped, not MAC-framed, not
/// spooled; the link lifecycle governs it (a link-break closes the tunnel).
/// Reliable-ordered ⇒ congestion surfaces as lag never loss ⇒ on-LAN posture.
// [impl->REQ-SHELL-4]
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema)]
pub struct Tunnel {
    /// Whether this shell opens the opaque tunnel on link-up.
    #[serde(default)]
    pub enable: bool,
    /// Optional diagnostic label for the opaque wire protocol (e.g. `usbip-urb`).
    /// Informational only — the substrate never interprets tunnel bytes.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub protocol: Option<String>,
}

impl Tunnel {
    /// Default ⇒ disabled with no label (the `skip_serializing_if` predicate so an
    /// untunnel'd shell never emits a `[shell.tunnel]` table).
    pub fn is_default(&self) -> bool {
        !self.enable && self.protocol.is_none()
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub enum Broadcast {
    #[serde(rename = "subnet")]
    Subnet,
    #[serde(rename = "same-node")]
    SameNode,
    #[serde(rename = "none")]
    None,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum CommandReceipt {
    Http,
    Stdin,
    Relay,
}

/// Per-shell instantiation-approval mode (`require_approval`). Reuses the consent
/// plumbing: `remembered` lets allow-always write a persistent grant; `always`
/// suppresses allow-always (prompt every spawn).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum ShellApproval {
    /// No approval (default — matches the system's everything-opt-in posture).
    #[default]
    None,
    /// Prompt; allow-always persists a grant, later spawns auto-allow.
    Remembered,
    /// Prompt on every spawn; allow-always suppressed (no persistent grant).
    Always,
}

impl ShellApproval {
    /// Whether this is the default (`none`) — for `skip_serializing_if`.
    pub fn is_default(&self) -> bool {
        matches!(self, ShellApproval::None)
    }
}

/// What happens when an owner is at its `max_instances_per_owner` cap.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum OverCap {
    /// Refuse the spawn outright (default).
    #[default]
    Reject,
    /// Require per-spawn approval beyond the cap (does not raise the cap).
    Approve,
}

impl OverCap {
    /// Whether this is the default (`reject`) — for `skip_serializing_if`.
    pub fn is_default(&self) -> bool {
        matches!(self, OverCap::Reject)
    }
}

/// Parse/validation failure — carries a human-readable message for the adapter
/// author rather than aborting.
#[derive(Debug)]
pub enum ManifestError {
    /// TOML deserialization failed (syntax or an unknown enum variant).
    Parse(toml::de::Error),
    /// Parsed cleanly but violated a cross-field invariant.
    Validation(String),
    /// A requested profile name is not declared on the (merged) adapter manifest.
    UnknownProfile(String),
}

impl fmt::Display for ManifestError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            ManifestError::Parse(e) => write!(f, "manifest parse error: {e}"),
            ManifestError::Validation(msg) => write!(f, "manifest invalid: {msg}"),
            ManifestError::UnknownProfile(name) => {
                write!(f, "unknown adapter profile '{name}'")
            }
        }
    }
}

impl std::error::Error for ManifestError {}

impl From<toml::de::Error> for ManifestError {
    fn from(e: toml::de::Error) -> Self {
        ManifestError::Parse(e)
    }
}

/// The published manifest JSON Schema (M6-D3, REQ-DOCS-4): generated from the
/// **same derives that parse adapter manifests**, so the published contract
/// structurally cannot drift from the parser. Carries a stable `$id` at the
/// canonical Pages URL (ADR-0014). The checked-in copy
/// (`crates/spt-runtime/manifest.schema.json`) is the site/release asset;
/// the `checked_in_schema_is_current` test is the drift gate (regenerate with
/// `SPT_BLESS=1 cargo test -p spt-runtime`).
///
/// The schema documents the *data model*; the on-disk format is TOML (JSON
/// Schema validates the equivalent JSON document — what `spt adapter add`'s
/// validate-first step checks after TOML parse). Cross-field invariants
/// ([`Manifest::validate`]) are enforced by spt-core, not expressible here.
// [impl->REQ-DOCS-4]
// [impl->REQ-DOCS-5]
pub fn manifest_json_schema() -> String {
    let schema = schemars::schema_for!(Manifest);
    let mut v = serde_json::to_value(&schema).expect("schema is JSON");
    let obj = v.as_object_mut().expect("root schema is an object");
    obj.insert(
        "$id".into(),
        "https://sabermage.github.io/spt-releases/manifest.schema.json".into(),
    );
    obj.insert("title".into(), "spt-core runtime manifest".into());
    obj.insert(
        "description".into(),
        "Per-adapter runtime manifest for the spt-core harness contract. \
         Authored as TOML (this schema describes the equivalent data model). \
         A manifest declares only what varies per harness/shell; command \
         templates are opaque strings spt-core never parses. Cross-field \
         invariants (kind<->[shell] agreement, strategy/avenue field \
         requirements) are enforced by spt-core's validate step beyond this \
         schema."
            .into(),
    );
    let mut s = serde_json::to_string_pretty(&v).expect("schema serializes");
    s.push('\n');
    s
}

impl Manifest {
    /// Parse a manifest from TOML text, then validate its cross-field
    /// invariants. Never panics on adapter input — malformed TOML, an unknown
    /// enum variant, or a broken invariant all return [`ManifestError`].
    pub fn from_toml_str(s: &str) -> Result<Manifest, ManifestError> {
        let manifest: Manifest = toml::from_str(s)?;
        manifest.validate()?;
        Ok(manifest)
    }

    /// Serialize back to TOML. Section structs are ordered scalars-before-tables
    /// so the emitter never hits "values must precede tables".
    pub fn to_toml_string(&self) -> Result<String, toml::ser::Error> {
        toml::to_string(self)
    }

    /// Check the cross-field invariants the type system can't: kind↔body
    /// agreement, strategy↔field presence, inject completeness.
    pub fn validate(&self) -> Result<(), ManifestError> {
        let err = |m: String| Err(ManifestError::Validation(m));

        if self.adapter.name.trim().is_empty() {
            return err("adapter.name must not be empty".into());
        }
        if self.adapter.version.trim().is_empty() {
            return err(format!(
                "adapter '{}': version must not be empty",
                self.adapter.name
            ));
        }

        // kind ↔ [shell] body agreement.
        match self.adapter.kind {
            AdapterKind::Shell if self.shell.is_none() => {
                return err(format!(
                    "shell adapter '{}' is missing its [shell] section",
                    self.adapter.name
                ));
            }
            AdapterKind::Harness if self.shell.is_some() => {
                return err(format!(
                    "harness adapter '{}' must not declare a [shell] section (set kind = \"shell\")",
                    self.adapter.name
                ));
            }
            _ => {}
        }

        // [shell.capabilities] act-gates (M11, REQ-CONSENT-3): a `class_key`
        // only scopes a grant, so it is meaningless without a gated
        // `require_approval` — refuse the contradiction at register time.
        // [impl->REQ-CONSENT-3]
        if let Some(shell) = &self.shell {
            // [shell.tunnel] (M11-W3, REQ-SHELL-4): the diagnostic protocol label
            // describes the tunnel's opaque wire — meaningless without the tunnel
            // enabled. Refuse the contradiction at register time.
            // [impl->REQ-SHELL-4]
            if shell.tunnel.protocol.is_some() && !shell.tunnel.enable {
                return err(
                    "[shell.tunnel]: `protocol` label needs `enable = true` — the label only \
                     describes an enabled tunnel's opaque wire"
                        .into(),
                );
            }
            for (op, cap) in &shell.capabilities {
                if cap.class_key.is_some() && cap.require_approval.is_default() {
                    return err(format!(
                        "[shell.capabilities.{op}]: `class_key` needs a gated `require_approval` \
                         (remembered|always) — a class key only scopes a grant"
                    ));
                }
            }
        }

        // history strategy ↔ required fields.
        if let Some(h) = &self.history {
            match h.strategy {
                HistoryStrategy::Fetcher if h.fetcher.is_none() => {
                    return err("[history] strategy = \"fetcher\" requires `fetcher`".into());
                }
                HistoryStrategy::LocateNormalize
                    if h.locate_template.is_none() || h.normalize_command.is_none() =>
                {
                    return err("[history] strategy = \"locate_normalize\" requires both `locate_template` and `normalize_command`".into());
                }
                _ => {}
            }
        }

        // [digest] (ADR-0019): the extractor must be present, the log source must
        // be resolvable (own `source`, or a [history] locate_template to reuse),
        // and a declared window must be non-zero.
        if let Some(d) = &self.digest {
            if d.extractor.trim().is_empty() {
                return err("[digest] requires a non-empty `extractor` command".into());
            }
            let has_history_locate = self
                .history
                .as_ref()
                .is_some_and(|h| h.locate_template.is_some());
            if d.source.is_none() && !has_history_locate {
                return err(
                    "[digest] needs `source` (own-source) or a [history] `locate_template` to read from \
                     (a log-less adapter pushes via `api digest-entry` and declares no [digest])"
                        .into(),
                );
            }
            if d.window_turns == Some(0) {
                return err("[digest] `window_turns` must be greater than 0".into());
            }
        }

        // message-idle-translation-binary (ADR-0022): when declared it must be a
        // non-empty path — an empty value is an adapter authoring mistake (spt-core
        // would try to spawn nothing). The path itself is opaque (resolved at
        // endpoint bringup, like every other adapter command).
        // [impl->REQ-MSG-IDLE-TRANSLATION-BINARY]
        if let Some(bin) = &self.message_idle_translation_binary {
            if bin.path.trim().is_empty() {
                return err(
                    "[message-idle-translation-binary] `path` must be non-empty (omit the table \
                     entirely for no translation binary)"
                        .into(),
                );
            }
        }

        // env: an inject directive needs a value.
        for (name, var) in &self.env {
            if var.direction == EnvDirection::Inject && var.value.is_none() {
                return err(format!(
                    "[env.{name}] direction = \"inject\" requires a `value`"
                ));
            }
        }

        // update avenue ↔ required fields.
        if let Some(u) = &self.update {
            match u.avenue {
                UpdateAvenue::Delegated if u.command.is_none() => {
                    return err("[update] avenue = \"delegated\" requires `command`".into());
                }
                UpdateAvenue::FilePull if u.repo.is_none() => {
                    return err("[update] avenue = \"file_pull\" requires `repo`".into());
                }
                UpdateAvenue::FilePull if u.signing_key.is_none() => {
                    // Adapter content signing (REQ-UPD-5): a pulled payload must be
                    // verifiable, so a file_pull adapter must declare its key.
                    return err(
                        "[update] avenue = \"file_pull\" requires `signing_key` (adapter content signing)".into(),
                    );
                }
                // gh_release needs the repo whose releases ship updates; `asset`
                // defaults to `adapter.spt` and `signing_key` is OPTIONAL (absent
                // ⇒ HTTPS+GitHub acquisition trust, present ⇒ fail-closed verify).
                // [impl->REQ-UPD-9]
                UpdateAvenue::GhRelease if u.repo.is_none() => {
                    return err(
                        "[update] avenue = \"gh_release\" requires `repo` (the user/repo whose GitHub releases ship updates)".into(),
                    );
                }
                _ => {}
            }
        }

        Ok(())
    }
}

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

    /// A full harness manifest exercising every section (the worked
    /// `claude-spt` example from MANIFEST.md, trimmed to the parsed surface).
    const HARNESS_FIXTURE: &str = r#"
[adapter]
name = "claude-spt"
version = "2.0.0"
min_spt_core_version = "1.0.0"
hostable_types = ["LiveAgent", "ReadyAgent", "Psyche", "Worker"]

[hooks.SessionStart]
fires = "api seed --pid {parent_pid} --session-id {session_id} --adapter {adapter_name}"
reads = ["session_id", "source", "agent_type"]
can_inject = true

[hooks.Stop]
fires = "api state idle"
can_inject = false

[session]
commune_dir = ".claude"
signoff_dir = ".claude"

[session.psyche_init]
command = "claude -p --agent owl-psyche --name {session_name} --model sonnet"
cwd = "{psyche_dir}"
env_remove = ["OWL_SESSION_ID"]
recursion_guard_env = "SPT_ECHO_COMMUNE"
detach = true
keys = ["session_name", "psyche_dir"]

[env.OWL_SESSION_ID]
direction = "inject"
value = "{session_id}"
channel = "CLAUDE_ENV_FILE"

[history]
strategy = "fetcher"
fetcher = "claude-spt-history --session {session_id}"

[digest]
extractor = "claude-spt-digest --session {session_id} --in {source}"
source = "~/.claude/projects/{project}/{session_id}.jsonl"
window_turns = 5
arg_truncation = 40
sprint_collapse = true

[inject]
activity = ["hook"]
idle = ["pty", "hook"]

[identity]
session_id_source = "post_spawn"
parent_ancestor_name = "claude"

[update]
avenue = "delegated"
command = "claude plugin update spt"
version_check = true
"#;

    const SHELL_FIXTURE: &str = r#"
[adapter]
name = "GameRobot"
kind = "shell"
version = "1.0.0"
min_spt_core_version = "1.0.0"

[shell]
spawn = "gamerobot-shell --link {link_token}"
ephemeral = false
broadcast = "subnet"
command_receipt = "http"
persistent = true
can_shutdown = false

[shell.capabilities]
move = { args = ["direction", "distance"] }
screenshot = {}

[shell.sensory]
types = ["image", "sound", "event"]
"#;

    #[test] // [unit->REQ-MANIFEST-1]
    fn harness_fixture_parses_all_sections() {
        let m = Manifest::from_toml_str(HARNESS_FIXTURE).expect("parse");
        assert_eq!(m.adapter.name, "claude-spt");
        assert_eq!(m.adapter.kind, AdapterKind::Harness);
        assert_eq!(m.adapter.hostable_types.len(), 4);
        // hooks
        assert!(m.hooks["SessionStart"].can_inject);
        assert!(!m.hooks["Stop"].can_inject);
        assert_eq!(
            m.hooks["SessionStart"].reads,
            ["session_id", "source", "agent_type"]
        );
        // session: dirs + one role
        assert_eq!(m.session.commune_dir.as_deref(), Some(".claude"));
        let role = m.session.psyche_init.as_ref().unwrap();
        assert!(role.detach);
        assert_eq!(role.env_remove, ["OWL_SESSION_ID"]);
        assert_eq!(
            role.recursion_guard_env.as_deref(),
            Some("SPT_ECHO_COMMUNE")
        );
        assert_eq!(m.session.roles().len(), 1);
        // env
        assert_eq!(m.env["OWL_SESSION_ID"].direction, EnvDirection::Inject);
        // digest: the extractor seam with own source + presentation defaults
        let dig = m.digest.as_ref().unwrap();
        assert_eq!(dig.extractor, "claude-spt-digest --session {session_id} --in {source}");
        assert_eq!(dig.window_turns, Some(5));
        assert_eq!(dig.arg_truncation, Some(40));
        assert_eq!(dig.sprint_collapse, Some(true));
        // history / inject / identity / update enums
        assert_eq!(m.history.unwrap().strategy, HistoryStrategy::Fetcher);
        let inject = m.inject.unwrap();
        assert_eq!(inject.idle, [InjectMethod::Pty, InjectMethod::Hook]);
        assert_eq!(
            m.identity.unwrap().session_id_source,
            SessionIdSource::PostSpawn
        );
        assert_eq!(m.update.unwrap().avenue, UpdateAvenue::Delegated);
    }

    // [unit->REQ-UPD-5] adapter content signing: a `file_pull` avenue must declare
    // both `repo` and a `signing_key` (so a pulled payload is verifiable); a
    // `delegated` avenue carries the `self_verifies` attest instead. Both shapes
    // round-trip through TOML.
    #[test]
    fn update_avenue_content_signing_fields() {
        // file_pull without signing_key → rejected.
        let no_key = "[adapter]\nname=\"a\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n\
                      [update]\navenue=\"file_pull\"\nrepo=\"https://x/y\"\n";
        let err = Manifest::from_toml_str(no_key).unwrap_err().to_string();
        assert!(
            err.contains("signing_key"),
            "file_pull must require a content key: {err}"
        );

        // file_pull with repo + signing_key → ok, fields preserved + round-trip.
        let ok = "[adapter]\nname=\"a\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n\
                  [update]\navenue=\"file_pull\"\nrepo=\"https://x/y\"\nsigning_key=\"deadbeef\"\n";
        let m = Manifest::from_toml_str(ok).expect("file_pull with key parses");
        let u = m.update.clone().unwrap();
        assert_eq!(u.avenue, UpdateAvenue::FilePull);
        assert_eq!(u.signing_key.as_deref(), Some("deadbeef"));
        assert!(!u.self_verifies);
        let reparsed = Manifest::from_toml_str(&m.to_toml_string().unwrap()).unwrap();
        assert_eq!(m.update, reparsed.update);

        // delegated with self_verifies attest → ok (no signing_key needed).
        let deleg = "[adapter]\nname=\"a\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n\
                     [update]\navenue=\"delegated\"\ncommand=\"claude plugin update\"\nself_verifies=true\n";
        let md = Manifest::from_toml_str(deleg).expect("delegated parses");
        assert!(
            md.update.unwrap().self_verifies,
            "delegated attest preserved"
        );
    }

    // [unit->REQ-UPD-9] the gh_release avenue: a manifest declaring
    // `avenue = "gh_release"` + `repo` validates (with `asset`/`signing_key`
    // optional, round-tripping); a gh_release manifest MISSING `repo` is
    // rejected at validation.
    #[test]
    fn update_avenue_gh_release_fields() {
        // gh_release with repo only → ok (asset defaults, signing_key optional).
        let bare = "[adapter]\nname=\"a\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n\
                    [update]\navenue=\"gh_release\"\nrepo=\"user/repo\"\n";
        let m = Manifest::from_toml_str(bare).expect("gh_release with repo parses");
        let u = m.update.clone().unwrap();
        assert_eq!(u.avenue, UpdateAvenue::GhRelease);
        assert_eq!(u.repo.as_deref(), Some("user/repo"));
        assert!(u.asset.is_none(), "asset is optional (defaults to adapter.spt)");
        assert!(u.signing_key.is_none(), "signing_key is optional for gh_release");
        let reparsed = Manifest::from_toml_str(&m.to_toml_string().unwrap()).unwrap();
        assert_eq!(m.update, reparsed.update, "gh_release round-trips");

        // gh_release with an explicit asset + optional signing_key → preserved.
        let full = "[adapter]\nname=\"a\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n\
                    [update]\navenue=\"gh_release\"\nrepo=\"user/repo\"\n\
                    asset=\"my-adapter.spt\"\nsigning_key=\"deadbeef\"\n";
        let mf = Manifest::from_toml_str(full).expect("gh_release with asset+key parses");
        let uf = mf.update.unwrap();
        assert_eq!(uf.asset.as_deref(), Some("my-adapter.spt"));
        assert_eq!(uf.signing_key.as_deref(), Some("deadbeef"));

        // gh_release MISSING repo → rejected at validation.
        let no_repo = "[adapter]\nname=\"a\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n\
                       [update]\navenue=\"gh_release\"\n";
        let err = Manifest::from_toml_str(no_repo).unwrap_err();
        assert!(matches!(err, ManifestError::Validation(_)));
        assert!(
            err.to_string().contains("repo"),
            "gh_release must require repo: {err}"
        );
    }

    // [unit->REQ-SHELL-4] [shell.tunnel] opt-in: an enabled tunnel with a
    // diagnostic protocol label parses + round-trips; a default (absent) tunnel
    // serializes no `[shell.tunnel]` table; a `protocol` label without
    // `enable = true` is refused at validate (the label only describes an enabled
    // tunnel's opaque wire).
    #[test]
    fn shell_tunnel_opt_in() {
        let base = "[adapter]\nname=\"u\"\nkind=\"shell\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n\
                    [shell]\nspawn=\"usbip-shell --link {link_token}\"\n";

        // enabled + labelled → parses, fields preserved, round-trips.
        let enabled = format!("{base}\n[shell.tunnel]\nenable = true\nprotocol = \"usbip-urb\"\n");
        let m = Manifest::from_toml_str(&enabled).expect("enabled tunnel parses");
        let t = &m.shell.as_ref().unwrap().tunnel;
        assert!(t.enable);
        assert_eq!(t.protocol.as_deref(), Some("usbip-urb"));
        let reparsed = Manifest::from_toml_str(&m.to_toml_string().unwrap()).unwrap();
        assert_eq!(m.shell.unwrap().tunnel, reparsed.shell.unwrap().tunnel);

        // absent tunnel → default, and serializes no [shell.tunnel] table.
        let m0 = Manifest::from_toml_str(base).expect("no tunnel parses");
        assert!(m0.shell.as_ref().unwrap().tunnel.is_default());
        assert!(
            !m0.to_toml_string().unwrap().contains("[shell.tunnel]"),
            "a default tunnel must not emit a [shell.tunnel] table"
        );

        // protocol label without enable → validate refuses the contradiction.
        let bad = format!("{base}\n[shell.tunnel]\nprotocol = \"usbip-urb\"\n");
        let err = Manifest::from_toml_str(&bad).unwrap_err().to_string();
        assert!(
            err.contains("enable = true"),
            "label-without-enable must be refused: {err}"
        );
    }

    // [unit->REQ-MSG-IDLE-TRANSLATION-BINARY] the opt-in idle-delivery translation
    // binary (ADR-0022, doyle OPT-B ruling): a `[message-idle-translation-binary]`