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 serde::{Deserialize, Serialize};
21	use std::collections::BTreeMap;
22	use std::fmt;
23
24	/// A fully-parsed runtime manifest. Top-level sections map 1:1 to the
25	/// `docs/MANIFEST.md` tables; all but `[adapter]` are optional so a minimal
26	/// adapter declares only its header.
27	#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
28	pub struct Manifest {
29	    pub adapter: Adapter,
30	    /// `[hooks.<event>]` — inbound hook table, keyed by harness event name.
31	    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
32	    pub hooks: BTreeMap<String, Hook>,
33	    /// `[session]` — watched-dir keys plus the `[session.<role>]` templates.
34	    #[serde(default, skip_serializing_if = "Session::is_empty")]
35	    pub session: Session,
36	    /// `[env.<VAR>]` — env-var inject/read table.
37	    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
38	    pub env: BTreeMap<String, EnvVar>,
39	    #[serde(default, skip_serializing_if = "Option::is_none")]
40	    pub history: Option<History>,
41	    #[serde(default, skip_serializing_if = "Option::is_none")]
42	    pub inject: Option<Inject>,
43	    #[serde(default, skip_serializing_if = "Option::is_none")]
44	    pub identity: Option<Identity>,
45	    #[serde(default, skip_serializing_if = "Option::is_none")]
46	    pub update: Option<Update>,
47	    /// `[shell]` body — present iff `adapter.kind = "shell"` (validated).
48	    #[serde(default, skip_serializing_if = "Option::is_none")]
49	    pub shell: Option<Shell>,
50	    /// `[pty_digest]` — the live-activity-buffer pattern seam (ADR-0008). The
51	    /// adapter supplies the boundary + tool patterns; spt-core only *runs* them
52	    /// over the broker PTY byte stream (the no-built-in-parser rule — identical to
53	    /// opaque command templates). Absent ⇒ the digest is unavailable for this
54	    /// adapter.
55	    #[serde(default, skip_serializing_if = "Option::is_none")]
56	    pub pty_digest: Option<PtyDigest>,
57	}
58
59	/// `[adapter]` — the manifest header, readable before any update (compat gate).
60	#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
61	pub struct Adapter {
62	    pub name: String,
63	    #[serde(default)]
64	    pub kind: AdapterKind,
65	    pub version: String,
66	    /// Lowest spt-core version this adapter tolerates (compat gate).
67	    pub min_spt_core_version: String,
68	    /// Endpoint types this adapter can host (`LiveAgent`, `Worker`, …).
69	    #[serde(default, skip_serializing_if = "Vec::is_empty")]
70	    pub hostable_types: Vec<String>,
71	}
72
73	/// The two adapter kinds. A `harness` hosts agents; a `shell` provides a driven
74	/// surface (MANIFEST §Shell adapters).
75	#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
76	#[serde(rename_all = "snake_case")]
77	pub enum AdapterKind {
78	    #[default]
79	    Harness,
80	    Shell,
81	}
82
83	/// `[hooks.<event>]` — one harness event → the `api` command it fires, the
84	/// stdin fields it maps, and whether it can surface context to the agent.
85	#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
86	pub struct Hook {
87	    /// Opaque `api …` command line the harness invokes for this event.
88	    pub fires: String,
89	    #[serde(default, skip_serializing_if = "Vec::is_empty")]
90	    pub reads: Vec<String>,
91	    /// Whether this hook can inject context (false ⇒ sentinel/relay fallback).
92	    #[serde(default)]
93	    pub can_inject: bool,
94	}
95
96	/// `[session]` — the watched-dir keys (`commune_dir`/`signoff_dir`) co-located
97	/// with the fixed set of `[session.<role>]` command templates.
98	#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
99	pub struct Session {
100	    // Watched dirs (direct `[session]` keys). Filenames are spt-core-fixed;
101	    // only the directory is adapter-declared (MANIFEST §watched dirs).
102	    #[serde(default, skip_serializing_if = "Option::is_none")]
103	    pub commune_dir: Option<String>,
104	    #[serde(default, skip_serializing_if = "Option::is_none")]
105	    pub signoff_dir: Option<String>,
106	    // Role templates (`[session.<role>]`). `self` is the agent's own session;
107	    // the `psyche_*`/`echo_commune`/`signoff` roles are parsed here but only
108	    // *executed* in M2b.
109	    #[serde(rename = "self", default, skip_serializing_if = "Option::is_none")]
110	    pub self_: Option<SessionRole>,
111	    #[serde(default, skip_serializing_if = "Option::is_none")]
112	    pub psyche_init: Option<SessionRole>,
113	    #[serde(default, skip_serializing_if = "Option::is_none")]
114	    pub psyche_resume: Option<SessionRole>,
115	    #[serde(default, skip_serializing_if = "Option::is_none")]
116	    pub echo_commune: Option<SessionRole>,
117	    #[serde(default, skip_serializing_if = "Option::is_none")]
118	    pub signoff: Option<SessionRole>,
119	    /// `[session.notif]` — the endpoint-native notification render
120	    /// (ADR-0007's `notif_command` seam, REQ-NOTIF-2): an OS toast, a
121	    /// GameRobot `alert-symbol`, anything the adapter can run. Spawned
122	    /// detached when a notif surfaces at this endpoint, combinable with the
123	    /// agent-surface delivery. Keys spt-core fills: `{notif_id}`,
124	    /// `{notif_from}`, `{notif_subnet}`, `{notif_body}`.
125	    // [impl->REQ-NOTIF-2]
126	    #[serde(default, skip_serializing_if = "Option::is_none")]
127	    pub notif: Option<SessionRole>,
128	}
129
130	impl Session {
131	    /// True when no dirs and no roles are declared (skip-serialize helper).
132	    pub fn is_empty(&self) -> bool {
133	        self.commune_dir.is_none()
134	            && self.signoff_dir.is_none()
135	            && self.self_.is_none()
136	            && self.psyche_init.is_none()
137	            && self.psyche_resume.is_none()
138	            && self.echo_commune.is_none()
139	            && self.signoff.is_none()
140	            && self.notif.is_none()
141	    }
142
143	    /// All declared role templates, paired with their role name. Used by the
144	    /// runtime to enumerate spawnable roles.
145	    pub fn roles(&self) -> Vec<(&'static str, &SessionRole)> {
146	        let mut out = Vec::new();
147	        for (name, role) in [
148	            ("self", &self.self_),
149	            ("psyche_init", &self.psyche_init),
150	            ("psyche_resume", &self.psyche_resume),
151	            ("echo_commune", &self.echo_commune),
152	            ("signoff", &self.signoff),
153	            ("notif", &self.notif),
154	        ] {
155	            if let Some(r) = role {
156	                out.push((name, r));
157	            }
158	        }
159	        out
160	    }
161	}
162
163	/// `[session.<role>]` — one opaque outbound command template plus its spawn
164	/// context. Model/tools/flags all live inside `command`, never as fields
165	/// (MANIFEST §session roles). No nested tables here (keeps TOML round-trip
166	/// emission scalar-before-table clean).
167	#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
168	pub struct SessionRole {
169	    /// Opaque command line, with `{key}` substitution placeholders.
170	    pub command: String,
171	    #[serde(default, skip_serializing_if = "Option::is_none")]
172	    pub cwd: Option<String>,
173	    /// Env var set on summarizer children so their own hooks bail (recursion
174	    /// guard).
175	    #[serde(default, skip_serializing_if = "Option::is_none")]
176	    pub recursion_guard_env: Option<String>,
177	    #[serde(default)]
178	    pub detach: bool,
179	    /// Env vars to strip from the child's inherited environment.
180	    #[serde(default, skip_serializing_if = "Vec::is_empty")]
181	    pub env_remove: Vec<String>,
182	    /// Substitution keys spt-core guarantees to fill for this role.
183	    #[serde(default, skip_serializing_if = "Vec::is_empty")]
184	    pub keys: Vec<String>,
185	}
186
187	/// `[env.<VAR>]` — a single env-var directive.
188	#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
189	pub struct EnvVar {
190	    pub direction: EnvDirection,
191	    /// Value to inject (with substitution); required for `inject`.
192	    #[serde(default, skip_serializing_if = "Option::is_none")]
193	    pub value: Option<String>,
194	    /// Harness-hosted injection channel (spt-hosted inherits from the broker).
195	    #[serde(default, skip_serializing_if = "Option::is_none")]
196	    pub channel: Option<String>,
197	}
198
199	#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
200	#[serde(rename_all = "snake_case")]
201	pub enum EnvDirection {
202	    Inject,
203	    Read,
204	}
205
206	/// `[history]` — transcript access strategy.
207	#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
208	pub struct History {
209	    pub strategy: HistoryStrategy,
210	    /// `fetcher` strategy: adapter binary emitting normalized history.
211	    #[serde(default, skip_serializing_if = "Option::is_none")]
212	    pub fetcher: Option<String>,
213	    /// `locate_normalize` strategy: where the raw transcript lives.
214	    #[serde(default, skip_serializing_if = "Option::is_none")]
215	    pub locate_template: Option<String>,
216	    /// `locate_normalize` strategy: command normalizing the raw transcript.
217	    #[serde(default, skip_serializing_if = "Option::is_none")]
218	    pub normalize_command: Option<String>,
219	}
220
221	#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
222	#[serde(rename_all = "snake_case")]
223	pub enum HistoryStrategy {
224	    /// spt-core asks the adapter (pull, adapter binary emits normalized).
225	    Fetcher,
226	    /// spt-core locates the raw transcript then normalizes it.
227	    LocateNormalize,
228	    /// Adapter pushes via `api history-log`; spt-core stores (Path-B).
229	    Native,
230	}
231
232	/// `[inject]` — inject-input methods per activity state.
233	#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
234	pub struct Inject {
235	    #[serde(default, skip_serializing_if = "Vec::is_empty")]
236	    pub activity: Vec<InjectMethod>,
237	    #[serde(default, skip_serializing_if = "Vec::is_empty")]
238	    pub idle: Vec<InjectMethod>,
239	}
240
241	#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
242	#[serde(rename_all = "snake_case")]
243	pub enum InjectMethod {
244	    Pty,
245	    Hook,
246	    Relay,
247	    Http,
248	}
249
250	/// `[identity]` — how the harness's session id is obtained.
251	#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
252	pub struct Identity {
253	    pub session_id_source: SessionIdSource,
254	    /// Process-tree anchor name when `session_id` is absent.
255	    #[serde(default, skip_serializing_if = "Option::is_none")]
256	    pub parent_ancestor_name: Option<String>,
257	}
258
259	#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
260	#[serde(rename_all = "snake_case")]
261	pub enum SessionIdSource {
262	    /// Discovered after spawn (process-tree / wrapper handoff).
263	    PostSpawn,
264	    /// Injected as a UUID the harness echoes back.
265	    UuidInject,
266	}
267
268	/// `[update]` — adapter self-update directive (parsed in M2a; conducted in M3).
269	#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
270	pub struct Update {
271	    pub avenue: UpdateAvenue,
272	    /// `delegated` avenue: the command spt-core delegates to.
273	    #[serde(default, skip_serializing_if = "Option::is_none")]
274	    pub command: Option<String>,
275	    /// `file_pull` avenue: source repo.
276	    #[serde(default, skip_serializing_if = "Option::is_none")]
277	    pub repo: Option<String>,
278	    /// `file_pull` avenue: path selector.
279	    #[serde(default, skip_serializing_if = "Option::is_none")]
280	    pub path_regex: Option<String>,
281	    /// `file_pull` avenue: the adapter's Ed25519 **content-signing public key**
282	    /// (64 hex chars / 32 bytes). spt-core verifies a pulled payload against this
283	    /// per-adapter key before applying it (REQ-UPD-5 adapter content signing,
284	    /// ADR-0004 §D) — the adapter author signs their own releases; spt-core's
285	    /// release key stays scoped to spt-core. **Required for `file_pull`** (there
286	    /// are bytes to verify); not applicable to `delegated` (opaque updater).
287	    #[serde(default, skip_serializing_if = "Option::is_none")]
288	    pub signing_key: Option<String>,
289	    /// `delegated` avenue: the adapter attests its own updater verifies the
290	    /// content it installs (e.g. `claude.exe plugin update` checks its own
291	    /// signatures). spt-core cannot see a delegated updater's bytes, so it
292	    /// delegates the trust **only** when this is set; an unattested delegated
293	    /// update is skipped as unverifiable (REQ-UPD-5).
294	    #[serde(default)]
295	    pub self_verifies: bool,
296	    /// Verify spt-core satisfies `min_spt_core_version` before/after.
297	    #[serde(default)]
298	    pub version_check: bool,
299	    /// Optional inverse of install — run by `spt adapter remove` once the adapter
300	    /// is quiesced (the mirror of `spt adapter add`, which reuses this section as
301	    /// the install mechanism). Absent ⇒ spt-core's default cleanup. (Modeled in
302	    /// M2a; conducted with adapter-registration later.)
303	    #[serde(default, skip_serializing_if = "Option::is_none")]
304	    pub uninstall: Option<String>,
305	}
306
307	#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
308	#[serde(rename_all = "snake_case")]
309	pub enum UpdateAvenue {
310	    /// Delegate to the adapter's own updater (e.g. `claude plugin update`).
311	    Delegated,
312	    /// spt-core pulls files from a repo.
313	    FilePull,
314	}
315
316	/// `[shell]` — the body of a `kind = "shell"` adapter (a driven surface).
317	#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
318	pub struct Shell {
319	    /// Broker-launched opaque spawn command.
320	    pub spawn: String,
321	    /// Ephemeral ⇒ no offline perch + no history retention.
322	    #[serde(default)]
323	    pub ephemeral: bool,
324	    #[serde(default, skip_serializing_if = "Option::is_none")]
325	    pub broadcast: Option<Broadcast>,
326	    /// How the shell receives agent commands.
327	    #[serde(default, skip_serializing_if = "Option::is_none")]
328	    pub command_receipt: Option<CommandReceipt>,
329	    /// Instruction sent to the binary on link-break.
330	    #[serde(default, skip_serializing_if = "Option::is_none")]
331	    pub pre_close: Option<String>,
332	    /// Graceful-termination window before force-close.
333	    #[serde(default, skip_serializing_if = "Option::is_none")]
334	    pub close_timeout_ms: Option<u64>,
335	    /// Auto-online whenever the owner endpoint is online.
336	    #[serde(default)]
337	    pub persistent: bool,
338	    /// Long-running wake-watcher run WHILE offline; exit ⇒ revive.
339	    #[serde(default, skip_serializing_if = "Option::is_none")]
340	    pub wake_command: Option<String>,
341	    /// Whether the shell may fire `api owner-shutdown` to suspend its owner.
342	    #[serde(default)]
343	    pub can_shutdown: bool,
344	    /// Per-spawn user approval gate (floor; a node/endpoint setting may tighten).
345	    /// Absent ⇒ `none`. (Modeled now; conducted when shells land.)
346	    #[serde(default, skip_serializing_if = "ShellApproval::is_default")]
347	    pub require_approval: ShellApproval,
348	    /// Optional ceiling on concurrent-existing instances per owner endpoint
349	    /// (online + offline both count). Absent ⇒ unlimited.
350	    #[serde(default, skip_serializing_if = "Option::is_none")]
351	    pub max_instances_per_owner: Option<u32>,
352	    /// What happens at the cap: `reject` (default) or `approve` (per-spawn
353	    /// approval beyond the cap; does not raise it). Only meaningful with a cap.
354	    #[serde(default, skip_serializing_if = "OverCap::is_default")]
355	    pub over_cap: OverCap,
356	    /// `[shell.capabilities]` — the command vocabulary (agent→shell).
357	    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
358	    pub capabilities: BTreeMap<String, ShellCapability>,
359	    /// `[shell.sensory]` — the sensory vocabulary (shell→agent).
360	    #[serde(default, skip_serializing_if = "Sensory::is_empty")]
361	    pub sensory: Sensory,
362	}
363
364	/// One entry in `[shell.capabilities]` — a command and its argument names.
365	#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
366	pub struct ShellCapability {
367	    #[serde(default, skip_serializing_if = "Vec::is_empty")]
368	    pub args: Vec<String>,
369	}
370
371	/// `[shell.sensory]` — the sensory payload types a shell may emit.
372	#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
373	pub struct Sensory {
374	    #[serde(default, skip_serializing_if = "Vec::is_empty")]
375	    pub types: Vec<String>,
376	}
377
378	impl Sensory {
379	    pub fn is_empty(&self) -> bool {
380	        self.types.is_empty()
381	    }
382	}
383
384	#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
385	pub enum Broadcast {
386	    #[serde(rename = "subnet")]
387	    Subnet,
388	    #[serde(rename = "same-node")]
389	    SameNode,
390	    #[serde(rename = "none")]
391	    None,
392	}
393
394	#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
395	#[serde(rename_all = "snake_case")]
396	pub enum CommandReceipt {
397	    Http,
398	    Stdin,
399	    Relay,
400	}
401
402	/// Per-shell instantiation-approval mode (`require_approval`). Reuses the consent
403	/// plumbing: `remembered` lets allow-always write a persistent grant; `always`
404	/// suppresses allow-always (prompt every spawn).
405	#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
406	#[serde(rename_all = "snake_case")]
407	pub enum ShellApproval {
408	    /// No approval (default — matches the system's everything-opt-in posture).
409	    #[default]
410	    None,
411	    /// Prompt; allow-always persists a grant, later spawns auto-allow.
412	    Remembered,
413	    /// Prompt on every spawn; allow-always suppressed (no persistent grant).
414	    Always,
415	}
416
417	impl ShellApproval {
418	    /// Whether this is the default (`none`) — for `skip_serializing_if`.
419	    pub fn is_default(&self) -> bool {
420	        matches!(self, ShellApproval::None)
421	    }
422	}
423
424	/// What happens when an owner is at its `max_instances_per_owner` cap.
425	#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
426	#[serde(rename_all = "snake_case")]
427	pub enum OverCap {
428	    /// Refuse the spawn outright (default).
429	    #[default]
430	    Reject,
431	    /// Require per-spawn approval beyond the cap (does not raise the cap).
432	    Approve,
433	}
434
435	impl OverCap {
436	    /// Whether this is the default (`reject`) — for `skip_serializing_if`.
437	    pub fn is_default(&self) -> bool {
438	        matches!(self, OverCap::Reject)
439	    }
440	}
441
442	/// `[pty_digest]` — the adapter-supplied patterns for the live activity buffer
443	/// (ADR-0008). `input_pattern`/`agent_pattern` are the turn-boundary markers;
444	/// `tool_patterns` is one regex per known tool and `catchall_pattern` an optional
445	/// final catchall for user-installed tools (each naming `name`/`arg` capture
446	/// groups). spt-core compiles + runs these over the broker PTY stream — it never
447	/// parses harness transcripts (the history-subsystem no-built-in-parser rule the
448	/// digest deliberately honors). The patterns are **opaque to spt-core**, exactly
449	/// like the `[session.*]` command templates.
450	///
451	/// `window_turns` is the only presentation override exposed here; the remaining
452	/// presentation knobs (arg truncation, ANSI stripping, scan-buffer cap) stay
453	/// spt-core-owned defaults. `persist` opts this endpoint into the coarse Path-B
454	/// activity log (ADR-0008 "Option (C)") — off by default.
455	#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
456	pub struct PtyDigest {
457	    /// User-input boundary marker (regex). Opens a span until the next match.
458	    pub input_pattern: String,
459	    /// Agent-output boundary marker (regex). Opens a span until the next match.
460	    pub agent_pattern: String,
461	    /// One regex per known tool; each should name an `arg` (and may name a
462	    /// `name`) capture group.
463	    #[serde(default, skip_serializing_if = "Vec::is_empty")]
464	    pub tool_patterns: Vec<String>,
465	    /// Optional final catchall for unknowable user-installed tools.
466	    #[serde(default, skip_serializing_if = "Option::is_none")]
467	    pub catchall_pattern: Option<String>,
468	    /// Window depth override (last N user turns). `None` ⇒ spt-core default (~3).
469	    #[serde(default, skip_serializing_if = "Option::is_none")]
470	    pub window_turns: Option<usize>,
471	    /// Opt-in Path-B persistence (ADR-0008 "Option (C)"): append completed turns
472	    /// to the perch's native history log as a coarse activity record.
473	    #[serde(default, skip_serializing_if = "is_false")]
474	    pub persist: bool,
475	}
476
477	/// `skip_serializing_if` helper for a `bool` defaulting to `false`.
478	fn is_false(b: &bool) -> bool {
479	    !*b
480	}
481
482	/// Parse/validation failure — carries a human-readable message for the adapter
483	/// author rather than aborting.
484	#[derive(Debug)]
485	pub enum ManifestError {
486	    /// TOML deserialization failed (syntax or an unknown enum variant).
487	    Parse(toml::de::Error),
488	    /// Parsed cleanly but violated a cross-field invariant.
489	    Validation(String),
490	}
491
492	impl fmt::Display for ManifestError {
493	    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
494	        match self {
495	            ManifestError::Parse(e) => write!(f, "manifest parse error: {e}"),
496	            ManifestError::Validation(msg) => write!(f, "manifest invalid: {msg}"),
497	        }
498	    }
499	}
500
501	impl std::error::Error for ManifestError {}
502
503	impl From<toml::de::Error> for ManifestError {
504	    fn from(e: toml::de::Error) -> Self {
505	        ManifestError::Parse(e)
506	    }
507	}
508
509	impl Manifest {
510	    /// Parse a manifest from TOML text, then validate its cross-field
511	    /// invariants. Never panics on adapter input — malformed TOML, an unknown
512	    /// enum variant, or a broken invariant all return [`ManifestError`].
513	    pub fn from_toml_str(s: &str) -> Result<Manifest, ManifestError> {
514	        let manifest: Manifest = toml::from_str(s)?;
515	        manifest.validate()?;
516	        Ok(manifest)
517	    }
518
519	    /// Serialize back to TOML. Section structs are ordered scalars-before-tables
520	    /// so the emitter never hits "values must precede tables".
521	    pub fn to_toml_string(&self) -> Result<String, toml::ser::Error> {
522	        toml::to_string(self)
523	    }
524
525	    /// Check the cross-field invariants the type system can't: kind↔body
526	    /// agreement, strategy↔field presence, inject completeness.
527	    pub fn validate(&self) -> Result<(), ManifestError> {
528	        let err = |m: String| Err(ManifestError::Validation(m));
529
530	        if self.adapter.name.trim().is_empty() {
531	            return err("adapter.name must not be empty".into());
532	        }
533	        if self.adapter.version.trim().is_empty() {
534	            return err(format!(
535	                "adapter '{}': version must not be empty",
536	                self.adapter.name
537	            ));
538	        }
539
540	        // kind ↔ [shell] body agreement.
541	        match self.adapter.kind {
542	            AdapterKind::Shell if self.shell.is_none() => {
543	                return err(format!(
544	                    "shell adapter '{}' is missing its [shell] section",
545	                    self.adapter.name
546	                ));
547	            }
548	            AdapterKind::Harness if self.shell.is_some() => {
549	                return err(format!(
550	                    "harness adapter '{}' must not declare a [shell] section (set kind = \"shell\")",
551	                    self.adapter.name
552	                ));
553	            }
554	            _ => {}
555	        }
556
557	        // history strategy ↔ required fields.
558	        if let Some(h) = &self.history {
559	            match h.strategy {
560	                HistoryStrategy::Fetcher if h.fetcher.is_none() => {
561	                    return err("[history] strategy = \"fetcher\" requires `fetcher`".into());
562	                }
563	                HistoryStrategy::LocateNormalize
564	                    if h.locate_template.is_none() || h.normalize_command.is_none() =>
565	                {
566	                    return err("[history] strategy = \"locate_normalize\" requires both `locate_template` and `normalize_command`".into());
567	                }
568	                _ => {}
569	            }
570	        }
571
572	        // env: an inject directive needs a value.
573	        for (name, var) in &self.env {
574	            if var.direction == EnvDirection::Inject && var.value.is_none() {
575	                return err(format!(
576	                    "[env.{name}] direction = \"inject\" requires a `value`"
577	                ));
578	            }
579	        }
580
581	        // pty_digest: the two boundary markers are load-bearing — a digest with
582	        // no input/agent boundary degenerates to one undifferentiated span, so
583	        // require them non-empty when the section is declared. The tool patterns
584	        // + catchall are optional (an adapter may advertise no tools).
585	        // [impl->REQ-TERM-4]
586	        if let Some(d) = &self.pty_digest {
587	            if d.input_pattern.trim().is_empty() || d.agent_pattern.trim().is_empty() {
588	                return err(
589	                    "[pty_digest] requires non-empty `input_pattern` and `agent_pattern`".into(),
590	                );
591	            }
592	        }
593
594	        // update avenue ↔ required fields.
595	        if let Some(u) = &self.update {
596	            match u.avenue {
597	                UpdateAvenue::Delegated if u.command.is_none() => {
598	                    return err("[update] avenue = \"delegated\" requires `command`".into());
599	                }
600	                UpdateAvenue::FilePull if u.repo.is_none() => {
601	                    return err("[update] avenue = \"file_pull\" requires `repo`".into());
602	                }
603	                UpdateAvenue::FilePull if u.signing_key.is_none() => {
604	                    // Adapter content signing (REQ-UPD-5): a pulled payload must be
605	                    // verifiable, so a file_pull adapter must declare its key.
606	                    return err(
607	                        "[update] avenue = \"file_pull\" requires `signing_key` (adapter content signing)".into(),
608	                    );
609	                }
610	                _ => {}
611	            }
612	        }
613
614	        Ok(())
615	    }
616	}
617
618	#[cfg(test)]
619	mod tests {
620	    use super::*;
621
622	    /// A full harness manifest exercising every section (the worked
623	    /// `claude-spt` example from MANIFEST.md, trimmed to the parsed surface).
624	    const HARNESS_FIXTURE: &str = r#"
625	[adapter]
626	name = "claude-spt"
627	version = "2.0.0"
628	min_spt_core_version = "1.0.0"
629	hostable_types = ["LiveAgent", "ReadyAgent", "Psyche", "Worker"]
630
631	[hooks.SessionStart]
632	fires = "api seed --pid {parent_pid} --session-id {session_id} --adapter {adapter_name}"
633	reads = ["session_id", "source", "agent_type"]
634	can_inject = true
635
636	[hooks.Stop]
637	fires = "api state idle"
638	can_inject = false
639
640	[session]
641	commune_dir = ".claude"
642	signoff_dir = ".claude"
643
644	[session.psyche_init]
645	command = "claude -p --agent owl-psyche --name {session_name} --model sonnet"
646	cwd = "{psyche_dir}"
647	env_remove = ["OWL_SESSION_ID"]
648	recursion_guard_env = "SPT_ECHO_COMMUNE"
649	detach = true
650	keys = ["session_name", "psyche_dir"]
651
652	[env.OWL_SESSION_ID]
653	direction = "inject"
654	value = "{session_id}"
655	channel = "CLAUDE_ENV_FILE"
656
657	[history]
658	strategy = "fetcher"
659	fetcher = "claude-spt-history --session {session_id}"
660
661	[inject]
662	activity = ["hook"]
663	idle = ["pty", "hook"]
664
665	[identity]
666	session_id_source = "post_spawn"
667	parent_ancestor_name = "claude"
668
669	[update]
670	avenue = "delegated"
671	command = "claude plugin update spt"
672	version_check = true
673
674	[pty_digest]
675	input_pattern = "\\nUSER> "
676	agent_pattern = "\\nAGENT> "
677	tool_patterns = ["(?P<name>Write)\\((?P<arg>[^)]*)\\)"]
678	catchall_pattern = "(?P<name>[A-Z][a-zA-Z]+)\\((?P<arg>[^)]*)\\)"
679	window_turns = 5
680	persist = true
681	"#;
682
683	    const SHELL_FIXTURE: &str = r#"
684	[adapter]
685	name = "GameRobot"
686	kind = "shell"
687	version = "1.0.0"
688	min_spt_core_version = "1.0.0"
689
690	[shell]
691	spawn = "gamerobot-shell --link {link_token}"
692	ephemeral = false
693	broadcast = "subnet"
694	command_receipt = "http"
695	persistent = true
696	can_shutdown = false
697
698	[shell.capabilities]
699	move = { args = ["direction", "distance"] }
700	screenshot = {}
701
702	[shell.sensory]
703	types = ["image", "sound", "event"]
704	"#;
705
706	    #[test] // [unit->REQ-MANIFEST-1]
707	    fn harness_fixture_parses_all_sections() {
708	        let m = Manifest::from_toml_str(HARNESS_FIXTURE).expect("parse");
709	        assert_eq!(m.adapter.name, "claude-spt");
710	        assert_eq!(m.adapter.kind, AdapterKind::Harness);
711	        assert_eq!(m.adapter.hostable_types.len(), 4);
712	        // hooks
713	        assert!(m.hooks["SessionStart"].can_inject);
714	        assert!(!m.hooks["Stop"].can_inject);
715	        assert_eq!(m.hooks["SessionStart"].reads, ["session_id", "source", "agent_type"]);
716	        // session: dirs + one role
717	        assert_eq!(m.session.commune_dir.as_deref(), Some(".claude"));
718	        let role = m.session.psyche_init.as_ref().unwrap();
719	        assert!(role.detach);
720	        assert_eq!(role.env_remove, ["OWL_SESSION_ID"]);
721	        assert_eq!(role.recursion_guard_env.as_deref(), Some("SPT_ECHO_COMMUNE"));
722	        assert_eq!(m.session.roles().len(), 1);
723	        // env
724	        assert_eq!(m.env["OWL_SESSION_ID"].direction, EnvDirection::Inject);
725	        // history / inject / identity / update enums
726	        assert_eq!(m.history.unwrap().strategy, HistoryStrategy::Fetcher);
727	        let inject = m.inject.unwrap();
728	        assert_eq!(inject.idle, [InjectMethod::Pty, InjectMethod::Hook]);
729	        assert_eq!(m.identity.unwrap().session_id_source, SessionIdSource::PostSpawn);
730	        assert_eq!(m.update.unwrap().avenue, UpdateAvenue::Delegated);
731	        // pty_digest seam (ADR-0008)
732	        let d = m.pty_digest.unwrap();
733	        assert_eq!(d.input_pattern, "\\nUSER> ", "regex source: literal backslash-n, not a newline");
734	        assert_eq!(d.tool_patterns.len(), 1);
735	        assert!(d.catchall_pattern.is_some());
736	        assert_eq!(d.window_turns, Some(5));
737	        assert!(d.persist);
738	    }
739
740	    // [unit->REQ-UPD-5] adapter content signing: a `file_pull` avenue must declare
741	    // both `repo` and a `signing_key` (so a pulled payload is verifiable); a
742	    // `delegated` avenue carries the `self_verifies` attest instead. Both shapes
743	    // round-trip through TOML.
744	    #[test]
745	    fn update_avenue_content_signing_fields() {
746	        // file_pull without signing_key → rejected.
747	        let no_key = "[adapter]\nname=\"a\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n\
748	                      [update]\navenue=\"file_pull\"\nrepo=\"https://x/y\"\n";
749	        let err = Manifest::from_toml_str(no_key).unwrap_err().to_string();
750	        assert!(err.contains("signing_key"), "file_pull must require a content key: {err}");
751
752	        // file_pull with repo + signing_key → ok, fields preserved + round-trip.
753	        let ok = "[adapter]\nname=\"a\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n\
754	                  [update]\navenue=\"file_pull\"\nrepo=\"https://x/y\"\nsigning_key=\"deadbeef\"\n";
755	        let m = Manifest::from_toml_str(ok).expect("file_pull with key parses");
756	        let u = m.update.clone().unwrap();
757	        assert_eq!(u.avenue, UpdateAvenue::FilePull);
758	        assert_eq!(u.signing_key.as_deref(), Some("deadbeef"));
759	        assert!(!u.self_verifies);
760	        let reparsed = Manifest::from_toml_str(&m.to_toml_string().unwrap()).unwrap();
761	        assert_eq!(m.update, reparsed.update);
762
763	        // delegated with self_verifies attest → ok (no signing_key needed).
764	        let deleg = "[adapter]\nname=\"a\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n\
765	                     [update]\navenue=\"delegated\"\ncommand=\"claude plugin update\"\nself_verifies=true\n";
766	        let md = Manifest::from_toml_str(deleg).expect("delegated parses");
767	        assert!(md.update.unwrap().self_verifies, "delegated attest preserved");
768	    }
769
770	    #[test] // [unit->REQ-TERM-4] the pty_digest seam round-trips through TOML.
771	    fn pty_digest_round_trips() {
772	        let m = Manifest::from_toml_str(HARNESS_FIXTURE).expect("parse");
773	        let reparsed = Manifest::from_toml_str(&m.to_toml_string().unwrap()).expect("re-parse");
774	        assert_eq!(m.pty_digest, reparsed.pty_digest);
775	    }
776
777	    #[test] // [unit->REQ-TERM-4] pty_digest defaults: tools empty, catchall none,
778	    // window default, persist off — a minimal digest declares only the boundaries.
779	    fn pty_digest_minimal_defaults() {
780	        let m = Manifest::from_toml_str(
781	            "[adapter]\nname=\"m\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n\
782	             [pty_digest]\ninput_pattern=\"U> \"\nagent_pattern=\"A> \"\n",
783	        )
784	        .expect("parse");
785	        let d = m.pty_digest.unwrap();
786	        assert!(d.tool_patterns.is_empty());
787	        assert!(d.catchall_pattern.is_none());
788	        assert_eq!(d.window_turns, None);
789	        assert!(!d.persist, "persist is opt-in (off by default)");
790	    }
791
792	    #[test] // [unit->REQ-TERM-4] a pty_digest missing a boundary marker is rejected
793	    // (an empty boundary degenerates the parse to one undifferentiated span).
794	    fn pty_digest_requires_both_boundaries() {
795	        let bad = "[adapter]\nname=\"m\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n\
796	                   [pty_digest]\ninput_pattern=\"U> \"\nagent_pattern=\"\"\n";
797	        let e = Manifest::from_toml_str(bad).unwrap_err();
798	        assert!(matches!(e, ManifestError::Validation(_)));
799	        assert!(e.to_string().contains("pty_digest"));
800	    }
801
802	    #[test] // [unit->REQ-MANIFEST-1]
803	    fn shell_fixture_parses() {
804	        let m = Manifest::from_toml_str(SHELL_FIXTURE).expect("parse");
805	        assert_eq!(m.adapter.kind, AdapterKind::Shell);
806	        let shell = m.shell.as_ref().unwrap();
807	        assert_eq!(shell.broadcast, Some(Broadcast::Subnet));
808	        assert_eq!(shell.command_receipt, Some(CommandReceipt::Http));
809	        assert_eq!(shell.capabilities["move"].args, ["direction", "distance"]);
810	        assert!(shell.capabilities["screenshot"].args.is_empty());
811	        assert_eq!(shell.sensory.types, ["image", "sound", "event"]);
812	    }
813
814	    #[test] // [unit->REQ-MANIFEST-1]
815	    fn round_trips_through_toml() {
816	        let m = Manifest::from_toml_str(HARNESS_FIXTURE).expect("parse");
817	        let serialized = m.to_toml_string().expect("serialize");
818	        let reparsed = Manifest::from_toml_str(&serialized).expect("re-parse");
819	        assert_eq!(m, reparsed, "manifest must survive a serialize→parse round-trip");
820	    }
821
822	    #[test] // [unit->REQ-MANIFEST-1]
823	    fn unknown_enum_variant_is_an_error_not_a_panic() {
824	        let bad = r#"
825	[adapter]
826	name = "x"
827	version = "1.0.0"
828	min_spt_core_version = "1.0.0"
829
830	[history]
831	strategy = "telepathy"
832	"#;
833	        let e = Manifest::from_toml_str(bad).unwrap_err();
834	        assert!(matches!(e, ManifestError::Parse(_)), "got {e}");
835	        // message names the offending value
836	        assert!(e.to_string().contains("telepathy") || e.to_string().contains("strategy"));
837	    }
838
839	    #[test] // [unit->REQ-MANIFEST-1]
840	    fn shell_kind_without_shell_section_fails_validation() {
841	        let bad = r#"
842	[adapter]
843	name = "x"
844	kind = "shell"
845	version = "1.0.0"
846	min_spt_core_version = "1.0.0"
847	"#;
848	        let e = Manifest::from_toml_str(bad).unwrap_err();
849	        assert!(matches!(e, ManifestError::Validation(_)));
850	        assert!(e.to_string().contains("[shell]"));
851	    }
852
853	    #[test] // [unit->REQ-MANIFEST-1]
854	    fn harness_kind_with_shell_section_fails_validation() {
855	        let bad = r#"
856	[adapter]
857	name = "x"
858	version = "1.0.0"
859	min_spt_core_version = "1.0.0"
860
861	[shell]
862	spawn = "x"
863	"#;
864	        let e = Manifest::from_toml_str(bad).unwrap_err();
865	        assert!(matches!(e, ManifestError::Validation(_)));
866	    }
867
868	    #[test] // [unit->REQ-MANIFEST-1]
869	    fn inject_env_without_value_fails_validation() {
870	        let bad = r#"
871	[adapter]
872	name = "x"
873	version = "1.0.0"
874	min_spt_core_version = "1.0.0"
875
876	[env.FOO]
877	direction = "inject"
878	"#;
879	        let e = Manifest::from_toml_str(bad).unwrap_err();
880	        assert!(e.to_string().contains("FOO"));
881	    }
882
883	    #[test] // [unit->REQ-MANIFEST-1]
884	    fn fetcher_strategy_requires_fetcher_field() {
885	        let bad = r#"
886	[adapter]
887	name = "x"
888	version = "1.0.0"
889	min_spt_core_version = "1.0.0"
890
891	[history]
892	strategy = "fetcher"
893	"#;
894	        let e = Manifest::from_toml_str(bad).unwrap_err();
895	        assert!(matches!(e, ManifestError::Validation(_)));
896	    }
897
898	    #[test] // [unit->REQ-MANIFEST-1]
899	    fn minimal_header_only_manifest_is_valid() {
900	        let m = Manifest::from_toml_str(
901	            "[adapter]\nname = \"min\"\nversion = \"1.0.0\"\nmin_spt_core_version = \"1.0.0\"\n",
902	        )
903	        .expect("parse");
904	        assert_eq!(m.adapter.name, "min");
905	        assert!(m.session.is_empty());
906	        assert!(m.hooks.is_empty());
907	    }
908
909	    #[test] // [unit->REQ-MANIFEST-1]
910	    fn syntactically_broken_toml_is_an_error_not_a_panic() {
911	        let e = Manifest::from_toml_str("this is not = = toml").unwrap_err();
912	        assert!(matches!(e, ManifestError::Parse(_)));
913	    }
914	}
915

//! 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 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)]
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>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub inject: Option<Inject>,
    #[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>,
    /// `[pty_digest]` — the live-activity-buffer pattern seam (ADR-0008). The
    /// adapter supplies the boundary + tool patterns; spt-core only *runs* them
    /// over the broker PTY byte stream (the no-built-in-parser rule — identical to
    /// opaque command templates). Absent ⇒ the digest is unavailable for this
    /// adapter.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub pty_digest: Option<PtyDigest>,
}

/// `[adapter]` — the manifest header, readable before any update (compat gate).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
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>,
}

/// 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)]
#[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)]
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,
}

/// `[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)]
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>,
    #[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.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_),
            ("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)]
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)]
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)]
#[serde(rename_all = "snake_case")]
pub enum EnvDirection {
    Inject,
    Read,
}

/// `[history]` — transcript access strategy.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
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)]
#[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,
}

/// `[inject]` — inject-input methods per activity state.
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
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)]
#[serde(rename_all = "snake_case")]
pub enum InjectMethod {
    Pty,
    Hook,
    Relay,
    Http,
}

/// `[identity]` — how the harness's session id is obtained.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
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)]
#[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)]
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` avenue: source repo.
    #[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>,
    /// `file_pull` avenue: 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); 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)]
#[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,
}

/// `[shell]` — the body of a `kind = "shell"` adapter (a driven surface).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
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,
}

/// One entry in `[shell.capabilities]` — a command and its argument names.
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct ShellCapability {
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub args: Vec<String>,
}

/// `[shell.sensory]` — the sensory payload types a shell may emit.
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
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()
    }
}

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

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[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)]
#[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)]
#[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)
    }
}

/// `[pty_digest]` — the adapter-supplied patterns for the live activity buffer
/// (ADR-0008). `input_pattern`/`agent_pattern` are the turn-boundary markers;
/// `tool_patterns` is one regex per known tool and `catchall_pattern` an optional
/// final catchall for user-installed tools (each naming `name`/`arg` capture
/// groups). spt-core compiles + runs these over the broker PTY stream — it never
/// parses harness transcripts (the history-subsystem no-built-in-parser rule the
/// digest deliberately honors). The patterns are **opaque to spt-core**, exactly
/// like the `[session.*]` command templates.
///
/// `window_turns` is the only presentation override exposed here; the remaining
/// presentation knobs (arg truncation, ANSI stripping, scan-buffer cap) stay
/// spt-core-owned defaults. `persist` opts this endpoint into the coarse Path-B
/// activity log (ADR-0008 "Option (C)") — off by default.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PtyDigest {
    /// User-input boundary marker (regex). Opens a span until the next match.
    pub input_pattern: String,
    /// Agent-output boundary marker (regex). Opens a span until the next match.
    pub agent_pattern: String,
    /// One regex per known tool; each should name an `arg` (and may name a
    /// `name`) capture group.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub tool_patterns: Vec<String>,
    /// Optional final catchall for unknowable user-installed tools.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub catchall_pattern: Option<String>,
    /// Window depth override (last N user turns). `None` ⇒ spt-core default (~3).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub window_turns: Option<usize>,
    /// Opt-in Path-B persistence (ADR-0008 "Option (C)"): append completed turns
    /// to the perch's native history log as a coarse activity record.
    #[serde(default, skip_serializing_if = "is_false")]
    pub persist: bool,
}

/// `skip_serializing_if` helper for a `bool` defaulting to `false`.
fn is_false(b: &bool) -> bool {
    !*b
}

/// 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),
}

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}"),
        }
    }
}

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

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

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
                ));
            }
            _ => {}
        }

        // 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());
                }
                _ => {}
            }
        }

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

        // pty_digest: the two boundary markers are load-bearing — a digest with
        // no input/agent boundary degenerates to one undifferentiated span, so
        // require them non-empty when the section is declared. The tool patterns
        // + catchall are optional (an adapter may advertise no tools).
        // [impl->REQ-TERM-4]
        if let Some(d) = &self.pty_digest {
            if d.input_pattern.trim().is_empty() || d.agent_pattern.trim().is_empty() {
                return err(
                    "[pty_digest] requires non-empty `input_pattern` and `agent_pattern`".into(),
                );
            }
        }

        // 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(),
                    );
                }
                _ => {}
            }
        }

        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}"

[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

[pty_digest]
input_pattern = "\\nUSER> "
agent_pattern = "\\nAGENT> "
tool_patterns = ["(?P<name>Write)\\((?P<arg>[^)]*)\\)"]
catchall_pattern = "(?P<name>[A-Z][a-zA-Z]+)\\((?P<arg>[^)]*)\\)"
window_turns = 5
persist = 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);
        // 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);
        // pty_digest seam (ADR-0008)
        let d = m.pty_digest.unwrap();
        assert_eq!(d.input_pattern, "\\nUSER> ", "regex source: literal backslash-n, not a newline");
        assert_eq!(d.tool_patterns.len(), 1);
        assert!(d.catchall_pattern.is_some());
        assert_eq!(d.window_turns, Some(5));
        assert!(d.persist);
    }

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

    #[test] // [unit->REQ-TERM-4] the pty_digest seam round-trips through TOML.
    fn pty_digest_round_trips() {
        let m = Manifest::from_toml_str(HARNESS_FIXTURE).expect("parse");
        let reparsed = Manifest::from_toml_str(&m.to_toml_string().unwrap()).expect("re-parse");
        assert_eq!(m.pty_digest, reparsed.pty_digest);
    }

    #[test] // [unit->REQ-TERM-4] pty_digest defaults: tools empty, catchall none,
    // window default, persist off — a minimal digest declares only the boundaries.
    fn pty_digest_minimal_defaults() {
        let m = Manifest::from_toml_str(
            "[adapter]\nname=\"m\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n\
             [pty_digest]\ninput_pattern=\"U> \"\nagent_pattern=\"A> \"\n",
        )
        .expect("parse");
        let d = m.pty_digest.unwrap();
        assert!(d.tool_patterns.is_empty());
        assert!(d.catchall_pattern.is_none());
        assert_eq!(d.window_turns, None);
        assert!(!d.persist, "persist is opt-in (off by default)");
    }

    #[test] // [unit->REQ-TERM-4] a pty_digest missing a boundary marker is rejected
    // (an empty boundary degenerates the parse to one undifferentiated span).
    fn pty_digest_requires_both_boundaries() {
        let bad = "[adapter]\nname=\"m\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n\
                   [pty_digest]\ninput_pattern=\"U> \"\nagent_pattern=\"\"\n";
        let e = Manifest::from_toml_str(bad).unwrap_err();
        assert!(matches!(e, ManifestError::Validation(_)));
        assert!(e.to_string().contains("pty_digest"));
    }

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

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

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

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

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

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

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

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

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

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

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

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

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


PowerShell

cargo search schemars --limit 3 2>&1; cargo search jsonschema --limit 2 2>&1

Check schemars latest version


schemars = "1.2.1"                           # Generate JSON Schemas from Rust code
vld-schemars = "0.3.0"                       # Bidirectional bridge between vld and schemars — convert JSON schemas in both direc…
apistos-schemars_derive = "1.0.0-alpha.2"    # Macros for #[derive(JsonSchema)], for use with schemars
... and 526 crates more (use --limit N to see more)
note: to learn more about a package, run `cargo info <name>`
jsonschema = "0.46.5"          # JSON schema validaton library
sqlite-jsonschema = "0.2.3"    # A SQLite extension for validating JSON documents
... and 583 crates more (use --limit N to see more)
note: to learn more about a package, run `cargo info <name>`


Edit

[dependencies]
spt-proto = { path = "../spt-proto" }
spt-store = { path = "../spt-store" }
spt-msg = { path = "../spt-msg" }
serde = { version = "1", features = ["derive"] }
toml = "0.8"

[dependencies]
spt-proto = { path = "../spt-proto" }
spt-store = { path = "../spt-store" }
spt-msg = { path = "../spt-msg" }
serde = { version = "1", features = ["derive"] }
toml = "0.8"
# M6-D3: the public manifest JSON Schema (REQ-DOCS-4) is generated from these
# same types — the schema can never drift from the parser because both are one
# set of derives. serde_json renders it.
schemars = "1"
serde_json = "1"


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

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


1	[package]
2	name = "spt-runtime"
3	version = "0.0.0"
4	edition = "2021"
5	publish = false
6	description = "spt-core harness contract: runtime-manifest schema + AgentRuntime/ManifestRuntime (opaque-template substitution + spawn-session seam). The outbound half of the harness-agnostic contract (M2a). Public SDK (R-ARCH-2)."
7
8	[dependencies]
9	spt-proto = { path = "../spt-proto" }
10	spt-store = { path = "../spt-store" }
11	spt-msg = { path = "../spt-msg" }
12	serde = { version = "1", features = ["derive"] }
13	toml = "0.8"
14
15	[dev-dependencies]
16	tempfile = "3"
17

[package]
name = "spt-runtime"
version = "0.0.0"
edition = "2021"
publish = false
description = "spt-core harness contract: runtime-manifest schema + AgentRuntime/ManifestRuntime (opaque-template substitution + spawn-session seam). The outbound half of the harness-agnostic contract (M2a). Public SDK (R-ARCH-2)."

[dependencies]
spt-proto = { path = "../spt-proto" }
spt-store = { path = "../spt-store" }
spt-msg = { path = "../spt-msg" }
serde = { version = "1", features = ["derive"] }
toml = "0.8"

[dev-dependencies]
tempfile = "3"


serde = { version = "1", features = ["derive"] }
toml = "0.8"

[dev-dependencies]
tempfile = "3"

serde = { version = "1", features = ["derive"] }
toml = "0.8"
# M6-D3: the public manifest JSON Schema (REQ-DOCS-4) is generated from these
# same types — the published schema can never drift from the parser because
# both are one set of derives. serde_json renders the emitted document.
schemars = "1"
serde_json = "1"

[dev-dependencies]
tempfile = "3"


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

serde = { version = "1", features = ["derive"] }
toml = "0.8"
# M6-D3: the public manifest JSON Schema (REQ-DOCS-4) is generated from these
# same types — the published schema can never drift from the parser because
# both are one set of derives. serde_json renders the emitted document.
schemars = "1"
serde_json = "1"

[dev-dependencies]
tempfile = "3"

[package]
name = "spt-runtime"
version = "0.0.0"
edition = "2021"
publish = false
description = "spt-core harness contract: runtime-manifest schema + AgentRuntime/ManifestRuntime (opaque-template substitution + spawn-session seam). The outbound half of the harness-agnostic contract (M2a). Public SDK (R-ARCH-2)."

[dependencies]
spt-proto = { path = "../spt-proto" }
spt-store = { path = "../spt-store" }
spt-msg = { path = "../spt-msg" }
serde = { version = "1", features = ["derive"] }
toml = "0.8"

[dev-dependencies]
tempfile = "3"

+# M6-D3: the public manifest JSON Schema (REQ-DOCS-4) is generated from these

+# same types — the published schema can never drift from the parser because

+# both are one set of derives. serde_json renders the emitted document.


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

/// 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

-#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]

+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]

/// `[hooks.<event>]` — inbound hook table, keyed by harness event name.

/// `[adapter]` — the manifest header, readable before any update (compat gate).

/// `[hooks.<event>]` — one harness event → the `api` command it fires, the

/// stdin fields it maps, and whether it can surface context to the agent.

/// Opaque `api …` command line the harness invokes for this event.

/// `[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)]

+#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema)]

// Watched dirs (direct `[session]` keys). Filenames are spt-core-fixed;

// only the directory is adapter-declared (MANIFEST §watched dirs).

/// 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).

/// Opaque command line, with `{key}` substitution placeholders.

/// `[env.<VAR>]` — a single env-var directive.

/// Value to inject (with substitution); required for `inject`.

-#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]

+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]

/// `[history]` — transcript access strategy.

/// `fetcher` strategy: adapter binary emitting normalized history.

/// spt-core asks the adapter (pull, adapter binary emits normalized).

/// `[inject]` — inject-input methods per activity state.

#[serde(default, skip_serializing_if = "Vec::is_empty")]

/// `[identity]` — how the harness's session id is obtained.

/// Process-tree anchor name when `session_id` is absent.

/// Discovered after spawn (process-tree / wrapper handoff).

/// `[update]` — adapter self-update directive (parsed in M2a; conducted in M3).

/// `delegated` avenue: the command spt-core delegates to.

/// Delegate to the adapter's own updater (e.g. `claude plugin update`).

/// `[shell]` — the body of a `kind = "shell"` adapter (a driven surface).

/// Broker-launched opaque spawn command.

/// One entry in `[shell.capabilities]` — a command and its argument names.

/// `[shell.sensory]` — the sensory payload types a shell may emit.

/// 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)]

+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]

/// No approval (default — matches the system's everything-opt-in posture).

/// What happens when an owner is at its `max_instances_per_owner` cap.

/// Refuse the spawn outright (default).

/// presentation knobs (arg truncation, ANSI stripping, scan-buffer cap) stay

/// spt-core-owned defaults. `persist` opts this endpoint into the coarse Path-B

/// activity log (ADR-0008 "Option (C)") — off by default.

-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]

+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]

/// User-input boundary marker (regex). Opens a span until the next match.


/// The two adapter kinds. A `harness` hosts agents; a `shell` provides a driven

-#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]

+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)]


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

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


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

//
// [impl->REQ-MANIFEST-1]
+use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::fmt;


impl Manifest {
    /// Parse a manifest from TOML text, then validate its cross-field
    /// invariants. Never panics on adapter input — malformed TOML, an unknown

/// 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-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


/// 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-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

+/// 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.

+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");

+        "https://sabermage.github.io/spt-releases/manifest.schema.json".into(),

+    obj.insert("title".into(), "spt-core runtime manifest".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 \

+    let mut s = serde_json::to_string_pretty(&v).expect("schema serializes");


M6-D1: spt-releases repo + public face (README, licenses)

Create SaberMage/spt-releases private via gh; author README.md per grill decision 7; LICENSE-MIT + LICENSE-BINARY; Pages config deferred to D7 flip. Activate REQ-REL-1 doc stage.

Creating spt-releases repo + public face

M6-D2: installer scripts (install.sh / install.ps1)

Non-interactive: latest-release lookup → platform asset fetch → sha256 verify → per-OS install root → user PATH → print absolute path. Idempotent. CI rung against staged artifact + spt --version. Activate REQ-INSTALL-1/2/3/5.

M6-D3: manifest JSON Schema (schemars) + drift gate

schemars derives on spt-runtime manifest types; emit manifest.schema.json at stable site-root path + release asset; CI regenerate+diff gate. Activate REQ-DOCS-4 (partial), REQ-DOCS-5.

M6-D4: docs-site Tier 1 content (mdBook)

mdBook skeleton + Starlight-inspired theme; mental model; human quickstart; adapter quickstart (mock-adapter); harness-contract vertical complete; shell getting-started (spt-shell-notify); stubs for remaining verticals. Activate REQ-DOCS-1/2/3.

M6-D5: agent-export + generation plumbing

llms.txt + llms-full.txt; raw .md alongside HTML; CLI ref from clap; rustdoc → Pages; source-repo CI publish job → spt-releases Pages branch; drift gates. REQ-REL-1 impl, REQ-DOCS-4/5 complete.

M6-D6: release pipeline + signing ceremony

Workspace → 0.1.0; tag-triggered build matrix both runners; cross-repo release via fine-grained token; key ceremony per ADR-0015 (primary + recovery, embed both pubkeys in VerifyPolicy trusted set, seed backups); manual SignedRelease signing. Activate REQ-REL-2/3.

M6-D7: v0.1.0 ship + flip public + acceptance run

Tag v0.1.0; ceremony; publish release; flip spt-releases public; Pages live; acceptance proof per M6-PLAN (clean-machine install, quickstarts, llms.txt + schema fetch, staged v0.1.1 verify, recovery-key verify).


/// invariants ([`Manifest::validate`]) are enforced by spt-core, not expressible here.
// [impl->REQ-DOCS-5]
pub fn manifest_json_schema() -> String {

/// 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 {


<tool_use_error>String to replace not found in file.
String: /// invariants ([`Manifest::validate`]) are enforced by spt-core, not expressible here.
// [impl->REQ-DOCS-5]
pub fn manifest_json_schema() -> String {</tool_use_error>

Error: String to replace not found in file.
String: /// invariants ([`Manifest::validate`]) are enforced by spt-core, not expressible here.
// [impl->REQ-DOCS-5]
pub fn manifest_json_schema() -> String {


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

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

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

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

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