Skip to main content

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}