Skip to main content

codewhale_workflow/
fleet_exact.rs

1//! Exact named-Fleet schema — fully resolved members, no late model choice.
2//!
3//! A named Fleet is a saved, reusable team. Two forms of `fleets/<name>.toml`
4//! exist and both keep working:
5//!
6//! - **Legacy** (`[roles]` role → AgentProfile id). See [`crate::NamedFleet`].
7//!   Legacy files declare no `schema` key, which is what makes the legacy form
8//!   *explicitly detectable* rather than inferred from a missing table.
9//! - **Exact** (`schema = "exact"`). Every member owns a stable member id/role,
10//!   an exact configured provider id, an exact model id, a requested reasoning
11//!   policy, and a permission ceiling.
12//!
13//! The exact form deliberately has **no** late-binding selectors. `inherit`,
14//! `faster`/fast siblings, model-strength classes, and `model = "auto"` are
15//! rejected at parse time, not silently resolved later — a Fleet the operator
16//! saved is the Fleet that runs. Users switch Fleets; models never switch
17//! themselves.
18//!
19//! The **Adaptive Reasoning Router is not a Fleet member.** A Fleet says *who*
20//! runs; a Router is a separate, optional, reusable service that decides only
21//! *how hard an already frozen route thinks*. An exact Fleet points at one by
22//! name — `reasoning_router = "luna-low"` — and the same saved profile may be
23//! referenced by any number of Fleets. See [`crate::reasoning_router`].
24//!
25//! The prototype form (`[[members]]` with `kind = "router"`) still parses, is
26//! labelled **legacy inline**, and normalizes into the same captured service.
27//! It is retained for compatibility only; it is not a second runtime concept.
28
29use std::collections::BTreeMap;
30
31use serde::{Deserialize, Serialize};
32use thiserror::Error;
33
34use crate::reasoning_router::{FleetRouterRef, ReasoningRouterError, RouterCallReasoning};
35
36/// Wire value of the `schema` key that selects the exact form.
37pub const EXACT_FLEET_SCHEMA_KIND: &str = "exact";
38/// Wire value recorded for files in the pre-exact role→profile form.
39pub const LEGACY_FLEET_SCHEMA_KIND: &str = "legacy";
40/// Current revision of the exact schema.
41pub const EXACT_FLEET_SCHEMA_REVISION: u32 = 1;
42
43/// Member kind that selects the Fleet Router.
44pub const ROUTER_MEMBER_KIND: &str = "router";
45/// Member kind for an ordinary dispatchable worker.
46pub const WORKER_MEMBER_KIND: &str = "worker";
47
48/// The Router's public id. A Router is addressed by this literal everywhere a
49/// receipt, decision, or error names it, whatever the file called the member.
50/// No worker may claim it — see [`ExactFleetError::ReservedRouterIdentity`].
51pub const ROUTER_PUBLIC_ID: &str = "router";
52/// The Router's public role. Identical to [`ROUTER_PUBLIC_ID`]: a Router has
53/// exactly one identity and it is not a dispatchable role.
54pub const ROUTER_PUBLIC_ROLE: &str = "router";
55
56/// Public role names that were renamed, and what they are now called.
57///
58/// A saved Fleet, a gate, a handoff record, and a task option are four
59/// different places the *same* role name is written down, and they are written
60/// at different times: a Fleet file saved a year ago says `oracle`, a workflow
61/// script written today says `consultant`. Canonicalizing in only one of those
62/// places is what turns a rename into a lookup failure, so every boundary that
63/// compares a role goes through [`canonical_role_key`].
64///
65/// New schemas and receipts always record the canonical name — the alias is an
66/// input spelling, never an output one.
67pub const ROLE_ALIASES: &[(&str, &str)] = &[("oracle", "consultant"), ("advisor", "consultant")];
68
69/// The canonical, case-folded key a role compares under.
70///
71/// Trims, lowercases, and resolves a renamed public role to its current name.
72/// This is the *only* way roles are compared anywhere in the exact-Fleet path:
73/// parse writes the canonical name into the roster, `validate` detects
74/// duplicates under it, and every lookup resolves the caller's spelling through
75/// it. A member saved as `oracle` and a task naming `consultant` therefore meet,
76/// and so do the reverse.
77#[must_use]
78pub fn canonical_role_key(value: &str) -> String {
79    let key = value.trim().to_ascii_lowercase();
80    ROLE_ALIASES
81        .iter()
82        .find(|(alias, _)| *alias == key)
83        .map_or(key, |(_, canonical)| (*canonical).to_string())
84}
85
86/// The case-folded key a member **id** compares under.
87///
88/// Ids are identities, not names, so they get no alias table — but they do get
89/// case folding, because `ExactFleet` is `Deserialize` and a roster can reach a
90/// lookup without having passed the parser that lowercased it.
91#[must_use]
92pub fn canonical_member_key(value: &str) -> String {
93    value.trim().to_ascii_lowercase()
94}
95
96/// Selector tokens that are legal elsewhere in CodeWhale but are exactly what
97/// the exact schema exists to forbid. Rejecting them by value (in addition to
98/// `deny_unknown_fields` rejecting `model_strength`/`loadout`/`model_class` as
99/// keys) is what keeps "exact" honest.
100const FORBIDDEN_ROUTE_SELECTORS: &[&str] = &[
101    "auto", "inherit", "parent", "same", "faster", "fast", "cheap", "strong", "balanced", "default",
102];
103
104/// A concrete reasoning tier. Unlike [`RequestedReasoning`] this has no `auto`
105/// — it is what a request actually runs at.
106#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
107#[serde(rename_all = "snake_case")]
108pub enum ReasoningTier {
109    Off,
110    Low,
111    Medium,
112    High,
113    Max,
114}
115
116impl ReasoningTier {
117    #[must_use]
118    pub const fn as_str(self) -> &'static str {
119        match self {
120            Self::Off => "off",
121            Self::Low => "low",
122            Self::Medium => "medium",
123            Self::High => "high",
124            Self::Max => "max",
125        }
126    }
127
128    /// Parse a concrete tier. `auto` is intentionally NOT accepted here.
129    pub fn parse(value: &str) -> Option<Self> {
130        match value.trim().to_ascii_lowercase().as_str() {
131            "off" | "none" | "disabled" => Some(Self::Off),
132            "low" | "minimal" => Some(Self::Low),
133            "medium" | "mid" => Some(Self::Medium),
134            "high" => Some(Self::High),
135            "max" | "maximum" | "xhigh" => Some(Self::Max),
136            _ => None,
137        }
138    }
139}
140
141/// The reasoning policy a member *requests*. `Auto` is an explicit per-member
142/// opt-in, never a global mode.
143#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
144#[serde(rename_all = "snake_case")]
145pub enum RequestedReasoning {
146    Off,
147    Low,
148    Medium,
149    High,
150    Max,
151    Auto,
152}
153
154impl RequestedReasoning {
155    #[must_use]
156    pub const fn as_str(self) -> &'static str {
157        match self {
158            Self::Off => "off",
159            Self::Low => "low",
160            Self::Medium => "medium",
161            Self::High => "high",
162            Self::Max => "max",
163            Self::Auto => "auto",
164        }
165    }
166
167    #[must_use]
168    pub const fn is_auto(self) -> bool {
169        matches!(self, Self::Auto)
170    }
171
172    /// The concrete tier this request names, or `None` for `auto`.
173    #[must_use]
174    pub const fn tier(self) -> Option<ReasoningTier> {
175        match self {
176            Self::Off => Some(ReasoningTier::Off),
177            Self::Low => Some(ReasoningTier::Low),
178            Self::Medium => Some(ReasoningTier::Medium),
179            Self::High => Some(ReasoningTier::High),
180            Self::Max => Some(ReasoningTier::Max),
181            Self::Auto => None,
182        }
183    }
184
185    pub fn parse(value: &str) -> Option<Self> {
186        if matches!(value.trim().to_ascii_lowercase().as_str(), "auto") {
187            return Some(Self::Auto);
188        }
189        ReasoningTier::parse(value).map(|tier| match tier {
190            ReasoningTier::Off => Self::Off,
191            ReasoningTier::Low => Self::Low,
192            ReasoningTier::Medium => Self::Medium,
193            ReasoningTier::High => Self::High,
194            ReasoningTier::Max => Self::Max,
195        })
196    }
197}
198
199/// Shell posture, ordered most → least restrictive so `min_with` is the safe
200/// side of a clamp.
201#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
202#[serde(rename_all = "snake_case")]
203pub enum ShellCeiling {
204    None,
205    ReadOnly,
206    Full,
207}
208
209impl ShellCeiling {
210    #[must_use]
211    pub const fn as_str(self) -> &'static str {
212        match self {
213            Self::None => "none",
214            Self::ReadOnly => "read_only",
215            Self::Full => "full",
216        }
217    }
218
219    #[must_use]
220    pub fn min_with(self, other: Self) -> Self {
221        if self <= other { self } else { other }
222    }
223}
224
225/// The most a member is allowed to do. This is a *ceiling*, never a grant:
226/// [`PermissionCeiling::clamp_to`] can only ever narrow against the active
227/// session posture, so a saved Fleet can never raise live authority.
228#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
229pub struct PermissionCeiling {
230    pub write: bool,
231    /// Whether the member may be handed a **model-visible network tool**
232    /// (fetch, browse, HTTP).
233    ///
234    /// This is deliberately *not* a statement about transport. Host-owned
235    /// provider inference — the ordinary API call CodeWhale makes on the
236    /// member's behalf — always happens over the network and is not governed
237    /// by this field. A member with `network_tool = false` still runs on a
238    /// remote model; it simply has no tool with which to reach the network
239    /// itself. Receipts disclose that distinction rather than implying an
240    /// air-gap.
241    #[serde(alias = "network")]
242    pub network_tool: bool,
243    pub shell: ShellCeiling,
244    /// Nested-delegation budget this member may consume.
245    pub delegation_depth: u32,
246    /// Whether the member may be handed tools at all.
247    pub tools: bool,
248}
249
250impl PermissionCeiling {
251    /// The Router's fixed posture: no tools (so no network tool), no shell, no
252    /// writes, no delegation. Not configurable — see [`RouterMember`].
253    ///
254    /// The Router itself is still *inferred* by its configured provider over
255    /// the network; that is host-owned transport, disclosed on the receipt.
256    pub const ROUTER: Self = Self {
257        write: false,
258        network_tool: false,
259        shell: ShellCeiling::None,
260        delegation_depth: 0,
261        tools: false,
262    };
263
264    /// Named presets accepted by `permissions = "<preset>"`.
265    pub fn preset(name: &str) -> Option<Self> {
266        let base = |write, network_tool, shell, delegation_depth| Self {
267            write,
268            network_tool,
269            shell,
270            delegation_depth,
271            tools: true,
272        };
273        match name.trim().to_ascii_lowercase().as_str() {
274            "none" => Some(Self::ROUTER),
275            "analyst" => Some(base(false, false, ShellCeiling::None, 0)),
276            "read_only" | "readonly" => Some(base(false, false, ShellCeiling::ReadOnly, 0)),
277            "tester" | "verifier" => Some(base(false, false, ShellCeiling::Full, 0)),
278            "read_write" | "readwrite" => Some(base(true, false, ShellCeiling::Full, 0)),
279            "full" => Some(base(true, true, ShellCeiling::Full, 1)),
280            _ => None,
281        }
282    }
283
284    /// Narrow this ceiling against the active session posture. Every field
285    /// takes the more restrictive side, so the result can never grant more
286    /// than either input.
287    #[must_use]
288    pub fn clamp_to(self, session: Self) -> Self {
289        Self {
290            write: self.write && session.write,
291            network_tool: self.network_tool && session.network_tool,
292            shell: self.shell.min_with(session.shell),
293            delegation_depth: self.delegation_depth.min(session.delegation_depth),
294            tools: self.tools && session.tools,
295        }
296    }
297}
298
299impl Default for PermissionCeiling {
300    fn default() -> Self {
301        Self::preset("read_only").expect("read_only is a known preset")
302    }
303}
304
305/// The exact provider/model a member is frozen to before any reasoning
306/// resolution runs. Nothing downstream may change these two strings.
307#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
308pub struct FrozenRoute {
309    pub provider: String,
310    pub model: String,
311}
312
313/// A dispatchable exact Fleet member.
314#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
315pub struct ExactMember {
316    /// Stable member id — the identity a run refers to.
317    pub id: String,
318    /// Role name; defaults to the member id.
319    pub role: String,
320    /// Exact configured provider id (a `[providers.<id>]` key or a built-in id).
321    pub provider: String,
322    /// Exact model id.
323    pub model: String,
324    /// Requested reasoning policy for this member.
325    pub reasoning: RequestedReasoning,
326    /// Permission ceiling/default for this member.
327    pub permissions: PermissionCeiling,
328}
329
330impl ExactMember {
331    /// The provider/model pair, frozen. Callers resolve reasoning *after* this.
332    #[must_use]
333    pub fn frozen_route(&self) -> FrozenRoute {
334        FrozenRoute {
335            provider: self.provider.clone(),
336            model: self.model.clone(),
337        }
338    }
339
340    #[must_use]
341    pub const fn is_dispatchable(&self) -> bool {
342        true
343    }
344}
345
346/// The **legacy inline** Router form: a `[[members]]` entry with
347/// `kind = "router"`.
348///
349/// Retained for compatibility with Fleet files written against the prototype.
350/// It is normalized into [`crate::reasoning_router::CapturedReasoningRouter`]
351/// at capture, so nothing downstream sees two kinds of Router. New Fleets
352/// should use `reasoning_router = "<name>"` and a saved profile, which is what
353/// lets several Fleets share one Router configuration.
354#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
355pub struct RouterMember {
356    pub id: String,
357    pub provider: String,
358    pub model: String,
359    /// What the Router's own call runs at — `off` or `low` only, exactly as for
360    /// a saved profile. Defaults to `off`; `auto` is rejected (a router cannot
361    /// ask a router what to think) and `medium`/`high`/`max` are rejected
362    /// rather than clamped.
363    #[serde(default, alias = "reasoning")]
364    pub call_reasoning: RouterCallReasoning,
365}
366
367impl RouterMember {
368    /// The Router's public id — always the literal `router`, regardless of the
369    /// member id the file used. Receipts and errors name this, so a Fleet
370    /// cannot disguise its Router behind a friendly label.
371    #[must_use]
372    pub const fn public_id(&self) -> &'static str {
373        ROUTER_PUBLIC_ID
374    }
375
376    /// The Router's public role — always the literal `router`.
377    #[must_use]
378    pub const fn public_role(&self) -> &'static str {
379        ROUTER_PUBLIC_ROLE
380    }
381
382    /// A Router is never a worker. This is a constant, not a policy lookup.
383    #[must_use]
384    pub const fn is_dispatchable(&self) -> bool {
385        false
386    }
387
388    /// The Router's tool surface is empty, always.
389    #[must_use]
390    pub const fn tool_surface(&self) -> &'static [&'static str] {
391        &[]
392    }
393
394    /// The Router's fixed permission ceiling.
395    #[must_use]
396    pub const fn permissions(&self) -> PermissionCeiling {
397        PermissionCeiling::ROUTER
398    }
399
400    #[must_use]
401    pub fn frozen_route(&self) -> FrozenRoute {
402        FrozenRoute {
403            provider: self.provider.clone(),
404            model: self.model.clone(),
405        }
406    }
407}
408
409/// A parsed exact Fleet.
410#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
411pub struct ExactFleet {
412    pub name: String,
413    #[serde(default, skip_serializing_if = "Option::is_none")]
414    pub description: Option<String>,
415    pub schema_revision: u32,
416    pub members: Vec<ExactMember>,
417    /// Name of the saved Reasoning Router profile this Fleet references. The
418    /// profile is a separate, reusable service — several Fleets may name the
419    /// same one. Accepts a qualified `origin/name`.
420    #[serde(default, skip_serializing_if = "Option::is_none")]
421    pub reasoning_router: Option<String>,
422    /// The legacy inline Router, if the file used the prototype form.
423    #[serde(default, skip_serializing_if = "Option::is_none")]
424    pub router: Option<RouterMember>,
425}
426
427impl ExactFleet {
428    /// Look up a dispatchable member by its **member id only**.
429    ///
430    /// Roles are semantic labels used by gates, handoffs, and records; ids are
431    /// what addresses a roster entry. Keeping the two lookups separate is what
432    /// lets a task carry a meaningful role (`builder`) while the runtime
433    /// resolves a distinct profile id (`implementer`) — see
434    /// [`Self::member_by_role`].
435    #[must_use]
436    pub fn member(&self, id: &str) -> Option<&ExactMember> {
437        let key = canonical_member_key(id);
438        self.members
439            .iter()
440            .find(|member| canonical_member_key(&member.id) == key)
441    }
442
443    /// Look up a dispatchable member by its semantic role.
444    ///
445    /// Both sides resolve through [`canonical_role_key`], so a member saved
446    /// under a renamed role (`oracle`) is found by a task, gate, or handoff that
447    /// names either spelling.
448    #[must_use]
449    pub fn member_by_role(&self, role: &str) -> Option<&ExactMember> {
450        let key = canonical_role_key(role);
451        self.members
452            .iter()
453            .find(|member| canonical_role_key(&member.role) == key)
454    }
455
456    /// Look up a member by id first, then by role. Roster invariants forbid an
457    /// id/role collision, so this can never be order-dependent.
458    #[must_use]
459    pub fn member_by_id_or_role(&self, id_or_role: &str) -> Option<&ExactMember> {
460        self.member(id_or_role)
461            .or_else(|| self.member_by_role(id_or_role))
462    }
463
464    /// How this Fleet points at its Router, if it does at all.
465    #[must_use]
466    pub fn router_ref(&self) -> Option<FleetRouterRef> {
467        if let Some(name) = &self.reasoning_router {
468            return Some(FleetRouterRef::Profile { name: name.clone() });
469        }
470        self.router
471            .as_ref()
472            .map(|member| FleetRouterRef::LegacyInline(Box::new(member.clone())))
473    }
474
475    /// The legacy inline Router member, if the file used the prototype form.
476    #[must_use]
477    pub fn legacy_inline_router(&self) -> Option<&RouterMember> {
478        self.router.as_ref()
479    }
480
481    /// Whether any member explicitly requested `reasoning = "auto"`.
482    #[must_use]
483    pub fn has_auto_member(&self) -> bool {
484        self.members.iter().any(|member| member.reasoning.is_auto())
485    }
486
487    /// Re-check every roster invariant that [`Self::parse`] enforces.
488    ///
489    /// `ExactFleet` is `pub` and `Deserialize`, so a value can reach a snapshot
490    /// without ever passing through the TOML parser. Capture calls this so a
491    /// hand-built or round-tripped roster cannot smuggle in a duplicate role, an
492    /// id/role collision, or a worker claiming the Router's identity.
493    pub fn validate(&self) -> Result<(), ExactFleetError> {
494        if self.members.is_empty() {
495            return Err(ExactFleetError::NoMembers {
496                fleet: self.name.clone(),
497            });
498        }
499
500        let mut ids: BTreeMap<String, ()> = BTreeMap::new();
501        let mut roles: BTreeMap<String, ()> = BTreeMap::new();
502
503        for member in &self.members {
504            let id = canonical_member_key(&member.id);
505            // Duplicate detection runs on the canonical role key, so a roster
506            // carrying both `oracle` and `consultant` is caught as the collision
507            // it is rather than resolving by list order at lookup time.
508            let role = canonical_role_key(&member.role);
509            if id.is_empty() {
510                return Err(ExactFleetError::InvalidToken {
511                    field: "member id".to_string(),
512                    value: member.id.clone(),
513                });
514            }
515            if role.is_empty() {
516                return Err(ExactFleetError::InvalidToken {
517                    field: "member role".to_string(),
518                    value: member.role.clone(),
519                });
520            }
521            // A worker may never be called `router`, by id or by role: the
522            // Router's public identity is that literal, and a worker wearing it
523            // would make a receipt ambiguous about who decided the reasoning.
524            for (field, value) in [("id", &id), ("role", &role)] {
525                if value.as_str() == ROUTER_PUBLIC_ID {
526                    return Err(ExactFleetError::ReservedRouterIdentity {
527                        id: member.id.clone(),
528                        field: field.to_string(),
529                    });
530                }
531            }
532            if ids.insert(id.clone(), ()).is_some() {
533                return Err(ExactFleetError::DuplicateMember { id });
534            }
535            if roles.insert(role.clone(), ()).is_some() {
536                return Err(ExactFleetError::DuplicateRole { role });
537            }
538        }
539
540        // An id belonging to one member and a role belonging to a *different*
541        // member would make `member()` lookup order-dependent, so it is a
542        // collision even though neither set has an internal duplicate.
543        for member in &self.members {
544            let id = canonical_member_key(&member.id);
545            if let Some(other) = self
546                .members
547                .iter()
548                .find(|other| other.id != member.id && canonical_role_key(&other.role) == id)
549            {
550                return Err(ExactFleetError::IdRoleCollision {
551                    id: member.id.clone(),
552                    other: other.id.clone(),
553                });
554            }
555        }
556
557        Ok(())
558    }
559
560    /// Parse an exact Fleet from TOML text.
561    pub fn parse(text: &str) -> Result<Self, ExactFleetError> {
562        let doc: ExactFleetToml =
563            toml::from_str(text).map_err(|error| ExactFleetError::Parse(error.to_string()))?;
564        Self::from_toml(doc)
565    }
566
567    fn from_toml(doc: ExactFleetToml) -> Result<Self, ExactFleetError> {
568        if !doc
569            .schema
570            .trim()
571            .eq_ignore_ascii_case(EXACT_FLEET_SCHEMA_KIND)
572        {
573            return Err(ExactFleetError::UnknownSchema {
574                schema: doc.schema.trim().to_string(),
575            });
576        }
577        if doc.schema_revision != EXACT_FLEET_SCHEMA_REVISION {
578            return Err(ExactFleetError::UnsupportedRevision {
579                revision: doc.schema_revision,
580                supported: EXACT_FLEET_SCHEMA_REVISION,
581            });
582        }
583        let name = require_token(&doc.name, "name")?;
584
585        let mut members = Vec::new();
586        let mut router: Option<RouterMember> = None;
587        let mut seen: BTreeMap<String, ()> = BTreeMap::new();
588
589        for raw in doc.members {
590            let id = require_token(&raw.id, "member id")?;
591            if seen.insert(id.clone(), ()).is_some() {
592                return Err(ExactFleetError::DuplicateMember { id });
593            }
594            let provider = require_exact_route_token(&raw.provider, &id, "provider")?;
595            let model = require_exact_route_token(&raw.model, &id, "model")?;
596            let kind = raw
597                .kind
598                .as_deref()
599                .map(str::trim)
600                .filter(|kind| !kind.is_empty())
601                .unwrap_or(WORKER_MEMBER_KIND)
602                .to_ascii_lowercase();
603
604            match kind.as_str() {
605                ROUTER_MEMBER_KIND => {
606                    if router.is_some() {
607                        return Err(ExactFleetError::MultipleRouters);
608                    }
609                    if raw.permissions.is_some() {
610                        return Err(ExactFleetError::RouterPermissionsDeclared { id });
611                    }
612                    if raw.role.is_some() {
613                        return Err(ExactFleetError::RouterRoleDeclared { id });
614                    }
615                    let call_reasoning = match raw.reasoning.as_deref() {
616                        None => RouterCallReasoning::default(),
617                        Some(value) if value.trim().eq_ignore_ascii_case("auto") => {
618                            return Err(ExactFleetError::RouterAutoReasoning { id });
619                        }
620                        // The cheap ceiling is a property of the *service*, not
621                        // of how it was written down, so the legacy inline form
622                        // gets the identical rejection a saved profile gets.
623                        Some(value) => RouterCallReasoning::parse(value, &id)
624                            .map_err(|source| ExactFleetError::Router { source })?,
625                    };
626                    router = Some(RouterMember {
627                        id,
628                        provider,
629                        model,
630                        call_reasoning,
631                    });
632                }
633                WORKER_MEMBER_KIND => {
634                    let role = match raw.role.as_deref() {
635                        Some(role) => require_member_role(role)?,
636                        None => id.clone(),
637                    };
638                    let reasoning = match raw.reasoning.as_deref() {
639                        None => RequestedReasoning::Off,
640                        Some(value) => RequestedReasoning::parse(value).ok_or_else(|| {
641                            ExactFleetError::InvalidReasoning {
642                                id: id.clone(),
643                                value: value.trim().to_string(),
644                            }
645                        })?,
646                    };
647                    let permissions = match raw.permissions.as_deref() {
648                        None => PermissionCeiling::default(),
649                        Some(preset) => PermissionCeiling::preset(preset).ok_or_else(|| {
650                            ExactFleetError::UnknownPermissionPreset {
651                                id: id.clone(),
652                                preset: preset.trim().to_string(),
653                            }
654                        })?,
655                    };
656                    members.push(ExactMember {
657                        id,
658                        role,
659                        provider,
660                        model,
661                        reasoning,
662                        permissions,
663                    });
664                }
665                other => {
666                    return Err(ExactFleetError::UnknownMemberKind {
667                        id,
668                        kind: other.to_string(),
669                    });
670                }
671            }
672        }
673
674        if members.is_empty() {
675            return Err(ExactFleetError::NoMembers { fleet: name });
676        }
677
678        // One Router per Fleet, named exactly one way. Declaring both forms is
679        // an error rather than a precedence rule: a silent winner here would
680        // decide which provider sees every routing summary.
681        let reasoning_router = match doc.reasoning_router.as_deref().map(str::trim) {
682            Some(value) if !value.is_empty() => {
683                if router.is_some() {
684                    return Err(ExactFleetError::ConflictingRouterDeclarations { fleet: name });
685                }
686                Some(value.to_string())
687            }
688            _ => None,
689        };
690
691        let fleet = Self {
692            name,
693            description: doc.description,
694            schema_revision: doc.schema_revision,
695            members,
696            reasoning_router,
697            router,
698        };
699        // One authority for the roster invariants, shared with capture-time
700        // revalidation so the two can never drift.
701        fleet.validate()?;
702        Ok(fleet)
703    }
704}
705
706/// Peek at a fleet document's `schema` key without committing to a form.
707///
708/// Returns `None` for legacy files, which declare no `schema` key at all.
709/// A malformed document returns `None` too; the legacy parser then owns the
710/// error, keeping old files on the old diagnostics.
711#[must_use]
712pub fn declared_schema_kind(text: &str) -> Option<String> {
713    #[derive(Deserialize)]
714    struct SchemaProbe {
715        #[serde(default)]
716        schema: Option<String>,
717    }
718
719    let probe: SchemaProbe = toml::from_str(text).ok()?;
720    probe
721        .schema
722        .map(|schema| schema.trim().to_ascii_lowercase())
723        .filter(|schema| !schema.is_empty())
724}
725
726#[derive(Debug, Deserialize)]
727#[serde(deny_unknown_fields)]
728struct ExactFleetToml {
729    name: String,
730    #[serde(default)]
731    description: Option<String>,
732    schema: String,
733    #[serde(default = "default_schema_revision")]
734    schema_revision: u32,
735    /// Reference to a saved Reasoning Router profile. Optional: a Fleet whose
736    /// members all pin explicit tiers needs no Router at all.
737    #[serde(default)]
738    reasoning_router: Option<String>,
739    #[serde(default)]
740    members: Vec<ExactMemberToml>,
741}
742
743/// `deny_unknown_fields` is load-bearing here: it is what rejects
744/// `model_strength`, `loadout`, `model_class`, and any other late-binding
745/// selector someone tries to smuggle into an exact member.
746#[derive(Debug, Deserialize)]
747#[serde(deny_unknown_fields)]
748struct ExactMemberToml {
749    id: String,
750    #[serde(default)]
751    kind: Option<String>,
752    #[serde(default)]
753    role: Option<String>,
754    provider: String,
755    model: String,
756    #[serde(default)]
757    reasoning: Option<String>,
758    #[serde(default)]
759    permissions: Option<String>,
760}
761
762const fn default_schema_revision() -> u32 {
763    EXACT_FLEET_SCHEMA_REVISION
764}
765
766fn require_token(value: &str, field: &str) -> Result<String, ExactFleetError> {
767    crate::role_resolve::normalize_token(value).ok_or_else(|| ExactFleetError::InvalidToken {
768        field: field.to_string(),
769        value: value.trim().to_string(),
770    })
771}
772
773/// Canonicalize the renamed public roles at the saved-Fleet boundary, so a new
774/// schema and every receipt it produces record only the current name.
775///
776/// Exact Fleets otherwise permit domain-specific semantic roles (for example
777/// `auditor`), so this is intentionally not a closed-role parser. The alias
778/// table is shared with [`canonical_role_key`], which is what makes an *old*
779/// file — parsed before this canonicalization existed, or reaching the roster
780/// through `Deserialize` — still resolvable by either spelling at lookup time.
781fn require_member_role(value: &str) -> Result<String, ExactFleetError> {
782    let role = require_token(value, "member role")?;
783    Ok(canonical_role_key(&role))
784}
785
786/// Provider/model ids keep their configured casing (a model id is
787/// case-sensitive on the wire) but must be non-empty, whitespace-free, and must
788/// not be a late-binding selector.
789fn require_exact_route_token(
790    value: &str,
791    member: &str,
792    field: &str,
793) -> Result<String, ExactFleetError> {
794    let trimmed = value.trim();
795    if trimmed.is_empty()
796        || trimmed
797            .chars()
798            .any(|ch| ch.is_whitespace() || matches!(ch, '"' | '\'' | '`' | '='))
799    {
800        return Err(ExactFleetError::InvalidToken {
801            field: format!("{member}.{field}"),
802            value: trimmed.to_string(),
803        });
804    }
805    if FORBIDDEN_ROUTE_SELECTORS
806        .iter()
807        .any(|selector| trimmed.eq_ignore_ascii_case(selector))
808    {
809        return Err(ExactFleetError::LateBindingSelector {
810            id: member.to_string(),
811            field: field.to_string(),
812            value: trimmed.to_string(),
813        });
814    }
815    Ok(trimmed.to_string())
816}
817
818#[derive(Debug, Clone, PartialEq, Eq, Error)]
819pub enum ExactFleetError {
820    #[error("failed to parse exact fleet file: {0}")]
821    Parse(String),
822    #[error("unknown fleet schema `{schema}`; expected `exact`")]
823    UnknownSchema { schema: String },
824    #[error(
825        "exact fleet schema revision {revision} is not supported (this build reads {supported})"
826    )]
827    UnsupportedRevision { revision: u32, supported: u32 },
828    #[error("{field} must be a non-empty token without whitespace, quotes, or `=` (got `{value}`)")]
829    InvalidToken { field: String, value: String },
830    #[error("duplicate fleet member id `{id}`")]
831    DuplicateMember { id: String },
832    #[error(
833        "duplicate fleet member role `{role}`; two members cannot answer to the same role or a \
834         task naming it would resolve to whichever one happened to be listed first"
835    )]
836    DuplicateRole { role: String },
837    #[error(
838        "member `{id}` collides with member `{other}`: one member's id is another member's role, \
839         so a task naming it would resolve by list order rather than by identity"
840    )]
841    IdRoleCollision { id: String, other: String },
842    #[error(
843        "member `{id}` claims the reserved {field} `router`; that identity belongs to the fleet \
844         router, which is declared with `kind = \"router\"` and is never dispatchable"
845    )]
846    ReservedRouterIdentity { id: String, field: String },
847    #[error(
848        "fleet `{fleet}` snapshot content hash does not describe its own contents (recorded \
849         `{recorded}`, recomputed `{recomputed}`). The snapshot was edited or migrated after \
850         capture, so its hash cannot be used as evidence that a run matched a saved definition. \
851         Re-capture the fleet."
852    )]
853    ContentHashMismatch {
854        fleet: String,
855        recorded: String,
856        recomputed: String,
857    },
858    #[error("fleet `{fleet}` declares no dispatchable members")]
859    NoMembers { fleet: String },
860    #[error("member `{id}` has unknown kind `{kind}`; expected `worker` or `router`")]
861    UnknownMemberKind { id: String, kind: String },
862    #[error("a fleet may declare at most one router member")]
863    MultipleRouters,
864    #[error(
865        "member `{id}`.{field} is `{value}`, but exact fleets forbid late-binding route selectors \
866         (inherit, fast siblings, model strength, or model=auto). Name the exact provider/model."
867    )]
868    LateBindingSelector {
869        id: String,
870        field: String,
871        value: String,
872    },
873    #[error(
874        "member `{id}` has invalid reasoning `{value}`; expected off, low, medium, high, max, or auto"
875    )]
876    InvalidReasoning { id: String, value: String },
877    #[error("member `{id}` has unknown permission preset `{preset}`")]
878    UnknownPermissionPreset { id: String, preset: String },
879    #[error("router member `{id}` may not declare permissions; a router gets none by construction")]
880    RouterPermissionsDeclared { id: String },
881    #[error(
882        "router member `{id}` may not declare a role; a router is never dispatched as a worker"
883    )]
884    RouterRoleDeclared { id: String },
885    #[error(
886        "router member `{id}` may not request reasoning `auto`; a router's own thinking is a fixed tier (default off)"
887    )]
888    RouterAutoReasoning { id: String },
889    #[error(
890        "fleet `{fleet}` declares both `reasoning_router = \"...\"` and an inline \
891         `kind = \"router\"` member. A fleet references exactly one reasoning router service; \
892         pick the saved profile (preferred, and shareable across fleets) or the legacy inline \
893         form, not both."
894    )]
895    ConflictingRouterDeclarations { fleet: String },
896    #[error(transparent)]
897    Router {
898        #[from]
899        source: ReasoningRouterError,
900    },
901}
902
903#[cfg(test)]
904mod role_alias_tests {
905    use super::*;
906
907    /// A Fleet saved before the rename. The file spells the advisory role
908    /// `oracle`; everything downstream must call it `consultant`.
909    const RENAMED_ROLE_FLEET: &str = r#"
910name = "counsel"
911schema = "exact"
912
913[[members]]
914id = "advisor-one"
915role = "oracle"
916provider = "zai"
917model = "glm-5"
918permissions = "analyst"
919"#;
920
921    /// Parse canonicalizes on the way in, so the roster — and therefore every
922    /// receipt built from it — records only the current name.
923    #[test]
924    fn parsing_a_renamed_role_stores_the_canonical_name() {
925        let fleet = ExactFleet::parse(RENAMED_ROLE_FLEET).expect("parse");
926        assert_eq!(fleet.members[0].role, "consultant");
927    }
928
929    /// The compatibility half: a saved task, gate, or handoff that still spells
930    /// the role the old way resolves to the same member. This is the lookup that
931    /// used to fail, because parse canonicalized and the lookup did not.
932    #[test]
933    fn every_alias_spelling_resolves_to_the_same_member() {
934        let fleet = ExactFleet::parse(RENAMED_ROLE_FLEET).expect("parse");
935
936        for spelling in ["consultant", "oracle", "advisor", "Oracle", " ADVISOR "] {
937            let member = fleet
938                .member_by_role(spelling)
939                .unwrap_or_else(|| panic!("`{spelling}` must resolve"));
940            assert_eq!(member.id, "advisor-one");
941            assert_eq!(member.role, "consultant", "receipts stay canonical");
942        }
943
944        // `member_by_id_or_role` is what the runtime actually calls.
945        assert_eq!(
946            fleet
947                .member_by_id_or_role("oracle")
948                .expect("alias resolves through the combined lookup")
949                .id,
950            "advisor-one"
951        );
952    }
953
954    /// A Fleet written against the *new* name keeps working, and is equally
955    /// reachable by the old one — the rename is bidirectional at the lookup.
956    #[test]
957    fn a_canonical_role_is_reachable_by_its_alias() {
958        let text = RENAMED_ROLE_FLEET.replace(r#"role = "oracle""#, r#"role = "consultant""#);
959        let fleet = ExactFleet::parse(&text).expect("parse");
960
961        assert_eq!(fleet.members[0].role, "consultant");
962        assert!(fleet.member_by_role("oracle").is_some());
963        assert!(fleet.member_by_role("advisor").is_some());
964    }
965
966    /// A roster that reaches `validate` through `Deserialize` — never having
967    /// passed the parser — is still judged on canonical keys. `oracle` and
968    /// `consultant` are one role, so declaring both is the collision it looks
969    /// like, not a pair that resolves by list order.
970    #[test]
971    fn an_alias_and_its_canonical_name_collide_on_reload() {
972        let member = |id: &str, role: &str| ExactMember {
973            id: id.to_string(),
974            role: role.to_string(),
975            provider: "zai".to_string(),
976            model: "glm-5".to_string(),
977            reasoning: RequestedReasoning::Off,
978            permissions: PermissionCeiling::default(),
979        };
980        let fleet = ExactFleet {
981            name: "counsel".to_string(),
982            description: None,
983            schema_revision: EXACT_FLEET_SCHEMA_REVISION,
984            members: vec![member("a", "oracle"), member("b", "consultant")],
985            reasoning_router: None,
986            router: None,
987        };
988
989        assert!(matches!(
990            fleet.validate(),
991            Err(ExactFleetError::DuplicateRole { role }) if role == "consultant"
992        ));
993    }
994
995    /// Ids are identities, not names: no alias table, but case folding, because
996    /// a deserialized roster never passed the parser that lowercased it.
997    #[test]
998    fn member_ids_resolve_case_insensitively_without_aliasing() {
999        let fleet = ExactFleet {
1000            name: "counsel".to_string(),
1001            description: None,
1002            schema_revision: EXACT_FLEET_SCHEMA_REVISION,
1003            members: vec![ExactMember {
1004                id: "Builder".to_string(),
1005                role: "auditor".to_string(),
1006                provider: "zai".to_string(),
1007                model: "glm-5".to_string(),
1008                reasoning: RequestedReasoning::Off,
1009                permissions: PermissionCeiling::default(),
1010            }],
1011            reasoning_router: None,
1012            router: None,
1013        };
1014
1015        assert!(fleet.member("builder").is_some());
1016        assert!(fleet.member("Builder").is_some());
1017        // `oracle` is a role alias, never an id alias.
1018        assert!(fleet.member("oracle").is_none());
1019    }
1020
1021    #[test]
1022    fn canonical_role_key_maps_only_the_declared_aliases() {
1023        assert_eq!(canonical_role_key(" Oracle "), "consultant");
1024        assert_eq!(canonical_role_key("ADVISOR"), "consultant");
1025        assert_eq!(canonical_role_key("consultant"), "consultant");
1026        // Unrelated semantic roles pass through untouched, case-folded only.
1027        assert_eq!(canonical_role_key("Auditor"), "auditor");
1028        assert_eq!(canonical_role_key("router"), "router");
1029    }
1030}
1031
1032#[cfg(test)]
1033mod tests {
1034    use super::*;
1035
1036    const GLM_FLEET: &str = r#"
1037name = "glm-pair"
1038description = "GLM worker with a GLM Turbo router"
1039schema = "exact"
1040schema_revision = 1
1041
1042[[members]]
1043id = "implementer"
1044role = "builder"
1045provider = "zai"
1046model = "glm-5"
1047reasoning = "auto"
1048permissions = "read_write"
1049
1050[[members]]
1051id = "router"
1052kind = "router"
1053provider = "zai"
1054model = "glm-5-turbo"
1055"#;
1056
1057    /// A Fleet that references a saved, reusable Router service by name — the
1058    /// form new Fleets use.
1059    const NAMED_ROUTER_FLEET: &str = r#"
1060name = "glm-pair"
1061schema = "exact"
1062reasoning_router = "luna-low"
1063
1064[[members]]
1065id = "implementer"
1066role = "builder"
1067provider = "zai"
1068model = "glm-5"
1069reasoning = "auto"
1070"#;
1071
1072    #[test]
1073    fn exact_fleet_parses_members_and_a_legacy_inline_router() {
1074        let fleet = ExactFleet::parse(GLM_FLEET).expect("parse");
1075        assert_eq!(fleet.name, "glm-pair");
1076        assert_eq!(fleet.schema_revision, EXACT_FLEET_SCHEMA_REVISION);
1077        assert_eq!(fleet.members.len(), 1);
1078
1079        // Id and role are separate lookups: a role is a semantic label, an id
1080        // addresses a roster entry.
1081        let member = fleet.member_by_role("builder").expect("role lookup");
1082        assert_eq!(member.id, "implementer");
1083        assert_eq!(
1084            fleet.member("implementer").expect("id lookup").id,
1085            "implementer"
1086        );
1087        assert!(
1088            fleet.member("builder").is_none(),
1089            "an id lookup must not answer to a role"
1090        );
1091        assert_eq!(member.provider, "zai");
1092        assert_eq!(member.model, "glm-5");
1093        assert_eq!(member.reasoning, RequestedReasoning::Auto);
1094        assert!(member.permissions.write);
1095        assert!(!member.permissions.network_tool);
1096
1097        let router = fleet.legacy_inline_router().expect("inline router");
1098        assert_eq!(router.provider, "zai");
1099        assert_eq!(router.model, "glm-5-turbo");
1100        // The call tier defaults to off when the file says nothing.
1101        assert_eq!(router.call_reasoning, RouterCallReasoning::Off);
1102        assert!(matches!(
1103            fleet.router_ref(),
1104            Some(FleetRouterRef::LegacyInline(_))
1105        ));
1106    }
1107
1108    #[test]
1109    fn legacy_advisory_role_names_canonicalize_to_consultant() {
1110        for legacy in ["oracle", "advisor"] {
1111            let text = GLM_FLEET.replace("role = \"builder\"", &format!("role = \"{legacy}\""));
1112            let fleet = ExactFleet::parse(&text).expect("legacy role parses");
1113            assert_eq!(fleet.members[0].role, "consultant");
1114            assert!(fleet.member_by_role("consultant").is_some());
1115            // The rename resolves in both directions: the roster stores the
1116            // canonical name, and a caller still spelling the legacy one lands
1117            // on the same member rather than on nothing.
1118            assert_eq!(
1119                fleet
1120                    .member_by_role(legacy)
1121                    .map(|member| member.id.as_str()),
1122                fleet
1123                    .member_by_role("consultant")
1124                    .map(|member| member.id.as_str()),
1125            );
1126        }
1127    }
1128
1129    /// The preferred form: the Router is a *reference* to a saved service, so
1130    /// several Fleets can point at one configuration.
1131    #[test]
1132    fn a_fleet_references_a_named_reasoning_router_service() {
1133        let fleet = ExactFleet::parse(NAMED_ROUTER_FLEET).expect("parse");
1134
1135        assert_eq!(fleet.reasoning_router.as_deref(), Some("luna-low"));
1136        assert!(fleet.legacy_inline_router().is_none());
1137        assert!(matches!(
1138            fleet.router_ref(),
1139            Some(FleetRouterRef::Profile { ref name }) if name == "luna-low"
1140        ));
1141        assert!(fleet.has_auto_member());
1142
1143        // A qualified origin is accepted verbatim; resolution happens in the
1144        // host that owns the search roots.
1145        let qualified = NAMED_ROUTER_FLEET.replace("\"luna-low\"", "\"codewhale_home/luna-low\"");
1146        assert!(matches!(
1147            ExactFleet::parse(&qualified).expect("parse").router_ref(),
1148            Some(FleetRouterRef::Profile { ref name }) if name == "codewhale_home/luna-low"
1149        ));
1150    }
1151
1152    /// Both forms at once would make a silent winner decide which provider sees
1153    /// every routing summary, so it is an error instead.
1154    #[test]
1155    fn declaring_both_router_forms_is_rejected() {
1156        let both = GLM_FLEET.replace(
1157            "schema_revision = 1",
1158            "schema_revision = 1\nreasoning_router = \"luna-low\"",
1159        );
1160        let err = ExactFleet::parse(&both).expect_err("two router declarations");
1161        assert!(
1162            matches!(err, ExactFleetError::ConflictingRouterDeclarations { .. }),
1163            "{err:?}"
1164        );
1165        assert!(err.to_string().contains("exactly one"), "{err}");
1166    }
1167
1168    /// The cheap call ceiling belongs to the service, not to how it was written
1169    /// down: the inline form gets the identical rejection a saved profile does.
1170    #[test]
1171    fn a_legacy_inline_router_may_not_request_an_expensive_call_tier() {
1172        for value in ["medium", "high", "max"] {
1173            let text = format!("{GLM_FLEET}reasoning = \"{value}\"\n");
1174            let err = ExactFleet::parse(&text).expect_err("expensive router tier");
1175            assert!(
1176                matches!(
1177                    err,
1178                    ExactFleetError::Router {
1179                        source: ReasoningRouterError::CallReasoningTooExpensive { .. }
1180                    }
1181                ),
1182                "value={value} err={err:?}"
1183            );
1184        }
1185
1186        let low = format!("{GLM_FLEET}reasoning = \"low\"\n");
1187        assert_eq!(
1188            ExactFleet::parse(&low)
1189                .expect("low is allowed")
1190                .legacy_inline_router()
1191                .expect("router")
1192                .call_reasoning,
1193            RouterCallReasoning::Low
1194        );
1195    }
1196
1197    #[test]
1198    fn a_router_is_not_dispatchable_and_holds_no_authority() {
1199        let fleet = ExactFleet::parse(GLM_FLEET).expect("parse");
1200        let router = fleet.legacy_inline_router().expect("router");
1201
1202        assert!(!router.is_dispatchable());
1203        assert!(router.tool_surface().is_empty());
1204        let permissions = router.permissions();
1205        assert!(!permissions.tools);
1206        assert!(!permissions.write);
1207        assert!(!permissions.network_tool);
1208        assert_eq!(permissions.shell, ShellCeiling::None);
1209        assert_eq!(permissions.delegation_depth, 0);
1210
1211        // The router is not reachable through worker lookup either.
1212        assert!(fleet.member_by_id_or_role("router").is_none());
1213        assert!(fleet.members.iter().all(ExactMember::is_dispatchable));
1214    }
1215
1216    #[test]
1217    fn late_binding_selectors_are_rejected() {
1218        for (field, value) in [
1219            ("model", "auto"),
1220            ("model", "inherit"),
1221            ("model", "faster"),
1222            ("model", "strong"),
1223            ("provider", "inherit"),
1224        ] {
1225            let text = format!(
1226                r#"
1227name = "f"
1228schema = "exact"
1229
1230[[members]]
1231id = "w"
1232provider = "{provider}"
1233model = "{model}"
1234"#,
1235                provider = if field == "provider" { value } else { "zai" },
1236                model = if field == "model" { value } else { "glm-5" },
1237            );
1238            let err = ExactFleet::parse(&text).expect_err("selector must be rejected");
1239            assert!(
1240                matches!(err, ExactFleetError::LateBindingSelector { .. }),
1241                "field={field} value={value} err={err:?}"
1242            );
1243        }
1244    }
1245
1246    #[test]
1247    fn model_strength_and_loadout_keys_are_rejected() {
1248        for key in ["model_strength", "loadout", "model_class", "model_hint"] {
1249            let text = format!(
1250                r#"
1251name = "f"
1252schema = "exact"
1253
1254[[members]]
1255id = "w"
1256provider = "zai"
1257model = "glm-5"
1258{key} = "strong"
1259"#
1260            );
1261            let err = ExactFleet::parse(&text).expect_err("unknown key must be rejected");
1262            assert!(
1263                matches!(err, ExactFleetError::Parse(_)),
1264                "key={key} err={err:?}"
1265            );
1266        }
1267    }
1268
1269    #[test]
1270    fn router_rejects_auth_permissions_role_and_auto_reasoning() {
1271        let base = r#"
1272name = "f"
1273schema = "exact"
1274
1275[[members]]
1276id = "w"
1277provider = "zai"
1278model = "glm-5"
1279
1280[[members]]
1281id = "router"
1282kind = "router"
1283provider = "zai"
1284model = "glm-5-turbo"
1285"#;
1286        let permissions = format!("{base}permissions = \"full\"\n");
1287        assert!(matches!(
1288            ExactFleet::parse(&permissions).expect_err("permissions rejected"),
1289            ExactFleetError::RouterPermissionsDeclared { .. }
1290        ));
1291
1292        let role = format!("{base}role = \"builder\"\n");
1293        assert!(matches!(
1294            ExactFleet::parse(&role).expect_err("role rejected"),
1295            ExactFleetError::RouterRoleDeclared { .. }
1296        ));
1297
1298        let auto = format!("{base}reasoning = \"auto\"\n");
1299        assert!(matches!(
1300            ExactFleet::parse(&auto).expect_err("auto rejected"),
1301            ExactFleetError::RouterAutoReasoning { .. }
1302        ));
1303    }
1304
1305    #[test]
1306    fn duplicate_members_and_multiple_routers_fail() {
1307        let duplicate = r#"
1308name = "f"
1309schema = "exact"
1310
1311[[members]]
1312id = "w"
1313provider = "zai"
1314model = "glm-5"
1315
1316[[members]]
1317id = "w"
1318provider = "zai"
1319model = "glm-5"
1320"#;
1321        assert!(matches!(
1322            ExactFleet::parse(duplicate).expect_err("duplicate"),
1323            ExactFleetError::DuplicateMember { .. }
1324        ));
1325
1326        let two_routers = r#"
1327name = "f"
1328schema = "exact"
1329
1330[[members]]
1331id = "w"
1332provider = "zai"
1333model = "glm-5"
1334
1335[[members]]
1336id = "r1"
1337kind = "router"
1338provider = "zai"
1339model = "glm-5-turbo"
1340
1341[[members]]
1342id = "r2"
1343kind = "router"
1344provider = "zai"
1345model = "glm-5-turbo"
1346"#;
1347        assert!(matches!(
1348            ExactFleet::parse(two_routers).expect_err("two routers"),
1349            ExactFleetError::MultipleRouters
1350        ));
1351    }
1352
1353    /// Two members answering to one role, or one member's id being another's
1354    /// role, would make `member()` resolve by list order instead of identity.
1355    #[test]
1356    fn duplicate_roles_and_id_role_collisions_are_rejected() {
1357        let duplicate_role = r#"
1358name = "f"
1359schema = "exact"
1360
1361[[members]]
1362id = "a"
1363role = "builder"
1364provider = "zai"
1365model = "glm-5"
1366
1367[[members]]
1368id = "b"
1369role = "builder"
1370provider = "zai"
1371model = "glm-5"
1372"#;
1373        assert!(matches!(
1374            ExactFleet::parse(duplicate_role).expect_err("duplicate role"),
1375            ExactFleetError::DuplicateRole { .. }
1376        ));
1377
1378        // `b`'s role is `a`'s id: naming "a" would be ambiguous.
1379        let collision = r#"
1380name = "f"
1381schema = "exact"
1382
1383[[members]]
1384id = "a"
1385role = "builder"
1386provider = "zai"
1387model = "glm-5"
1388
1389[[members]]
1390id = "b"
1391role = "a"
1392provider = "zai"
1393model = "glm-5"
1394"#;
1395        assert!(matches!(
1396            ExactFleet::parse(collision).expect_err("id/role collision"),
1397            ExactFleetError::IdRoleCollision { .. }
1398        ));
1399    }
1400
1401    /// `router` is the Router's public identity. A worker may not wear it by
1402    /// either id or role.
1403    #[test]
1404    fn a_worker_may_not_claim_the_router_identity() {
1405        for (id, role) in [("router", None), ("helper", Some("router"))] {
1406            let role_line = role.map_or(String::new(), |role| format!("role = \"{role}\"\n"));
1407            let text = format!(
1408                r#"
1409name = "f"
1410schema = "exact"
1411
1412[[members]]
1413id = "{id}"
1414{role_line}provider = "zai"
1415model = "glm-5"
1416"#
1417            );
1418            let err = ExactFleet::parse(&text).expect_err("reserved router identity");
1419            assert!(
1420                matches!(err, ExactFleetError::ReservedRouterIdentity { .. }),
1421                "id={id} role={role:?} err={err:?}"
1422            );
1423        }
1424    }
1425
1426    /// `ExactFleet` is `pub` and `Deserialize`, so the invariants must be
1427    /// re-checkable on a value that never went through the TOML parser.
1428    #[test]
1429    fn capture_time_revalidation_catches_a_hand_built_roster() {
1430        let member = |id: &str, role: &str| ExactMember {
1431            id: id.to_string(),
1432            role: role.to_string(),
1433            provider: "zai".to_string(),
1434            model: "glm-5".to_string(),
1435            reasoning: RequestedReasoning::Off,
1436            permissions: PermissionCeiling::default(),
1437        };
1438
1439        let valid = ExactFleet {
1440            name: "f".to_string(),
1441            description: None,
1442            schema_revision: EXACT_FLEET_SCHEMA_REVISION,
1443            members: vec![member("a", "scout"), member("b", "builder")],
1444            reasoning_router: None,
1445            router: None,
1446        };
1447        valid.validate().expect("a clean roster validates");
1448
1449        for (fleet, label) in [
1450            (
1451                ExactFleet {
1452                    members: vec![member("a", "scout"), member("a", "builder")],
1453                    ..valid.clone()
1454                },
1455                "duplicate id",
1456            ),
1457            (
1458                ExactFleet {
1459                    members: vec![member("a", "scout"), member("b", "scout")],
1460                    ..valid.clone()
1461                },
1462                "duplicate role",
1463            ),
1464            (
1465                ExactFleet {
1466                    members: vec![member("router", "scout")],
1467                    ..valid.clone()
1468                },
1469                "reserved router id",
1470            ),
1471            (
1472                ExactFleet {
1473                    members: vec![member("a", "scout"), member("b", "a")],
1474                    ..valid.clone()
1475                },
1476                "id/role collision",
1477            ),
1478        ] {
1479            assert!(
1480                fleet.validate().is_err(),
1481                "{label} must not survive revalidation"
1482            );
1483        }
1484
1485        // A serde round-trip is exactly how such a value reaches a snapshot.
1486        let smuggled: ExactFleet = serde_json::from_str(
1487            &serde_json::to_string(&ExactFleet {
1488                members: vec![member("a", "scout"), member("b", "scout")],
1489                ..valid
1490            })
1491            .expect("serialize"),
1492        )
1493        .expect("deserialize");
1494        assert!(matches!(
1495            smuggled
1496                .validate()
1497                .expect_err("round-trip must not launder it"),
1498            ExactFleetError::DuplicateRole { .. }
1499        ));
1500    }
1501
1502    #[test]
1503    fn a_router_only_fleet_has_no_dispatchable_members() {
1504        let text = r#"
1505name = "f"
1506schema = "exact"
1507
1508[[members]]
1509id = "router"
1510kind = "router"
1511provider = "zai"
1512model = "glm-5-turbo"
1513"#;
1514        assert!(matches!(
1515            ExactFleet::parse(text).expect_err("router alone is not a fleet"),
1516            ExactFleetError::NoMembers { .. }
1517        ));
1518    }
1519
1520    #[test]
1521    fn permission_ceiling_can_only_narrow_the_session_posture() {
1522        let session = PermissionCeiling {
1523            write: false,
1524            network_tool: false,
1525            shell: ShellCeiling::ReadOnly,
1526            delegation_depth: 0,
1527            tools: true,
1528        };
1529        let member = PermissionCeiling::preset("full").expect("preset");
1530
1531        let clamped = member.clamp_to(session);
1532
1533        assert!(
1534            !clamped.write,
1535            "member must not gain write over a read-only session"
1536        );
1537        assert!(!clamped.network_tool);
1538        assert_eq!(clamped.shell, ShellCeiling::ReadOnly);
1539        assert_eq!(clamped.delegation_depth, 0);
1540    }
1541
1542    #[test]
1543    fn declared_schema_kind_distinguishes_the_two_forms() {
1544        assert_eq!(declared_schema_kind(GLM_FLEET).as_deref(), Some("exact"));
1545        assert_eq!(
1546            declared_schema_kind("name = \"stopship\"\n\n[roles]\nscout = \"scout\"\n"),
1547            None
1548        );
1549    }
1550
1551    #[test]
1552    fn unsupported_revision_fails_closed() {
1553        let text = r#"
1554name = "f"
1555schema = "exact"
1556schema_revision = 99
1557
1558[[members]]
1559id = "w"
1560provider = "zai"
1561model = "glm-5"
1562"#;
1563        assert!(matches!(
1564            ExactFleet::parse(text).expect_err("future revision"),
1565            ExactFleetError::UnsupportedRevision { revision: 99, .. }
1566        ));
1567    }
1568}