<task-notification>
<task-id>bg7cqpvni</task-id>
Monitor event: "« spt event »"
<event><EVENT-PART seq="1/9" id="7b7724d0" type="msg" from="todlando">G1 READY — M9 Wave 1 (adapter customization) is up for your gate. PR #7 (m9-adapter-custom→main), CI queued on both runners (will confirm green; sending now so your substantive review can start in parallel — it's CI-independent).

Landed this cycle, all 3 features (all per-commit gates green; full local w</EVENT-PART>
<EVENT-PART seq="2/9" id="7b7724d0">orkspace sweep GREEN, 0 fail across libs+bins+e2e; workspace clippy -D clean; --no-default-features builds; xtask check OK / traceable EXIT 0; schema re-blessed; CLI ref regenerated):

T1 profiles (REQ-MANIFEST-2): engine was already done; this cycle wired ALL 12 adapter_name consumer seams from M9-WAVE1-ADDRESSING-SURVEY.md through the </EVENT-PART>
<EVENT-PART seq="3/9" id="7b7724d0">merged view + the CLI mgmt surface. Three resolvers share ONE apply_option_overlay core: resolve_option (disk), resolve_option_in (preloaded slice — daemon seams), resolve_option_onto (caller-supplied parent — the api --manifest runtime seam). shell_section_of (shellhost+shellwake) now return OWNED Shell. Cap filter + api validation compar</EVENT-PART>
<EVENT-PART seq="4/9" id="7b7724d0">e PARENT segments (survey risk #2). Canon-id #8 hazard CLOSED: mint_shell_id splits to parent → id stays colon-free &lt;parent&gt;-&lt;n&gt; (REQ-HAZARD-ID-CHARSET, with a unit), composite rides only adapter_name. resolve_option now honors the active flag (soft-deregistered refuses new resolution). CLI: create-profile/delete-profile (local-o</EVENT-PART>
<EVENT-PART seq="5/9" id="7b7724d0">nly) + list renders profiles adjacent.

T2 strings (REQ-MANIFEST-3 [doc,impl,unit]): [strings] toml::Table rides resolve_overlay for free; get-string &lt;option&gt; &lt;key.path&gt; overlay-resolved; set-string edits a LOCAL profile only (shipped refused, missing local errs, re-validated through create-time guards). Data-only.

T3 </EVENT-PART>
<EVENT-PART seq="6/9" id="7b7724d0">hints (REQ-MANIFEST-4 [doc,impl,unit]): ordered [[hints]] {keywords literal/regex, text}; api hint --session reads stdin msg → ≤1 matched hint/message, once/session via durable seen-set (perch::session_dir; /clear=new session=re-arm); skip-seen→next-match (DECISION: an already-seen keyword falls through to the next distinct unseen match </EVENT-PART>
<EVENT-PART seq="7/9" id="7b7724d0">rather than emitting nothing — flag if you want strict &quot;first-match-only&quot;); profiles overlay [[hints]] by leaf-replace (array replace wholesale).

Your G1 checklist coordinates: merge unit tables = spt-runtime/src/profile.rs + manifest.rs tests; survey = M9-WAVE1-ADDRESSING-SURVEY.md (all 12 consumed); tighten-only floor = pr</EVENT-PART>
<EVENT-PART seq="8/9" id="7b7724d0">ofile::check_tighten_only; shadow-refusal + CLI shape = registry.rs + cli.rs cmd_adapter; resolution-path proofs = registry resolve_option_in_matches_disk + shellwake reconcile_resolves_profile_overlay + api load_manife
...(truncated)</event>
</task-notification>


CAVEMAN MODE ACTIVE (full). Drop articles/filler/pleasantries/hedging. Fragments OK. Code/commits/security: write normal.


<owl_reachability_notice>
You have an active owl perch. When spawning subagents or doing work, use run_in_background: true so you stay reachable for incoming messages.
</owl_reachability_notice>


G1 ready, PR #7. Message truncated at part 8/9 — need tail + part 9. Let me pull the branch and get the full ping, then start substantive review.


Bash

git fetch origin --quiet 2>&1; git checkout m9-adapter-custom --quiet 2>&1; git pull origin m9-adapter-custom --quiet 2>&1; git log --oneline -15 m9-adapter-custom

Checkout and update m9-adapter-custom branch


0f34daa feat(runtime): keyword hints — [[hints]] + once-per-session api hint surface
de5841a feat(runtime): adapter strings — [strings] KV tree, get-string/set-string
cdaf8e4 feat(cli): adapter profile verbs — create-profile/delete-profile + list render
8b9c52e feat(api): resolve composite adapter options on the runtime surface
e26cd8e feat(cli): spawn/drive/teardown resolve composite adapter options
4d208a4 feat(store): mint colon-free shell id from the composite's parent segment
ba54595 feat(runtime): daemon seams resolve composite adapter options
3abd4df feat(runtime): resolve_option_in slice helper for daemon seams
51f37bc feat(runtime): local profiles, registration guards + resolve_option
3b701a7 docs(m9): adapter_name consumer survey — G1 addressing artifact
5441c2c feat(runtime): shipped-profile parsing, resolver + tighten-only consent floor
4129b81 feat(runtime): adapter-profile leaf-replace merge — pure fn + contract table
d338c2d docs(roadmap): M9 adapter customization & session surfaces — milestone entry + JIT plan
bd469b9 docs(context): milestone-A mints — live role, keyword hints, spt rc; Gateway revival settled on existing machinery
8dc22ff docs(context): Gateway grill — endpoint type, Shell model extensions, adapter profiles, digest log-source revision


11 commits above base. Got enough of the ping to start. This is a substantive quality gate — reviewing actual code, not the summary. Let me pull the diff overview and the G1 artifacts in parallel.


Diff stat of Wave 1 against base


 CONTEXT.md                              |   3 +
 Cargo.lock                              |   1 +
 M9-WAVE1-ADDRESSING-SURVEY.md           |  90 ++++++
 crates/spt-daemon/src/linkhost.rs       |  14 +-
 crates/spt-daemon/src/resting.rs        |   4 +-
 crates/spt-daemon/src/shellhost.rs      |  46 +--
 crates/spt-daemon/src/shellwake.rs      |  95 ++++--
 crates/spt-runtime/Cargo.toml           |   3 +
 crates/spt-runtime/manifest.schema.json |  42 +++
 crates/spt-runtime/src/lib.rs           |   2 +
 crates/spt-runtime/src/manifest.rs      | 124 ++++++++
 crates/spt-runtime/src/profile.rs       | 411 ++++++++++++++++++++++++++
 crates/spt-runtime/src/registry.rs      | 492 ++++++++++++++++++++++++++++++++
 crates/spt-store/src/perch.rs           |  10 +
 crates/spt-store/src/shellinfo.rs       |  57 +++-
 crates/spt/src/api/mod.rs               |  61 +++-
 crates/spt/src/api/reporting.rs         | 129 ++++++++-
 crates/spt/src/cli.rs                   | 382 +++++++++++++++++++++++--
 crates/spt/tests/shell_sleepwake_e2e.rs |   4 +-
 docs-site/src/cli/reference.md          | 127 ++++++++-
 traceable-reqs.toml                     |  12 +
 21 files changed, 2014 insertions(+), 95 deletions(-)


$OWL poll doyle --once --timeout 3 2>&1 | head -50; echo "---done---"

Drain any remaining spool parts


⚠ DUPLICATE:doyle already has an active listener (pid 40024)
---done---


Listener alive (duplicate = proof, expected). ~2000-line diff. Reading G1 artifacts + core engine in parallel.


1	# M9 Wave 1 — `adapter_name` consumer survey (composite addressing)
2
3	> **G1 artifact** (doyle reviews this list whole, not just the diff — composite
4	> `<adapter>:<profile>` is a rename-class change; a missed consumer silently
5	> resolves a profile to its parent). Produced before T1 wiring; the source of
6	> truth for "every place a bare `adapter_name` rides today" (CONTEXT.md §adapter
7	> profile). Branch `m9-adapter-custom`.
8
9	## Resolution rule (the contract every seam below must honor)
10
11	```
12	address := "<adapter>"            → parent manifest, UNMODIFIED (short-circuit)
13	        |  "<adapter>:<profile>"  → parent lookup → profile::resolve(parent, profile)
14	```
15
16	- **Split on the first `:`** to get `(adapter, Option<profile>)`. Bare name ⇒ no
17	  profile ⇒ parent unmodified (never calls `resolve`).
18	- **Registry stays parent-keyed** (confirmed with doyle): profiles are *runtime
19	  selection*, not registration identity. Lookups split the composite, then look
20	  up the **parent** in the registry, then overlay.
21	- The **composite string** is what persists in perch identity (`info.json`,
22	  shell canon id, telemetry) — so a resumed/relaunched endpoint re-resolves the
23	  same merged view.
24
25	## Crate layout (one line each)
26
27	`spt-runtime` manifest parse/validate/registry/**profile** · `spt-store` perch
28	storage (info.json, shellinfo, seed) · `spt` CLI (adapter/shell/api) · `spt-daemon`
29	shell launch/discovery/wake/link · `spt-proto` envelope · `spt-live` live session ·
30	`spt-msg` delivery · `spt-net` transport · `spt-term` terminal · `xtask` build.
31
32	## Consumers — classified
33
34	### A. MUST resolve through the merged view (split → parent lookup → overlay)
35
36	| # | Site | File | Note |
37	|---|------|------|------|
38	| 1 | shell manifest resolution seam | `spt-daemon/src/shellhost.rs` `shell_section_of()` (~446), match `r.name == adapter_name` (449) | the core seam: composite → parent → overlay; bare = parent |
39	| 2 | link-host capability resolution | `spt-daemon/src/linkhost.rs` (~92,105,109) | resolve shell capabilities from merged manifest |
40	| 3 | wake relaunch resolution | `spt-daemon/src/shellwake.rs` `shell_section_of()` (~397) | persistent-offline relaunch must re-resolve same view |
41	| 4 | api `--adapter` validation | `spt/src/api/mod.rs` (~388-402) `manifest.adapter.name != adapter` | compare **parent part** of composite, not whole |
42	| 5 | api capability advertise + sensory/env | `spt/src/api/reporting.rs` (~68-82,273-288,388-410) | hostable_types/sensory/[env] from merged manifest |
43	| 6 | shell instance filter (parent) | `spt/src/cli.rs` (~4496) `i.adapter_name == adapter` | filter must compare correctly under composite |
44
45	### B. MUST carry/store the composite string (identity — opaque, but composite-valued)
46
47	| # | Site | File | Note |
48	|---|------|------|------|
49	| 7 | perch `info.json` adapter field | `spt-store/src/info.rs` (~117-124) `adapter: Option<String>` | store composite; re-resolved on bind/resume |
50	| 8 | shell canon id + ordinal | `spt-store/src/shellinfo.rs` `adapter_name` (50), `mint_shell_id`/`instance_ordinal`/`format!("{adapter}-{n}")` (117,130,150) | **HAZARD**: `:` separator vs `-<n>` ordinal — id parse must not confuse them |
51	| 9 | seed contract | `spt-store/src/seed.rs` (~31) `adapter: String` | opaque; composite flows through |
52	| 10 | spawn/wake template `{adapter_name}` subst | `shellhost.rs` (129), `shellwake.rs` (~418) | shell binary sees the full composite string |
53	| 11 | `ShellCmd::Spawn { adapter }` CLI arg + handler | `spt/src/cli.rs` (459-465, 4350-4382) | accept `<adapter>:<profile>`, split before registry lookup |
54	| 12 | api `ApiArgs { adapter }` | `spt/src/api/mod.rs` (34-41) | accept composite, store on context |
55
56	### C. Parent-name only (registry/registration — NO composite)
57
58	| Site | File | Note |
59	|------|------|------|
60	| registry record/dirs | `spt-runtime/src/registry.rs` (50,92-98,138,173) | `adapters/<name>/` keyed by parent; split composite *before* `load_record` |
61	| `spt adapter add/remove/list` | `spt/src/cli.rs` `cmd_adapter` (4184-4340) | parent name only (profiles are not registered as adapters) |
62	| manifest `adapter.name` field | `spt-runtime/src/manifest.rs` (63, validate 570) | parent identity |
63
64	### D. Display-only (render the composite; no logic change)
65
66	| Site | File |
67	|------|------|
68	| shell discovery/list lines | `spt-daemon/src/shelldisc.rs` (32,86-90,112-116,136-137,228,248) |
69	| notif routing names | `spt-daemon/src/notif.rs` (~334) |
70	| adapter add/remove/list logs | `spt/src/cli.rs` (4276-4335) — parent names |
71
72	## Risks flagged
73
74	1. **Canon-id `:` vs `-<n>` ambiguity** (consumer #8). `format!("{adapter}-{n}")`
75	   with a composite `claude-spt:work` → `claude-spt:work-0`. `instance_ordinal`
76	   parses the trailing `-<n>`; the `:` is inside the adapter segment, so trailing
77	   `-<n>` parse is unaffected **iff** profile names cannot contain `-<n>`-like
78	   suffixes that confuse the split. Mitigation: split id on the **last** `-`, and
79	   forbid `:` / `-` in profile names at create-profile (validate).
80	2. **#4/#6 comparison** must extract the parent segment, never string-equal the
81	   whole composite against `manifest.adapter.name`.
82	3. **Silent parent fallback** is the headline failure mode — any A-row that skips
83	   the overlay resolves to parent silently. G1 reviews this table for completeness.
84
85	## Wiring order (T1 remaining commits)
86
87	1. Local profiles + registration (shadow-refusal, floor at registration) — `registry.rs`.
88	2. Composite resolver entry point (split + parent lookup + `resolve`) — a single
89	   `spt-runtime` helper every A/B site calls; then wire sites #1–#12.
90	3. CLI `create-profile`/`delete-profile` + `adapter list` parent+profiles render.
91

# M9 Wave 1 — `adapter_name` consumer survey (composite addressing)

> **G1 artifact** (doyle reviews this list whole, not just the diff — composite
> `<adapter>:<profile>` is a rename-class change; a missed consumer silently
> resolves a profile to its parent). Produced before T1 wiring; the source of
> truth for "every place a bare `adapter_name` rides today" (CONTEXT.md §adapter
> profile). Branch `m9-adapter-custom`.

## Resolution rule (the contract every seam below must honor)

```
address := "<adapter>"            → parent manifest, UNMODIFIED (short-circuit)
        |  "<adapter>:<profile>"  → parent lookup → profile::resolve(parent, profile)
```

- **Split on the first `:`** to get `(adapter, Option<profile>)`. Bare name ⇒ no
  profile ⇒ parent unmodified (never calls `resolve`).
- **Registry stays parent-keyed** (confirmed with doyle): profiles are *runtime
  selection*, not registration identity. Lookups split the composite, then look
  up the **parent** in the registry, then overlay.
- The **composite string** is what persists in perch identity (`info.json`,
  shell canon id, telemetry) — so a resumed/relaunched endpoint re-resolves the
  same merged view.

## Crate layout (one line each)

`spt-runtime` manifest parse/validate/registry/**profile** · `spt-store` perch
storage (info.json, shellinfo, seed) · `spt` CLI (adapter/shell/api) · `spt-daemon`
shell launch/discovery/wake/link · `spt-proto` envelope · `spt-live` live session ·
`spt-msg` delivery · `spt-net` transport · `spt-term` terminal · `xtask` build.

## Consumers — classified

### A. MUST resolve through the merged view (split → parent lookup → overlay)

| # | Site | File | Note |
|---|------|------|------|
| 1 | shell manifest resolution seam | `spt-daemon/src/shellhost.rs` `shell_section_of()` (~446), match `r.name == adapter_name` (449) | the core seam: composite → parent → overlay; bare = parent |
| 2 | link-host capability resolution | `spt-daemon/src/linkhost.rs` (~92,105,109) | resolve shell capabilities from merged manifest |
| 3 | wake relaunch resolution | `spt-daemon/src/shellwake.rs` `shell_section_of()` (~397) | persistent-offline relaunch must re-resolve same view |
| 4 | api `--adapter` validation | `spt/src/api/mod.rs` (~388-402) `manifest.adapter.name != adapter` | compare **parent part** of composite, not whole |
| 5 | api capability advertise + sensory/env | `spt/src/api/reporting.rs` (~68-82,273-288,388-410) | hostable_types/sensory/[env] from merged manifest |
| 6 | shell instance filter (parent) | `spt/src/cli.rs` (~4496) `i.adapter_name == adapter` | filter must compare correctly under composite |

### B. MUST carry/store the composite string (identity — opaque, but composite-valued)

| # | Site | File | Note |
|---|------|------|------|
| 7 | perch `info.json` adapter field | `spt-store/src/info.rs` (~117-124) `adapter: Option<String>` | store composite; re-resolved on bind/resume |
| 8 | shell canon id + ordinal | `spt-store/src/shellinfo.rs` `adapter_name` (50), `mint_shell_id`/`instance_ordinal`/`format!("{adapter}-{n}")` (117,130,150) | **HAZARD**: `:` separator vs `-<n>` ordinal — id parse must not confuse them |
| 9 | seed contract | `spt-store/src/seed.rs` (~31) `adapter: String` | opaque; composite flows through |
| 10 | spawn/wake template `{adapter_name}` subst | `shellhost.rs` (129), `shellwake.rs` (~418) | shell binary sees the full composite string |
| 11 | `ShellCmd::Spawn { adapter }` CLI arg + handler | `spt/src/cli.rs` (459-465, 4350-4382) | accept `<adapter>:<profile>`, split before registry lookup |
| 12 | api `ApiArgs { adapter }` | `spt/src/api/mod.rs` (34-41) | accept composite, store on context |

### C. Parent-name only (registry/registration — NO composite)

| Site | File | Note |
|------|------|------|
| registry record/dirs | `spt-runtime/src/registry.rs` (50,92-98,138,173) | `adapters/<name>/` keyed by parent; split composite *before* `load_record` |
| `spt adapter add/remove/list` | `spt/src/cli.rs` `cmd_adapter` (4184-4340) | parent name only (profiles are not registered as adapters) |
| manifest `adapter.name` field | `spt-runtime/src/manifest.rs` (63, validate 570) | parent identity |

### D. Display-only (render the composite; no logic change)

| Site | File |
|------|------|
| shell discovery/list lines | `spt-daemon/src/shelldisc.rs` (32,86-90,112-116,136-137,228,248) |
| notif routing names | `spt-daemon/src/notif.rs` (~334) |
| adapter add/remove/list logs | `spt/src/cli.rs` (4276-4335) — parent names |

## Risks flagged

1. **Canon-id `:` vs `-<n>` ambiguity** (consumer #8). `format!("{adapter}-{n}")`
   with a composite `claude-spt:work` → `claude-spt:work-0`. `instance_ordinal`
   parses the trailing `-<n>`; the `:` is inside the adapter segment, so trailing
   `-<n>` parse is unaffected **iff** profile names cannot contain `-<n>`-like
   suffixes that confuse the split. Mitigation: split id on the **last** `-`, and
   forbid `:` / `-` in profile names at create-profile (validate).
2. **#4/#6 comparison** must extract the parent segment, never string-equal the
   whole composite against `manifest.adapter.name`.
3. **Silent parent fallback** is the headline failure mode — any A-row that skips
   the overlay resolves to parent silently. G1 reviews this table for completeness.

## Wiring order (T1 remaining commits)

1. Local profiles + registration (shadow-refusal, floor at registration) — `registry.rs`.
2. Composite resolver entry point (split + parent lookup + `resolve`) — a single
   `spt-runtime` helper every A/B site calls; then wire sites #1–#12.
3. CLI `create-profile`/`delete-profile` + `adapter list` parent+profiles render.


1	//! Adapter-profile overlay merge — leaf-replace semantics (CONTEXT.md §adapter
2	//! profile).
3	//!
4	//! A *profile* is a named **sparse overlay** on its parent adapter manifest. The
5	//! merge rule is **leaf-replace**: a profile key replaces the whole value at that
6	//! path — tables recurse key-by-key, but every non-table value (scalar, array,
7	//! datetime) overrides wholesale. Arrays are **replaced, never spliced or
8	//! appended**. Keys present only in the parent survive; keys present only in the
9	//! overlay are added. The merged [`toml::Value`] re-deserializes into a complete
10	//! [`crate::Manifest`], so the profile behaves as a distinct adapter option
11	//! everywhere a bare `adapter_name` rides.
12	//!
13	//! This is a pure function with no I/O; the unit table below is its contract.
14	//
15	// [impl->REQ-MANIFEST-2]
16
17	use crate::manifest::{Manifest, ManifestError, OverCap, ShellApproval};
18	use toml::Value;
19
20	/// Leaf-replace deep merge of `overlay` onto `base`.
21	///
22	/// When both sides at a key are tables, recurse so only the differing **leaves**
23	/// change (a nested path like `shell.require_approval` is replaced without
24	/// disturbing its sibling keys). Otherwise the overlay value wins wholesale —
25	/// this is what makes arrays *replaced, never spliced* and lets a profile change
26	/// a value's type. An empty-table overlay is a no-op (returns `base` unchanged).
27	pub fn merge_leaf_replace(base: &Value, overlay: &Value) -> Value {
28	    match (base, overlay) {
29	        (Value::Table(b), Value::Table(o)) => {
30	            let mut out = b.clone();
31	            for (key, ov) in o {
32	                let merged = match b.get(key) {
33	                    Some(bv) => merge_leaf_replace(bv, ov),
34	                    None => ov.clone(),
35	                };
36	                out.insert(key.clone(), merged);
37	            }
38	            Value::Table(out)
39	        }
40	        // Any non-table overlay (or a base/overlay type mismatch) replaces the
41	        // base value wholesale — the leaf-replace rule.
42	        _ => overlay.clone(),
43	    }
44	}
45
46	/// Split an adapter **option** string into `(adapter, profile?)` on the **first**
47	/// `:` — the canonical composite addressing form. A bare name (no `:`) yields
48	/// `(name, None)` and resolves to the parent unmodified; `<adapter>:<profile>`
49	/// yields `(adapter, Some(profile))`. The split is on the first `:` so an adapter
50	/// name itself may not contain one (it never does), while a profile name may.
51	pub fn split_option(option: &str) -> (&str, Option<&str>) {
52	    match option.split_once(':') {
53	        Some((adapter, profile)) => (adapter, Some(profile)),
54	        None => (option, None),
55	    }
56	}
57
58	/// Resolve a **shipped** profile (`[profiles.<name>]` declared on `parent`) into
59	/// a complete, validated manifest — the merged view a `<adapter>:<profile>`
60	/// address selects. A bare adapter name never calls this (it uses the parent
61	/// unmodified).
62	///
63	/// Errors: [`ManifestError::UnknownProfile`] for an undeclared name, else as
64	/// [`resolve_overlay`].
65	// [impl->REQ-MANIFEST-2]
66	pub fn resolve(parent: &Manifest, profile: &str) -> Result<Manifest, ManifestError> {
67	    let overlay = parent
68	        .profiles
69	        .get(profile)
70	        .ok_or_else(|| ManifestError::UnknownProfile(profile.to_string()))?;
71	    resolve_overlay(parent, overlay)
72	}
73
74	/// Apply an arbitrary sparse `overlay` onto `parent` — the shared core of both
75	/// shipped-profile ([`resolve`]) and **local**-profile (registry) resolution.
76	/// Serializes the parent to a [`Value`], leaf-replace-merges the overlay,
77	/// re-deserializes, drops the profile catalogue from the resolved view, then
78	/// re-runs the manifest invariants **and** the tighten-only consent floor.
79	///
80	/// Errors: a parse error if the merge produces an invalid manifest, or a
81	/// validation error if the merged manifest breaks an invariant or *loosens* a
82	/// consent floor.
83	// [impl->REQ-MANIFEST-2]
84	pub fn resolve_overlay(parent: &Manifest, overlay: &Value) -> Result<Manifest, ManifestError> {
85	    let base = Value::try_from(parent)
86	        .map_err(|e| ManifestError::Validation(format!("profile base serialize failed: {e}")))?;
87	    let merged_val = merge_leaf_replace(&base, overlay);
88	    let mut merged: Manifest = merged_val.try_into().map_err(ManifestError::Parse)?;
89	    // The resolved view is one concrete adapter option; it does not re-advertise
90	    // the parent's profile catalogue.
91	    merged.profiles.clear();
92	    merged.validate()?;
93	    check_tighten_only(parent, &merged)?;
94	    Ok(merged)
95	}
96
97	/// Read a dot-separated `key_path` out of a `[strings]` KV tree: each segment
98	/// indexes a nested table, the final segment's value is returned. `None` if any
99	/// segment is missing or a non-final segment is not a table. Used by
100	/// `spt adapter get-string` after the profile overlay has merged the tree.
101	// [impl->REQ-MANIFEST-3]
102	pub fn string_at<'a>(strings: &'a toml::Table, key_path: &str) -> Option<&'a Value> {
103	    let mut parts = key_path.split('.');
104	    let mut v = strings.get(parts.next()?)?;
105	    for seg in parts {
106	        v = v.as_table()?.get(seg)?;
107	    }
108	    Some(v)
109	}
110
111	/// Set `value` at a dot-separated `key_path` in a `[strings]` tree, creating
112	/// intermediate tables as needed (the write half of `set-string`). Errors on an
113	/// empty path segment or a path that traverses an existing non-table leaf.
114	// [impl->REQ-MANIFEST-3]
115	pub fn set_string_at(
116	    strings: &mut toml::Table,
117	    key_path: &str,
118	    value: Value,
119	) -> Result<(), ManifestError> {
120	    let parts: Vec<&str> = key_path.split('.').collect();
121	    if parts.iter().any(|s| s.is_empty()) {
122	        return Err(ManifestError::Validation(format!(
123	            "invalid key path '{key_path}': empty segment"
124	        )));
125	    }
126	    let (last, lead) = parts.split_last().expect("split('.') yields >=1 part");
127	    let mut table = strings;
128	    for seg in lead {
129	        let entry = table
130	            .entry(seg.to_string())
131	            .or_insert_with(|| Value::Table(toml::Table::new()));
132	        table = entry.as_table_mut().ok_or_else(|| {
133	            ManifestError::Validation(format!(
134	                "key path '{key_path}' traverses a non-table at '{seg}'"
135	            ))
136	        })?;
137	    }
138	    table.insert(last.to_string(), value);
139	    Ok(())
140	}
141
142	/// How much user approval a [`ShellApproval`] demands (higher = tighter).
143	fn approval_rank(a: ShellApproval) -> u8 {
144	    match a {
145	        ShellApproval::None => 0,
146	        ShellApproval::Remembered => 1,
147	        ShellApproval::Always => 2,
148	    }
149	}
150
151	/// How restrictive an [`OverCap`] is (higher = tighter). `reject` refuses a spawn
152	/// at the cap outright; `approve` permits it behind a per-spawn approval.
153	fn over_cap_rank(o: OverCap) -> u8 {
154	    match o {
155	        OverCap::Approve => 0,
156	        OverCap::Reject => 1,
157	    }
158	}
159
160	/// Consent floors are **tighten-only** (CONTEXT.md §adapter profile): a profile
161	/// may demand *more* approval than its parent, never less. Refused at
162	/// registration if the merged profile loosens an approval-class floor
163	/// (`require_approval`, or the at-cap `over_cap` behavior). Capacity limits
164	/// (`max_instances_per_owner`) are not approval-class and are not floored here.
165	// [impl->REQ-MANIFEST-2]
166	pub fn check_tighten_only(parent: &Manifest, merged: &Manifest) -> Result<(), ManifestError> {
167	    if let (Some(p), Some(m)) = (parent.shell.as_ref(), merged.shell.as_ref()) {
168	        if approval_rank(m.require_approval) < approval_rank(p.require_approval) {
169	            return Err(ManifestError::Validation(format!(
170	                "profile loosens a consent floor: require_approval {:?} is laxer than parent {:?} (tighten-only)",
171	                m.require_approval, p.require_approval
172	            )));
173	        }
174	        if over_cap_rank(m.over_cap) < over_cap_rank(p.over_cap) {
175	            return Err(ManifestError::Validation(format!(
176	                "profile loosens a consent floor: over_cap {:?} is laxer than parent {:?} (tighten-only)",
177	                m.over_cap, p.over_cap
178	            )));
179	        }
180	    }
181	    Ok(())
182	}
183
184	#[cfg(test)]
185	mod tests {
186	    use super::*;
187
188	    fn v(s: &str) -> Value {
189	        toml::from_str(s).expect("test TOML parses")
190	    }
191
192	    /// The leaf-replace contract, case by case (CONTEXT.md §adapter profile).
193	    // [unit->REQ-MANIFEST-2]
194	    #[test]
195	    fn leaf_replace_contract() {
196	        // Scalar leaf replacement.
197	        assert_eq!(
198	            merge_leaf_replace(&v("a = 1"), &v("a = 2")),
199	            v("a = 2"),
200	            "scalar leaf replaced"
201	        );
202
203	        // Table recursion: only the named leaf changes, the sibling survives.
204	        assert_eq!(
205	            merge_leaf_replace(&v("[s]\nx = 1\ny = 2"), &v("[s]\nx = 9")),
206	            v("[s]\nx = 9\ny = 2"),
207	            "nested table recurses leaf-wise, sibling preserved"
208	        );
209
210	        // Array replacement — never spliced or appended.
211	        assert_eq!(
212	            merge_leaf_replace(&v("a = [1, 2, 3]"), &v("a = [9]")),
213	            v("a = [9]"),
214	            "array replaced wholesale, not spliced"
215	        );
216
217	        // Key present only in the overlay is added.
218	        assert_eq!(
219	            merge_leaf_replace(&v("a = 1"), &v("b = 2")),
220	            v("a = 1\nb = 2"),
221	            "overlay-only key added"
222	        );
223
224	        // Key present only in the base survives.
225	        assert_eq!(
226	            merge_leaf_replace(&v("a = 1\nb = 2"), &v("a = 5")),
227	            v("a = 5\nb = 2"),
228	            "base-only key survives"
229	        );
230
231	        // Empty-table overlay is a no-op.
232	        assert_eq!(
233	            merge_leaf_replace(&v("a = 1\n[s]\nx = 1"), &v("")),
234	            v("a = 1\n[s]\nx = 1"),
235	            "empty overlay is a no-op"
236	        );
237
238	        // Type change at a leaf: overlay table replaces a base scalar wholesale.
239	        assert_eq!(
240	            merge_leaf_replace(&v("a = 1"), &v("[a]\nx = 1")),
241	            v("[a]\nx = 1"),
242	            "overlay replaces a base scalar with a table (type change)"
243	        );
244
245	        // Type change the other way: overlay scalar replaces a base table.
246	        assert_eq!(
247	            merge_leaf_replace(&v("[a]\nx = 1"), &v("a = 7")),
248	            v("a = 7"),
249	            "overlay scalar replaces a base table wholesale"
250	        );
251	    }
252
253	    /// Deeply nested paths replace only the addressed leaf.
254	    // [unit->REQ-MANIFEST-2]
255	    #[test]
256	    fn deep_nested_leaf_replace() {
257	        let base = v("[shell.capabilities.run]\nargs = [\"cmd\"]\n[shell]\nrequire_approval = \"none\"");
258	        let overlay = v("[shell]\nrequire_approval = \"always\"");
259	        let merged = merge_leaf_replace(&base, &overlay);
260	        // The capabilities sub-table is untouched; only require_approval flips.
261	        assert_eq!(
262	            merged,
263	            v("[shell.capabilities.run]\nargs = [\"cmd\"]\n[shell]\nrequire_approval = \"always\"")
264	        );
265	    }
266
267	    /// A shell adapter with shipped profiles: `work` tightens the floor, `loose`
268	    /// loosens it, `caploose` loosens the at-cap behavior.
269	    const PROFILED_SHELL: &str = r#"
270	[adapter]
271	name = "claude-spt"
272	kind = "shell"
273	version = "1.0.0"
274	min_spt_core_version = "1.0.0"
275
276	[shell]
277	spawn = "run --thing"
278	require_approval = "remembered"
279
280	[profiles.work]
281	[profiles.work.shell]
282	require_approval = "always"
283
284	[profiles.loose]
285	[profiles.loose.shell]
286	require_approval = "none"
287
288	[profiles.caploose]
289	[profiles.caploose.shell]
290	over_cap = "approve"
291	"#;
292
293	    /// Resolving a shipped profile applies the overlay leaf-wise and yields a
294	    /// complete, validated manifest with the parent's untouched leaves intact.
295	    // [unit->REQ-MANIFEST-2]
296	    #[test]
297	    fn resolve_shipped_profile_applies_overlay() {
298	        let parent = Manifest::from_toml_str(PROFILED_SHELL).expect("parent parses");
299	        let merged = resolve(&parent, "work").expect("work resolves");
300	        let shell = merged.shell.expect("merged has shell");
301	        assert_eq!(shell.require_approval, ShellApproval::Always, "floor tightened");
302	        assert_eq!(shell.spawn, "run --thing", "untouched leaf survives the merge");
303	        assert!(merged.profiles.is_empty(), "resolved view drops the profile catalogue");
304	    }
305
306	    /// An undeclared profile name is a distinct, typed error.
307	    // [unit->REQ-MANIFEST-2]
308	    #[test]
309	    fn resolve_unknown_profile_errors() {
310	        let parent = Manifest::from_toml_str(PROFILED_SHELL).expect("parent parses");
311	        assert!(matches!(
312	            resolve(&parent, "nope"),
313	            Err(ManifestError::UnknownProfile(n)) if n == "nope"
314	        ));
315	    }
316
317	    /// Tighten-only: a profile that *raises* the approval demand is accepted.
318	    // [unit->REQ-MANIFEST-2]
319	    #[test]
320	    fn tighten_only_allows_tightening() {
321	        let parent = Manifest::from_toml_str(PROFILED_SHELL).expect("parent parses");
322	        assert!(resolve(&parent, "work").is_ok(), "remembered -> always is a tighten");
323	    }
324
325	    /// Tighten-only: a profile that *lowers* the approval floor is refused at
326	    /// resolution (the registration-time guard).
327	    // [unit->REQ-MANIFEST-2]
328	    #[test]
329	    fn tighten_only_refuses_loosened_require_approval() {
330	        let parent = Manifest::from_toml_str(PROFILED_SHELL).expect("parent parses");
331	        let err = resolve(&parent, "loose").expect_err("remembered -> none must be refused");
332	        match err {
333	            ManifestError::Validation(m) => assert!(
334	                m.contains("require_approval") && m.contains("tighten-only"),
335	                "floor error names the field: {m}"
336	            ),
337	            other => panic!("expected a validation error, got {other:?}"),
338	        }
339	    }
340
341	    /// Tighten-only also floors the at-cap `over_cap` behavior (reject -> approve
342	    /// is a loosen).
343	    // [unit->REQ-MANIFEST-2]
344	    #[test]
345	    fn tighten_only_refuses_loosened_over_cap() {
346	        let parent = Manifest::from_toml_str(PROFILED_SHELL).expect("parent parses");
347	        let err = resolve(&parent, "caploose").expect_err("reject -> approve must be refused");
348	        assert!(matches!(err, ManifestError::Validation(m) if m.contains("over_cap")));
349	    }
350
351	    /// `[strings]` dot-path read/write: nested traversal, missing key, and the
352	    /// write building intermediate tables; a non-table mid-path errs.
353	    // [unit->REQ-MANIFEST-3]
354	    #[test]
355	    fn string_dot_path_read_write() {
356	        let mut t: toml::Table = toml::from_str(
357	            "greeting = \"hi\"\n[hook]\nadditionalContext = \"ctx\"\n",
358	        )
359	        .unwrap();
360
361	        // Read: top-level leaf, nested leaf, and a miss.
362	        assert_eq!(string_at(&t, "greeting").and_then(|v| v.as_str()), Some("hi"));
363	        assert_eq!(
364	            string_at(&t, "hook.additionalContext").and_then(|v| v.as_str()),
365	            Some("ctx")
366	        );
367	        assert!(string_at(&t, "hook.missing").is_none());
368	        assert!(string_at(&t, "nope").is_none());
369	        // A non-final segment that isn't a table is a miss, not a panic.
370	        assert!(string_at(&t, "greeting.x").is_none());
371
372	        // Write: overwrite a leaf, and create an intermediate table.
373	        set_string_at(&mut t, "greeting", Value::String("yo".into())).unwrap();
374	        set_string_at(&mut t, "deep.nest.key", Value::String("v".into())).unwrap();
375	        assert_eq!(string_at(&t, "greeting").and_then(|v| v.as_str()), Some("yo"));
376	        assert_eq!(string_at(&t, "deep.nest.key").and_then(|v| v.as_str()), Some("v"));
377	        // Writing through an existing non-table leaf errs (no clobber).
378	        assert!(set_string_at(&mut t, "greeting.x", Value::String("z".into())).is_err());
379	        // An empty segment errs.
380	        assert!(set_string_at(&mut t, "a..b", Value::String("z".into())).is_err());
381	    }
382
383	    /// A profile overlays `[strings]` like any section (leaf-replace): the
384	    /// profile's key wins, the parent's untouched string survives.
385	    // [unit->REQ-MANIFEST-3]
386	    #[test]
387	    fn profile_overlays_strings() {
388	        let parent = Manifest::from_toml_str(
389	            "[adapter]\nname = \"a\"\nkind = \"harness\"\nversion = \"1\"\n\
390	             min_spt_core_version = \"0\"\n\n[strings]\nkept = \"base\"\nflip = \"base\"\n\n\
391	             [profiles.p]\n[profiles.p.strings]\nflip = \"profile\"\n",
392	        )
393	        .expect("parses");
394	        let merged = resolve(&parent, "p").expect("resolves");
395	        assert_eq!(merged.strings.get("kept").and_then(|v| v.as_str()), Some("base"));
396	        assert_eq!(merged.strings.get("flip").and_then(|v| v.as_str()), Some("profile"));
397	    }
398
399	    /// Composite addressing splits on the first `:`; a bare name has no profile.
400	    // [unit->REQ-MANIFEST-2]
401	    #[test]
402	    fn split_option_first_colon() {
403	        assert_eq!(split_option("claude-spt"), ("claude-spt", None));
404	        assert_eq!(split_option("claude-spt:work"), ("claude-spt", Some("work")));
405	        // A profile name may contain '-'; the split is on the first ':' only.
406	        assert_eq!(
407	            split_option("spt-usbip-driver:hid-only"),
408	            ("spt-usbip-driver", Some("hid-only"))
409	        );
410	    }
411	}
412

//! Adapter-profile overlay merge — leaf-replace semantics (CONTEXT.md §adapter
//! profile).
//!
//! A *profile* is a named **sparse overlay** on its parent adapter manifest. The
//! merge rule is **leaf-replace**: a profile key replaces the whole value at that
//! path — tables recurse key-by-key, but every non-table value (scalar, array,
//! datetime) overrides wholesale. Arrays are **replaced, never spliced or
//! appended**. Keys present only in the parent survive; keys present only in the
//! overlay are added. The merged [`toml::Value`] re-deserializes into a complete
//! [`crate::Manifest`], so the profile behaves as a distinct adapter option
//! everywhere a bare `adapter_name` rides.
//!
//! This is a pure function with no I/O; the unit table below is its contract.
//
// [impl->REQ-MANIFEST-2]

use crate::manifest::{Manifest, ManifestError, OverCap, ShellApproval};
use toml::Value;

/// Leaf-replace deep merge of `overlay` onto `base`.
///
/// When both sides at a key are tables, recurse so only the differing **leaves**
/// change (a nested path like `shell.require_approval` is replaced without
/// disturbing its sibling keys). Otherwise the overlay value wins wholesale —
/// this is what makes arrays *replaced, never spliced* and lets a profile change
/// a value's type. An empty-table overlay is a no-op (returns `base` unchanged).
pub fn merge_leaf_replace(base: &Value, overlay: &Value) -> Value {
    match (base, overlay) {
        (Value::Table(b), Value::Table(o)) => {
            let mut out = b.clone();
            for (key, ov) in o {
                let merged = match b.get(key) {
                    Some(bv) => merge_leaf_replace(bv, ov),
                    None => ov.clone(),
                };
                out.insert(key.clone(), merged);
            }
            Value::Table(out)
        }
        // Any non-table overlay (or a base/overlay type mismatch) replaces the
        // base value wholesale — the leaf-replace rule.
        _ => overlay.clone(),
    }
}

/// Split an adapter **option** string into `(adapter, profile?)` on the **first**
/// `:` — the canonical composite addressing form. A bare name (no `:`) yields
/// `(name, None)` and resolves to the parent unmodified; `<adapter>:<profile>`
/// yields `(adapter, Some(profile))`. The split is on the first `:` so an adapter
/// name itself may not contain one (it never does), while a profile name may.
pub fn split_option(option: &str) -> (&str, Option<&str>) {
    match option.split_once(':') {
        Some((adapter, profile)) => (adapter, Some(profile)),
        None => (option, None),
    }
}

/// Resolve a **shipped** profile (`[profiles.<name>]` declared on `parent`) into
/// a complete, validated manifest — the merged view a `<adapter>:<profile>`
/// address selects. A bare adapter name never calls this (it uses the parent
/// unmodified).
///
/// Errors: [`ManifestError::UnknownProfile`] for an undeclared name, else as
/// [`resolve_overlay`].
// [impl->REQ-MANIFEST-2]
pub fn resolve(parent: &Manifest, profile: &str) -> Result<Manifest, ManifestError> {
    let overlay = parent
        .profiles
        .get(profile)
        .ok_or_else(|| ManifestError::UnknownProfile(profile.to_string()))?;
    resolve_overlay(parent, overlay)
}

/// Apply an arbitrary sparse `overlay` onto `parent` — the shared core of both
/// shipped-profile ([`resolve`]) and **local**-profile (registry) resolution.
/// Serializes the parent to a [`Value`], leaf-replace-merges the overlay,
/// re-deserializes, drops the profile catalogue from the resolved view, then
/// re-runs the manifest invariants **and** the tighten-only consent floor.
///
/// Errors: a parse error if the merge produces an invalid manifest, or a
/// validation error if the merged manifest breaks an invariant or *loosens* a
/// consent floor.
// [impl->REQ-MANIFEST-2]
pub fn resolve_overlay(parent: &Manifest, overlay: &Value) -> Result<Manifest, ManifestError> {
    let base = Value::try_from(parent)
        .map_err(|e| ManifestError::Validation(format!("profile base serialize failed: {e}")))?;
    let merged_val = merge_leaf_replace(&base, overlay);
    let mut merged: Manifest = merged_val.try_into().map_err(ManifestError::Parse)?;
    // The resolved view is one concrete adapter option; it does not re-advertise
    // the parent's profile catalogue.
    merged.profiles.clear();
    merged.validate()?;
    check_tighten_only(parent, &merged)?;
    Ok(merged)
}

/// Read a dot-separated `key_path` out of a `[strings]` KV tree: each segment
/// indexes a nested table, the final segment's value is returned. `None` if any
/// segment is missing or a non-final segment is not a table. Used by
/// `spt adapter get-string` after the profile overlay has merged the tree.
// [impl->REQ-MANIFEST-3]
pub fn string_at<'a>(strings: &'a toml::Table, key_path: &str) -> Option<&'a Value> {
    let mut parts = key_path.split('.');
    let mut v = strings.get(parts.next()?)?;
    for seg in parts {
        v = v.as_table()?.get(seg)?;
    }
    Some(v)
}

/// Set `value` at a dot-separated `key_path` in a `[strings]` tree, creating
/// intermediate tables as needed (the write half of `set-string`). Errors on an
/// empty path segment or a path that traverses an existing non-table leaf.
// [impl->REQ-MANIFEST-3]
pub fn set_string_at(
    strings: &mut toml::Table,
    key_path: &str,
    value: Value,
) -> Result<(), ManifestError> {
    let parts: Vec<&str> = key_path.split('.').collect();
    if parts.iter().any(|s| s.is_empty()) {
        return Err(ManifestError::Validation(format!(
            "invalid key path '{key_path}': empty segment"
        )));
    }
    let (last, lead) = parts.split_last().expect("split('.') yields >=1 part");
    let mut table = strings;
    for seg in lead {
        let entry = table
            .entry(seg.to_string())
            .or_insert_with(|| Value::Table(toml::Table::new()));
        table = entry.as_table_mut().ok_or_else(|| {
            ManifestError::Validation(format!(
                "key path '{key_path}' traverses a non-table at '{seg}'"
            ))
        })?;
    }
    table.insert(last.to_string(), value);
    Ok(())
}

/// How much user approval a [`ShellApproval`] demands (higher = tighter).
fn approval_rank(a: ShellApproval) -> u8 {
    match a {
        ShellApproval::None => 0,
        ShellApproval::Remembered => 1,
        ShellApproval::Always => 2,
    }
}

/// How restrictive an [`OverCap`] is (higher = tighter). `reject` refuses a spawn
/// at the cap outright; `approve` permits it behind a per-spawn approval.
fn over_cap_rank(o: OverCap) -> u8 {
    match o {
        OverCap::Approve => 0,
        OverCap::Reject => 1,
    }
}

/// Consent floors are **tighten-only** (CONTEXT.md §adapter profile): a profile
/// may demand *more* approval than its parent, never less. Refused at
/// registration if the merged profile loosens an approval-class floor
/// (`require_approval`, or the at-cap `over_cap` behavior). Capacity limits
/// (`max_instances_per_owner`) are not approval-class and are not floored here.
// [impl->REQ-MANIFEST-2]
pub fn check_tighten_only(parent: &Manifest, merged: &Manifest) -> Result<(), ManifestError> {
    if let (Some(p), Some(m)) = (parent.shell.as_ref(), merged.shell.as_ref()) {
        if approval_rank(m.require_approval) < approval_rank(p.require_approval) {
            return Err(ManifestError::Validation(format!(
                "profile loosens a consent floor: require_approval {:?} is laxer than parent {:?} (tighten-only)",
                m.require_approval, p.require_approval
            )));
        }
        if over_cap_rank(m.over_cap) < over_cap_rank(p.over_cap) {
            return Err(ManifestError::Validation(format!(
                "profile loosens a consent floor: over_cap {:?} is laxer than parent {:?} (tighten-only)",
                m.over_cap, p.over_cap
            )));
        }
    }
    Ok(())
}

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

    fn v(s: &str) -> Value {
        toml::from_str(s).expect("test TOML parses")
    }

    /// The leaf-replace contract, case by case (CONTEXT.md §adapter profile).
    // [unit->REQ-MANIFEST-2]
    #[test]
    fn leaf_replace_contract() {
        // Scalar leaf replacement.
        assert_eq!(
            merge_leaf_replace(&v("a = 1"), &v("a = 2")),
            v("a = 2"),
            "scalar leaf replaced"
        );

        // Table recursion: only the named leaf changes, the sibling survives.
        assert_eq!(
            merge_leaf_replace(&v("[s]\nx = 1\ny = 2"), &v("[s]\nx = 9")),
            v("[s]\nx = 9\ny = 2"),
            "nested table recurses leaf-wise, sibling preserved"
        );

        // Array replacement — never spliced or appended.
        assert_eq!(
            merge_leaf_replace(&v("a = [1, 2, 3]"), &v("a = [9]")),
            v("a = [9]"),
            "array replaced wholesale, not spliced"
        );

        // Key present only in the overlay is added.
        assert_eq!(
            merge_leaf_replace(&v("a = 1"), &v("b = 2")),
            v("a = 1\nb = 2"),
            "overlay-only key added"
        );

        // Key present only in the base survives.
        assert_eq!(
            merge_leaf_replace(&v("a = 1\nb = 2"), &v("a = 5")),
            v("a = 5\nb = 2"),
            "base-only key survives"
        );

        // Empty-table overlay is a no-op.
        assert_eq!(
            merge_leaf_replace(&v("a = 1\n[s]\nx = 1"), &v("")),
            v("a = 1\n[s]\nx = 1"),
            "empty overlay is a no-op"
        );

        // Type change at a leaf: overlay table replaces a base scalar wholesale.
        assert_eq!(
            merge_leaf_replace(&v("a = 1"), &v("[a]\nx = 1")),
            v("[a]\nx = 1"),
            "overlay replaces a base scalar with a table (type change)"
        );

        // Type change the other way: overlay scalar replaces a base table.
        assert_eq!(
            merge_leaf_replace(&v("[a]\nx = 1"), &v("a = 7")),
            v("a = 7"),
            "overlay scalar replaces a base table wholesale"
        );
    }

    /// Deeply nested paths replace only the addressed leaf.
    // [unit->REQ-MANIFEST-2]
    #[test]
    fn deep_nested_leaf_replace() {
        let base = v("[shell.capabilities.run]\nargs = [\"cmd\"]\n[shell]\nrequire_approval = \"none\"");
        let overlay = v("[shell]\nrequire_approval = \"always\"");
        let merged = merge_leaf_replace(&base, &overlay);
        // The capabilities sub-table is untouched; only require_approval flips.
        assert_eq!(
            merged,
            v("[shell.capabilities.run]\nargs = [\"cmd\"]\n[shell]\nrequire_approval = \"always\"")
        );
    }

    /// A shell adapter with shipped profiles: `work` tightens the floor, `loose`
    /// loosens it, `caploose` loosens the at-cap behavior.
    const PROFILED_SHELL: &str = r#"
[adapter]
name = "claude-spt"
kind = "shell"
version = "1.0.0"
min_spt_core_version = "1.0.0"

[shell]
spawn = "run --thing"
require_approval = "remembered"

[profiles.work]
[profiles.work.shell]
require_approval = "always"

[profiles.loose]
[profiles.loose.shell]
require_approval = "none"

[profiles.caploose]
[profiles.caploose.shell]
over_cap = "approve"
"#;

    /// Resolving a shipped profile applies the overlay leaf-wise and yields a
    /// complete, validated manifest with the parent's untouched leaves intact.
    // [unit->REQ-MANIFEST-2]
    #[test]
    fn resolve_shipped_profile_applies_overlay() {
        let parent = Manifest::from_toml_str(PROFILED_SHELL).expect("parent parses");
        let merged = resolve(&parent, "work").expect("work resolves");
        let shell = merged.shell.expect("merged has shell");
        assert_eq!(shell.require_approval, ShellApproval::Always, "floor tightened");
        assert_eq!(shell.spawn, "run --thing", "untouched leaf survives the merge");
        assert!(merged.profiles.is_empty(), "resolved view drops the profile catalogue");
    }

    /// An undeclared profile name is a distinct, typed error.
    // [unit->REQ-MANIFEST-2]
    #[test]
    fn resolve_unknown_profile_errors() {
        let parent = Manifest::from_toml_str(PROFILED_SHELL).expect("parent parses");
        assert!(matches!(
            resolve(&parent, "nope"),
            Err(ManifestError::UnknownProfile(n)) if n == "nope"
        ));
    }

    /// Tighten-only: a profile that *raises* the approval demand is accepted.
    // [unit->REQ-MANIFEST-2]
    #[test]
    fn tighten_only_allows_tightening() {
        let parent = Manifest::from_toml_str(PROFILED_SHELL).expect("parent parses");
        assert!(resolve(&parent, "work").is_ok(), "remembered -> always is a tighten");
    }

    /// Tighten-only: a profile that *lowers* the approval floor is refused at
    /// resolution (the registration-time guard).
    // [unit->REQ-MANIFEST-2]
    #[test]
    fn tighten_only_refuses_loosened_require_approval() {
        let parent = Manifest::from_toml_str(PROFILED_SHELL).expect("parent parses");
        let err = resolve(&parent, "loose").expect_err("remembered -> none must be refused");
        match err {
            ManifestError::Validation(m) => assert!(
                m.contains("require_approval") && m.contains("tighten-only"),
                "floor error names the field: {m}"
            ),
            other => panic!("expected a validation error, got {other:?}"),
        }
    }

    /// Tighten-only also floors the at-cap `over_cap` behavior (reject -> approve
    /// is a loosen).
    // [unit->REQ-MANIFEST-2]
    #[test]
    fn tighten_only_refuses_loosened_over_cap() {
        let parent = Manifest::from_toml_str(PROFILED_SHELL).expect("parent parses");
        let err = resolve(&parent, "caploose").expect_err("reject -> approve must be refused");
        assert!(matches!(err, ManifestError::Validation(m) if m.contains("over_cap")));
    }

    /// `[strings]` dot-path read/write: nested traversal, missing key, and the
    /// write building intermediate tables; a non-table mid-path errs.
    // [unit->REQ-MANIFEST-3]
    #[test]
    fn string_dot_path_read_write() {
        let mut t: toml::Table = toml::from_str(
            "greeting = \"hi\"\n[hook]\nadditionalContext = \"ctx\"\n",
        )
        .unwrap();

        // Read: top-level leaf, nested leaf, and a miss.
        assert_eq!(string_at(&t, "greeting").and_then(|v| v.as_str()), Some("hi"));
        assert_eq!(
            string_at(&t, "hook.additionalContext").and_then(|v| v.as_str()),
            Some("ctx")
        );
        assert!(string_at(&t, "hook.missing").is_none());
        assert!(string_at(&t, "nope").is_none());
        // A non-final segment that isn't a table is a miss, not a panic.
        assert!(string_at(&t, "greeting.x").is_none());

        // Write: overwrite a leaf, and create an intermediate table.
        set_string_at(&mut t, "greeting", Value::String("yo".into())).unwrap();
        set_string_at(&mut t, "deep.nest.key", Value::String("v".into())).unwrap();
        assert_eq!(string_at(&t, "greeting").and_then(|v| v.as_str()), Some("yo"));
        assert_eq!(string_at(&t, "deep.nest.key").and_then(|v| v.as_str()), Some("v"));
        // Writing through an existing non-table leaf errs (no clobber).
        assert!(set_string_at(&mut t, "greeting.x", Value::String("z".into())).is_err());
        // An empty segment errs.
        assert!(set_string_at(&mut t, "a..b", Value::String("z".into())).is_err());
    }

    /// A profile overlays `[strings]` like any section (leaf-replace): the
    /// profile's key wins, the parent's untouched string survives.
    // [unit->REQ-MANIFEST-3]
    #[test]
    fn profile_overlays_strings() {
        let parent = Manifest::from_toml_str(
            "[adapter]\nname = \"a\"\nkind = \"harness\"\nversion = \"1\"\n\
             min_spt_core_version = \"0\"\n\n[strings]\nkept = \"base\"\nflip = \"base\"\n\n\
             [profiles.p]\n[profiles.p.strings]\nflip = \"profile\"\n",
        )
        .expect("parses");
        let merged = resolve(&parent, "p").expect("resolves");
        assert_eq!(merged.strings.get("kept").and_then(|v| v.as_str()), Some("base"));
        assert_eq!(merged.strings.get("flip").and_then(|v| v.as_str()), Some("profile"));
    }

    /// Composite addressing splits on the first `:`; a bare name has no profile.
    // [unit->REQ-MANIFEST-2]
    #[test]
    fn split_option_first_colon() {
        assert_eq!(split_option("claude-spt"), ("claude-spt", None));
        assert_eq!(split_option("claude-spt:work"), ("claude-spt", Some("work")));
        // A profile name may contain '-'; the split is on the first ':' only.
        assert_eq!(
            split_option("spt-usbip-driver:hid-only"),
            ("spt-usbip-driver", Some("hid-only"))
        );
    }
}


Profile engine is clean — leaf-replace unit table covers all CONTEXT cases (scalar, nested sibling-preserve, array-wholesale, overlay-only-add, base survive, empty no-op, both type-change directions). One tension to chase: doc comment says "profile name may contain `:`" but survey Risk #1 mitigation says forbid `:`/`-` at create-profile. Need to see which the registry enforced. Reading registry + canon-id hazard + comparison seams.