1	//! Adapter registration lifecycle (M5-D2, REQ-INSTALL-4) — the node-local
2	//! registered-adapter set under `{SPT_HOME}/adapters/`.
3	//!
4	//! `spt adapter add` validates the manifest **first** (nothing is recorded for
5	//! an invalid one), then records it (PRD R-INSTALL-4): a **copy** of the
6	//! manifest for `file_pull` / avenue-less adapters (spt-core owns the bytes),
7	//! a **pointer** back to the source tree for `delegated` ones (the adapter's
8	//! own updater owns its install; we re-read its manifest live so a delegated
9	//! self-update never leaves a stale copy behind). One command and one dir for
10	//! both `kind = "harness"` and `kind = "shell"`.
11	//!
12	//! Registration is **node-local capability** (may this node drive/launch the
13	//! adapter), not subnet advertisement — discovery rides the registry/broadcast
14	//! gates elsewhere. The registered set is the single source consumed by:
15	//! creation-time adapter selection (auto-if-one/ask-if-many over
16	//! `hostable_types`), shell discovery (D3d), and the R-UPD-5 self-update
17	//! ripple.
18	//!
19	//! `spt adapter remove` is a **soft-deregister**: the record stays with
20	//! `active = false`, so new creation no longer offers the adapter while
21	//! existing/live instances keep running off it. The manifest's optional
22	//! `[update] uninstall` template is *returned* to the caller to conduct once
23	//! the adapter is quiesced — this module records state, it never execs.
24	//! Re-adding a deregistered name re-activates it (refreshing the record).
25	//!
26	//! Layout per adapter: `adapters/<name>/record.toml` (+ `manifest.toml` in
27	//! copy mode). Records write atomically (REQ-HAZARD-EBUSY-RENAME stance via
28	//! `spt_store::atomic`).
29	// [impl->REQ-INSTALL-4]
30
31	use serde::{Deserialize, Serialize};
32	use std::path::{Path, PathBuf};
33	use toml::Value;
34
35	use spt_store::atomic::atomic_write_string;
36
37	use crate::manifest::{AdapterKind, Manifest, UpdateAvenue};
38	use crate::profile;
39
40	/// How the manifest is held: a copy under `adapters/<name>/`, or a pointer
41	/// re-read from the source tree per load.
42	#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
43	#[serde(rename_all = "snake_case")]
44	pub enum RecordMode {
45	    Copy,
46	    Pointer,
47	}
48
49	/// One registered adapter's durable record (`adapters/<name>/record.toml`).
50	#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
51	pub struct AdapterRecord {
52	    pub name: String,
53	    pub kind: AdapterKind,
54	    pub mode: RecordMode,
55	    /// Where the adapter was registered from (the manifest's directory) —
56	    /// the live manifest location in pointer mode, provenance in copy mode.
57	    pub source_dir: String,
58	    pub registered_at_ms: u64,
59	    /// `false` = soft-deregistered: hidden from new-creation/discovery;
60	    /// existing instances keep running (REQ-INSTALL-4).
61	    pub active: bool,
62	}
63
64	/// Registration failures — adapter-author-facing, so they carry the why.
65	#[derive(Debug)]
66	pub enum RegistryError {
67	    /// The source manifest failed to read.
68	    Io(std::io::Error),
69	    /// The source manifest failed parse/validation — nothing was recorded.
70	    Manifest(crate::manifest::ManifestError),
71	    /// `remove`/lookup named an adapter that has no record.
72	    NotRegistered(String),
73	    /// A record on disk is unreadable/corrupt (named so the operator can fix).
74	    BadRecord(String),
75	    /// A local profile name is not a safe single path segment.
76	    InvalidProfileName(String),
77	    /// A local profile would shadow a shipped profile of the same name (refused
78	    /// at registration — CONTEXT.md §adapter profile).
79	    ProfileShadowsShipped(String),
80	    /// `delete-profile`/`get-string` named a local profile that has no file.
81	    ProfileNotFound(String),
82	    /// `delete-profile` named a **shipped** profile (immutable — CLI operates on
83	    /// local profiles only).
84	    ShippedProfileImmutable(String),
85	}
86
87	impl std::fmt::Display for RegistryError {
88	    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89	        match self {
90	            RegistryError::Io(e) => write!(f, "io: {e}"),
91	            RegistryError::Manifest(e) => write!(f, "manifest invalid: {e}"),
92	            RegistryError::NotRegistered(n) => write!(f, "adapter not registered: {n}"),
93	            RegistryError::BadRecord(n) => write!(f, "corrupt adapter record: {n}"),
94	            RegistryError::InvalidProfileName(n) => {
95	                write!(f, "invalid profile name '{n}' (no ':', path separators, or whitespace)")
96	            }
97	            RegistryError::ProfileShadowsShipped(n) => {
98	                write!(f, "local profile '{n}' would shadow a shipped profile of the same name")
99	            }
100	            RegistryError::ProfileNotFound(n) => write!(f, "local profile not found: {n}"),
101	            RegistryError::ShippedProfileImmutable(n) => {
102	                write!(f, "profile '{n}' is shipped (adapter-owned); only local profiles are editable")
103	            }
104	        }
105	    }
106	}
107
108	impl From<std::io::Error> for RegistryError {
109	    fn from(e: std::io::Error) -> Self {
110	        RegistryError::Io(e)
111	    }
112	}
113
114	fn record_file(adapters_dir: &Path, name: &str) -> PathBuf {
115	    adapters_dir.join(name).join("record.toml")
116	}
117
118	fn manifest_copy_file(adapters_dir: &Path, name: &str) -> PathBuf {
119	    adapters_dir.join(name).join("manifest.toml")
120	}
121
122	/// Resolve the manifest file inside a source: `<dir>/manifest.toml`, or the
123	/// path itself when it already names a file.
124	fn source_manifest_file(source: &Path) -> PathBuf {
125	    if source.is_dir() {
126	        source.join("manifest.toml")
127	    } else {
128	        source.to_path_buf()
129	    }
130	}
131
132	/// Register (or re-register) the adapter whose manifest lives at `source`
133	/// (a directory containing `manifest.toml`, or the manifest path itself).
134	/// Validate-first: an invalid manifest records **nothing**. Mode falls out of
135	/// the `[update]` avenue — `delegated` ⇒ pointer, `file_pull`/absent ⇒ copy.
136	/// Re-adding an existing name refreshes the record and re-activates a
137	/// soft-deregistered one. Returns the record; the caller conducts
138	/// install-is-first-update through the manifest's `[update]` avenue.
139	// [impl->REQ-INSTALL-4]
140	pub fn register(
141	    adapters_dir: &Path,
142	    source: &Path,
143	    now_ms: u64,
144	) -> Result<(AdapterRecord, Manifest), RegistryError> {
145	    let manifest_file = source_manifest_file(source);
146	    let toml_src = std::fs::read_to_string(&manifest_file)?;
147	    let manifest = Manifest::from_toml_str(&toml_src).map_err(RegistryError::Manifest)?;
148
149	    let mode = match manifest.update.as_ref().map(|u| u.avenue) {
150	        Some(UpdateAvenue::Delegated) => RecordMode::Pointer,
151	        Some(UpdateAvenue::FilePull) | None => RecordMode::Copy,
152	    };
153	    let source_dir = manifest_file
154	        .parent()
155	        .unwrap_or(Path::new("."))
156	        .to_string_lossy()
157	        .into_owned();
158
159	    let record = AdapterRecord {
160	        name: manifest.adapter.name.clone(),
161	        kind: manifest.adapter.kind,
162	        mode,
163	        source_dir,
164	        registered_at_ms: now_ms,
165	        active: true,
166	    };
167
168	    let dir = adapters_dir.join(&record.name);
169	    std::fs::create_dir_all(&dir)?;
170	    if mode == RecordMode::Copy {
171	        atomic_write_string(&manifest_copy_file(adapters_dir, &record.name), &toml_src)?;
172	    }
173	    save_record(adapters_dir, &record)?;
174	    Ok((record, manifest))
175	}
176
177	/// Soft-deregister `name`: flip `active = false` (the record + any manifest
178	/// copy stay — existing instances keep running off them). Returns the
179	/// manifest's optional `[update] uninstall` template for the caller to
180	/// conduct once the adapter is quiesced (`spt adapter remove`'s optional
181	/// second half); `None` = spt-core default cleanup. Idempotent on an
182	/// already-inactive record.
183	// [impl->REQ-INSTALL-4]
184	pub fn deregister(adapters_dir: &Path, name: &str) -> Result<Option<String>, RegistryError> {
185	    let mut record = load_record(adapters_dir, name)?;
186	    record.active = false;
187	    save_record(adapters_dir, &record)?;
188	    let uninstall = load_manifest(adapters_dir, &record)
189	        .ok()
190	        .and_then(|m| m.update.and_then(|u| u.uninstall));
191	    Ok(uninstall)
192	}
193
194	/// One adapter's record by name.
195	pub fn load_record(adapters_dir: &Path, name: &str) -> Result<AdapterRecord, RegistryError> {
196	    let path = record_file(adapters_dir, name);
197	    if !path.exists() {
198	        return Err(RegistryError::NotRegistered(name.to_string()));
199	    }
200	    let s = std::fs::read_to_string(&path)?;
201	    toml::from_str(&s).map_err(|_| RegistryError::BadRecord(name.to_string()))
202	}
203
204	fn save_record(adapters_dir: &Path, record: &AdapterRecord) -> Result<(), RegistryError> {
205	    let s = toml::to_string_pretty(record)
206	        .map_err(|e| RegistryError::BadRecord(format!("{}: {e}", record.name)))?;
207	    atomic_write_string(&record_file(adapters_dir, &record.name), &s)?;
208	    Ok(())
209	}
210
211	/// The record's manifest: the held copy in copy mode, the live source tree in
212	/// pointer mode (a delegated adapter's self-updates are always honored).
213	// [impl->REQ-INSTALL-4]
214	pub fn load_manifest(
215	    adapters_dir: &Path,
216	    record: &AdapterRecord,
217	) -> Result<Manifest, RegistryError> {
218	    let path = match record.mode {
219	        RecordMode::Copy => manifest_copy_file(adapters_dir, &record.name),
220	        RecordMode::Pointer => source_manifest_file(Path::new(&record.source_dir)),
221	    };
222	    let s = std::fs::read_to_string(&path)?;
223	    Manifest::from_toml_str(&s).map_err(RegistryError::Manifest)
224	}
225
226	/// Every record under `adapters/` (active and not — callers filter), sorted
227	/// by name for a stable order. Tolerant: an unreadable entry is skipped, the
228	/// rest still list (the stale-registry degrade stance).
229	pub fn all_records(adapters_dir: &Path) -> Vec<AdapterRecord> {
230	    let Ok(entries) = std::fs::read_dir(adapters_dir) else {
231	        return Vec::new();
232	    };
233	    let mut out: Vec<AdapterRecord> = entries
234	        .filter_map(|e| e.ok())
235	        .filter_map(|e| load_record(adapters_dir, &e.file_name().to_string_lossy()).ok())
236	        .collect();
237	    out.sort_by(|a, b| a.name.cmp(&b.name));
238	    out
239	}
240
241	/// The **active** registered set with manifests resolved — the single source
242	/// for creation-time adapter selection, shell discovery, and the R-UPD-5
243	/// ripple. A pointer-mode record whose source tree has vanished is skipped
244	/// (tolerant), not fatal.
245	// [impl->REQ-INSTALL-4]
246	pub fn registered(adapters_dir: &Path) -> Vec<(AdapterRecord, Manifest)> {
247	    all_records(adapters_dir)
248	        .into_iter()
249	        .filter(|r| r.active)
250	        .filter_map(|r| load_manifest(adapters_dir, &r).ok().map(|m| (r, m)))
251	        .collect()
252	}
253
254	// ───────────────────────── Local profiles (M9 / REQ-MANIFEST-2) ─────────────
255	//
256	// A **local** profile is a node-local, user-authored sparse overlay registered
257	// *beside* the adapter at `adapters/<adapter>/profiles/<profile>.toml`. Because
258	// `register` never clears that dir, locals **survive adapter re-registration**
259	// (the manifest copy is adapter-owned and overwritten by updates; the user's
260	// customization is not). Locals may not shadow a shipped profile's name (refused
261	// at create) and are floored tighten-only at create. CLI verbs (`create-profile`,
262	// `delete-profile`, `set-string`) operate on locals only.
263
264	/// `adapters/<adapter>/profiles/`.
265	fn local_profiles_dir(adapters_dir: &Path, adapter: &str) -> PathBuf {
266	    adapters_dir.join(adapter).join("profiles")
267	}
268
269	/// `adapters/<adapter>/profiles/<profile>.toml`.
270	fn local_profile_file(adapters_dir: &Path, adapter: &str, profile: &str) -> PathBuf {
271	    local_profiles_dir(adapters_dir, adapter).join(format!("{profile}.toml"))
272	}
273
274	/// A profile name must be a single, safe path segment usable in the composite
275	/// `<adapter>:<profile>` address: non-empty, no `:` (the address separator), no
276	/// path separators, no whitespace, and not a dot-segment.
277	fn validate_profile_name(profile: &str) -> Result<(), RegistryError> {
278	    let bad = profile.is_empty()
279	        || profile == "."
280	        || profile == ".."
281	        || profile
282	            .chars()
283	            .any(|c| c == ':' || c == '/' || c == '\\' || c.is_whitespace());
284	    if bad {
285	        return Err(RegistryError::InvalidProfileName(profile.to_string()));
286	    }
287	    Ok(())
288	}
289
290	/// The local profile overlay [`Value`] for `<adapter>/profiles/<profile>.toml`.
291	pub fn load_local_overlay(
292	    adapters_dir: &Path,
293	    adapter: &str,
294	    profile: &str,
295	) -> Result<Value, RegistryError> {
296	    let path = local_profile_file(adapters_dir, adapter, profile);
297	    if !path.exists() {
298	        return Err(RegistryError::ProfileNotFound(profile.to_string()));
299	    }
300	    let s = std::fs::read_to_string(&path)?;
301	    toml::from_str(&s).map_err(|_| RegistryError::BadRecord(format!("profiles/{profile}")))
302	}
303
304	/// Local profile names registered for `adapter`, sorted (drops unreadable
305	/// entries — the tolerant-registry stance).
306	pub fn local_profile_names(adapters_dir: &Path, adapter: &str) -> Vec<String> {
307	    let Ok(entries) = std::fs::read_dir(local_profiles_dir(adapters_dir, adapter)) else {
308	        return Vec::new();
309	    };
310	    let mut out: Vec<String> = entries
311	        .filter_map(|e| e.ok())
312	        .filter_map(|e| {
313	            let name = e.file_name().to_string_lossy().into_owned();
314	            name.strip_suffix(".toml").map(str::to_string)
315	        })
316	        .collect();
317	    out.sort();
318	    out
319	}
320
321	/// Create (or overwrite) a local profile for `adapter` from a sparse overlay TOML
322	/// document. Refuses an invalid name, a name that shadows a **shipped** profile,
323	/// an overlay that fails to parse, or one that — once merged onto the parent —
324	/// breaks a manifest invariant or **loosens a consent floor** (tighten-only).
325	/// Nothing is written unless every check passes.
326	// [impl->REQ-MANIFEST-2]
327	pub fn create_local_profile(
328	    adapters_dir: &Path,
329	    adapter: &str,
330	    profile: &str,
331	    overlay_toml: &str,
332	) -> Result<(), RegistryError> {
333	    validate_profile_name(profile)?;
334	    let record = load_record(adapters_dir, adapter)?;
335	    let parent = load_manifest(adapters_dir, &record)?;
336	    if parent.profiles.contains_key(profile) {
337	        return Err(RegistryError::ProfileShadowsShipped(profile.to_string()));
338	    }
339	    let overlay: Value = toml::from_str(overlay_toml)
340	        .map_err(|e| RegistryError::Manifest(crate::manifest::ManifestError::Parse(e)))?;
341	    // Validate by resolving: merge onto the parent, run manifest invariants + the
342	    // tighten-only floor. A failure refuses the create (nothing written).
343	    profile::resolve_overlay(&parent, &overlay).map_err(RegistryError::Manifest)?;
344	    let dir = local_profiles_dir(adapters_dir, adapter);
345	    std::fs::create_dir_all(&dir)?;
346	    atomic_write_string(&local_profile_file(adapters_dir, adapter, profile), overlay_toml)?;
347	    Ok(())
348	}
349
350	/// Delete a local profile. Refuses a **shipped** profile name (adapter-owned,
351	/// immutable) and errors if no local file exists.
352	// [impl->REQ-MANIFEST-2]
353	pub fn delete_local_profile(
354	    adapters_dir: &Path,
355	    adapter: &str,
356	    profile: &str,
357	) -> Result<(), RegistryError> {
358	    let record = load_record(adapters_dir, adapter)?;
359	    let parent = load_manifest(adapters_dir, &record)?;
360	    if parent.profiles.contains_key(profile) {
361	        return Err(RegistryError::ShippedProfileImmutable(profile.to_string()));
362	    }
363	    let path = local_profile_file(adapters_dir, adapter, profile);
364	    if !path.exists() {
365	        return Err(RegistryError::ProfileNotFound(profile.to_string()));
366	    }
367	    std::fs::remove_file(&path)?;
368	    Ok(())
369	}
370
371	/// Read a `[strings]` dot-path from an adapter **option**'s merged view — the
372	/// `spt adapter get-string <adapter-option> <key.path>` resolver. Resolves the
373	/// option (bare = parent, else shipped/local overlay) exactly as every other
374	/// consumer, then dot-path-traverses the merged `[strings]` tree. `Ok(None)` =
375	/// the key is absent (a clean "not set", distinct from an unresolvable option).
376	// [impl->REQ-MANIFEST-3]
377	pub fn get_string(
378	    adapters_dir: &Path,
379	    option: &str,
380	    key_path: &str,
381	) -> Result<Option<Value>, RegistryError> {
382	    let (_, manifest) = resolve_option(adapters_dir, option)?;
383	    Ok(profile::string_at(&manifest.strings, key_path).cloned())
384	}
385
386	/// Set a `[strings]` dot-path on a **local** profile's overlay — the
387	/// `spt adapter set-string` sugar. Edits only the node-local overlay file
388	/// (`profiles/<profile>.toml`); a shipped profile name is refused (adapter-owned,
389	/// immutable) and a missing local profile errors (`create-profile` first). The
390	/// edited overlay is re-validated through the same create-time guards (manifest
391	/// invariants + tighten-only floor) before it is written.
392	// [impl->REQ-MANIFEST-3]
393	pub fn set_local_string(
394	    adapters_dir: &Path,
395	    adapter: &str,
396	    profile: &str,
397	    key_path: &str,
398	    value: &str,
399	) -> Result<(), RegistryError> {
400	    let record = load_record(adapters_dir, adapter)?;
401	    let parent = load_manifest(adapters_dir, &record)?;
402	    if parent.profiles.contains_key(profile) {
403	        return Err(RegistryError::ShippedProfileImmutable(profile.to_string()));
404	    }
405	    // Edit the existing local overlay in place (create-profile mints it first).
406	    let mut overlay = load_local_overlay(adapters_dir, adapter, profile)?;
407	    let overlay_table = overlay
408	        .as_table_mut()
409	        .ok_or_else(|| RegistryError::BadRecord(format!("profiles/{profile}")))?;
410	    let strings = overlay_table
411	        .entry("strings".to_string())
412	        .or_insert_with(|| Value::Table(toml::Table::new()));
413	    let strings_table = strings
414	        .as_table_mut()
415	        .ok_or_else(|| RegistryError::BadRecord(format!("profiles/{profile}: [strings] not a table")))?;
416	    profile::set_string_at(strings_table, key_path, Value::String(value.to_string()))
417	        .map_err(RegistryError::Manifest)?;
418	    let overlay_toml = toml::to_string_pretty(&overlay)
419	        .map_err(|e| RegistryError::Manifest(crate::manifest::ManifestError::Validation(e.to_string())))?;
420	    // Re-validate + write atomically through the create-time guards.
421	    create_local_profile(adapters_dir, adapter, profile, &overlay_toml)
422	}
423
424	/// Resolve an adapter **option** (`<adapter>` or `<adapter>:<profile>`) to its
425	/// record and complete, validated manifest — the single entry point every
426	/// composite-addressing consumer calls. A bare name returns the parent
427	/// unmodified (short-circuit). A profile is looked up **shipped-first** (the
428	/// adapter's `[profiles.<name>]`), then as a **local** overlay file; the two
429	/// namespaces are disjoint (locals may not shadow shipped).
430	// [impl->REQ-MANIFEST-2]
431	pub fn resolve_option(
432	    adapters_dir: &Path,
433	    option: &str,
434	) -> Result<(AdapterRecord, Manifest), RegistryError> {
435	    let (adapter, profile) = profile::split_option(option);
436	    let record = load_record(adapters_dir, adapter)?;
437	    // A soft-deregistered adapter is hidden from new resolution (the same
438	    // active-only view `registered()` gives the daemon seams): existing
439	    // instances keep running, but spawn/drive/relaunch refuse and fall back.
440	    if !record.active {
441	        return Err(RegistryError::NotRegistered(adapter.to_string()));
442	    }
443	    let parent = load_manifest(adapters_dir, &record)?;
444	    let manifest = apply_option_overlay(&parent, adapters_dir, adapter, profile)?;
445	    Ok((record, manifest))
446	}
447
448	/// Resolve an adapter **option** against an **already-loaded** registered set —
449	/// the daemon seam. `registered()` is called once per edge event, then every
450	/// hosted shell is re-resolved against the in-memory slice rather than reloading
451	/// each parent from disk. Same contract as [`resolve_option`] (split → parent
452	/// lookup → overlay), but the parent comes from the slice; **local** overlays
453	/// still read from `adapters_dir`. Returns the merged, validated manifest (the
454	/// record already lives in the slice).
455	// [impl->REQ-MANIFEST-2]
456	pub fn resolve_option_in(
457	    registered: &[(AdapterRecord, Manifest)],
458	    adapters_dir: &Path,
459	    option: &str,
460	) -> Result<Manifest, RegistryError> {
461	    let (adapter, profile) = profile::split_option(option);
462	    let parent = registered
463	        .iter()
464	        .find(|(r, _)| r.name == adapter)
465	        .map(|(_, m)| m)
466	        .ok_or_else(|| RegistryError::NotRegistered(adapter.to_string()))?;
467	    apply_option_overlay(parent, adapters_dir, adapter, profile)
468	}
469
470	/// Resolve an adapter **option** onto a **caller-supplied** parent manifest —
471	/// the runtime-manifest seam (`spt api --adapter <option> --manifest <file>`).
472	/// The parent comes from the adapter's own manifest file (authoritative at
473	/// runtime, possibly newer than the registry copy), not a registry load; the
474	/// `:<profile>` segment of `option` is then overlaid: a **shipped** profile from
475	/// the parent file's own `[profiles]`, a **local** one from `adapters_dir`. A
476	/// bare option returns the parent unmodified. The caller has already matched the
477	/// parent segment against `parent.adapter.name`.
478	// [impl->REQ-MANIFEST-2]
479	pub fn resolve_option_onto(
480	    parent: &Manifest,
481	    adapters_dir: &Path,
482	    option: &str,
483	) -> Result<Manifest, RegistryError> {
484	    let (adapter, profile) = profile::split_option(option);
485	    apply_option_overlay(parent, adapters_dir, adapter, profile)
486	}
487
488	/// Shared overlay-application core of [`resolve_option`] and
489	/// [`resolve_option_in`]: a bare name (no profile) returns the parent
490	/// unmodified; a **shipped** profile resolves from `parent.profiles`; otherwise
491	/// a **local** overlay is read from `adapters_dir` and merged. The two profile
492	/// namespaces are disjoint (locals may not shadow shipped — enforced at create).
493	// [impl->REQ-MANIFEST-2]
494	fn apply_option_overlay(
495	    parent: &Manifest,
496	    adapters_dir: &Path,
497	    adapter: &str,
498	    profile: Option<&str>,
499	) -> Result<Manifest, RegistryError> {
500	    match profile {
501	        None => Ok(parent.clone()),
502	        Some(p) if parent.profiles.contains_key(p) => {
503	            profile::resolve(parent, p).map_err(RegistryError::Manifest)
504	        }
505	        Some(p) => {
506	            let overlay = load_local_overlay(adapters_dir, adapter, p)?;
507	            profile::resolve_overlay(parent, &overlay).map_err(RegistryError::Manifest)
508	        }
509	    }
510	}
511
512	#[cfg(test)]
513	mod tests {
514	    use super::*;
515
516	    const HARNESS_COPY: &str = r#"
517	[adapter]
518	name = "mock-harness"
519	kind = "harness"
520	version = "1.0.0"
521	min_spt_core_version = "0.0.1"
522	hostable_types = ["LiveAgent"]
523	"#;
524
525	    // A copy-mode (avenue-less) shell adapter carrying one shipped profile that
526	    // tightens the floor — the base for local-profile tests.
527	    const SHELL_COPY_PROFILED: &str = r#"
528	[adapter]
529	name = "mock-shell"
530	kind = "shell"
531	version = "1.0.0"
532	min_spt_core_version = "0.0.1"
533
534	[shell]
535	spawn = "mock --link {link_token}"
536	require_approval = "remembered"
537
538	[profiles.locked]
539	[profiles.locked.shell]
540	require_approval = "always"
541	"#;
542
543	    const SHELL_DELEGATED: &str = r#"
544	[adapter]
545	name = "mock-shell"
546	kind = "shell"
547	version = "1.0.0"
548	min_spt_core_version = "0.0.1"
549
550	[update]
551	avenue = "delegated"
552	command = "mock-shell-updater"
553	self_verifies = true
554	uninstall = "mock-shell-uninstaller --purge"
555
556	[shell]
557	spawn = "mock-shell --link {link_token}"
558	"#;
559
560	    fn seed_source(dir: &Path, name: &str, toml_src: &str) -> PathBuf {
561	        let src = dir.join(name);
562	        std::fs::create_dir_all(&src).unwrap();
563	        std::fs::write(src.join("manifest.toml"), toml_src).unwrap();
564	        src
565	    }
566
567	    // [unit->REQ-INSTALL-4] register validates-first and records by avenue:
568	    // an avenue-less harness is copied; a delegated shell is a pointer whose
569	    // manifest reads live from the source tree. One dir serves both kinds.
570	    #[test]
571	    fn register_copy_vs_pointer_by_avenue() {
572	        let tmp = tempfile::tempdir().unwrap();
573	        let adapters = tmp.path().join("adapters");
574	        let harness_src = seed_source(tmp.path(), "h-src", HARNESS_COPY);
575	        let shell_src = seed_source(tmp.path(), "s-src", SHELL_DELEGATED);
576
577	        let (h, hm) = register(&adapters, &harness_src, 1000).unwrap();
578	        assert_eq!((h.mode, h.kind), (RecordMode::Copy, AdapterKind::Harness));
579	        assert_eq!(hm.adapter.name, "mock-harness");
580	        assert!(
581	            adapters.join("mock-harness").join("manifest.toml").exists(),
582	            "copy held"
583	        );
584
585	        let (s, _) = register(&adapters, &shell_src, 2000).unwrap();
586	        assert_eq!((s.mode, s.kind), (RecordMode::Pointer, AdapterKind::Shell));
587	        assert!(
588	            !adapters.join("mock-shell").join("manifest.toml").exists(),
589	            "no copy"
590	        );
591	        // Pointer reads live: mutate the source, the loaded manifest follows.
592	        std::fs::write(
593	            shell_src.join("manifest.toml"),
594	            SHELL_DELEGATED.replace("1.0.0", "1.1.0"),
595	        )
596	        .unwrap();
597	        let live = load_manifest(&adapters, &s).unwrap();
598	        assert_eq!(live.adapter.version, "1.1.0");
599
600	        let set = registered(&adapters);
601	        assert_eq!(
602	            set.iter().map(|(r, _)| r.name.as_str()).collect::<Vec<_>>(),
603	            vec!["mock-harness", "mock-shell"],
604	            "stable name order"
605	        );
606	    }
607
608	    // [unit->REQ-INSTALL-4] an invalid manifest records nothing — the
609	    // validate-first rule (manifest-first, then install).
610	    #[test]
611	    fn invalid_manifest_records_nothing() {
612	        let tmp = tempfile::tempdir().unwrap();
613	        let adapters = tmp.path().join("adapters");
614	        // kind = "shell" without a [shell] body fails validation.
615	        let bad = seed_source(
616	            tmp.path(),
617	            "bad-src",
618	            "[adapter]\nname = \"bad\"\nkind = \"shell\"\nversion = \"1\"\nmin_spt_core_version = \"0\"\n",
619	        );
620	        assert!(matches!(
621	            register(&adapters, &bad, 0),
622	            Err(RegistryError::Manifest(_))
623	        ));
624	        assert!(!adapters.join("bad").exists(), "nothing recorded");
625	        assert!(registered(&adapters).is_empty());
626	    }
627
628	    // [unit->REQ-INSTALL-4] remove is a soft-deregister: hidden from the
629	    // active set, record + copy retained (live instances keep running), the
630	    // manifest's uninstall template surfaced to the caller; re-add
631	    // re-activates.
632	    #[test]
633	    fn deregister_is_soft_and_readd_reactivates() {
634	        let tmp = tempfile::tempdir().unwrap();
635	        let adapters = tmp.path().join("adapters");
636	        let shell_src = seed_source(tmp.path(), "s-src", SHELL_DELEGATED);
637	        register(&adapters, &shell_src, 1000).unwrap();
638
639	        let uninstall = deregister(&adapters, "mock-shell").unwrap();
640	        assert_eq!(uninstall.as_deref(), Some("mock-shell-uninstaller --purge"));
641	        assert!(
642	            registered(&adapters).is_empty(),
643	            "hidden from the active set"
644	        );
645	        let rec = load_record(&adapters, "mock-shell").unwrap();
646	        assert!(!rec.active, "record retained, inactive");
647	        // resolve_option honors the active-only view: a soft-deregistered
648	        // adapter refuses new resolution (spawn/drive/relaunch).
649	        assert!(matches!(
650	            resolve_option(&adapters, "mock-shell"),
651	            Err(RegistryError::NotRegistered(_))
652	        ));
653
654	        // Unknown name errs; double-deregister is idempotent.
655	        assert!(matches!(
656	            deregister(&adapters, "ghost"),
657	            Err(RegistryError::NotRegistered(_))
658	        ));
659	        assert!(deregister(&adapters, "mock-shell").is_ok());
660
661	        let (re, _) = register(&adapters, &shell_src, 2000).unwrap();
662	        assert!(
663	            re.active && re.registered_at_ms == 2000,
664	            "re-add re-activates + refreshes"
665	        );
666	        assert_eq!(registered(&adapters).len(), 1);
667	    }
668
669	    /// Register the profiled shell adapter into a fresh `adapters/` dir.
670	    fn registered_shell(tmp: &Path) -> PathBuf {
671	        let adapters = tmp.join("adapters");
672	        let src = seed_source(tmp, "ms-src", SHELL_COPY_PROFILED);
673	        register(&adapters, &src, 1000).unwrap();
674	        adapters
675	    }
676
677	    fn approval(m: &Manifest) -> crate::manifest::ShellApproval {
678	        m.shell.as_ref().unwrap().require_approval
679	    }
680
681	    // [unit->REQ-MANIFEST-2] resolve_option: bare name = parent unmodified;
682	    // a shipped profile and a local profile each resolve to the merged view.
683	    #[test]
684	    fn resolve_option_bare_shipped_and_local() {
685	        use crate::manifest::ShellApproval;
686	        let tmp = tempfile::tempdir().unwrap();
687	        let adapters = registered_shell(tmp.path());
688
689	        // Bare = parent.
690	        let (_, base) = resolve_option(&adapters, "mock-shell").unwrap();
691	        assert_eq!(approval(&base), ShellApproval::Remembered, "bare = parent");
692
693	        // Shipped profile.
694	        let (_, shipped) = resolve_option(&adapters, "mock-shell:locked").unwrap();
695	        assert_eq!(approval(&shipped), ShellApproval::Always, "shipped resolves");
696
697	        // Local profile (tightens further-or-equal — always over remembered).
698	        create_local_profile(&adapters, "mock-shell", "work", "[shell]\nrequire_approval = \"always\"\n")
699	            .unwrap();
700	        let (_, local) = resolve_option(&adapters, "mock-shell:work").unwrap();
701	        assert_eq!(approval(&local), ShellApproval::Always, "local resolves");
702	        assert_eq!(local_profile_names(&adapters, "mock-shell"), vec!["work"]);
703	    }
704
705	    // [unit->REQ-MANIFEST-2] resolve_option_in (the daemon seam) resolves the
706	    // same merged view as resolve_option against a **preloaded** registered
707	    // slice — bare = parent, shipped + local both overlay; an unregistered
708	    // parent is NotRegistered.
709	    #[test]
710	    fn resolve_option_in_matches_disk() {
711	        use crate::manifest::ShellApproval;
712	        let tmp = tempfile::tempdir().unwrap();
713	        let adapters = registered_shell(tmp.path());
714	        create_local_profile(&adapters, "mock-shell", "work", "[shell]\nrequire_approval = \"always\"\n")
715	            .unwrap();
716	        let set = registered(&adapters);
717
718	        let bare = resolve_option_in(&set, &adapters, "mock-shell").unwrap();
719	        assert_eq!(approval(&bare), ShellApproval::Remembered, "bare = parent");
720	        let shipped = resolve_option_in(&set, &adapters, "mock-shell:locked").unwrap();
721	        assert_eq!(approval(&shipped), ShellApproval::Always, "shipped overlays");
722	        let local = resolve_option_in(&set, &adapters, "mock-shell:work").unwrap();
723	        assert_eq!(approval(&local), ShellApproval::Always, "local overlays");
724
725	        assert!(matches!(
726	            resolve_option_in(&set, &adapters, "ghost:work"),
727	            Err(RegistryError::NotRegistered(n)) if n == "ghost"
728	        ));
729	    }
730
731	    // [unit->REQ-MANIFEST-2] a local profile survives adapter re-registration
732	    // (the manifest copy is overwritten; the user's profiles/ dir is not).
733	    #[test]
734	    fn local_profile_survives_readd() {
735	        let tmp = tempfile::tempdir().unwrap();
736	        let adapters = registered_shell(tmp.path());
737	        create_local_profile(&adapters, "mock-shell", "work", "[shell]\nrequire_approval = \"always\"\n")
738	            .unwrap();
739
740	        // Re-register the adapter (adapter update / re-add).
741	        let src = seed_source(tmp.path(), "ms-src2", SHELL_COPY_PROFILED);
742	        register(&adapters, &src, 2000).unwrap();
743
744	        assert!(
745	            resolve_option(&adapters, "mock-shell:work").is_ok(),
746	            "local profile still resolvable after re-add"
747	        );
748	    }
749
750	    // [unit->REQ-MANIFEST-2] create-time guards: shadow a shipped name, loosen
751	    // the floor, or an invalid name — each refused, nothing written.
752	    #[test]
753	    fn create_local_profile_guards() {
754	        let tmp = tempfile::tempdir().unwrap();
755	        let adapters = registered_shell(tmp.path());
756
757	        // Shadow a shipped profile.
758	        assert!(matches!(
759	            create_local_profile(&adapters, "mock-shell", "locked", "[shell]\nrequire_approval = \"always\"\n"),
760	            Err(RegistryError::ProfileShadowsShipped(n)) if n == "locked"
761	        ));
762
763	        // Loosen the consent floor (remembered -> none).
764	        assert!(matches!(
765	            create_local_profile(&adapters, "mock-shell", "loose", "[shell]\nrequire_approval = \"none\"\n"),
766	            Err(RegistryError::Manifest(_))
767	        ));
768
769	        // Invalid name (contains the address separator).
770	        assert!(matches!(
771	            create_local_profile(&adapters, "mock-shell", "a:b", "[shell]\nrequire_approval = \"always\"\n"),
772	            Err(RegistryError::InvalidProfileName(_))
773	        ));
774
775	        assert!(
776	            local_profile_names(&adapters, "mock-shell").is_empty(),
777	            "no file written for any refused create"
778	        );
779	    }
780
781	    // [unit->REQ-MANIFEST-2] delete-profile: shipped is immutable, local is
782	    // removed, a missing local errors.
783	    #[test]
784	    fn delete_local_profile_rules() {
785	        let tmp = tempfile::tempdir().unwrap();
786	        let adapters = registered_shell(tmp.path());
787	        create_local_profile(&adapters, "mock-shell", "work", "[shell]\nrequire_approval = \"always\"\n")
788	            .unwrap();
789
790	        assert!(matches!(
791	            delete_local_profile(&adapters, "mock-shell", "locked"),
792	            Err(RegistryError::ShippedProfileImmutable(_))
793	        ));
794	        assert!(delete_local_profile(&adapters, "mock-shell", "work").is_ok());
795	        assert!(matches!(
796	            resolve_option(&adapters, "mock-shell:work"),
797	            Err(RegistryError::ProfileNotFound(_))
798	        ));
799	        assert!(matches!(
800	            delete_local_profile(&adapters, "mock-shell", "ghost"),
801	            Err(RegistryError::ProfileNotFound(_))
802	        ));
803	    }
804
805	    // [unit->REQ-MANIFEST-3] get-string resolves through the overlay (a local
806	    // profile's [strings] override the base; the merged view is what reads), and
807	    // set-string edits a LOCAL profile only — a shipped name + a bare option are
808	    // refused, a missing key reads None.
809	    #[test]
810	    fn strings_get_set_through_overlay() {
811	        let tmp = tempfile::tempdir().unwrap();
812	        // A shell adapter carrying base strings + a shipped profile.
813	        let src_toml = "[adapter]\nname = \"mock-shell\"\nkind = \"shell\"\nversion = \"1.0.0\"\n\
814	             min_spt_core_version = \"0.0.1\"\n\n[shell]\nspawn = \"x\"\nrequire_approval = \"remembered\"\n\n\
815	             [strings]\nbase = \"parent\"\n\n[profiles.locked]\n[profiles.locked.shell]\nrequire_approval = \"always\"\n";
816	        let adapters = tmp.path().join("adapters");
817	        let src = seed_source(tmp.path(), "ms-src", src_toml);
818	        register(&adapters, &src, 1000).unwrap();
819
820	        // Bare option reads the base string; a missing key is None.
821	        assert_eq!(
822	            get_string(&adapters, "mock-shell", "base").unwrap().and_then(|v| v.as_str().map(str::to_string)),
823	            Some("parent".to_string())
824	        );
825	        assert!(get_string(&adapters, "mock-shell", "absent").unwrap().is_none());
826
827	        // set-string requires a LOCAL profile to exist first.
828	        create_local_profile(&adapters, "mock-shell", "work", "").unwrap();
829	        set_local_string(&adapters, "mock-shell", "work", "base", "overridden").unwrap();
830	        set_local_string(&adapters, "mock-shell", "work", "hook.ctx", "deep").unwrap();
831	        // The composite reads the overlaid value; the bare option is unchanged.
832	        assert_eq!(
833	            get_string(&adapters, "mock-shell:work", "base").unwrap().and_then(|v| v.as_str().map(str::to_string)),
834	            Some("overridden".to_string())
835	        );
836	        assert_eq!(
837	            get_string(&adapters, "mock-shell:work", "hook.ctx").unwrap().and_then(|v| v.as_str().map(str::to_string)),
838	            Some("deep".to_string())
839	        );
840	        assert_eq!(
841	            get_string(&adapters, "mock-shell", "base").unwrap().and_then(|v| v.as_str().map(str::to_string)),
842	            Some("parent".to_string()),
843	            "the bare parent is untouched"
844	        );
845
846	        // A shipped profile is immutable; a missing local errs.
847	        assert!(matches!(
848	            set_local_string(&adapters, "mock-shell", "locked", "base", "x"),
849	            Err(RegistryError::ShippedProfileImmutable(_))
850	        ));
851	        assert!(matches!(
852	            set_local_string(&adapters, "mock-shell", "ghost", "base", "x"),
853	            Err(RegistryError::ProfileNotFound(_))
854	        ));
855	    }
856	}
857

//! Adapter registration lifecycle (M5-D2, REQ-INSTALL-4) — the node-local
//! registered-adapter set under `{SPT_HOME}/adapters/`.
//!
//! `spt adapter add` validates the manifest **first** (nothing is recorded for
//! an invalid one), then records it (PRD R-INSTALL-4): a **copy** of the
//! manifest for `file_pull` / avenue-less adapters (spt-core owns the bytes),
//! a **pointer** back to the source tree for `delegated` ones (the adapter's
//! own updater owns its install; we re-read its manifest live so a delegated
//! self-update never leaves a stale copy behind). One command and one dir for
//! both `kind = "harness"` and `kind = "shell"`.
//!
//! Registration is **node-local capability** (may this node drive/launch the
//! adapter), not subnet advertisement — discovery rides the registry/broadcast
//! gates elsewhere. The registered set is the single source consumed by:
//! creation-time adapter selection (auto-if-one/ask-if-many over
//! `hostable_types`), shell discovery (D3d), and the R-UPD-5 self-update
//! ripple.
//!
//! `spt adapter remove` is a **soft-deregister**: the record stays with
//! `active = false`, so new creation no longer offers the adapter while
//! existing/live instances keep running off it. The manifest's optional
//! `[update] uninstall` template is *returned* to the caller to conduct once
//! the adapter is quiesced — this module records state, it never execs.
//! Re-adding a deregistered name re-activates it (refreshing the record).
//!
//! Layout per adapter: `adapters/<name>/record.toml` (+ `manifest.toml` in
//! copy mode). Records write atomically (REQ-HAZARD-EBUSY-RENAME stance via
//! `spt_store::atomic`).
// [impl->REQ-INSTALL-4]

use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use toml::Value;

use spt_store::atomic::atomic_write_string;

use crate::manifest::{AdapterKind, Manifest, UpdateAvenue};
use crate::profile;

/// How the manifest is held: a copy under `adapters/<name>/`, or a pointer
/// re-read from the source tree per load.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RecordMode {
    Copy,
    Pointer,
}

/// One registered adapter's durable record (`adapters/<name>/record.toml`).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AdapterRecord {
    pub name: String,
    pub kind: AdapterKind,
    pub mode: RecordMode,
    /// Where the adapter was registered from (the manifest's directory) —
    /// the live manifest location in pointer mode, provenance in copy mode.
    pub source_dir: String,
    pub registered_at_ms: u64,
    /// `false` = soft-deregistered: hidden from new-creation/discovery;
    /// existing instances keep running (REQ-INSTALL-4).
    pub active: bool,
}

/// Registration failures — adapter-author-facing, so they carry the why.
#[derive(Debug)]
pub enum RegistryError {
    /// The source manifest failed to read.
    Io(std::io::Error),
    /// The source manifest failed parse/validation — nothing was recorded.
    Manifest(crate::manifest::ManifestError),
    /// `remove`/lookup named an adapter that has no record.
    NotRegistered(String),
    /// A record on disk is unreadable/corrupt (named so the operator can fix).
    BadRecord(String),
    /// A local profile name is not a safe single path segment.
    InvalidProfileName(String),
    /// A local profile would shadow a shipped profile of the same name (refused
    /// at registration — CONTEXT.md §adapter profile).
    ProfileShadowsShipped(String),
    /// `delete-profile`/`get-string` named a local profile that has no file.
    ProfileNotFound(String),
    /// `delete-profile` named a **shipped** profile (immutable — CLI operates on
    /// local profiles only).
    ShippedProfileImmutable(String),
}

impl std::fmt::Display for RegistryError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            RegistryError::Io(e) => write!(f, "io: {e}"),
            RegistryError::Manifest(e) => write!(f, "manifest invalid: {e}"),
            RegistryError::NotRegistered(n) => write!(f, "adapter not registered: {n}"),
            RegistryError::BadRecord(n) => write!(f, "corrupt adapter record: {n}"),
            RegistryError::InvalidProfileName(n) => {
                write!(f, "invalid profile name '{n}' (no ':', path separators, or whitespace)")
            }
            RegistryError::ProfileShadowsShipped(n) => {
                write!(f, "local profile '{n}' would shadow a shipped profile of the same name")
            }
            RegistryError::ProfileNotFound(n) => write!(f, "local profile not found: {n}"),
            RegistryError::ShippedProfileImmutable(n) => {
                write!(f, "profile '{n}' is shipped (adapter-owned); only local profiles are editable")
            }
        }
    }
}

impl From<std::io::Error> for RegistryError {
    fn from(e: std::io::Error) -> Self {
        RegistryError::Io(e)
    }
}

fn record_file(adapters_dir: &Path, name: &str) -> PathBuf {
    adapters_dir.join(name).join("record.toml")
}

fn manifest_copy_file(adapters_dir: &Path, name: &str) -> PathBuf {
    adapters_dir.join(name).join("manifest.toml")
}

/// Resolve the manifest file inside a source: `<dir>/manifest.toml`, or the
/// path itself when it already names a file.
fn source_manifest_file(source: &Path) -> PathBuf {
    if source.is_dir() {
        source.join("manifest.toml")
    } else {
        source.to_path_buf()
    }
}

/// Register (or re-register) the adapter whose manifest lives at `source`
/// (a directory containing `manifest.toml`, or the manifest path itself).
/// Validate-first: an invalid manifest records **nothing**. Mode falls out of
/// the `[update]` avenue — `delegated` ⇒ pointer, `file_pull`/absent ⇒ copy.
/// Re-adding an existing name refreshes the record and re-activates a
/// soft-deregistered one. Returns the record; the caller conducts
/// install-is-first-update through the manifest's `[update]` avenue.
// [impl->REQ-INSTALL-4]
pub fn register(
    adapters_dir: &Path,
    source: &Path,
    now_ms: u64,
) -> Result<(AdapterRecord, Manifest), RegistryError> {
    let manifest_file = source_manifest_file(source);
    let toml_src = std::fs::read_to_string(&manifest_file)?;
    let manifest = Manifest::from_toml_str(&toml_src).map_err(RegistryError::Manifest)?;

    let mode = match manifest.update.as_ref().map(|u| u.avenue) {
        Some(UpdateAvenue::Delegated) => RecordMode::Pointer,
        Some(UpdateAvenue::FilePull) | None => RecordMode::Copy,
    };
    let source_dir = manifest_file
        .parent()
        .unwrap_or(Path::new("."))
        .to_string_lossy()
        .into_owned();

    let record = AdapterRecord {
        name: manifest.adapter.name.clone(),
        kind: manifest.adapter.kind,
        mode,
        source_dir,
        registered_at_ms: now_ms,
        active: true,
    };

    let dir = adapters_dir.join(&record.name);
    std::fs::create_dir_all(&dir)?;
    if mode == RecordMode::Copy {
        atomic_write_string(&manifest_copy_file(adapters_dir, &record.name), &toml_src)?;
    }
    save_record(adapters_dir, &record)?;
    Ok((record, manifest))
}

/// Soft-deregister `name`: flip `active = false` (the record + any manifest
/// copy stay — existing instances keep running off them). Returns the
/// manifest's optional `[update] uninstall` template for the caller to
/// conduct once the adapter is quiesced (`spt adapter remove`'s optional
/// second half); `None` = spt-core default cleanup. Idempotent on an
/// already-inactive record.
// [impl->REQ-INSTALL-4]
pub fn deregister(adapters_dir: &Path, name: &str) -> Result<Option<String>, RegistryError> {
    let mut record = load_record(adapters_dir, name)?;
    record.active = false;
    save_record(adapters_dir, &record)?;
    let uninstall = load_manifest(adapters_dir, &record)
        .ok()
        .and_then(|m| m.update.and_then(|u| u.uninstall));
    Ok(uninstall)
}

/// One adapter's record by name.
pub fn load_record(adapters_dir: &Path, name: &str) -> Result<AdapterRecord, RegistryError> {
    let path = record_file(adapters_dir, name);
    if !path.exists() {
        return Err(RegistryError::NotRegistered(name.to_string()));
    }
    let s = std::fs::read_to_string(&path)?;
    toml::from_str(&s).map_err(|_| RegistryError::BadRecord(name.to_string()))
}

fn save_record(adapters_dir: &Path, record: &AdapterRecord) -> Result<(), RegistryError> {
    let s = toml::to_string_pretty(record)
        .map_err(|e| RegistryError::BadRecord(format!("{}: {e}", record.name)))?;
    atomic_write_string(&record_file(adapters_dir, &record.name), &s)?;
    Ok(())
}

/// The record's manifest: the held copy in copy mode, the live source tree in
/// pointer mode (a delegated adapter's self-updates are always honored).
// [impl->REQ-INSTALL-4]
pub fn load_manifest(
    adapters_dir: &Path,
    record: &AdapterRecord,
) -> Result<Manifest, RegistryError> {
    let path = match record.mode {
        RecordMode::Copy => manifest_copy_file(adapters_dir, &record.name),
        RecordMode::Pointer => source_manifest_file(Path::new(&record.source_dir)),
    };
    let s = std::fs::read_to_string(&path)?;
    Manifest::from_toml_str(&s).map_err(RegistryError::Manifest)
}

/// Every record under `adapters/` (active and not — callers filter), sorted
/// by name for a stable order. Tolerant: an unreadable entry is skipped, the
/// rest still list (the stale-registry degrade stance).
pub fn all_records(adapters_dir: &Path) -> Vec<AdapterRecord> {
    let Ok(entries) = std::fs::read_dir(adapters_dir) else {
        return Vec::new();
    };
    let mut out: Vec<AdapterRecord> = entries
        .filter_map(|e| e.ok())
        .filter_map(|e| load_record(adapters_dir, &e.file_name().to_string_lossy()).ok())
        .collect();
    out.sort_by(|a, b| a.name.cmp(&b.name));
    out
}

/// The **active** registered set with manifests resolved — the single source
/// for creation-time adapter selection, shell discovery, and the R-UPD-5
/// ripple. A pointer-mode record whose source tree has vanished is skipped
/// (tolerant), not fatal.
// [impl->REQ-INSTALL-4]
pub fn registered(adapters_dir: &Path) -> Vec<(AdapterRecord, Manifest)> {
    all_records(adapters_dir)
        .into_iter()
        .filter(|r| r.active)
        .filter_map(|r| load_manifest(adapters_dir, &r).ok().map(|m| (r, m)))
        .collect()
}

// ───────────────────────── Local profiles (M9 / REQ-MANIFEST-2) ─────────────
//
// A **local** profile is a node-local, user-authored sparse overlay registered
// *beside* the adapter at `adapters/<adapter>/profiles/<profile>.toml`. Because
// `register` never clears that dir, locals **survive adapter re-registration**
// (the manifest copy is adapter-owned and overwritten by updates; the user's
// customization is not). Locals may not shadow a shipped profile's name (refused
// at create) and are floored tighten-only at create. CLI verbs (`create-profile`,
// `delete-profile`, `set-string`) operate on locals only.

/// `adapters/<adapter>/profiles/`.
fn local_profiles_dir(adapters_dir: &Path, adapter: &str) -> PathBuf {
    adapters_dir.join(adapter).join("profiles")
}

/// `adapters/<adapter>/profiles/<profile>.toml`.
fn local_profile_file(adapters_dir: &Path, adapter: &str, profile: &str) -> PathBuf {
    local_profiles_dir(adapters_dir, adapter).join(format!("{profile}.toml"))
}

/// A profile name must be a single, safe path segment usable in the composite
/// `<adapter>:<profile>` address: non-empty, no `:` (the address separator), no
/// path separators, no whitespace, and not a dot-segment.
fn validate_profile_name(profile: &str) -> Result<(), RegistryError> {
    let bad = profile.is_empty()
        || profile == "."
        || profile == ".."
        || profile
            .chars()
            .any(|c| c == ':' || c == '/' || c == '\\' || c.is_whitespace());
    if bad {
        return Err(RegistryError::InvalidProfileName(profile.to_string()));
    }
    Ok(())
}

/// The local profile overlay [`Value`] for `<adapter>/profiles/<profile>.toml`.
pub fn load_local_overlay(
    adapters_dir: &Path,
    adapter: &str,
    profile: &str,
) -> Result<Value, RegistryError> {
    let path = local_profile_file(adapters_dir, adapter, profile);
    if !path.exists() {
        return Err(RegistryError::ProfileNotFound(profile.to_string()));
    }
    let s = std::fs::read_to_string(&path)?;
    toml::from_str(&s).map_err(|_| RegistryError::BadRecord(format!("profiles/{profile}")))
}

/// Local profile names registered for `adapter`, sorted (drops unreadable
/// entries — the tolerant-registry stance).
pub fn local_profile_names(adapters_dir: &Path, adapter: &str) -> Vec<String> {
    let Ok(entries) = std::fs::read_dir(local_profiles_dir(adapters_dir, adapter)) else {
        return Vec::new();
    };
    let mut out: Vec<String> = entries
        .filter_map(|e| e.ok())
        .filter_map(|e| {
            let name = e.file_name().to_string_lossy().into_owned();
            name.strip_suffix(".toml").map(str::to_string)
        })
        .collect();
    out.sort();
    out
}

/// Create (or overwrite) a local profile for `adapter` from a sparse overlay TOML
/// document. Refuses an invalid name, a name that shadows a **shipped** profile,
/// an overlay that fails to parse, or one that — once merged onto the parent —
/// breaks a manifest invariant or **loosens a consent floor** (tighten-only).
/// Nothing is written unless every check passes.
// [impl->REQ-MANIFEST-2]
pub fn create_local_profile(
    adapters_dir: &Path,
    adapter: &str,
    profile: &str,
    overlay_toml: &str,
) -> Result<(), RegistryError> {
    validate_profile_name(profile)?;
    let record = load_record(adapters_dir, adapter)?;
    let parent = load_manifest(adapters_dir, &record)?;
    if parent.profiles.contains_key(profile) {
        return Err(RegistryError::ProfileShadowsShipped(profile.to_string()));
    }
    let overlay: Value = toml::from_str(overlay_toml)
        .map_err(|e| RegistryError::Manifest(crate::manifest::ManifestError::Parse(e)))?;
    // Validate by resolving: merge onto the parent, run manifest invariants + the
    // tighten-only floor. A failure refuses the create (nothing written).
    profile::resolve_overlay(&parent, &overlay).map_err(RegistryError::Manifest)?;
    let dir = local_profiles_dir(adapters_dir, adapter);
    std::fs::create_dir_all(&dir)?;
    atomic_write_string(&local_profile_file(adapters_dir, adapter, profile), overlay_toml)?;
    Ok(())
}

/// Delete a local profile. Refuses a **shipped** profile name (adapter-owned,
/// immutable) and errors if no local file exists.
// [impl->REQ-MANIFEST-2]
pub fn delete_local_profile(
    adapters_dir: &Path,
    adapter: &str,
    profile: &str,
) -> Result<(), RegistryError> {
    let record = load_record(adapters_dir, adapter)?;
    let parent = load_manifest(adapters_dir, &record)?;
    if parent.profiles.contains_key(profile) {
        return Err(RegistryError::ShippedProfileImmutable(profile.to_string()));
    }
    let path = local_profile_file(adapters_dir, adapter, profile);
    if !path.exists() {
        return Err(RegistryError::ProfileNotFound(profile.to_string()));
    }
    std::fs::remove_file(&path)?;
    Ok(())
}

/// Read a `[strings]` dot-path from an adapter **option**'s merged view — the
/// `spt adapter get-string <adapter-option> <key.path>` resolver. Resolves the
/// option (bare = parent, else shipped/local overlay) exactly as every other
/// consumer, then dot-path-traverses the merged `[strings]` tree. `Ok(None)` =
/// the key is absent (a clean "not set", distinct from an unresolvable option).
// [impl->REQ-MANIFEST-3]
pub fn get_string(
    adapters_dir: &Path,
    option: &str,
    key_path: &str,
) -> Result<Option<Value>, RegistryError> {
    let (_, manifest) = resolve_option(adapters_dir, option)?;
    Ok(profile::string_at(&manifest.strings, key_path).cloned())
}

/// Set a `[strings]` dot-path on a **local** profile's overlay — the
/// `spt adapter set-string` sugar. Edits only the node-local overlay file
/// (`profiles/<profile>.toml`); a shipped profile name is refused (adapter-owned,
/// immutable) and a missing local profile errors (`create-profile` first). The
/// edited overlay is re-validated through the same create-time guards (manifest
/// invariants + tighten-only floor) before it is written.
// [impl->REQ-MANIFEST-3]
pub fn set_local_string(
    adapters_dir: &Path,
    adapter: &str,
    profile: &str,
    key_path: &str,
    value: &str,
) -> Result<(), RegistryError> {
    let record = load_record(adapters_dir, adapter)?;
    let parent = load_manifest(adapters_dir, &record)?;
    if parent.profiles.contains_key(profile) {
        return Err(RegistryError::ShippedProfileImmutable(profile.to_string()));
    }
    // Edit the existing local overlay in place (create-profile mints it first).
    let mut overlay = load_local_overlay(adapters_dir, adapter, profile)?;
    let overlay_table = overlay
        .as_table_mut()
        .ok_or_else(|| RegistryError::BadRecord(format!("profiles/{profile}")))?;
    let strings = overlay_table
        .entry("strings".to_string())
        .or_insert_with(|| Value::Table(toml::Table::new()));
    let strings_table = strings
        .as_table_mut()
        .ok_or_else(|| RegistryError::BadRecord(format!("profiles/{profile}: [strings] not a table")))?;
    profile::set_string_at(strings_table, key_path, Value::String(value.to_string()))
        .map_err(RegistryError::Manifest)?;
    let overlay_toml = toml::to_string_pretty(&overlay)
        .map_err(|e| RegistryError::Manifest(crate::manifest::ManifestError::Validation(e.to_string())))?;
    // Re-validate + write atomically through the create-time guards.
    create_local_profile(adapters_dir, adapter, profile, &overlay_toml)
}

/// Resolve an adapter **option** (`<adapter>` or `<adapter>:<profile>`) to its
/// record and complete, validated manifest — the single entry point every
/// composite-addressing consumer calls. A bare name returns the parent
/// unmodified (short-circuit). A profile is looked up **shipped-first** (the
/// adapter's `[profiles.<name>]`), then as a **local** overlay file; the two
/// namespaces are disjoint (locals may not shadow shipped).
// [impl->REQ-MANIFEST-2]
pub fn resolve_option(
    adapters_dir: &Path,
    option: &str,
) -> Result<(AdapterRecord, Manifest), RegistryError> {
    let (adapter, profile) = profile::split_option(option);
    let record = load_record(adapters_dir, adapter)?;
    // A soft-deregistered adapter is hidden from new resolution (the same
    // active-only view `registered()` gives the daemon seams): existing
    // instances keep running, but spawn/drive/relaunch refuse and fall back.
    if !record.active {
        return Err(RegistryError::NotRegistered(adapter.to_string()));
    }
    let parent = load_manifest(adapters_dir, &record)?;
    let manifest = apply_option_overlay(&parent, adapters_dir, adapter, profile)?;
    Ok((record, manifest))
}

/// Resolve an adapter **option** against an **already-loaded** registered set —
/// the daemon seam. `registered()` is called once per edge event, then every
/// hosted shell is re-resolved against the in-memory slice rather than reloading
/// each parent from disk. Same contract as [`resolve_option`] (split → parent
/// lookup → overlay), but the parent comes from the slice; **local** overlays
/// still read from `adapters_dir`. Returns the merged, validated manifest (the
/// record already lives in the slice).
// [impl->REQ-MANIFEST-2]
pub fn resolve_option_in(
    registered: &[(AdapterRecord, Manifest)],
    adapters_dir: &Path,
    option: &str,
) -> Result<Manifest, RegistryError> {
    let (adapter, profile) = profile::split_option(option);
    let parent = registered
        .iter()
        .find(|(r, _)| r.name == adapter)
        .map(|(_, m)| m)
        .ok_or_else(|| RegistryError::NotRegistered(adapter.to_string()))?;
    apply_option_overlay(parent, adapters_dir, adapter, profile)
}

/// Resolve an adapter **option** onto a **caller-supplied** parent manifest —
/// the runtime-manifest seam (`spt api --adapter <option> --manifest <file>`).
/// The parent comes from the adapter's own manifest file (authoritative at
/// runtime, possibly newer than the registry copy), not a registry load; the
/// `:<profile>` segment of `option` is then overlaid: a **shipped** profile from
/// the parent file's own `[profiles]`, a **local** one from `adapters_dir`. A
/// bare option returns the parent unmodified. The caller has already matched the
/// parent segment against `parent.adapter.name`.
// [impl->REQ-MANIFEST-2]
pub fn resolve_option_onto(
    parent: &Manifest,
    adapters_dir: &Path,
    option: &str,
) -> Result<Manifest, RegistryError> {
    let (adapter, profile) = profile::split_option(option);
    apply_option_overlay(parent, adapters_dir, adapter, profile)
}

/// Shared overlay-application core of [`resolve_option`] and
/// [`resolve_option_in`]: a bare name (no profile) returns the parent
/// unmodified; a **shipped** profile resolves from `parent.profiles`; otherwise
/// a **local** overlay is read from `adapters_dir` and merged. The two profile
/// namespaces are disjoint (locals may not shadow shipped — enforced at create).
// [impl->REQ-MANIFEST-2]
fn apply_option_overlay(
    parent: &Manifest,
    adapters_dir: &Path,
    adapter: &str,
    profile: Option<&str>,
) -> Result<Manifest, RegistryError> {
    match profile {
        None => Ok(parent.clone()),
        Some(p) if parent.profiles.contains_key(p) => {
            profile::resolve(parent, p).map_err(RegistryError::Manifest)
        }
        Some(p) => {
            let overlay = load_local_overlay(adapters_dir, adapter, p)?;
            profile::resolve_overlay(parent, &overlay).map_err(RegistryError::Manifest)
        }
    }
}

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

    const HARNESS_COPY: &str = r#"
[adapter]
name = "mock-harness"
kind = "harness"
version = "1.0.0"
min_spt_core_version = "0.0.1"
hostable_types = ["LiveAgent"]
"#;

    // A copy-mode (avenue-less) shell adapter carrying one shipped profile that
    // tightens the floor — the base for local-profile tests.
    const SHELL_COPY_PROFILED: &str = r#"
[adapter]
name = "mock-shell"
kind = "shell"
version = "1.0.0"
min_spt_core_version = "0.0.1"

[shell]
spawn = "mock --link {link_token}"
require_approval = "remembered"

[profiles.locked]
[profiles.locked.shell]
require_approval = "always"
"#;

    const SHELL_DELEGATED: &str = r#"
[adapter]
name = "mock-shell"
kind = "shell"
version = "1.0.0"
min_spt_core_version = "0.0.1"

[update]
avenue = "delegated"
command = "mock-shell-updater"
self_verifies = true
uninstall = "mock-shell-uninstaller --purge"

[shell]
spawn = "mock-shell --link {link_token}"
"#;

    fn seed_source(dir: &Path, name: &str, toml_src: &str) -> PathBuf {
        let src = dir.join(name);
        std::fs::create_dir_all(&src).unwrap();
        std::fs::write(src.join("manifest.toml"), toml_src).unwrap();
        src
    }

    // [unit->REQ-INSTALL-4] register validates-first and records by avenue:
    // an avenue-less harness is copied; a delegated shell is a pointer whose
    // manifest reads live from the source tree. One dir serves both kinds.
    #[test]
    fn register_copy_vs_pointer_by_avenue() {
        let tmp = tempfile::tempdir().unwrap();
        let adapters = tmp.path().join("adapters");
        let harness_src = seed_source(tmp.path(), "h-src", HARNESS_COPY);
        let shell_src = seed_source(tmp.path(), "s-src", SHELL_DELEGATED);

        let (h, hm) = register(&adapters, &harness_src, 1000).unwrap();
        assert_eq!((h.mode, h.kind), (RecordMode::Copy, AdapterKind::Harness));
        assert_eq!(hm.adapter.name, "mock-harness");
        assert!(
            adapters.join("mock-harness").join("manifest.toml").exists(),
            "copy held"
        );

        let (s, _) = register(&adapters, &shell_src, 2000).unwrap();
        assert_eq!((s.mode, s.kind), (RecordMode::Pointer, AdapterKind::Shell));
        assert!(
            !adapters.join("mock-shell").join("manifest.toml").exists(),
            "no copy"
        );
        // Pointer reads live: mutate the source, the loaded manifest follows.
        std::fs::write(
            shell_src.join("manifest.toml"),
            SHELL_DELEGATED.replace("1.0.0", "1.1.0"),
        )
        .unwrap();
        let live = load_manifest(&adapters, &s).unwrap();
        assert_eq!(live.adapter.version, "1.1.0");

        let set = registered(&adapters);
        assert_eq!(
            set.iter().map(|(r, _)| r.name.as_str()).collect::<Vec<_>>(),
            vec!["mock-harness", "mock-shell"],
            "stable name order"
        );
    }

    // [unit->REQ-INSTALL-4] an invalid manifest records nothing — the
    // validate-first rule (manifest-first, then install).
    #[test]
    fn invalid_manifest_records_nothing() {
        let tmp = tempfile::tempdir().unwrap();
        let adapters = tmp.path().join("adapters");
        // kind = "shell" without a [shell] body fails validation.
        let bad = seed_source(
            tmp.path(),
            "bad-src",
            "[adapter]\nname = \"bad\"\nkind = \"shell\"\nversion = \"1\"\nmin_spt_core_version = \"0\"\n",
        );
        assert!(matches!(
            register(&adapters, &bad, 0),
            Err(RegistryError::Manifest(_))
        ));
        assert!(!adapters.join("bad").exists(), "nothing recorded");
        assert!(registered(&adapters).is_empty());
    }

    // [unit->REQ-INSTALL-4] remove is a soft-deregister: hidden from the
    // active set, record + copy retained (live instances keep running), the
    // manifest's uninstall template surfaced to the caller; re-add
    // re-activates.
    #[test]
    fn deregister_is_soft_and_readd_reactivates() {
        let tmp = tempfile::tempdir().unwrap();
        let adapters = tmp.path().join("adapters");
        let shell_src = seed_source(tmp.path(), "s-src", SHELL_DELEGATED);
        register(&adapters, &shell_src, 1000).unwrap();

        let uninstall = deregister(&adapters, "mock-shell").unwrap();
        assert_eq!(uninstall.as_deref(), Some("mock-shell-uninstaller --purge"));
        assert!(
            registered(&adapters).is_empty(),
            "hidden from the active set"
        );
        let rec = load_record(&adapters, "mock-shell").unwrap();
        assert!(!rec.active, "record retained, inactive");
        // resolve_option honors the active-only view: a soft-deregistered
        // adapter refuses new resolution (spawn/drive/relaunch).
        assert!(matches!(
            resolve_option(&adapters, "mock-shell"),
            Err(RegistryError::NotRegistered(_))
        ));

        // Unknown name errs; double-deregister is idempotent.
        assert!(matches!(
            deregister(&adapters, "ghost"),
            Err(RegistryError::NotRegistered(_))
        ));
        assert!(deregister(&adapters, "mock-shell").is_ok());

        let (re, _) = register(&adapters, &shell_src, 2000).unwrap();
        assert!(
            re.active && re.registered_at_ms == 2000,
            "re-add re-activates + refreshes"
        );
        assert_eq!(registered(&adapters).len(), 1);
    }

    /// Register the profiled shell adapter into a fresh `adapters/` dir.
    fn registered_shell(tmp: &Path) -> PathBuf {
        let adapters = tmp.join("adapters");
        let src = seed_source(tmp, "ms-src", SHELL_COPY_PROFILED);
        register(&adapters, &src, 1000).unwrap();
        adapters
    }

    fn approval(m: &Manifest) -> crate::manifest::ShellApproval {
        m.shell.as_ref().unwrap().require_approval
    }

    // [unit->REQ-MANIFEST-2] resolve_option: bare name = parent unmodified;
    // a shipped profile and a local profile each resolve to the merged view.
    #[test]
    fn resolve_option_bare_shipped_and_local() {
        use crate::manifest::ShellApproval;
        let tmp = tempfile::tempdir().unwrap();
        let adapters = registered_shell(tmp.path());

        // Bare = parent.
        let (_, base) = resolve_option(&adapters, "mock-shell").unwrap();
        assert_eq!(approval(&base), ShellApproval::Remembered, "bare = parent");

        // Shipped profile.
        let (_, shipped) = resolve_option(&adapters, "mock-shell:locked").unwrap();
        assert_eq!(approval(&shipped), ShellApproval::Always, "shipped resolves");

        // Local profile (tightens further-or-equal — always over remembered).
        create_local_profile(&adapters, "mock-shell", "work", "[shell]\nrequire_approval = \"always\"\n")
            .unwrap();
        let (_, local) = resolve_option(&adapters, "mock-shell:work").unwrap();
        assert_eq!(approval(&local), ShellApproval::Always, "local resolves");
        assert_eq!(local_profile_names(&adapters, "mock-shell"), vec!["work"]);
    }

    // [unit->REQ-MANIFEST-2] resolve_option_in (the daemon seam) resolves the
    // same merged view as resolve_option against a **preloaded** registered
    // slice — bare = parent, shipped + local both overlay; an unregistered
    // parent is NotRegistered.
    #[test]
    fn resolve_option_in_matches_disk() {
        use crate::manifest::ShellApproval;
        let tmp = tempfile::tempdir().unwrap();
        let adapters = registered_shell(tmp.path());
        create_local_profile(&adapters, "mock-shell", "work", "[shell]\nrequire_approval = \"always\"\n")
            .unwrap();
        let set = registered(&adapters);

        let bare = resolve_option_in(&set, &adapters, "mock-shell").unwrap();
        assert_eq!(approval(&bare), ShellApproval::Remembered, "bare = parent");
        let shipped = resolve_option_in(&set, &adapters, "mock-shell:locked").unwrap();
        assert_eq!(approval(&shipped), ShellApproval::Always, "shipped overlays");
        let local = resolve_option_in(&set, &adapters, "mock-shell:work").unwrap();
        assert_eq!(approval(&local), ShellApproval::Always, "local overlays");

        assert!(matches!(
            resolve_option_in(&set, &adapters, "ghost:work"),
            Err(RegistryError::NotRegistered(n)) if n == "ghost"
        ));
    }

    // [unit->REQ-MANIFEST-2] a local profile survives adapter re-registration
    // (the manifest copy is overwritten; the user's profiles/ dir is not).
    #[test]
    fn local_profile_survives_readd() {
        let tmp = tempfile::tempdir().unwrap();
        let adapters = registered_shell(tmp.path());
        create_local_profile(&adapters, "mock-shell", "work", "[shell]\nrequire_approval = \"always\"\n")
            .unwrap();

        // Re-register the adapter (adapter update / re-add).
        let src = seed_source(tmp.path(), "ms-src2", SHELL_COPY_PROFILED);
        register(&adapters, &src, 2000).unwrap();

        assert!(
            resolve_option(&adapters, "mock-shell:work").is_ok(),
            "local profile still resolvable after re-add"
        );
    }

    // [unit->REQ-MANIFEST-2] create-time guards: shadow a shipped name, loosen
    // the floor, or an invalid name — each refused, nothing written.
    #[test]
    fn create_local_profile_guards() {
        let tmp = tempfile::tempdir().unwrap();
        let adapters = registered_shell(tmp.path());

        // Shadow a shipped profile.
        assert!(matches!(
            create_local_profile(&adapters, "mock-shell", "locked", "[shell]\nrequire_approval = \"always\"\n"),
            Err(RegistryError::ProfileShadowsShipped(n)) if n == "locked"
        ));

        // Loosen the consent floor (remembered -> none).
        assert!(matches!(
            create_local_profile(&adapters, "mock-shell", "loose", "[shell]\nrequire_approval = \"none\"\n"),
            Err(RegistryError::Manifest(_))
        ));

        // Invalid name (contains the address separator).
        assert!(matches!(
            create_local_profile(&adapters, "mock-shell", "a:b", "[shell]\nrequire_approval = \"always\"\n"),
            Err(RegistryError::InvalidProfileName(_))
        ));

        assert!(
            local_profile_names(&adapters, "mock-shell").is_empty(),
            "no file written for any refused create"
        );
    }

    // [unit->REQ-MANIFEST-2] delete-profile: shipped is immutable, local is
    // removed, a missing local errors.
    #[test]
    fn delete_local_profile_rules() {
        let tmp = tempfile::tempdir().unwrap();
        let adapters = registered_shell(tmp.path());
        create_local_profile(&adapters, "mock-shell", "work", "[shell]\nrequire_approval = \"always\"\n")
            .unwrap();

        assert!(matches!(
            delete_local_profile(&adapters, "mock-shell", "locked"),
            Err(RegistryError::ShippedProfileImmutable(_))
        ));
        assert!(delete_local_profile(&adapters, "mock-shell", "work").is_ok());
        assert!(matches!(
            resolve_option(&adapters, "mock-shell:work"),
            Err(RegistryError::ProfileNotFound(_))
        ));
        assert!(matches!(
            delete_local_profile(&adapters, "mock-shell", "ghost"),
            Err(RegistryError::ProfileNotFound(_))
        ));
    }

    // [unit->REQ-MANIFEST-3] get-string resolves through the overlay (a local
    // profile's [strings] override the base; the merged view is what reads), and
    // set-string edits a LOCAL profile only — a shipped name + a bare option are
    // refused, a missing key reads None.
    #[test]
    fn strings_get_set_through_overlay() {
        let tmp = tempfile::tempdir().unwrap();
        // A shell adapter carrying base strings + a shipped profile.
        let src_toml = "[adapter]\nname = \"mock-shell\"\nkind = \"shell\"\nversion = \"1.0.0\"\n\
             min_spt_core_version = \"0.0.1\"\n\n[shell]\nspawn = \"x\"\nrequire_approval = \"remembered\"\n\n\
             [strings]\nbase = \"parent\"\n\n[profiles.locked]\n[profiles.locked.shell]\nrequire_approval = \"always\"\n";
        let adapters = tmp.path().join("adapters");
        let src = seed_source(tmp.path(), "ms-src", src_toml);
        register(&adapters, &src, 1000).unwrap();

        // Bare option reads the base string; a missing key is None.
        assert_eq!(
            get_string(&adapters, "mock-shell", "base").unwrap().and_then(|v| v.as_str().map(str::to_string)),
            Some("parent".to_string())
        );
        assert!(get_string(&adapters, "mock-shell", "absent").unwrap().is_none());

        // set-string requires a LOCAL profile to exist first.
        create_local_profile(&adapters, "mock-shell", "work", "").unwrap();
        set_local_string(&adapters, "mock-shell", "work", "base", "overridden").unwrap();
        set_local_string(&adapters, "mock-shell", "work", "hook.ctx", "deep").unwrap();
        // The composite reads the overlaid value; the bare option is unchanged.
        assert_eq!(
            get_string(&adapters, "mock-shell:work", "base").unwrap().and_then(|v| v.as_str().map(str::to_string)),
            Some("overridden".to_string())
        );
        assert_eq!(
            get_string(&adapters, "mock-shell:work", "hook.ctx").unwrap().and_then(|v| v.as_str().map(str::to_string)),
            Some("deep".to_string())
        );
        assert_eq!(
            get_string(&adapters, "mock-shell", "base").unwrap().and_then(|v| v.as_str().map(str::to_string)),
            Some("parent".to_string()),
            "the bare parent is untouched"
        );

        // A shipped profile is immutable; a missing local errs.
        assert!(matches!(
            set_local_string(&adapters, "mock-shell", "locked", "base", "x"),
            Err(RegistryError::ShippedProfileImmutable(_))
        ));
        assert!(matches!(
            set_local_string(&adapters, "mock-shell", "ghost", "base", "x"),
            Err(RegistryError::ProfileNotFound(_))
        ));
    }
}


Registry is solid — profile-name validation forbids `:`/path-seps/whitespace/dot-segments, shadow-refusal both ways (create + delete + set-string), tighten-only enforced via resolve, three resolvers share one `apply_option_overlay` core, soft-deregister honored. Now the canon-id hazard (#8) and the parent-segment comparison seams (#4, #6) — the two flagged risks.