1#[cfg(test)]
2mod tests;
3
4use std::collections::{HashMap, HashSet};
5use std::path::Path;
6
7use serde::{Deserialize, Serialize};
8use thiserror::Error;
9use utoipa::ToSchema;
10
11pub use crate::scheduling::PolicySla;
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct WorkspaceConfig {
16 #[serde(default)]
17 pub policies: HashMap<String, PolicyConfig>,
18 #[serde(default)]
19 pub orchestrators: HashMap<String, OrchestratorConfig>,
20 #[serde(default)]
21 pub rooms: HashMap<String, RoomConfig>,
22 #[serde(default, skip_serializing_if = "Option::is_none")]
23 pub shared: Option<Vec<ContextRef>>,
24 #[serde(default, skip_serializing_if = "Option::is_none")]
25 pub default_room: Option<String>,
26 #[serde(default, skip_serializing_if = "Option::is_none")]
28 pub agents: Option<AgentsConfig>,
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct AgentsConfig {
34 pub config_file: String,
36 #[serde(default, skip_serializing_if = "Option::is_none")]
38 pub dashboard_port: Option<u16>,
39}
40
41#[derive(Debug, Clone, Deserialize, Default)]
50pub struct QuorumConfig {
51 #[serde(default)]
52 pub policies: HashMap<String, PolicyConfig>,
53 #[serde(default)]
54 pub orchestrators: HashMap<String, OrchestratorConfig>,
55 #[serde(default)]
56 pub rooms: HashMap<String, RoomConfig>,
57 #[serde(default, skip_serializing_if = "Option::is_none")]
58 pub default_room: Option<String>,
59 #[serde(default, skip_serializing_if = "Option::is_none")]
60 pub shared: Option<Vec<ContextRef>>,
61 #[serde(default)]
63 pub providers: HashMap<String, crate::config::ProviderEntry>,
64 #[serde(default)]
66 pub agents: Vec<crate::agents::config::AgentConfig>,
67 #[serde(default, skip_serializing_if = "Option::is_none")]
68 pub response_sla_secs: Option<u64>,
69 #[serde(default)]
70 pub telemetry: crate::telemetry::TelemetryConfig,
71 #[serde(default, skip_serializing_if = "Option::is_none")]
72 pub dashboard_port: Option<u16>,
73}
74
75impl QuorumConfig {
76 pub fn load(path: &Path) -> Result<Self, ConfigError> {
79 let contents = std::fs::read_to_string(path)?;
80 let config: Self = serde_yaml::from_str(&contents)?;
81 config.to_workspace().validate()?;
82 config.validate_fleet()?;
83 Ok(config)
84 }
85
86 fn validate_fleet(&self) -> Result<(), ConfigError> {
90 let mut seen = HashSet::new();
91 for (index, agent) in self.agents.iter().enumerate() {
92 if agent.name.trim().is_empty() {
93 return Err(ConfigError::FleetEmptyAgentName { index });
94 }
95 if !seen.insert(agent.name.as_str()) {
96 return Err(ConfigError::FleetDuplicateAgent {
97 name: agent.name.clone(),
98 });
99 }
100 if !agent.provider_id.is_empty() && !self.providers.contains_key(&agent.provider_id) {
101 return Err(ConfigError::FleetUnknownProvider {
102 agent: agent.name.clone(),
103 provider: agent.provider_id.clone(),
104 });
105 }
106 }
107 Ok(())
108 }
109
110 pub fn load_workspace(path: &Path) -> Result<WorkspaceConfig, ConfigError> {
116 match Self::load(path) {
117 Ok(q) => Ok(q.to_workspace()),
118 Err(_) => WorkspaceConfig::load(path),
119 }
120 }
121
122 pub fn to_workspace(&self) -> WorkspaceConfig {
126 WorkspaceConfig {
127 policies: self.policies.clone(),
128 orchestrators: self.orchestrators.clone(),
129 rooms: self.rooms.clone(),
130 shared: self.shared.clone(),
131 default_room: self.default_room.clone(),
132 agents: None,
133 }
134 }
135
136 pub fn to_fleet(&self) -> crate::config::AgentFleetConfig {
140 crate::config::AgentFleetConfig {
141 providers: self.providers.clone(),
142 agents: self.agents.clone(),
143 orchestrators: Vec::new(),
144 response_sla_secs: self.response_sla_secs,
145 telemetry: self.telemetry.clone(),
146 dashboard_port: self.dashboard_port,
147 }
148 }
149}
150
151#[derive(Debug, Clone, Copy, Serialize, Deserialize, ToSchema, Default, PartialEq, Eq)]
153#[serde(rename_all = "snake_case")]
154pub enum PolicyMode {
155 Passthrough,
156 Moderator,
157 #[default]
158 Deliberation,
159}
160
161#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
165pub struct PolicyConfig {
166 #[serde(default, skip_serializing_if = "Option::is_none")]
167 pub agents: Option<Vec<String>>,
168 #[serde(default, skip_serializing_if = "Option::is_none")]
169 pub roles: Option<Vec<RoleConfig>>,
170 #[serde(default = "default_rounds", alias = "rounds")]
171 pub max_rounds: u32,
172 #[serde(alias = "convergence_threshold", default = "default_effort")]
173 pub effort: f32,
174 #[serde(default, skip_serializing_if = "Option::is_none")]
175 pub sla: Option<PolicySla>,
176 #[serde(default, skip_serializing_if = "Option::is_none")]
177 pub capabilities: Option<Vec<String>>,
178 #[serde(default, skip_serializing_if = "Option::is_none")]
179 pub tags: Option<Vec<String>>,
180 #[serde(default, skip_serializing_if = "is_deliberation")]
182 pub mode: PolicyMode,
183}
184
185fn is_deliberation(mode: &PolicyMode) -> bool {
186 *mode == PolicyMode::Deliberation
187}
188
189impl PolicyConfig {
190 pub fn policy_id(&self) -> String {
195 use sha2::{Digest, Sha256};
196
197 #[derive(serde::Serialize)]
199 struct HashableRole {
200 role: String,
201 count: u8,
202 capabilities: Vec<String>,
203 #[serde(default, skip_serializing_if = "Option::is_none")]
204 pinned_agents: Option<Vec<String>>,
205 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
206 moderator: bool,
207 }
208
209 #[derive(serde::Serialize)]
215 struct HashablePolicy {
216 #[serde(default, skip_serializing_if = "Option::is_none")]
217 agents: Option<Vec<String>>,
218 #[serde(default, skip_serializing_if = "Option::is_none")]
219 roles: Option<Vec<HashableRole>>,
220 max_rounds: u32,
221 effort: f32,
222 #[serde(default, skip_serializing_if = "Option::is_none")]
223 sla: Option<PolicySla>,
224 #[serde(default, skip_serializing_if = "Option::is_none")]
225 capabilities: Option<Vec<String>>,
226 #[serde(default, skip_serializing_if = "Option::is_none")]
227 tags: Option<Vec<String>>,
228 #[serde(default, skip_serializing_if = "is_deliberation")]
229 mode: PolicyMode,
230 }
231
232 let hashable = HashablePolicy {
233 agents: self.agents.clone(),
234 roles: self.roles.as_ref().map(|roles| {
235 roles
236 .iter()
237 .map(|r| HashableRole {
238 role: r.role.clone(),
239 count: r.count,
240 capabilities: r.capabilities.clone(),
241 pinned_agents: r.pinned_agents.clone(),
242 moderator: r.moderator,
243 })
244 .collect()
245 }),
246 max_rounds: self.max_rounds,
247 effort: self.effort,
248 sla: self.sla.clone(),
249 capabilities: self.capabilities.clone(),
250 tags: self.tags.clone(),
251 mode: self.mode,
252 };
253
254 let canonical = serde_json::to_string(&hashable).expect("PolicyConfig must serialize");
255 let hash = Sha256::digest(canonical.as_bytes());
256 format!("{hash:x}")
257 }
258}
259
260#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
263pub struct RoomConfig {
264 pub policy: String,
265 #[serde(default, skip_serializing_if = "Option::is_none")]
266 pub orchestrator: Option<String>,
267}
268
269#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
270pub struct OrchestratorConfig {
271 #[serde(default, skip_serializing_if = "Option::is_none")]
275 pub mode: Option<OrchestratorMode>,
276 #[serde(default, skip_serializing_if = "Option::is_none")]
278 pub address: Option<String>,
279 #[serde(default, skip_serializing_if = "Option::is_none")]
282 pub token: Option<String>,
283 #[serde(default, skip_serializing_if = "Option::is_none")]
285 pub nats_url: Option<String>,
286 #[serde(default, skip_serializing_if = "Option::is_none")]
289 pub config_file: Option<String>,
290}
291
292#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, ToSchema)]
293#[serde(rename_all = "snake_case")]
294pub enum OrchestratorMode {
295 Embedded,
297 Remote,
299}
300
301#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
302pub struct RoleConfig {
303 pub role: String,
304 #[serde(default = "default_role_count")]
305 pub count: u8,
306 pub capabilities: Vec<String>,
307 #[serde(default)]
308 pub context: Option<Vec<ContextRef>>,
309 #[serde(default, skip_serializing_if = "Option::is_none")]
312 pub pinned_agents: Option<Vec<String>>,
313 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
316 pub moderator: bool,
317}
318
319#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
320pub struct ContextRef {
321 pub name: String,
322 pub path: String,
323}
324
325fn default_rounds() -> u32 {
326 3
327}
328
329fn default_effort() -> f32 {
330 0.6
331}
332
333fn default_role_count() -> u8 {
334 1
335}
336
337#[derive(Debug, Error)]
338pub enum ConfigError {
339 #[error("no policies defined")]
340 NoPolicies,
341
342 #[error("no orchestrators defined")]
343 NoOrchestrators,
344
345 #[error("no rooms defined")]
346 NoRooms,
347
348 #[error("policy '{policy}': agents and roles are mutually exclusive")]
349 AgentsAndRolesExclusive { policy: String },
350
351 #[error("policy '{policy}': must specify either agents or roles")]
352 NeitherAgentsNorRoles { policy: String },
353
354 #[error("policy '{policy}' ({mode}): requires at least {min} agent(s), got {count}")]
355 TooFewAgents {
356 policy: String,
357 count: usize,
358 min: usize,
359 mode: &'static str,
360 },
361
362 #[error("policy '{policy}': duplicate role name '{role}'")]
363 DuplicateRole { policy: String, role: String },
364
365 #[error("policy '{policy}', role '{role}': count must be >= 1")]
366 RoleCountZero { policy: String, role: String },
367
368 #[error("policy '{policy}', role '{role}': capabilities must not be empty")]
369 EmptyCapabilities { policy: String, role: String },
370
371 #[error("policy '{policy}': effort must be in [0.0, 1.0], got {value}")]
372 InvalidConvergence { policy: String, value: f32 },
373
374 #[error("policy '{policy}': max_rounds must be >= 1")]
375 ZeroRounds { policy: String },
376
377 #[error("policy '{policy}': sla.job_timeout_secs must be > 0")]
378 ZeroTimeout { policy: String },
379
380 #[error("policy '{policy}' ({mode}): total role count is {count}, need at least {min}")]
381 TooFewRoleAgents {
382 policy: String,
383 count: u32,
384 min: u32,
385 mode: &'static str,
386 },
387
388 #[error(
389 "policy '{policy}', role '{role}': pinned_agents count ({pinned}) exceeds role count ({count})"
390 )]
391 TooManyPinnedAgents {
392 policy: String,
393 role: String,
394 pinned: usize,
395 count: u8,
396 },
397
398 #[error("policy '{policy}', role '{role}': duplicate pinned agent '{agent}'")]
399 DuplicatePinnedAgent {
400 policy: String,
401 role: String,
402 agent: String,
403 },
404
405 #[error("policy '{policy}': too many agents ({count}), maximum is 255")]
406 TooManyAgents { policy: String, count: usize },
407
408 #[error("policy '{policy}', role '{role}': invalid capability tag '{tag}': {reason}")]
409 InvalidCapability {
410 policy: String,
411 role: String,
412 tag: String,
413 reason: String,
414 },
415
416 #[error("policy '{policy}': invalid capability tag '{tag}': {reason}")]
417 InvalidPolicyCapability {
418 policy: String,
419 tag: String,
420 reason: String,
421 },
422
423 #[error("policy '{policy}': invalid tag '{tag}': {reason}")]
424 InvalidPolicyTag {
425 policy: String,
426 tag: String,
427 reason: String,
428 },
429
430 #[error("room '{room}': references unknown policy '{policy}'")]
431 UnknownPolicy { room: String, policy: String },
432
433 #[error("room '{room}': references unknown orchestrator '{orchestrator}'")]
434 UnknownOrchestrator { room: String, orchestrator: String },
435
436 #[error("default_room '{name}' does not match any defined room")]
437 InvalidDefaultRoom { name: String },
438
439 #[error("failed to read config file: {0}")]
440 Io(#[from] std::io::Error),
441
442 #[error("failed to parse config YAML: {0}")]
443 Yaml(#[from] serde_yaml::Error),
444
445 #[error("room '{name}' not found (available: {available})")]
446 RoomNotFound { name: String, available: String },
447
448 #[error(
449 "multiple rooms defined but no --room flag or default_room set (available: {available})"
450 )]
451 AmbiguousRoom { available: String },
452
453 #[error("policy '{policy}': mode 'moderator' requires exactly one role with moderator: true")]
454 ModeratorRoleMissing { policy: String },
455
456 #[error("policy '{policy}': at most one role may have moderator: true")]
457 MultipleModeratorRoles { policy: String },
458
459 #[error(
460 "policy '{policy}': mode 'moderator' requires roles (not a flat agents list) \
461 so a role can be designated moderator: true"
462 )]
463 ModeratorRequiresRoles { policy: String },
464
465 #[error("fleet: agent at index {index} has an empty name")]
466 FleetEmptyAgentName { index: usize },
467
468 #[error("fleet: duplicate agent name '{name}'")]
469 FleetDuplicateAgent { name: String },
470
471 #[error("fleet: agent '{agent}' references unknown provider '{provider}'")]
472 FleetUnknownProvider { agent: String, provider: String },
473
474 #[error("{0}")]
475 ConfigFree(String),
476}
477
478impl ConfigError {
479 pub fn is_provisioning(&self) -> bool {
484 matches!(
485 self,
486 ConfigError::TooFewAgents { .. } | ConfigError::TooFewRoleAgents { .. }
487 )
488 }
489}
490
491fn policy_mode_name(mode: &PolicyMode) -> &'static str {
497 match mode {
498 PolicyMode::Deliberation => "deliberation",
499 PolicyMode::Passthrough => "passthrough",
500 PolicyMode::Moderator => "moderator",
501 }
502}
503
504pub fn validate_capability_tag(tag: &str) -> Result<(), String> {
505 if tag.is_empty() {
506 return Err("capability tag must not be empty".to_string());
507 }
508 if tag == "*" {
509 return Ok(());
510 }
511 if let Some(prefix) = tag.strip_suffix(":*") {
512 if prefix.is_empty() {
513 return Err("namespace before :* must not be empty".to_string());
514 }
515 return validate_tag_segment(prefix);
516 }
517 if tag.contains(':') {
518 let parts: Vec<&str> = tag.splitn(2, ':').collect();
519 validate_tag_segment(parts[0])?;
520 validate_tag_segment(parts[1])?;
521 return Ok(());
522 }
523 validate_tag_segment(tag)
524}
525
526fn validate_tag_segment(segment: &str) -> Result<(), String> {
527 if segment.is_empty() {
528 return Err("tag segment must not be empty".to_string());
529 }
530 if !segment
531 .chars()
532 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
533 {
534 return Err(format!(
535 "tag segment '{segment}' contains invalid characters (allowed: alphanumeric, -, _)"
536 ));
537 }
538 Ok(())
539}
540
541impl WorkspaceConfig {
542 pub fn load(path: &Path) -> Result<Self, ConfigError> {
544 let contents = std::fs::read_to_string(path)?;
545 let config: Self = serde_yaml::from_str(&contents)?;
546 config.validate()?;
547 Ok(config)
548 }
549
550 pub fn load_or_remote_default(path: &Path) -> Result<Self, ConfigError> {
556 if path.exists() {
557 QuorumConfig::load_workspace(path)
559 } else {
560 crate::cli::endpoint::remote_workspace().map_err(ConfigError::ConfigFree)
561 }
562 }
563
564 pub fn load_or_remote_default_for_view(path: &Path) -> Result<Self, ConfigError> {
571 if !path.exists() {
572 return crate::cli::endpoint::remote_workspace().map_err(ConfigError::ConfigFree);
573 }
574 let contents = std::fs::read_to_string(path)?;
575 let config: Self = match serde_yaml::from_str::<QuorumConfig>(&contents) {
579 Ok(q) => q.to_workspace(),
580 Err(_) => serde_yaml::from_str::<Self>(&contents)?,
581 };
582 match config.validate() {
583 Ok(()) => Ok(config),
584 Err(e) if e.is_provisioning() => Ok(config),
585 Err(e) => Err(e),
586 }
587 }
588
589 pub fn resolve_room<'a>(
595 &'a self,
596 room_flag: Option<&'a str>,
597 ) -> Result<(&'a str, &'a RoomConfig), ConfigError> {
598 let available = || {
599 self.rooms
600 .keys()
601 .map(|k| k.as_str())
602 .collect::<Vec<_>>()
603 .join(", ")
604 };
605
606 if let Some(name) = room_flag {
607 return self.rooms.get(name).map(|r| (name, r)).ok_or_else(|| {
608 ConfigError::RoomNotFound {
609 name: name.to_string(),
610 available: available(),
611 }
612 });
613 }
614
615 if let Some(ref default) = self.default_room {
616 return self
617 .rooms
618 .get_key_value(default.as_str())
619 .map(|(k, v)| (k.as_str(), v))
620 .ok_or_else(|| ConfigError::InvalidDefaultRoom {
621 name: default.clone(),
622 });
623 }
624
625 if self.rooms.len() == 1 {
626 let (k, v) = self.rooms.iter().next().unwrap();
627 return Ok((k.as_str(), v));
628 }
629
630 Err(ConfigError::AmbiguousRoom {
631 available: available(),
632 })
633 }
634
635 fn room_is_remote(&self, room: &RoomConfig) -> bool {
638 room.orchestrator
639 .as_ref()
640 .and_then(|name| self.orchestrators.get(name))
641 .map(|orch| orch.mode == Some(OrchestratorMode::Remote))
642 .unwrap_or(false)
643 }
644
645 pub fn validate(&self) -> Result<(), ConfigError> {
646 let has_rooms = !self.rooms.is_empty();
648
649 if has_rooms {
654 if self.orchestrators.is_empty() {
655 return Err(ConfigError::NoOrchestrators);
656 }
657 let needs_local_policy = self.rooms.values().any(|r| !self.room_is_remote(r));
658 if needs_local_policy && self.policies.is_empty() {
659 return Err(ConfigError::NoPolicies);
660 }
661 }
662
663 for (policy_name, policy) in &self.policies {
671 Self::validate_policy(policy_name, policy)?;
672 }
673
674 for (room_name, room) in &self.rooms {
676 if let Some(ref orch) = room.orchestrator
677 && !self.orchestrators.contains_key(orch)
678 {
679 return Err(ConfigError::UnknownOrchestrator {
680 room: room_name.clone(),
681 orchestrator: orch.clone(),
682 });
683 }
684 if !self.room_is_remote(room) && !self.policies.contains_key(&room.policy) {
687 return Err(ConfigError::UnknownPolicy {
688 room: room_name.clone(),
689 policy: room.policy.clone(),
690 });
691 }
692 }
693
694 if let Some(ref default) = self.default_room
695 && !self.rooms.contains_key(default)
696 {
697 return Err(ConfigError::InvalidDefaultRoom {
698 name: default.clone(),
699 });
700 }
701
702 Ok(())
703 }
704
705 fn validate_policy(name: &str, policy: &PolicyConfig) -> Result<(), ConfigError> {
706 match (&policy.agents, &policy.roles) {
707 (Some(_), Some(_)) => {
708 return Err(ConfigError::AgentsAndRolesExclusive {
709 policy: name.to_string(),
710 });
711 }
712 (None, None) => {
713 return Err(ConfigError::NeitherAgentsNorRoles {
714 policy: name.to_string(),
715 });
716 }
717 (Some(agents), None) => {
718 if policy.mode == PolicyMode::Moderator {
719 return Err(ConfigError::ModeratorRequiresRoles {
720 policy: name.to_string(),
721 });
722 }
723 let min_agents = match policy.mode {
724 PolicyMode::Deliberation => 2,
725 PolicyMode::Passthrough | PolicyMode::Moderator => 1,
726 };
727 if agents.len() < min_agents {
728 return Err(ConfigError::TooFewAgents {
729 policy: name.to_string(),
730 count: agents.len(),
731 min: min_agents,
732 mode: policy_mode_name(&policy.mode),
733 });
734 }
735 if agents.len() > 255 {
736 return Err(ConfigError::TooManyAgents {
737 policy: name.to_string(),
738 count: agents.len(),
739 });
740 }
741 }
742 (None, Some(roles)) => {
743 let mut seen_roles = HashSet::new();
744 let mut seen_pinned: HashSet<&String> = HashSet::new();
745 let mut total_count: u32 = 0;
746 let mut moderator_count: u32 = 0;
747
748 for role in roles {
749 if !seen_roles.insert(&role.role) {
750 return Err(ConfigError::DuplicateRole {
751 policy: name.to_string(),
752 role: role.role.clone(),
753 });
754 }
755 if role.count == 0 {
756 return Err(ConfigError::RoleCountZero {
757 policy: name.to_string(),
758 role: role.role.clone(),
759 });
760 }
761 if role.capabilities.is_empty() {
762 return Err(ConfigError::EmptyCapabilities {
763 policy: name.to_string(),
764 role: role.role.clone(),
765 });
766 }
767 for tag in &role.capabilities {
768 validate_capability_tag(tag).map_err(|reason| {
769 ConfigError::InvalidCapability {
770 policy: name.to_string(),
771 role: role.role.clone(),
772 tag: tag.clone(),
773 reason,
774 }
775 })?;
776 }
777 if let Some(ref pinned) = role.pinned_agents {
778 if pinned.len() > role.count as usize {
779 return Err(ConfigError::TooManyPinnedAgents {
780 policy: name.to_string(),
781 role: role.role.clone(),
782 pinned: pinned.len(),
783 count: role.count,
784 });
785 }
786 for agent in pinned {
787 if !seen_pinned.insert(agent) {
788 return Err(ConfigError::DuplicatePinnedAgent {
789 policy: name.to_string(),
790 role: role.role.clone(),
791 agent: agent.clone(),
792 });
793 }
794 }
795 }
796 if role.moderator {
797 moderator_count += 1;
798 }
799 total_count += role.count as u32;
800 }
801
802 if moderator_count > 1 {
803 return Err(ConfigError::MultipleModeratorRoles {
804 policy: name.to_string(),
805 });
806 }
807 if policy.mode == PolicyMode::Moderator && moderator_count == 0 {
808 return Err(ConfigError::ModeratorRoleMissing {
809 policy: name.to_string(),
810 });
811 }
812
813 let min_total: u32 = match policy.mode {
814 PolicyMode::Deliberation => 2,
815 PolicyMode::Passthrough | PolicyMode::Moderator => 1,
816 };
817 if total_count < min_total {
818 return Err(ConfigError::TooFewRoleAgents {
819 policy: name.to_string(),
820 count: total_count,
821 min: min_total,
822 mode: policy_mode_name(&policy.mode),
823 });
824 }
825 }
826 }
827
828 if policy.max_rounds == 0 {
829 return Err(ConfigError::ZeroRounds {
830 policy: name.to_string(),
831 });
832 }
833
834 if let Some(caps) = &policy.capabilities {
835 for tag in caps {
836 validate_capability_tag(tag).map_err(|reason| {
837 ConfigError::InvalidPolicyCapability {
838 policy: name.to_string(),
839 tag: tag.clone(),
840 reason,
841 }
842 })?;
843 }
844 }
845
846 if let Some(tags) = &policy.tags {
847 for tag in tags {
848 validate_capability_tag(tag).map_err(|reason| ConfigError::InvalidPolicyTag {
849 policy: name.to_string(),
850 tag: tag.clone(),
851 reason,
852 })?;
853 }
854 }
855
856 if !(0.0..=1.0).contains(&policy.effort) {
857 return Err(ConfigError::InvalidConvergence {
858 policy: name.to_string(),
859 value: policy.effort,
860 });
861 }
862
863 if let Some(sla) = &policy.sla
864 && sla.job_timeout_secs == 0
865 {
866 return Err(ConfigError::ZeroTimeout {
867 policy: name.to_string(),
868 });
869 }
870
871 Ok(())
872 }
873}