1use std::collections::BTreeMap;
30
31use serde::{Deserialize, Serialize};
32use thiserror::Error;
33
34use crate::reasoning_router::{FleetRouterRef, ReasoningRouterError, RouterCallReasoning};
35
36pub const EXACT_FLEET_SCHEMA_KIND: &str = "exact";
38pub const LEGACY_FLEET_SCHEMA_KIND: &str = "legacy";
40pub const EXACT_FLEET_SCHEMA_REVISION: u32 = 1;
42
43pub const ROUTER_MEMBER_KIND: &str = "router";
45pub const WORKER_MEMBER_KIND: &str = "worker";
47
48pub const ROUTER_PUBLIC_ID: &str = "router";
52pub const ROUTER_PUBLIC_ROLE: &str = "router";
55
56pub const ROLE_ALIASES: &[(&str, &str)] = &[("oracle", "consultant"), ("advisor", "consultant")];
68
69#[must_use]
78pub fn canonical_role_key(value: &str) -> String {
79 let key = value.trim().to_ascii_lowercase();
80 ROLE_ALIASES
81 .iter()
82 .find(|(alias, _)| *alias == key)
83 .map_or(key, |(_, canonical)| (*canonical).to_string())
84}
85
86#[must_use]
92pub fn canonical_member_key(value: &str) -> String {
93 value.trim().to_ascii_lowercase()
94}
95
96const FORBIDDEN_ROUTE_SELECTORS: &[&str] = &[
101 "auto", "inherit", "parent", "same", "faster", "fast", "cheap", "strong", "balanced", "default",
102];
103
104#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
107#[serde(rename_all = "snake_case")]
108pub enum ReasoningTier {
109 Off,
110 Low,
111 Medium,
112 High,
113 Max,
114}
115
116impl ReasoningTier {
117 #[must_use]
118 pub const fn as_str(self) -> &'static str {
119 match self {
120 Self::Off => "off",
121 Self::Low => "low",
122 Self::Medium => "medium",
123 Self::High => "high",
124 Self::Max => "max",
125 }
126 }
127
128 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
144#[serde(rename_all = "snake_case")]
145pub enum RequestedReasoning {
146 Off,
147 Low,
148 Medium,
149 High,
150 Max,
151 Auto,
152}
153
154impl RequestedReasoning {
155 #[must_use]
156 pub const fn as_str(self) -> &'static str {
157 match self {
158 Self::Off => "off",
159 Self::Low => "low",
160 Self::Medium => "medium",
161 Self::High => "high",
162 Self::Max => "max",
163 Self::Auto => "auto",
164 }
165 }
166
167 #[must_use]
168 pub const fn is_auto(self) -> bool {
169 matches!(self, Self::Auto)
170 }
171
172 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
202#[serde(rename_all = "snake_case")]
203pub enum ShellCeiling {
204 None,
205 ReadOnly,
206 Full,
207}
208
209impl ShellCeiling {
210 #[must_use]
211 pub const fn as_str(self) -> &'static str {
212 match self {
213 Self::None => "none",
214 Self::ReadOnly => "read_only",
215 Self::Full => "full",
216 }
217 }
218
219 #[must_use]
220 pub fn min_with(self, other: Self) -> Self {
221 if self <= other { self } else { other }
222 }
223}
224
225#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
229pub struct PermissionCeiling {
230 pub write: bool,
231 #[serde(alias = "network")]
242 pub network_tool: bool,
243 pub shell: ShellCeiling,
244 pub delegation_depth: u32,
246 pub tools: bool,
248}
249
250impl PermissionCeiling {
251 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 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 #[must_use]
288 pub fn clamp_to(self, session: Self) -> Self {
289 Self {
290 write: self.write && session.write,
291 network_tool: self.network_tool && session.network_tool,
292 shell: self.shell.min_with(session.shell),
293 delegation_depth: self.delegation_depth.min(session.delegation_depth),
294 tools: self.tools && session.tools,
295 }
296 }
297}
298
299impl Default for PermissionCeiling {
300 fn default() -> Self {
301 Self::preset("read_only").expect("read_only is a known preset")
302 }
303}
304
305#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
308pub struct FrozenRoute {
309 pub provider: String,
310 pub model: String,
311}
312
313#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
315pub struct ExactMember {
316 pub id: String,
318 pub role: String,
320 pub provider: String,
322 pub model: String,
324 pub reasoning: RequestedReasoning,
326 pub permissions: PermissionCeiling,
328}
329
330impl ExactMember {
331 #[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
355pub struct RouterMember {
356 pub id: String,
357 pub provider: String,
358 pub model: String,
359 #[serde(default, alias = "reasoning")]
364 pub call_reasoning: RouterCallReasoning,
365}
366
367impl RouterMember {
368 #[must_use]
372 pub const fn public_id(&self) -> &'static str {
373 ROUTER_PUBLIC_ID
374 }
375
376 #[must_use]
378 pub const fn public_role(&self) -> &'static str {
379 ROUTER_PUBLIC_ROLE
380 }
381
382 #[must_use]
384 pub const fn is_dispatchable(&self) -> bool {
385 false
386 }
387
388 #[must_use]
390 pub const fn tool_surface(&self) -> &'static [&'static str] {
391 &[]
392 }
393
394 #[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
411pub struct ExactFleet {
412 pub name: String,
413 #[serde(default, skip_serializing_if = "Option::is_none")]
414 pub description: Option<String>,
415 pub schema_revision: u32,
416 pub members: Vec<ExactMember>,
417 #[serde(default, skip_serializing_if = "Option::is_none")]
421 pub reasoning_router: Option<String>,
422 #[serde(default, skip_serializing_if = "Option::is_none")]
424 pub router: Option<RouterMember>,
425}
426
427impl ExactFleet {
428 #[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 #[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 #[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 #[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 #[must_use]
477 pub fn legacy_inline_router(&self) -> Option<&RouterMember> {
478 self.router.as_ref()
479 }
480
481 #[must_use]
483 pub fn has_auto_member(&self) -> bool {
484 self.members.iter().any(|member| member.reasoning.is_auto())
485 }
486
487 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 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 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 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 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 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 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 fleet.validate()?;
702 Ok(fleet)
703 }
704}
705
706#[must_use]
712pub fn declared_schema_kind(text: &str) -> Option<String> {
713 #[derive(Deserialize)]
714 struct SchemaProbe {
715 #[serde(default)]
716 schema: Option<String>,
717 }
718
719 let probe: SchemaProbe = toml::from_str(text).ok()?;
720 probe
721 .schema
722 .map(|schema| schema.trim().to_ascii_lowercase())
723 .filter(|schema| !schema.is_empty())
724}
725
726#[derive(Debug, Deserialize)]
727#[serde(deny_unknown_fields)]
728struct ExactFleetToml {
729 name: String,
730 #[serde(default)]
731 description: Option<String>,
732 schema: String,
733 #[serde(default = "default_schema_revision")]
734 schema_revision: u32,
735 #[serde(default)]
738 reasoning_router: Option<String>,
739 #[serde(default)]
740 members: Vec<ExactMemberToml>,
741}
742
743#[derive(Debug, Deserialize)]
747#[serde(deny_unknown_fields)]
748struct ExactMemberToml {
749 id: String,
750 #[serde(default)]
751 kind: Option<String>,
752 #[serde(default)]
753 role: Option<String>,
754 provider: String,
755 model: String,
756 #[serde(default)]
757 reasoning: Option<String>,
758 #[serde(default)]
759 permissions: Option<String>,
760}
761
762const fn default_schema_revision() -> u32 {
763 EXACT_FLEET_SCHEMA_REVISION
764}
765
766fn require_token(value: &str, field: &str) -> Result<String, ExactFleetError> {
767 crate::role_resolve::normalize_token(value).ok_or_else(|| ExactFleetError::InvalidToken {
768 field: field.to_string(),
769 value: value.trim().to_string(),
770 })
771}
772
773fn require_member_role(value: &str) -> Result<String, ExactFleetError> {
782 let role = require_token(value, "member role")?;
783 Ok(canonical_role_key(&role))
784}
785
786fn require_exact_route_token(
790 value: &str,
791 member: &str,
792 field: &str,
793) -> Result<String, ExactFleetError> {
794 let trimmed = value.trim();
795 if trimmed.is_empty()
796 || trimmed
797 .chars()
798 .any(|ch| ch.is_whitespace() || matches!(ch, '"' | '\'' | '`' | '='))
799 {
800 return Err(ExactFleetError::InvalidToken {
801 field: format!("{member}.{field}"),
802 value: trimmed.to_string(),
803 });
804 }
805 if FORBIDDEN_ROUTE_SELECTORS
806 .iter()
807 .any(|selector| trimmed.eq_ignore_ascii_case(selector))
808 {
809 return Err(ExactFleetError::LateBindingSelector {
810 id: member.to_string(),
811 field: field.to_string(),
812 value: trimmed.to_string(),
813 });
814 }
815 Ok(trimmed.to_string())
816}
817
818#[derive(Debug, Clone, PartialEq, Eq, Error)]
819pub enum ExactFleetError {
820 #[error("failed to parse exact fleet file: {0}")]
821 Parse(String),
822 #[error("unknown fleet schema `{schema}`; expected `exact`")]
823 UnknownSchema { schema: String },
824 #[error(
825 "exact fleet schema revision {revision} is not supported (this build reads {supported})"
826 )]
827 UnsupportedRevision { revision: u32, supported: u32 },
828 #[error("{field} must be a non-empty token without whitespace, quotes, or `=` (got `{value}`)")]
829 InvalidToken { field: String, value: String },
830 #[error("duplicate fleet member id `{id}`")]
831 DuplicateMember { id: String },
832 #[error(
833 "duplicate fleet member role `{role}`; two members cannot answer to the same role or a \
834 task naming it would resolve to whichever one happened to be listed first"
835 )]
836 DuplicateRole { role: String },
837 #[error(
838 "member `{id}` collides with member `{other}`: one member's id is another member's role, \
839 so a task naming it would resolve by list order rather than by identity"
840 )]
841 IdRoleCollision { id: String, other: String },
842 #[error(
843 "member `{id}` claims the reserved {field} `router`; that identity belongs to the fleet \
844 router, which is declared with `kind = \"router\"` and is never dispatchable"
845 )]
846 ReservedRouterIdentity { id: String, field: String },
847 #[error(
848 "fleet `{fleet}` snapshot content hash does not describe its own contents (recorded \
849 `{recorded}`, recomputed `{recomputed}`). The snapshot was edited or migrated after \
850 capture, so its hash cannot be used as evidence that a run matched a saved definition. \
851 Re-capture the fleet."
852 )]
853 ContentHashMismatch {
854 fleet: String,
855 recorded: String,
856 recomputed: String,
857 },
858 #[error("fleet `{fleet}` declares no dispatchable members")]
859 NoMembers { fleet: String },
860 #[error("member `{id}` has unknown kind `{kind}`; expected `worker` or `router`")]
861 UnknownMemberKind { id: String, kind: String },
862 #[error("a fleet may declare at most one router member")]
863 MultipleRouters,
864 #[error(
865 "member `{id}`.{field} is `{value}`, but exact fleets forbid late-binding route selectors \
866 (inherit, fast siblings, model strength, or model=auto). Name the exact provider/model."
867 )]
868 LateBindingSelector {
869 id: String,
870 field: String,
871 value: String,
872 },
873 #[error(
874 "member `{id}` has invalid reasoning `{value}`; expected off, low, medium, high, max, or auto"
875 )]
876 InvalidReasoning { id: String, value: String },
877 #[error("member `{id}` has unknown permission preset `{preset}`")]
878 UnknownPermissionPreset { id: String, preset: String },
879 #[error("router member `{id}` may not declare permissions; a router gets none by construction")]
880 RouterPermissionsDeclared { id: String },
881 #[error(
882 "router member `{id}` may not declare a role; a router is never dispatched as a worker"
883 )]
884 RouterRoleDeclared { id: String },
885 #[error(
886 "router member `{id}` may not request reasoning `auto`; a router's own thinking is a fixed tier (default off)"
887 )]
888 RouterAutoReasoning { id: String },
889 #[error(
890 "fleet `{fleet}` declares both `reasoning_router = \"...\"` and an inline \
891 `kind = \"router\"` member. A fleet references exactly one reasoning router service; \
892 pick the saved profile (preferred, and shareable across fleets) or the legacy inline \
893 form, not both."
894 )]
895 ConflictingRouterDeclarations { fleet: String },
896 #[error(transparent)]
897 Router {
898 #[from]
899 source: ReasoningRouterError,
900 },
901}
902
903#[cfg(test)]
904mod role_alias_tests {
905 use super::*;
906
907 const RENAMED_ROLE_FLEET: &str = r#"
910name = "counsel"
911schema = "exact"
912
913[[members]]
914id = "advisor-one"
915role = "oracle"
916provider = "zai"
917model = "glm-5"
918permissions = "analyst"
919"#;
920
921 #[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 #[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 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 #[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 #[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 #[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 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 assert_eq!(canonical_role_key("Auditor"), "auditor");
1028 assert_eq!(canonical_role_key("router"), "router");
1029 }
1030}
1031
1032#[cfg(test)]
1033mod tests {
1034 use super::*;
1035
1036 const GLM_FLEET: &str = r#"
1037name = "glm-pair"
1038description = "GLM worker with a GLM Turbo router"
1039schema = "exact"
1040schema_revision = 1
1041
1042[[members]]
1043id = "implementer"
1044role = "builder"
1045provider = "zai"
1046model = "glm-5"
1047reasoning = "auto"
1048permissions = "read_write"
1049
1050[[members]]
1051id = "router"
1052kind = "router"
1053provider = "zai"
1054model = "glm-5-turbo"
1055"#;
1056
1057 const NAMED_ROUTER_FLEET: &str = r#"
1060name = "glm-pair"
1061schema = "exact"
1062reasoning_router = "luna-low"
1063
1064[[members]]
1065id = "implementer"
1066role = "builder"
1067provider = "zai"
1068model = "glm-5"
1069reasoning = "auto"
1070"#;
1071
1072 #[test]
1073 fn exact_fleet_parses_members_and_a_legacy_inline_router() {
1074 let fleet = ExactFleet::parse(GLM_FLEET).expect("parse");
1075 assert_eq!(fleet.name, "glm-pair");
1076 assert_eq!(fleet.schema_revision, EXACT_FLEET_SCHEMA_REVISION);
1077 assert_eq!(fleet.members.len(), 1);
1078
1079 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 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 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 #[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 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 #[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 #[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 assert!(fleet.member_by_id_or_role("router").is_none());
1213 assert!(fleet.members.iter().all(ExactMember::is_dispatchable));
1214 }
1215
1216 #[test]
1217 fn late_binding_selectors_are_rejected() {
1218 for (field, value) in [
1219 ("model", "auto"),
1220 ("model", "inherit"),
1221 ("model", "faster"),
1222 ("model", "strong"),
1223 ("provider", "inherit"),
1224 ] {
1225 let text = format!(
1226 r#"
1227name = "f"
1228schema = "exact"
1229
1230[[members]]
1231id = "w"
1232provider = "{provider}"
1233model = "{model}"
1234"#,
1235 provider = if field == "provider" { value } else { "zai" },
1236 model = if field == "model" { value } else { "glm-5" },
1237 );
1238 let err = ExactFleet::parse(&text).expect_err("selector must be rejected");
1239 assert!(
1240 matches!(err, ExactFleetError::LateBindingSelector { .. }),
1241 "field={field} value={value} err={err:?}"
1242 );
1243 }
1244 }
1245
1246 #[test]
1247 fn model_strength_and_loadout_keys_are_rejected() {
1248 for key in ["model_strength", "loadout", "model_class", "model_hint"] {
1249 let text = format!(
1250 r#"
1251name = "f"
1252schema = "exact"
1253
1254[[members]]
1255id = "w"
1256provider = "zai"
1257model = "glm-5"
1258{key} = "strong"
1259"#
1260 );
1261 let err = ExactFleet::parse(&text).expect_err("unknown key must be rejected");
1262 assert!(
1263 matches!(err, ExactFleetError::Parse(_)),
1264 "key={key} err={err:?}"
1265 );
1266 }
1267 }
1268
1269 #[test]
1270 fn router_rejects_auth_permissions_role_and_auto_reasoning() {
1271 let base = r#"
1272name = "f"
1273schema = "exact"
1274
1275[[members]]
1276id = "w"
1277provider = "zai"
1278model = "glm-5"
1279
1280[[members]]
1281id = "router"
1282kind = "router"
1283provider = "zai"
1284model = "glm-5-turbo"
1285"#;
1286 let permissions = format!("{base}permissions = \"full\"\n");
1287 assert!(matches!(
1288 ExactFleet::parse(&permissions).expect_err("permissions rejected"),
1289 ExactFleetError::RouterPermissionsDeclared { .. }
1290 ));
1291
1292 let role = format!("{base}role = \"builder\"\n");
1293 assert!(matches!(
1294 ExactFleet::parse(&role).expect_err("role rejected"),
1295 ExactFleetError::RouterRoleDeclared { .. }
1296 ));
1297
1298 let auto = format!("{base}reasoning = \"auto\"\n");
1299 assert!(matches!(
1300 ExactFleet::parse(&auto).expect_err("auto rejected"),
1301 ExactFleetError::RouterAutoReasoning { .. }
1302 ));
1303 }
1304
1305 #[test]
1306 fn duplicate_members_and_multiple_routers_fail() {
1307 let duplicate = r#"
1308name = "f"
1309schema = "exact"
1310
1311[[members]]
1312id = "w"
1313provider = "zai"
1314model = "glm-5"
1315
1316[[members]]
1317id = "w"
1318provider = "zai"
1319model = "glm-5"
1320"#;
1321 assert!(matches!(
1322 ExactFleet::parse(duplicate).expect_err("duplicate"),
1323 ExactFleetError::DuplicateMember { .. }
1324 ));
1325
1326 let two_routers = r#"
1327name = "f"
1328schema = "exact"
1329
1330[[members]]
1331id = "w"
1332provider = "zai"
1333model = "glm-5"
1334
1335[[members]]
1336id = "r1"
1337kind = "router"
1338provider = "zai"
1339model = "glm-5-turbo"
1340
1341[[members]]
1342id = "r2"
1343kind = "router"
1344provider = "zai"
1345model = "glm-5-turbo"
1346"#;
1347 assert!(matches!(
1348 ExactFleet::parse(two_routers).expect_err("two routers"),
1349 ExactFleetError::MultipleRouters
1350 ));
1351 }
1352
1353 #[test]
1356 fn duplicate_roles_and_id_role_collisions_are_rejected() {
1357 let duplicate_role = r#"
1358name = "f"
1359schema = "exact"
1360
1361[[members]]
1362id = "a"
1363role = "builder"
1364provider = "zai"
1365model = "glm-5"
1366
1367[[members]]
1368id = "b"
1369role = "builder"
1370provider = "zai"
1371model = "glm-5"
1372"#;
1373 assert!(matches!(
1374 ExactFleet::parse(duplicate_role).expect_err("duplicate role"),
1375 ExactFleetError::DuplicateRole { .. }
1376 ));
1377
1378 let collision = r#"
1380name = "f"
1381schema = "exact"
1382
1383[[members]]
1384id = "a"
1385role = "builder"
1386provider = "zai"
1387model = "glm-5"
1388
1389[[members]]
1390id = "b"
1391role = "a"
1392provider = "zai"
1393model = "glm-5"
1394"#;
1395 assert!(matches!(
1396 ExactFleet::parse(collision).expect_err("id/role collision"),
1397 ExactFleetError::IdRoleCollision { .. }
1398 ));
1399 }
1400
1401 #[test]
1404 fn a_worker_may_not_claim_the_router_identity() {
1405 for (id, role) in [("router", None), ("helper", Some("router"))] {
1406 let role_line = role.map_or(String::new(), |role| format!("role = \"{role}\"\n"));
1407 let text = format!(
1408 r#"
1409name = "f"
1410schema = "exact"
1411
1412[[members]]
1413id = "{id}"
1414{role_line}provider = "zai"
1415model = "glm-5"
1416"#
1417 );
1418 let err = ExactFleet::parse(&text).expect_err("reserved router identity");
1419 assert!(
1420 matches!(err, ExactFleetError::ReservedRouterIdentity { .. }),
1421 "id={id} role={role:?} err={err:?}"
1422 );
1423 }
1424 }
1425
1426 #[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 let smuggled: ExactFleet = serde_json::from_str(
1487 &serde_json::to_string(&ExactFleet {
1488 members: vec![member("a", "scout"), member("b", "scout")],
1489 ..valid
1490 })
1491 .expect("serialize"),
1492 )
1493 .expect("deserialize");
1494 assert!(matches!(
1495 smuggled
1496 .validate()
1497 .expect_err("round-trip must not launder it"),
1498 ExactFleetError::DuplicateRole { .. }
1499 ));
1500 }
1501
1502 #[test]
1503 fn a_router_only_fleet_has_no_dispatchable_members() {
1504 let text = r#"
1505name = "f"
1506schema = "exact"
1507
1508[[members]]
1509id = "router"
1510kind = "router"
1511provider = "zai"
1512model = "glm-5-turbo"
1513"#;
1514 assert!(matches!(
1515 ExactFleet::parse(text).expect_err("router alone is not a fleet"),
1516 ExactFleetError::NoMembers { .. }
1517 ));
1518 }
1519
1520 #[test]
1521 fn permission_ceiling_can_only_narrow_the_session_posture() {
1522 let session = PermissionCeiling {
1523 write: false,
1524 network_tool: false,
1525 shell: ShellCeiling::ReadOnly,
1526 delegation_depth: 0,
1527 tools: true,
1528 };
1529 let member = PermissionCeiling::preset("full").expect("preset");
1530
1531 let clamped = member.clamp_to(session);
1532
1533 assert!(
1534 !clamped.write,
1535 "member must not gain write over a read-only session"
1536 );
1537 assert!(!clamped.network_tool);
1538 assert_eq!(clamped.shell, ShellCeiling::ReadOnly);
1539 assert_eq!(clamped.delegation_depth, 0);
1540 }
1541
1542 #[test]
1543 fn declared_schema_kind_distinguishes_the_two_forms() {
1544 assert_eq!(declared_schema_kind(GLM_FLEET).as_deref(), Some("exact"));
1545 assert_eq!(
1546 declared_schema_kind("name = \"stopship\"\n\n[roles]\nscout = \"scout\"\n"),
1547 None
1548 );
1549 }
1550
1551 #[test]
1552 fn unsupported_revision_fails_closed() {
1553 let text = r#"
1554name = "f"
1555schema = "exact"
1556schema_revision = 99
1557
1558[[members]]
1559id = "w"
1560provider = "zai"
1561model = "glm-5"
1562"#;
1563 assert!(matches!(
1564 ExactFleet::parse(text).expect_err("future revision"),
1565 ExactFleetError::UnsupportedRevision { revision: 99, .. }
1566 ));
1567 }
1568}