warble_cli/overlay.rs
1//! A patch applied to a parsed `profile.yml` before the compiler ever sees it.
2//!
3//! # Why this is not a compile stage
4//!
5//! A host that serves many accounts from one set of behaviors needs the same profile bound
6//! differently per account: a different set of mounted behaviors, different values supplied to
7//! them, a different charter. An overlay is that difference, expressed as a patch.
8//!
9//! It is applied **here**, to the deserialized [`ProfileFile`], before `warble::compile` is
10//! called. That placement is what keeps the mechanism small:
11//!
12//! - `compile`'s signature is untouched, and the compiler holds no notion of an overlay;
13//! - the compiler stays sans-IO — this module reads the patch file, as the host layer should;
14//! - **every existing compile-time check applies to the patched profile for free.** In
15//! particular the capability ceiling is enforced per mount, so a patch that mounts a behavior
16//! requiring something the profile does not permit is refused without any ordering rule having
17//! to be arranged. The same holds for slot declarations, context preconditions and required
18//! binds.
19//!
20//! # What a patch may touch, and why so little
21//!
22//! Which behaviors are mounted, the values bound to them, and which charter the profile uses.
23//! Nothing else — not a mounted behavior's internals, its steps, or its capabilities.
24//!
25//! Everything on that list is already expressible on a profile mount, so the patch adds an entry
26//! point rather than new expressive power. That is deliberate: a deployment that could rewrite
27//! what a behavior declares it needs would make the declaration meaningless, since the same
28//! behavior would claim different requirements per deployment. Differences in what a runtime can
29//! actually provide are expressed by target profiles and provider fragments instead, where an
30//! unmet need surfaces as a degrade or a loud failure.
31
32use std::collections::HashMap;
33use std::path::Path;
34
35use serde::Deserialize;
36use warble::{ProfileComponentMount, ProfileFile};
37
38/// The only document format version this build understands.
39const SUPPORTED_OVERLAY_VERSION: u32 = 1;
40
41/// A parsed overlay document.
42///
43/// `deny_unknown_fields` is load-bearing rather than tidiness: it is what makes an unsupported
44/// key a loud failure instead of a patch that silently does less than it appears to. Someone
45/// reasonably assuming that, say, a tier override belongs here finds out immediately.
46#[derive(Debug, Deserialize)]
47#[serde(deny_unknown_fields)]
48pub struct OverlayFile {
49 /// Version of this document format. Unrelated to `warble_ir_version` — an overlay adds no IR
50 /// field, so it never moves the IR line and is deliberately not part of that lockstep.
51 pub overlay: u32,
52 /// Replaces the profile's charter wholesale.
53 #[serde(default)]
54 pub system_prompt: Option<String>,
55 /// Behaviors to add.
56 #[serde(default)]
57 pub mount: Vec<OverlayMount>,
58 /// Behaviors to remove, by id.
59 #[serde(default)]
60 pub unmount: Vec<String>,
61 /// Values to change on an already-mounted behavior, keyed by its id. Merged key by key into
62 /// whatever the base profile supplied, so changing one value cannot silently drop the others
63 /// — including a value the component declares as required.
64 #[serde(default)]
65 pub bind: HashMap<String, HashMap<String, serde_yaml::Value>>,
66}
67
68/// One `mount:` entry — a behavior to add, with the values it should be bound with.
69#[derive(Debug, Deserialize)]
70#[serde(deny_unknown_fields)]
71pub struct OverlayMount {
72 #[serde(rename = "use")]
73 pub use_id: String,
74 #[serde(default)]
75 pub bind: Option<HashMap<String, serde_yaml::Value>>,
76}
77
78/// Reads and parses an overlay document.
79pub fn read_overlay(path: &Path) -> Result<OverlayFile, String> {
80 let raw = std::fs::read_to_string(path)
81 .map_err(|e| format!("failed to read overlay {}: {e}", path.display()))?;
82 let overlay: OverlayFile = serde_yaml::from_str(&raw)
83 .map_err(|e| format!("failed to parse overlay {}: {e}", path.display()))?;
84 if overlay.overlay != SUPPORTED_OVERLAY_VERSION {
85 return Err(format!(
86 "overlay {} declares format version {}, which this build does not understand \
87 (supported: {SUPPORTED_OVERLAY_VERSION})",
88 path.display(),
89 overlay.overlay
90 ));
91 }
92 Ok(overlay)
93}
94
95/// Applies an overlay to a parsed profile, in place.
96///
97/// Every ambiguous request is refused rather than resolved by a rule nobody asked for. Silently
98/// doing something defensible is worse than refusing here, because the author of a patch is
99/// typically not the author of the profile it patches: they cannot see the base to notice that
100/// their intent was reinterpreted.
101pub fn apply_overlay(profile: &mut ProfileFile, overlay: &OverlayFile) -> Result<(), String> {
102 // Checked before anything is applied, so a contradictory patch changes nothing at all rather
103 // than half of what it asked for.
104 for entry in &overlay.mount {
105 if overlay.unmount.contains(&entry.use_id) {
106 return Err(format!(
107 "overlay both mounts and unmounts '{}' — the intent is contradictory, so it is \
108 refused rather than resolved in one direction",
109 entry.use_id
110 ));
111 }
112 }
113 let mut seen: Vec<&str> = Vec::with_capacity(overlay.mount.len());
114 for entry in &overlay.mount {
115 if seen.contains(&entry.use_id.as_str()) {
116 return Err(format!(
117 "overlay mounts '{}' more than once — taking the last would silently discard the \
118 other entry's bind values",
119 entry.use_id
120 ));
121 }
122 seen.push(&entry.use_id);
123 }
124
125 for id in &overlay.unmount {
126 if !profile.components.iter().any(|m| &m.use_id == id) {
127 return Err(format!(
128 "overlay unmounts '{id}', which the profile does not mount — a typo here would \
129 otherwise remove nothing while appearing to"
130 ));
131 }
132 }
133 for id in overlay.bind.keys() {
134 if !profile.components.iter().any(|m| &m.use_id == id) {
135 return Err(format!(
136 "overlay binds values on '{id}', which the profile does not mount — to add it, \
137 use 'mount' with its bind values"
138 ));
139 }
140 }
141 for entry in &overlay.mount {
142 if profile.components.iter().any(|m| m.use_id == entry.use_id) {
143 return Err(format!(
144 "overlay mounts '{}', which the profile already mounts — use 'bind' to change an \
145 existing mount's values",
146 entry.use_id
147 ));
148 }
149 }
150
151 if let Some(system_prompt) = &overlay.system_prompt {
152 profile.system_prompt = Some(system_prompt.clone());
153 }
154
155 profile
156 .components
157 .retain(|m| !overlay.unmount.contains(&m.use_id));
158
159 for (id, values) in &overlay.bind {
160 let mount = profile
161 .components
162 .iter_mut()
163 .find(|m| &m.use_id == id)
164 .expect("presence was checked above");
165 let existing = mount.bind.get_or_insert_with(HashMap::new);
166 for (key, value) in values {
167 existing.insert(key.clone(), value.clone());
168 }
169 }
170
171 for entry in &overlay.mount {
172 profile.components.push(ProfileComponentMount {
173 use_id: entry.use_id.clone(),
174 config: None,
175 bind: entry.bind.clone(),
176 tier_overrides: None,
177 realization_kind: None,
178 guardrails: None,
179 brief: None,
180 });
181 }
182
183 // Last, because the count is only final once every add and remove has been applied.
184 if profile.components.is_empty() {
185 return Err(
186 "overlay leaves the profile with no mounted behaviors, which is not a harness that \
187 can be dispatched"
188 .to_string(),
189 );
190 }
191 Ok(())
192}