返回 CodeWhale
fleet_exact.rs
根目录 / crates / workflow / src / 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
29 use std::collections::BTreeMap;
30
31 use serde::{Deserialize, Serialize};
32 use thiserror::Error;
33
34 use crate::reasoning_router::{FleetRouterRef, ReasoningRouterError, RouterCallReasoning};
35
36 /// Wire value of the `schema` key that selects the exact form.
37 pub const EXACT_FLEET_SCHEMA_KIND: &str = "exact";
38 /// Wire value recorded for files in the pre-exact role→profile form.
39 pub const LEGACY_FLEET_SCHEMA_KIND: &str = "legacy";
40 /// Current revision of the exact schema.
41 pub const EXACT_FLEET_SCHEMA_REVISION: u32 = 1;
42
43 /// Member kind that selects the Fleet Router.
44 pub const ROUTER_MEMBER_KIND: &str = "router";
45 /// Member kind for an ordinary dispatchable worker.
46 pub 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`].
51 pub 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.
54 pub 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.
67 pub 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]
78 pub 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]
92 pub 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.
100 const 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")]
108 pub enum ReasoningTier {
109 Off,
110 Low,
111 Medium,
112 High,
113 Max,
114 }
115
116 impl 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")]
145 pub enum RequestedReasoning {
146 Off,
147 Low,
148 Medium,
149 High,
150 Max,
151 Auto,
152 }
153
154 impl 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")]
203 pub enum ShellCeiling {
204 None,
205 ReadOnly,
206 Full,
207 }
208
209 impl 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)]
229 pub 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
250 impl 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
299 impl 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)]
308 pub 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)]
315 pub 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
330 impl 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)]
355 pub 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
367 impl 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)]
411 pub 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
427 impl 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]
712 pub 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)]
728 struct 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)]
748 struct 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
762 const fn default_schema_revision() -> u32 {
763 EXACT_FLEET_SCHEMA_REVISION
764 }
765
766 fn 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.
781 fn 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.
789 fn 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)]
819 pub 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)]
904 mod 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#"
910 name = "counsel"
911 schema = "exact"
912
913 [[members]]
914 id = "advisor-one"
915 role = "oracle"
916 provider = "zai"
917 model = "glm-5"
918 permissions = "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)]
1033 mod tests {
1034 use super::*;
1035
1036 const GLM_FLEET: &str = r#"
1037 name = "glm-pair"
1038 description = "GLM worker with a GLM Turbo router"
1039 schema = "exact"
1040 schema_revision = 1
1041
1042 [[members]]
1043 id = "implementer"
1044 role = "builder"
1045 provider = "zai"
1046 model = "glm-5"
1047 reasoning = "auto"
1048 permissions = "read_write"
1049
1050 [[members]]
1051 id = "router"
1052 kind = "router"
1053 provider = "zai"
1054 model = "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#"
1060 name = "glm-pair"
1061 schema = "exact"
1062 reasoning_router = "luna-low"
1063
1064 [[members]]
1065 id = "implementer"
1066 role = "builder"
1067 provider = "zai"
1068 model = "glm-5"
1069 reasoning = "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#"
1227 name = "f"
1228 schema = "exact"
1229
1230 [[members]]
1231 id = "w"
1232 provider = "{provider}"
1233 model = "{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#"
1251 name = "f"
1252 schema = "exact"
1253
1254 [[members]]
1255 id = "w"
1256 provider = "zai"
1257 model = "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#"
1272 name = "f"
1273 schema = "exact"
1274
1275 [[members]]
1276 id = "w"
1277 provider = "zai"
1278 model = "glm-5"
1279
1280 [[members]]
1281 id = "router"
1282 kind = "router"
1283 provider = "zai"
1284 model = "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#"
1308 name = "f"
1309 schema = "exact"
1310
1311 [[members]]
1312 id = "w"
1313 provider = "zai"
1314 model = "glm-5"
1315
1316 [[members]]
1317 id = "w"
1318 provider = "zai"
1319 model = "glm-5"
1320 "#;
1321 assert!(matches!(
1322 ExactFleet::parse(duplicate).expect_err("duplicate"),
1323 ExactFleetError::DuplicateMember { .. }
1324 ));
1325
1326 let two_routers = r#"
1327 name = "f"
1328 schema = "exact"
1329
1330 [[members]]
1331 id = "w"
1332 provider = "zai"
1333 model = "glm-5"
1334
1335 [[members]]
1336 id = "r1"
1337 kind = "router"
1338 provider = "zai"
1339 model = "glm-5-turbo"
1340
1341 [[members]]
1342 id = "r2"
1343 kind = "router"
1344 provider = "zai"
1345 model = "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#"
1358 name = "f"
1359 schema = "exact"
1360
1361 [[members]]
1362 id = "a"
1363 role = "builder"
1364 provider = "zai"
1365 model = "glm-5"
1366
1367 [[members]]
1368 id = "b"
1369 role = "builder"
1370 provider = "zai"
1371 model = "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#"
1380 name = "f"
1381 schema = "exact"
1382
1383 [[members]]
1384 id = "a"
1385 role = "builder"
1386 provider = "zai"
1387 model = "glm-5"
1388
1389 [[members]]
1390 id = "b"
1391 role = "a"
1392 provider = "zai"
1393 model = "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#"
1409 name = "f"
1410 schema = "exact"
1411
1412 [[members]]
1413 id = "{id}"
1414 {role_line}provider = "zai"
1415 model = "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#"
1505 name = "f"
1506 schema = "exact"
1507
1508 [[members]]
1509 id = "router"
1510 kind = "router"
1511 provider = "zai"
1512 model = "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#"
1554 name = "f"
1555 schema = "exact"
1556 schema_revision = 99
1557
1558 [[members]]
1559 id = "w"
1560 provider = "zai"
1561 model = "glm-5"
1562 "#;
1563 assert!(matches!(
1564 ExactFleet::parse(text).expect_err("future revision"),
1565 ExactFleetError::UnsupportedRevision { revision: 99, .. }
1566 ));
1567 }
1568 }
1569
1569 lines RUST