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    #[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}