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