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 /// Entry eligibility for the new mount. Defaults to the profile mount default so an overlay
75 /// written before this field existed keeps producing an ordinary entry mount.
76 #[serde(default = "default_true")]
77 pub entrypoint: bool,
78 #[serde(default)]
79 pub bind: Option<HashMap<String, serde_yaml::Value>>,
80}
81
82fn default_true() -> bool {
83 true
84}
85
86/// Reads and parses an overlay document.
87pub fn read_overlay(path: &Path) -> Result<OverlayFile, String> {
88 let raw = std::fs::read_to_string(path)
89 .map_err(|e| format!("failed to read overlay {}: {e}", path.display()))?;
90 let overlay: OverlayFile = serde_yaml::from_str(&raw)
91 .map_err(|e| format!("failed to parse overlay {}: {e}", path.display()))?;
92 if overlay.overlay != SUPPORTED_OVERLAY_VERSION {
93 return Err(format!(
94 "overlay {} declares format version {}, which this build does not understand \
95 (supported: {SUPPORTED_OVERLAY_VERSION})",
96 path.display(),
97 overlay.overlay
98 ));
99 }
100 Ok(overlay)
101}
102
103/// Applies an overlay to a parsed profile, in place.
104///
105/// Every ambiguous request is refused rather than resolved by a rule nobody asked for. Silently
106/// doing something defensible is worse than refusing here, because the author of a patch is
107/// typically not the author of the profile it patches: they cannot see the base to notice that
108/// their intent was reinterpreted.
109pub fn apply_overlay(profile: &mut ProfileFile, overlay: &OverlayFile) -> Result<(), String> {
110 // Checked before anything is applied, so a contradictory patch changes nothing at all rather
111 // than half of what it asked for.
112 for entry in &overlay.mount {
113 if overlay.unmount.contains(&entry.use_id) {
114 return Err(format!(
115 "overlay both mounts and unmounts '{}' — the intent is contradictory, so it is \
116 refused rather than resolved in one direction",
117 entry.use_id
118 ));
119 }
120 }
121 let mut seen: Vec<&str> = Vec::with_capacity(overlay.mount.len());
122 for entry in &overlay.mount {
123 if seen.contains(&entry.use_id.as_str()) {
124 return Err(format!(
125 "overlay mounts '{}' more than once — taking the last would silently discard the \
126 other entry's bind values",
127 entry.use_id
128 ));
129 }
130 seen.push(&entry.use_id);
131 }
132
133 for id in &overlay.unmount {
134 if !profile.components.iter().any(|m| &m.use_id == id) {
135 return Err(format!(
136 "overlay unmounts '{id}', which the profile does not mount — a typo here would \
137 otherwise remove nothing while appearing to"
138 ));
139 }
140 }
141 for id in overlay.bind.keys() {
142 if !profile.components.iter().any(|m| &m.use_id == id) {
143 return Err(format!(
144 "overlay binds values on '{id}', which the profile does not mount — to add it, \
145 use 'mount' with its bind values"
146 ));
147 }
148 }
149 for entry in &overlay.mount {
150 if profile.components.iter().any(|m| m.use_id == entry.use_id) {
151 return Err(format!(
152 "overlay mounts '{}', which the profile already mounts — use 'bind' to change an \
153 existing mount's values",
154 entry.use_id
155 ));
156 }
157 }
158
159 if let Some(system_prompt) = &overlay.system_prompt {
160 profile.system_prompt = Some(system_prompt.clone());
161 }
162
163 profile
164 .components
165 .retain(|m| !overlay.unmount.contains(&m.use_id));
166
167 for (id, values) in &overlay.bind {
168 let mount = profile
169 .components
170 .iter_mut()
171 .find(|m| &m.use_id == id)
172 .expect("presence was checked above");
173 let existing = mount.bind.get_or_insert_with(HashMap::new);
174 for (key, value) in values {
175 existing.insert(key.clone(), value.clone());
176 }
177 }
178
179 for entry in &overlay.mount {
180 profile.components.push(ProfileComponentMount {
181 use_id: entry.use_id.clone(),
182 entrypoint: entry.entrypoint,
183 config: None,
184 bind: entry.bind.clone(),
185 tier_overrides: None,
186 realization_kind: None,
187 guardrails: None,
188 brief: None,
189 });
190 }
191
192 // Last, because the count is only final once every add and remove has been applied.
193 if profile.components.is_empty() {
194 return Err(
195 "overlay leaves the profile with no mounted behaviors, which is not a harness that \
196 can be dispatched"
197 .to_string(),
198 );
199 }
200 Ok(())
201}