Skip to main content

meerkat_mob/
validate.rs

1//! Definition validation for mob definitions.
2//!
3//! Validates that all cross-references in a `MobDefinition` are consistent:
4//! skill references resolve, MCP references exist, orchestrator profile exists,
5//! wiring rules reference valid profiles, and profile names are valid identifiers.
6
7use crate::MobBackendKind;
8use crate::definition::MobDefinition;
9use serde::{Deserialize, Serialize};
10use std::fmt;
11
12/// Diagnostic code for validation errors.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
14#[serde(rename_all = "snake_case")]
15pub enum DiagnosticCode {
16    /// A skill referenced by a profile is not defined in the skills section.
17    MissingSkillRef,
18    /// An MCP server referenced by a profile is not defined in the mcp section.
19    MissingMcpRef,
20    /// The orchestrator profile is not defined.
21    MissingOrchestratorProfile,
22    /// A profile name is not a valid identifier.
23    InvalidProfileName,
24    /// A wiring rule references a profile that does not exist.
25    InvalidWiringProfile,
26    /// Definition has no spawnable profiles.
27    EmptyProfiles,
28    /// External backend selected but config missing.
29    MissingExternalBackendConfig,
30    /// External backend config is invalid.
31    InvalidExternalBackendConfig,
32    /// A flow has cyclic dependencies.
33    FlowCycleDetected,
34    /// A flow dependency points to an unknown step.
35    FlowUnknownStep,
36    /// A flow step references an unknown role.
37    FlowUnknownRole,
38    /// Flow depth exceeds hard limit.
39    FlowDepthExceeded,
40    /// Topology references an unknown role.
41    TopologyUnknownRole,
42    /// Quorum policy is invalid.
43    QuorumInvalid,
44    /// Branch group has fewer than two steps.
45    BranchGroupEmpty,
46    /// Branch step is missing a condition.
47    BranchStepMissingCondition,
48    /// Branch steps in same group do not share dependency set.
49    BranchStepConflictingDeps,
50    /// `depends_on_mode = any` used without branch dependencies.
51    BranchJoinWithoutBranch,
52    /// Definition uses a reserved flow-system identifier.
53    ReservedSystemIdentifier,
54    /// Inline peer notification threshold is outside supported range.
55    InvalidInlinePeerNotificationThreshold,
56}
57
58impl fmt::Display for DiagnosticCode {
59    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
60        let s = match self {
61            Self::MissingSkillRef => "missing_skill_ref",
62            Self::MissingMcpRef => "missing_mcp_ref",
63            Self::MissingOrchestratorProfile => "missing_orchestrator_profile",
64            Self::InvalidProfileName => "invalid_profile_name",
65            Self::InvalidWiringProfile => "invalid_wiring_profile",
66            Self::EmptyProfiles => "empty_profiles",
67            Self::MissingExternalBackendConfig => "missing_external_backend_config",
68            Self::InvalidExternalBackendConfig => "invalid_external_backend_config",
69            Self::FlowCycleDetected => "flow_cycle_detected",
70            Self::FlowUnknownStep => "flow_unknown_step",
71            Self::FlowUnknownRole => "flow_unknown_role",
72            Self::FlowDepthExceeded => "flow_depth_exceeded",
73            Self::TopologyUnknownRole => "topology_unknown_role",
74            Self::QuorumInvalid => "quorum_invalid",
75            Self::BranchGroupEmpty => "branch_group_empty",
76            Self::BranchStepMissingCondition => "branch_step_missing_condition",
77            Self::BranchStepConflictingDeps => "branch_step_conflicting_deps",
78            Self::BranchJoinWithoutBranch => "branch_join_without_branch",
79            Self::ReservedSystemIdentifier => "reserved_system_identifier",
80            Self::InvalidInlinePeerNotificationThreshold => {
81                "invalid_inline_peer_notification_threshold"
82            }
83        };
84        f.write_str(s)
85    }
86}
87
88/// Diagnostic severity.
89#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
90#[serde(rename_all = "snake_case")]
91pub enum DiagnosticSeverity {
92    Error,
93    Warning,
94}
95
96/// A validation diagnostic with code, message, and optional location.
97#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
98pub struct Diagnostic {
99    /// Machine-readable diagnostic code.
100    pub code: DiagnosticCode,
101    /// Human-readable description.
102    pub message: String,
103    /// Optional path-like location (e.g. "profiles.worker.skills[0]").
104    pub location: Option<String>,
105    /// Severity of this diagnostic.
106    pub severity: DiagnosticSeverity,
107}
108
109/// Validate a mob definition for consistency.
110///
111/// Returns an empty `Vec` if the definition is valid.
112pub fn validate_definition(def: &MobDefinition) -> Vec<Diagnostic> {
113    let mut diagnostics = Vec::new();
114
115    if def.profiles.is_empty() {
116        diagnostics.push(Diagnostic {
117            code: DiagnosticCode::EmptyProfiles,
118            message: "mob definition must define at least one profile".to_string(),
119            location: Some("profiles".to_string()),
120            severity: DiagnosticSeverity::Error,
121        });
122    }
123
124    // Check orchestrator profile exists
125    if let Some(orch) = &def.orchestrator
126        && !def.profiles.contains_key(&orch.profile)
127    {
128        diagnostics.push(Diagnostic {
129            code: DiagnosticCode::MissingOrchestratorProfile,
130            message: format!(
131                "orchestrator profile '{}' is not defined",
132                orch.profile.as_str()
133            ),
134            location: Some("mob.orchestrator".to_string()),
135            severity: DiagnosticSeverity::Error,
136        });
137    }
138
139    // Check profile names are valid identifiers and cross-references
140    for (name, binding) in &def.profiles {
141        // Validate profile name
142        if !is_valid_identifier(name.as_str()) {
143            diagnostics.push(Diagnostic {
144                code: DiagnosticCode::InvalidProfileName,
145                message: format!("profile name '{}' is not a valid identifier", name.as_str()),
146                location: Some(format!("profiles.{}", name.as_str())),
147                severity: DiagnosticSeverity::Error,
148            });
149        }
150        if name.as_str() == crate::runtime::flow_system_member_id().as_str()
151            || name
152                .as_str()
153                .starts_with(crate::runtime::FLOW_SYSTEM_MEMBER_ID_PREFIX)
154        {
155            diagnostics.push(Diagnostic {
156                code: DiagnosticCode::ReservedSystemIdentifier,
157                message: format!(
158                    "profile name '{}' uses reserved flow-system identifier namespace",
159                    name.as_str()
160                ),
161                location: Some(format!("profiles.{}", name.as_str())),
162                severity: DiagnosticSeverity::Error,
163            });
164        }
165
166        // RealmRef bindings are validated at resolution time; skip inner checks
167        let Some(profile) = binding.as_inline() else {
168            continue;
169        };
170
171        // Check skill references
172        for (i, skill_ref) in profile.skills.iter().enumerate() {
173            if !def.skills.contains_key(skill_ref) {
174                diagnostics.push(Diagnostic {
175                    code: DiagnosticCode::MissingSkillRef,
176                    message: format!("skill '{skill_ref}' is not defined"),
177                    location: Some(format!("profiles.{}.skills[{}]", name.as_str(), i)),
178                    severity: DiagnosticSeverity::Error,
179                });
180            }
181        }
182
183        // Check MCP server references
184        for (i, mcp_ref) in profile.tools.mcp.iter().enumerate() {
185            if !def.mcp_servers.contains_key(mcp_ref) {
186                diagnostics.push(Diagnostic {
187                    code: DiagnosticCode::MissingMcpRef,
188                    message: format!("MCP server '{mcp_ref}' is not defined"),
189                    location: Some(format!("profiles.{}.tools.mcp[{}]", name.as_str(), i)),
190                    severity: DiagnosticSeverity::Error,
191                });
192            }
193        }
194
195        if let Some(threshold) = profile.max_inline_peer_notifications
196            && threshold < -1
197        {
198            diagnostics.push(Diagnostic {
199                code: DiagnosticCode::InvalidInlinePeerNotificationThreshold,
200                message: format!(
201                    "profiles.{} max_inline_peer_notifications={} is invalid (allowed: -1, 0, or >0)",
202                    name.as_str(),
203                    threshold
204                ),
205                location: Some(format!(
206                    "profiles.{}.max_inline_peer_notifications",
207                    name.as_str()
208                )),
209                severity: DiagnosticSeverity::Error,
210            });
211        }
212    }
213
214    // Check wiring rules reference valid profiles
215    for (i, rule) in def.wiring.role_wiring.iter().enumerate() {
216        if !def.profiles.contains_key(&rule.a) {
217            diagnostics.push(Diagnostic {
218                code: DiagnosticCode::InvalidWiringProfile,
219                message: format!(
220                    "wiring rule references non-existent profile '{}'",
221                    rule.a.as_str()
222                ),
223                location: Some(format!("wiring.role_wiring[{i}].a")),
224                severity: DiagnosticSeverity::Error,
225            });
226        }
227        if !def.profiles.contains_key(&rule.b) {
228            diagnostics.push(Diagnostic {
229                code: DiagnosticCode::InvalidWiringProfile,
230                message: format!(
231                    "wiring rule references non-existent profile '{}'",
232                    rule.b.as_str()
233                ),
234                location: Some(format!("wiring.role_wiring[{i}].b")),
235                severity: DiagnosticSeverity::Error,
236            });
237        }
238    }
239
240    let definition_uses_external_default = def.backend.default == MobBackendKind::External;
241    let profile_uses_external = def
242        .profiles
243        .values()
244        .filter_map(|b| b.as_inline())
245        .any(|profile| profile.backend == Some(MobBackendKind::External));
246    if definition_uses_external_default || profile_uses_external {
247        match &def.backend.external {
248            None => diagnostics.push(Diagnostic {
249                code: DiagnosticCode::MissingExternalBackendConfig,
250                message: "external backend selected but backend.external config is missing"
251                    .to_string(),
252                location: Some("backend.external".to_string()),
253                severity: DiagnosticSeverity::Error,
254            }),
255            Some(external) if external.address_base.trim().is_empty() => {
256                diagnostics.push(Diagnostic {
257                    code: DiagnosticCode::InvalidExternalBackendConfig,
258                    message: "backend.external.address_base must not be empty".to_string(),
259                    location: Some("backend.external.address_base".to_string()),
260                    severity: DiagnosticSeverity::Error,
261                });
262            }
263            Some(_) => {}
264        }
265    }
266
267    diagnostics
268}
269
270/// Split diagnostics into `(errors, warnings)`.
271pub fn partition_diagnostics(
272    diagnostics: impl IntoIterator<Item = Diagnostic>,
273) -> (Vec<Diagnostic>, Vec<Diagnostic>) {
274    diagnostics
275        .into_iter()
276        .partition(|diag| diag.severity == DiagnosticSeverity::Error)
277}
278
279/// Check if a string is a valid identifier (alphanumeric, hyphens, underscores).
280fn is_valid_identifier(s: &str) -> bool {
281    if s.is_empty() {
282        return false;
283    }
284    // Must start with a letter or underscore
285    let first = s.chars().next().unwrap_or(' ');
286    if !first.is_ascii_alphabetic() && first != '_' {
287        return false;
288    }
289    // Rest must be alphanumeric, hyphen, or underscore
290    s.chars()
291        .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
292}
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297    use crate::definition::{
298        BackendConfig, McpServerConfig, MobDefinition, OrchestratorConfig, RoleWiringRule,
299        SkillSource, WiringRules,
300    };
301    use crate::ids::{MobId, ProfileName};
302    use crate::profile::{Profile, ProfileBinding, ToolConfig};
303    use std::collections::BTreeMap;
304
305    fn base_profile() -> Profile {
306        Profile {
307            model: "claude-opus-4-6".to_string(),
308            skills: vec![],
309            tools: ToolConfig::default(),
310            peer_description: "test".to_string(),
311            external_addressable: false,
312            backend: None,
313            runtime_mode: crate::MobRuntimeMode::AutonomousHost,
314            max_inline_peer_notifications: None,
315            output_schema: None,
316            provider_params: None,
317        }
318    }
319
320    fn valid_definition() -> MobDefinition {
321        let mut profiles = BTreeMap::new();
322        profiles.insert(
323            ProfileName::from("lead"),
324            ProfileBinding::Inline({
325                let mut p = base_profile();
326                p.skills = vec!["skill-a".to_string()];
327                p.tools.mcp = vec!["server-a".to_string()];
328                p
329            }),
330        );
331        profiles.insert(
332            ProfileName::from("worker"),
333            ProfileBinding::Inline(base_profile()),
334        );
335
336        let mut skills = BTreeMap::new();
337        skills.insert(
338            "skill-a".to_string(),
339            SkillSource::Inline {
340                content: "You are a leader.".to_string(),
341            },
342        );
343
344        let mut mcp_servers = BTreeMap::new();
345        mcp_servers.insert(
346            "server-a".to_string(),
347            McpServerConfig {
348                command: vec!["node".to_string(), "server.js".to_string()],
349                url: None,
350                env: BTreeMap::new(),
351            },
352        );
353
354        MobDefinition {
355            id: MobId::from("test-mob"),
356            orchestrator: Some(OrchestratorConfig {
357                profile: ProfileName::from("lead"),
358            }),
359            profiles,
360            mcp_servers,
361            wiring: WiringRules {
362                auto_wire_orchestrator: true,
363                role_wiring: vec![RoleWiringRule {
364                    a: ProfileName::from("lead"),
365                    b: ProfileName::from("worker"),
366                }],
367            },
368            skills,
369            backend: BackendConfig::default(),
370            flows: BTreeMap::new(),
371            topology: None,
372            supervisor: None,
373            limits: None,
374            spawn_policy: None,
375            event_router: None,
376            owner_session_id: None,
377            session_cleanup_policy: crate::definition::SessionCleanupPolicy::Manual,
378            is_implicit: false,
379        }
380    }
381
382    #[test]
383    fn test_valid_definition_passes() {
384        let diagnostics = validate_definition(&valid_definition());
385        assert!(diagnostics.is_empty(), "unexpected: {diagnostics:?}");
386    }
387
388    #[test]
389    fn test_empty_profiles_is_invalid() {
390        let mut def = valid_definition();
391        def.profiles.clear();
392        let diagnostics = validate_definition(&def);
393        assert!(
394            diagnostics
395                .iter()
396                .any(|d| d.code == DiagnosticCode::EmptyProfiles),
397            "empty profile map must be rejected"
398        );
399    }
400
401    #[test]
402    fn test_missing_skill_ref() {
403        let mut def = valid_definition();
404        def.profiles
405            .get_mut(&ProfileName::from("lead"))
406            .unwrap()
407            .as_inline_mut()
408            .unwrap()
409            .skills
410            .push("nonexistent-skill".to_string());
411
412        let diagnostics = validate_definition(&def);
413        assert_eq!(diagnostics.len(), 1);
414        assert_eq!(diagnostics[0].code, DiagnosticCode::MissingSkillRef);
415        assert!(diagnostics[0].message.contains("nonexistent-skill"));
416        assert_eq!(diagnostics[0].severity, DiagnosticSeverity::Error);
417    }
418
419    #[test]
420    fn test_missing_mcp_ref() {
421        let mut def = valid_definition();
422        def.profiles
423            .get_mut(&ProfileName::from("lead"))
424            .unwrap()
425            .as_inline_mut()
426            .unwrap()
427            .tools
428            .mcp
429            .push("nonexistent-mcp".to_string());
430
431        let diagnostics = validate_definition(&def);
432        assert_eq!(diagnostics.len(), 1);
433        assert_eq!(diagnostics[0].code, DiagnosticCode::MissingMcpRef);
434        assert!(diagnostics[0].message.contains("nonexistent-mcp"));
435        assert_eq!(diagnostics[0].severity, DiagnosticSeverity::Error);
436    }
437
438    #[test]
439    fn test_missing_orchestrator_profile() {
440        let mut def = valid_definition();
441        def.orchestrator = Some(OrchestratorConfig {
442            profile: ProfileName::from("nonexistent"),
443        });
444
445        let diagnostics = validate_definition(&def);
446        assert!(
447            diagnostics
448                .iter()
449                .any(|d| d.code == DiagnosticCode::MissingOrchestratorProfile)
450        );
451    }
452
453    #[test]
454    fn test_invalid_profile_name() {
455        let mut def = valid_definition();
456        def.profiles.insert(
457            ProfileName::from("123-invalid"),
458            ProfileBinding::Inline(base_profile()),
459        );
460
461        let diagnostics = validate_definition(&def);
462        assert!(
463            diagnostics
464                .iter()
465                .any(|d| d.code == DiagnosticCode::InvalidProfileName)
466        );
467    }
468
469    #[test]
470    fn test_reserved_system_profile_name_rejected() {
471        let mut def = valid_definition();
472        def.profiles.insert(
473            crate::runtime::flow_system_member_id().as_str().into(),
474            ProfileBinding::Inline(base_profile()),
475        );
476
477        let diagnostics = validate_definition(&def);
478        assert!(diagnostics.iter().any(|d| {
479            d.code == DiagnosticCode::ReservedSystemIdentifier
480                && d.message.contains("reserved flow-system identifier")
481        }));
482    }
483
484    #[test]
485    fn test_invalid_wiring_profile() {
486        let mut def = valid_definition();
487        def.wiring.role_wiring.push(RoleWiringRule {
488            a: ProfileName::from("nonexistent-a"),
489            b: ProfileName::from("nonexistent-b"),
490        });
491
492        let diagnostics = validate_definition(&def);
493        let wiring_diags: Vec<_> = diagnostics
494            .iter()
495            .filter(|d| d.code == DiagnosticCode::InvalidWiringProfile)
496            .collect();
497        assert_eq!(wiring_diags.len(), 2);
498    }
499
500    #[test]
501    fn test_multiple_errors() {
502        let mut def = valid_definition();
503        def.orchestrator = Some(OrchestratorConfig {
504            profile: ProfileName::from("gone"),
505        });
506        def.profiles
507            .get_mut(&ProfileName::from("lead"))
508            .unwrap()
509            .as_inline_mut()
510            .unwrap()
511            .skills
512            .push("bad-skill".to_string());
513        def.profiles
514            .get_mut(&ProfileName::from("lead"))
515            .unwrap()
516            .as_inline_mut()
517            .unwrap()
518            .tools
519            .mcp
520            .push("bad-mcp".to_string());
521
522        let diagnostics = validate_definition(&def);
523        assert!(diagnostics.len() >= 3);
524    }
525
526    #[test]
527    fn test_external_backend_requires_external_config() {
528        let mut def = valid_definition();
529        def.backend.default = MobBackendKind::External;
530        let diagnostics = validate_definition(&def);
531        assert!(
532            diagnostics
533                .iter()
534                .any(|d| d.code == DiagnosticCode::MissingExternalBackendConfig)
535        );
536    }
537
538    #[test]
539    fn test_external_backend_rejects_empty_address_base() {
540        let mut def = valid_definition();
541        def.profiles
542            .get_mut(&ProfileName::from("worker"))
543            .expect("worker profile exists")
544            .as_inline_mut()
545            .unwrap()
546            .backend = Some(MobBackendKind::External);
547        def.backend.external = Some(crate::definition::ExternalBackendConfig {
548            address_base: "   ".to_string(),
549        });
550        let diagnostics = validate_definition(&def);
551        assert!(
552            diagnostics
553                .iter()
554                .any(|d| d.code == DiagnosticCode::InvalidExternalBackendConfig)
555        );
556    }
557
558    #[test]
559    fn test_parse_and_validate_rejects_missing_external_backend_config() {
560        let toml = r#"
561[mob]
562id = "mob-ext"
563
564[backend]
565default = "external"
566
567[profiles.worker]
568model = "claude-sonnet-4-5"
569"#;
570        let def = MobDefinition::from_toml(toml).expect("parse toml");
571        let diagnostics = validate_definition(&def);
572        assert!(
573            diagnostics
574                .iter()
575                .any(|d| d.code == DiagnosticCode::MissingExternalBackendConfig)
576        );
577    }
578
579    #[test]
580    fn test_invalid_inline_peer_notification_threshold_is_rejected() {
581        let mut def = valid_definition();
582        def.profiles
583            .get_mut(&ProfileName::from("lead"))
584            .expect("lead profile")
585            .as_inline_mut()
586            .unwrap()
587            .max_inline_peer_notifications = Some(-2);
588
589        let diagnostics = validate_definition(&def);
590        assert!(
591            diagnostics.iter().any(|d| {
592                d.code == DiagnosticCode::InvalidInlinePeerNotificationThreshold
593                    && d.location.as_deref() == Some("profiles.lead.max_inline_peer_notifications")
594            }),
595            "expected invalid inline threshold diagnostic"
596        );
597    }
598
599    #[test]
600    fn test_valid_identifier_patterns() {
601        assert!(is_valid_identifier("worker"));
602        assert!(is_valid_identifier("lead_agent"));
603        assert!(is_valid_identifier("agent-1"));
604        assert!(is_valid_identifier("_private"));
605        assert!(!is_valid_identifier(""));
606        assert!(!is_valid_identifier("123bad"));
607        assert!(!is_valid_identifier("-start"));
608        assert!(!is_valid_identifier("has space"));
609    }
610
611    #[test]
612    fn test_diagnostic_serde_roundtrip() {
613        let diag = Diagnostic {
614            code: DiagnosticCode::MissingSkillRef,
615            message: "skill 'foo' not found".to_string(),
616            location: Some("profiles.worker.skills[0]".to_string()),
617            severity: DiagnosticSeverity::Error,
618        };
619        let json = serde_json::to_string(&diag).unwrap();
620        let parsed: Diagnostic = serde_json::from_str(&json).unwrap();
621        assert_eq!(parsed, diag);
622    }
623
624    #[test]
625    fn test_partition_diagnostics() {
626        let diagnostics = vec![
627            Diagnostic {
628                code: DiagnosticCode::MissingSkillRef,
629                message: "missing".to_string(),
630                location: None,
631                severity: DiagnosticSeverity::Error,
632            },
633            Diagnostic {
634                code: DiagnosticCode::BranchJoinWithoutBranch,
635                message: "warn".to_string(),
636                location: None,
637                severity: DiagnosticSeverity::Warning,
638            },
639        ];
640        let (errors, warnings) = partition_diagnostics(diagnostics);
641        assert_eq!(errors.len(), 1);
642        assert_eq!(warnings.len(), 1);
643        assert_eq!(errors[0].severity, DiagnosticSeverity::Error);
644        assert_eq!(warnings[0].severity, DiagnosticSeverity::Warning);
645    }
646
647    #[test]
648    fn test_minimal_valid_definition() {
649        let def = MobDefinition {
650            id: MobId::from("minimal"),
651            orchestrator: None,
652            profiles: BTreeMap::new(),
653            mcp_servers: BTreeMap::new(),
654            wiring: WiringRules::default(),
655            skills: BTreeMap::new(),
656            backend: BackendConfig::default(),
657            flows: BTreeMap::new(),
658            topology: None,
659            supervisor: None,
660            limits: None,
661            spawn_policy: None,
662            event_router: None,
663            owner_session_id: None,
664            session_cleanup_policy: crate::definition::SessionCleanupPolicy::Manual,
665            is_implicit: false,
666        };
667        let diagnostics = validate_definition(&def);
668        assert!(
669            diagnostics
670                .iter()
671                .any(|d| d.code == DiagnosticCode::EmptyProfiles),
672            "minimal definition without profiles should fail validation"
673        );
674    }
675}