Skip to main content

codewhale_workflow/
fleet_composition.rs

1//! **Setup-time** Fleet composition: a suggestion schema, and nothing else.
2//!
3//! The same Reasoning Router service an operator attaches to a Fleet may later
4//! *assist* Fleet setup, by proposing which configured provider/model should
5//! take which role. That is a fundamentally different act from what the runtime
6//! router does, and this module exists to keep the two from ever being
7//! confused:
8//!
9//! | | Runtime router | Setup-time composition |
10//! |---|---|---|
11//! | Input | one frozen worker route | an explicit, redacted model pool + role list |
12//! | Output | a reasoning tier | a *suggested* provider/model per role |
13//! | Authority | applies immediately | **none** — a human must review and save |
14//!
15//! Three properties make this safe to have in the tree at all:
16//!
17//! 1. **It is pure.** No clock, no filesystem, no network, no config. It maps
18//!    an input value to an output value.
19//! 2. **It cannot save or launch.** There is no path from a
20//!    [`crate::fleet_composition::FleetCompositionProposal`] to an
21//!    [`crate::ExactFleet`], a snapshot, or a
22//!    spawn. Turning a proposal into a saved Fleet is a separate, explicit act
23//!    that belongs to the saved-Fleet UI lane.
24//! 3. **It cannot invent a model.** Every suggestion must name a model that is
25//!    already in the operator's explicitly configured pool; anything else is
26//!    rejected, not silently substituted.
27//!
28//! Every proposal is born
29//! [`crate::fleet_composition::RatificationState::Unratified`] and there is no
30//! method here that ratifies one. Ratification is a human act recorded
31//! elsewhere; this module can only ever describe a suggestion.
32//!
33//! ## The seam
34//!
35//! Runtime **must not** call anything in this module, and nothing here reads a
36//! [`crate::FleetSnapshot`] or mutates a saved Fleet. If a future lane wires
37//! composition into a UI, the wiring belongs on the UI side of this boundary:
38//! parse → propose → *show the human* → human saves a Fleet file → the ordinary
39//! exact-Fleet path takes over from there.
40
41use std::collections::BTreeSet;
42
43use serde::{Deserialize, Serialize};
44use thiserror::Error;
45
46use crate::redaction::redact_for_disclosure;
47
48/// One entry in the operator's explicitly configured model pool.
49///
50/// "Explicitly configured" is load-bearing: this is not a catalogue of models
51/// that exist in the world, it is the set the operator has already set up and
52/// is willing to spend money on.
53#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
54pub struct ConfiguredModel {
55    /// Exact configured provider id.
56    pub provider: String,
57    /// Exact model id.
58    pub model: String,
59    /// Optional non-secret operator note (e.g. "cheap", "long context").
60    /// Redacted on construction — a note is free text and may hold a path.
61    #[serde(default, skip_serializing_if = "Option::is_none")]
62    pub note: Option<String>,
63}
64
65impl ConfiguredModel {
66    /// Build a pool entry, redacting the note.
67    #[must_use]
68    pub fn new(provider: impl Into<String>, model: impl Into<String>, note: Option<&str>) -> Self {
69        Self {
70            provider: provider.into(),
71            model: model.into(),
72            note: note
73                .map(|note| redact_for_disclosure(note).into_text())
74                .filter(|note| !note.trim().is_empty()),
75        }
76    }
77
78    /// `provider/model` — the key a suggestion is checked against.
79    #[must_use]
80    pub fn key(&self) -> String {
81        format!("{}/{}", self.provider, self.model)
82    }
83}
84
85/// A role the operator wants filled.
86#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
87pub struct CompositionRole {
88    /// Semantic role name, e.g. `builder`.
89    pub role: String,
90    /// What the operator wants this role to do. Redacted on construction.
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub intent: Option<String>,
93}
94
95impl CompositionRole {
96    #[must_use]
97    pub fn new(role: impl Into<String>, intent: Option<&str>) -> Self {
98        Self {
99            role: role.into(),
100            intent: intent
101                .map(|intent| redact_for_disclosure(intent).into_text())
102                .filter(|intent| !intent.trim().is_empty()),
103        }
104    }
105}
106
107/// The complete, explicit input to a composition request.
108///
109/// Nothing is discovered: the caller states the pool and the roles. A composer
110/// that could go looking for models would be choosing on the operator's behalf,
111/// which is exactly the authority this schema withholds.
112#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
113pub struct FleetCompositionRequest {
114    /// The explicitly configured, already-redacted model pool.
115    pub pool: Vec<ConfiguredModel>,
116    /// The roles to fill.
117    pub roles: Vec<CompositionRole>,
118}
119
120impl FleetCompositionRequest {
121    /// Build and validate a request.
122    pub fn new(
123        pool: Vec<ConfiguredModel>,
124        roles: Vec<CompositionRole>,
125    ) -> Result<Self, CompositionError> {
126        if pool.is_empty() {
127            return Err(CompositionError::EmptyPool);
128        }
129        if roles.is_empty() {
130            return Err(CompositionError::NoRoles);
131        }
132        let mut seen = BTreeSet::new();
133        for role in &roles {
134            let key = role.role.trim().to_ascii_lowercase();
135            if key.is_empty() {
136                return Err(CompositionError::InvalidRole {
137                    role: role.role.clone(),
138                });
139            }
140            if !seen.insert(key.clone()) {
141                return Err(CompositionError::DuplicateRole { role: key });
142            }
143        }
144        Ok(Self { pool, roles })
145    }
146
147    /// Whether a provider/model pair is in the pool.
148    #[must_use]
149    pub fn pool_contains(&self, provider: &str, model: &str) -> bool {
150        self.pool
151            .iter()
152            .any(|entry| entry.provider == provider && entry.model == model)
153    }
154
155    /// The pool keys, for an error message that tells the operator what *is*
156    /// available without them having to go look.
157    #[must_use]
158    pub fn pool_keys(&self) -> Vec<String> {
159        self.pool.iter().map(ConfiguredModel::key).collect()
160    }
161}
162
163/// One suggested role → provider/model assignment.
164#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
165pub struct RoleSuggestion {
166    pub role: String,
167    pub provider: String,
168    pub model: String,
169    /// Short, redacted reason. Advisory text for a human reader only.
170    #[serde(default, skip_serializing_if = "Option::is_none")]
171    pub reason: Option<String>,
172}
173
174/// Whether a proposal has been reviewed and accepted by a human.
175///
176/// There is deliberately no constructor for [`Self::Ratified`] in this module:
177/// ratification happens where a human actually clicks, and that is not here.
178#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
179#[serde(rename_all = "snake_case")]
180pub enum RatificationState {
181    /// A suggestion. Not a Fleet, not saved, not runnable.
182    Unratified,
183    /// Reviewed and accepted by a human, elsewhere.
184    Ratified,
185}
186
187impl RatificationState {
188    #[must_use]
189    pub const fn as_str(self) -> &'static str {
190        match self {
191            Self::Unratified => "unratified",
192            Self::Ratified => "ratified",
193        }
194    }
195}
196
197/// A composition proposal: suggestions plus the fact that nobody has agreed.
198#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
199pub struct FleetCompositionProposal {
200    /// Always [`RatificationState::Unratified`] as produced here.
201    pub ratification: RatificationState,
202    /// One suggestion per requested role.
203    pub suggestions: Vec<RoleSuggestion>,
204    /// The banner a UI must show. Present in the value itself so a surface
205    /// cannot render the suggestions without it being obvious what they are.
206    pub advisory: String,
207}
208
209/// The advisory every proposal carries.
210pub const COMPOSITION_ADVISORY: &str = "Suggestion only — not saved, not running. Review each \
211     role's provider and model, then save a Fleet yourself. Nothing here changes an existing \
212     Fleet or starts a Workflow.";
213
214impl FleetCompositionProposal {
215    /// Validate a set of suggestions against the request that produced them.
216    ///
217    /// Every suggestion must name a role that was asked for and a provider/model
218    /// that is in the pool. A model outside the pool is an error, never a
219    /// substitution: silently swapping in something the operator did not
220    /// configure is the exact failure this whole schema exists to prevent.
221    pub fn validate(
222        request: &FleetCompositionRequest,
223        suggestions: Vec<RoleSuggestion>,
224    ) -> Result<Self, CompositionError> {
225        let mut seen = BTreeSet::new();
226        for suggestion in &suggestions {
227            let role = suggestion.role.trim().to_ascii_lowercase();
228            if !request
229                .roles
230                .iter()
231                .any(|wanted| wanted.role.trim().to_ascii_lowercase() == role)
232            {
233                return Err(CompositionError::UnknownRole {
234                    role: suggestion.role.clone(),
235                });
236            }
237            if !seen.insert(role.clone()) {
238                return Err(CompositionError::DuplicateRole { role });
239            }
240            if !request.pool_contains(&suggestion.provider, &suggestion.model) {
241                return Err(CompositionError::ModelOutsidePool {
242                    role: suggestion.role.clone(),
243                    provider: suggestion.provider.clone(),
244                    model: suggestion.model.clone(),
245                    pool: request.pool_keys(),
246                });
247            }
248        }
249
250        Ok(Self {
251            ratification: RatificationState::Unratified,
252            suggestions: suggestions
253                .into_iter()
254                .map(|suggestion| RoleSuggestion {
255                    reason: suggestion
256                        .reason
257                        .as_deref()
258                        .map(|reason| redact_for_disclosure(reason).into_text())
259                        .filter(|reason| !reason.trim().is_empty()),
260                    ..suggestion
261                })
262                .collect(),
263            advisory: COMPOSITION_ADVISORY.to_string(),
264        })
265    }
266
267    /// Whether this proposal may be acted on automatically. Always `false`.
268    ///
269    /// A constant rather than a field lookup, so no future edit can make a
270    /// proposal self-executing by flipping a boolean somewhere.
271    #[must_use]
272    pub const fn is_actionable(&self) -> bool {
273        false
274    }
275
276    /// Roles the request asked for that no suggestion covers.
277    #[must_use]
278    pub fn unfilled_roles(&self, request: &FleetCompositionRequest) -> Vec<String> {
279        request
280            .roles
281            .iter()
282            .filter(|wanted| {
283                !self.suggestions.iter().any(|suggestion| {
284                    suggestion
285                        .role
286                        .trim()
287                        .eq_ignore_ascii_case(wanted.role.trim())
288                })
289            })
290            .map(|wanted| wanted.role.clone())
291            .collect()
292    }
293}
294
295#[derive(Debug, Clone, PartialEq, Eq, Error)]
296pub enum CompositionError {
297    #[error(
298        "fleet composition needs an explicit configured model pool; there is nothing to choose \
299         from and this schema will not go looking"
300    )]
301    EmptyPool,
302    #[error("fleet composition needs at least one role to fill")]
303    NoRoles,
304    #[error("`{role}` is not a valid role name")]
305    InvalidRole { role: String },
306    #[error("role `{role}` appears more than once")]
307    DuplicateRole { role: String },
308    #[error("suggestion names role `{role}`, which was not one of the requested roles")]
309    UnknownRole { role: String },
310    #[error(
311        "suggestion for role `{role}` names `{provider}/{model}`, which is not in the configured \
312         model pool ({}). A composition may only choose from what the operator already \
313         configured — it is rejected rather than substituted.",
314        pool.join(", ")
315    )]
316    ModelOutsidePool {
317        role: String,
318        provider: String,
319        model: String,
320        pool: Vec<String>,
321    },
322}
323
324#[cfg(test)]
325mod tests {
326    use super::*;
327
328    fn request() -> FleetCompositionRequest {
329        FleetCompositionRequest::new(
330            vec![
331                ConfiguredModel::new("zai", "glm-5", Some("strong")),
332                ConfiguredModel::new("openai", "gpt-5.6-luna", Some("cheap")),
333            ],
334            vec![
335                CompositionRole::new("builder", Some("lands focused code changes")),
336                CompositionRole::new("scout", Some("fast read-only exploration")),
337            ],
338        )
339        .expect("valid request")
340    }
341
342    fn suggestion(role: &str, provider: &str, model: &str) -> RoleSuggestion {
343        RoleSuggestion {
344            role: role.to_string(),
345            provider: provider.to_string(),
346            model: model.to_string(),
347            reason: None,
348        }
349    }
350
351    #[test]
352    fn a_valid_proposal_is_born_unratified_and_not_actionable() {
353        let request = request();
354        let proposal = FleetCompositionProposal::validate(
355            &request,
356            vec![
357                suggestion("builder", "zai", "glm-5"),
358                suggestion("scout", "openai", "gpt-5.6-luna"),
359            ],
360        )
361        .expect("valid proposal");
362
363        assert_eq!(proposal.ratification, RatificationState::Unratified);
364        assert_eq!(proposal.ratification.as_str(), "unratified");
365        assert!(!proposal.is_actionable());
366        assert_eq!(proposal.advisory, COMPOSITION_ADVISORY);
367        assert!(proposal.advisory.contains("not saved"));
368        assert!(proposal.unfilled_roles(&request).is_empty());
369    }
370
371    /// The central guardrail: a model the operator never configured is an
372    /// error, and the error names what *is* available.
373    #[test]
374    fn a_model_outside_the_pool_is_rejected_not_substituted() {
375        let request = request();
376        let err = FleetCompositionProposal::validate(
377            &request,
378            vec![suggestion("builder", "anthropic", "claude-opus-5")],
379        )
380        .expect_err("out-of-pool model");
381
382        assert!(
383            matches!(err, CompositionError::ModelOutsidePool { .. }),
384            "{err:?}"
385        );
386        let message = err.to_string();
387        assert!(message.contains("zai/glm-5"), "{message}");
388        assert!(
389            message.contains("rejected rather than substituted"),
390            "{message}"
391        );
392    }
393
394    /// A right model on the wrong provider is still outside the pool.
395    #[test]
396    fn a_pool_entry_is_a_provider_and_model_pair_not_just_a_model() {
397        let request = request();
398        let err = FleetCompositionProposal::validate(
399            &request,
400            vec![suggestion("builder", "openai", "glm-5")],
401        )
402        .expect_err("provider must match too");
403        assert!(matches!(err, CompositionError::ModelOutsidePool { .. }));
404    }
405
406    #[test]
407    fn suggestions_must_answer_the_roles_that_were_asked_for() {
408        let request = request();
409        let err = FleetCompositionProposal::validate(
410            &request,
411            vec![suggestion("wizard", "zai", "glm-5")],
412        )
413        .expect_err("unknown role");
414        assert!(matches!(err, CompositionError::UnknownRole { .. }));
415
416        let duplicate = FleetCompositionProposal::validate(
417            &request,
418            vec![
419                suggestion("builder", "zai", "glm-5"),
420                suggestion("builder", "openai", "gpt-5.6-luna"),
421            ],
422        )
423        .expect_err("duplicate role");
424        assert!(matches!(duplicate, CompositionError::DuplicateRole { .. }));
425    }
426
427    #[test]
428    fn a_partial_proposal_reports_what_it_did_not_fill() {
429        let request = request();
430        let proposal = FleetCompositionProposal::validate(
431            &request,
432            vec![suggestion("builder", "zai", "glm-5")],
433        )
434        .expect("partial is allowed, and visible");
435
436        assert_eq!(proposal.unfilled_roles(&request), vec!["scout".to_string()]);
437    }
438
439    #[test]
440    fn an_empty_pool_or_role_list_is_refused() {
441        assert!(matches!(
442            FleetCompositionRequest::new(vec![], vec![CompositionRole::new("builder", None)])
443                .expect_err("empty pool"),
444            CompositionError::EmptyPool
445        ));
446        assert!(matches!(
447            FleetCompositionRequest::new(vec![ConfiguredModel::new("zai", "glm-5", None)], vec![])
448                .expect_err("no roles"),
449            CompositionError::NoRoles
450        ));
451    }
452
453    /// Free-text fields are operator prose and may hold a path or a key. They
454    /// are redacted on the way in, because a proposal is displayed and may be
455    /// saved alongside logs.
456    #[test]
457    fn free_text_is_redacted_on_the_way_in() {
458        let model = ConfiguredModel::new("zai", "glm-5", Some("configured in /Users/hunter/.env"));
459        assert!(!model.note.as_deref().unwrap().contains("/Users/"));
460
461        let role = CompositionRole::new("builder", Some("uses ZAI_API_KEY=zzz"));
462        assert!(!role.intent.as_deref().unwrap().contains("zzz"));
463
464        let request = FleetCompositionRequest::new(vec![model], vec![role]).expect("request");
465        let proposal = FleetCompositionProposal::validate(
466            &request,
467            vec![RoleSuggestion {
468                role: "builder".to_string(),
469                provider: "zai".to_string(),
470                model: "glm-5".to_string(),
471                reason: Some("matches /home/x/notes".to_string()),
472            }],
473        )
474        .expect("valid");
475
476        let json = serde_json::to_string(&proposal).expect("serialize");
477        assert!(!json.contains("/home/"), "{json}");
478    }
479
480    /// The schema is inert: a proposal serializes as a suggestion and carries
481    /// no route, snapshot, or launch surface a runtime could act on.
482    #[test]
483    fn a_proposal_carries_no_runtime_surface() {
484        let request = request();
485        let proposal = FleetCompositionProposal::validate(
486            &request,
487            vec![suggestion("builder", "zai", "glm-5")],
488        )
489        .expect("valid");
490        let json = serde_json::to_string(&proposal).expect("serialize");
491
492        assert!(json.contains("\"unratified\""), "{json}");
493        for forbidden in [
494            "snapshot",
495            "content_hash",
496            "permissions",
497            "reasoning_router",
498            "schema_revision",
499        ] {
500            assert!(
501                !json.contains(forbidden),
502                "a composition proposal must not look like a fleet: {forbidden} in {json}"
503            );
504        }
505    }
506}