1use crate::eval::Operator;
4use crate::model::{
5 is_safe_id, is_valid_vendor, CoreNodeType, FlowDefinition, FlowNode, FlowNodeType, SavedFlow,
6 SUPPORTED_SPEC_VERSIONS,
7};
8use crate::nodes::{BranchData, ConditionalData};
9use std::collections::HashSet;
10use std::fmt;
11
12#[derive(Debug, Clone, PartialEq, Eq)]
14pub enum ValidationError {
15 InvalidFlowId(String),
17 UnsupportedSpecVersion(String),
19 MultipleEntryNodes(usize),
21 DuplicateNodeId(String),
23 DuplicateEdgeId(String),
25 DanglingEdgeSource {
27 edge: String,
29 source: String,
31 },
32 DanglingEdgeTarget {
34 edge: String,
36 target: String,
38 },
39 InvalidVendorNamespace {
41 node: String,
43 node_type: String,
45 },
46 V2NodeInV1Document {
48 node: String,
50 node_type: String,
52 },
53 InvalidNodeData {
55 node: String,
57 message: String,
59 },
60 UnknownOperator {
62 node: String,
64 operator: String,
66 },
67 MissingHandleEdge {
69 node: String,
71 handle: String,
73 },
74 InvalidRequires {
77 message: String,
79 },
80 InvalidSchedule {
83 message: String,
85 },
86}
87
88impl fmt::Display for ValidationError {
89 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
90 match self {
91 ValidationError::InvalidFlowId(id) => {
92 write!(f, "invalid flow id {id:?}: must match [A-Za-z0-9-]{{1,64}}")
93 }
94 ValidationError::UnsupportedSpecVersion(v) => {
95 write!(f, "unsupported spec_version {v:?}; this parser supports {SUPPORTED_SPEC_VERSIONS:?}")
96 }
97 ValidationError::MultipleEntryNodes(n) => {
98 write!(f, "flow has {n} entry nodes; at most one is allowed")
99 }
100 ValidationError::DuplicateNodeId(id) => {
101 write!(f, "duplicate node id {id:?}")
102 }
103 ValidationError::DuplicateEdgeId(id) => {
104 write!(f, "duplicate edge id {id:?}")
105 }
106 ValidationError::DanglingEdgeSource { edge, source } => {
107 write!(f, "edge {edge:?} references unknown source node {source:?}")
108 }
109 ValidationError::DanglingEdgeTarget { edge, target } => {
110 write!(f, "edge {edge:?} references unknown target node {target:?}")
111 }
112 ValidationError::InvalidVendorNamespace { node, node_type } => {
113 write!(
114 f,
115 "node {node:?} has malformed custom node_type {node_type:?}: \
116 vendor prefix must match [a-z][a-z0-9_-]{{0,31}}"
117 )
118 }
119 ValidationError::V2NodeInV1Document { node, node_type } => {
120 write!(
121 f,
122 "node {node:?} uses v2 node type {node_type:?} but the document \
123 declares spec_version \"1\"; set spec_version to \"2\""
124 )
125 }
126 ValidationError::InvalidNodeData { node, message } => {
127 write!(f, "node {node:?} has invalid data: {message}")
128 }
129 ValidationError::UnknownOperator { node, operator } => {
130 write!(f, "node {node:?} uses unknown operator {operator:?}")
131 }
132 ValidationError::MissingHandleEdge { node, handle } => {
133 write!(f, "node {node:?} declares handle {handle:?} but no edge leaves it via that handle")
134 }
135 ValidationError::InvalidRequires { message } => {
136 write!(f, "invalid `requires`: {message}")
137 }
138 ValidationError::InvalidSchedule { message } => {
139 write!(f, "invalid schedule: {message}")
140 }
141 }
142 }
143}
144
145impl std::error::Error for ValidationError {}
146
147pub fn validate(flow: &SavedFlow) -> Vec<ValidationError> {
151 let mut errors = Vec::new();
152
153 if !is_safe_id(&flow.id) {
154 errors.push(ValidationError::InvalidFlowId(flow.id.clone()));
155 }
156
157 if !SUPPORTED_SPEC_VERSIONS.contains(&flow.spec_version.as_str()) {
158 errors.push(ValidationError::UnsupportedSpecVersion(
159 flow.spec_version.clone(),
160 ));
161 }
162
163 if let Some(req) = &flow.requires {
164 validate_requires(req, &mut errors);
165 }
166
167 validate_schedules(&flow.schedules, &mut errors);
168
169 validate_definition(&flow.flow, &flow.spec_version, &mut errors);
170 errors
171}
172
173fn validate_schedules(
178 schedules: &[crate::model::FlowScheduleSpec],
179 errors: &mut Vec<ValidationError>,
180) {
181 use crate::model::ScheduleTrigger;
182
183 let mut seen: HashSet<&str> = HashSet::new();
184 for s in schedules {
185 if s.id.trim().is_empty() {
186 errors.push(ValidationError::InvalidSchedule {
187 message: "schedule id must not be empty".to_string(),
188 });
189 } else if !seen.insert(s.id.as_str()) {
190 errors.push(ValidationError::InvalidSchedule {
191 message: format!("duplicate schedule id {:?}", s.id),
192 });
193 }
194 match &s.trigger {
195 ScheduleTrigger::Manual => {}
196 ScheduleTrigger::Minutes { interval } | ScheduleTrigger::Hours { interval } => {
197 if *interval == 0 {
198 errors.push(ValidationError::InvalidSchedule {
199 message: format!("schedule {:?} interval must be positive", s.id),
200 });
201 }
202 }
203 ScheduleTrigger::Cron { cron } => {
204 if cron.trim().is_empty() {
205 errors.push(ValidationError::InvalidSchedule {
206 message: format!("schedule {:?} has an empty cron expression", s.id),
207 });
208 }
209 }
210 }
211 }
212}
213
214fn validate_requires(req: &crate::requires::Requires, errors: &mut Vec<ValidationError>) {
219 use crate::requires::{is_valid_pack_id, is_valid_sha256};
220
221 let mut seen: HashSet<&str> = HashSet::new();
222 for pr in &req.packs {
223 if !is_valid_pack_id(&pr.id) {
224 errors.push(ValidationError::InvalidRequires {
225 message: format!(
226 "pack id {:?} must match [a-z0-9][a-z0-9_-]{{0,63}}",
227 pr.id
228 ),
229 });
230 }
231 if !seen.insert(pr.id.as_str()) {
232 errors.push(ValidationError::InvalidRequires {
233 message: format!("duplicate pack requirement {:?}", pr.id),
234 });
235 }
236 if let Some(range) = &pr.version
237 && semver::VersionReq::parse(range).is_err()
238 {
239 errors.push(ValidationError::InvalidRequires {
240 message: format!("pack {:?} has unparseable version range {range:?}", pr.id),
241 });
242 }
243 if let Some(rv) = &pr.resolved_version
244 && semver::Version::parse(rv).is_err()
245 {
246 errors.push(ValidationError::InvalidRequires {
247 message: format!("pack {:?} has unparseable resolved_version {rv:?}", pr.id),
248 });
249 }
250 if let Some(hash) = &pr.content_sha256
251 && !is_valid_sha256(hash)
252 {
253 errors.push(ValidationError::InvalidRequires {
254 message: format!(
255 "pack {:?} content_sha256 must be 64 lowercase hex chars",
256 pr.id
257 ),
258 });
259 }
260 }
261}
262
263pub fn validate_definition_only(def: &FlowDefinition) -> Vec<ValidationError> {
267 let mut errors = Vec::new();
268 validate_definition(def, crate::SPEC_VERSION, &mut errors);
269 errors
270}
271
272fn validate_definition(def: &FlowDefinition, spec_version: &str, errors: &mut Vec<ValidationError>) {
273 let entry_count = def
275 .nodes
276 .iter()
277 .filter(|n| matches!(n.node_type, FlowNodeType::Core(CoreNodeType::Entry)))
278 .count();
279 if entry_count > 1 {
280 errors.push(ValidationError::MultipleEntryNodes(entry_count));
281 }
282
283 let mut seen_nodes = HashSet::new();
285 for n in &def.nodes {
286 if !seen_nodes.insert(n.id.as_str()) {
287 errors.push(ValidationError::DuplicateNodeId(n.id.clone()));
288 }
289 match &n.node_type {
290 FlowNodeType::Custom(s) => {
292 if let Some((prefix, _)) = s.split_once(':') {
293 if !is_valid_vendor(prefix) {
294 errors.push(ValidationError::InvalidVendorNamespace {
295 node: n.id.clone(),
296 node_type: s.clone(),
297 });
298 }
299 } else {
300 errors.push(ValidationError::InvalidVendorNamespace {
304 node: n.id.clone(),
305 node_type: s.clone(),
306 });
307 }
308 }
309 FlowNodeType::Core(core) => {
310 if spec_version == "1" && core.is_v2() {
312 errors.push(ValidationError::V2NodeInV1Document {
313 node: n.id.clone(),
314 node_type: core.as_str().to_string(),
315 });
316 }
317 validate_core_node_data(n, *core, def, errors);
318 }
319 }
320 }
321
322 let node_ids: HashSet<&str> = def.nodes.iter().map(|n| n.id.as_str()).collect();
324 let mut seen_edges = HashSet::new();
325 for e in &def.edges {
326 if !seen_edges.insert(e.id.as_str()) {
327 errors.push(ValidationError::DuplicateEdgeId(e.id.clone()));
328 }
329 if !node_ids.contains(e.source.as_str()) {
330 errors.push(ValidationError::DanglingEdgeSource {
331 edge: e.id.clone(),
332 source: e.source.clone(),
333 });
334 }
335 if !node_ids.contains(e.target.as_str()) {
336 errors.push(ValidationError::DanglingEdgeTarget {
337 edge: e.id.clone(),
338 target: e.target.clone(),
339 });
340 }
341 }
342}
343
344fn has_handle_edge(def: &FlowDefinition, node_id: &str, handle: &str) -> bool {
346 def.edges
347 .iter()
348 .any(|e| e.source == node_id && e.source_handle.as_deref() == Some(handle))
349}
350
351fn validate_core_node_data(
355 node: &FlowNode,
356 core: CoreNodeType,
357 def: &FlowDefinition,
358 errors: &mut Vec<ValidationError>,
359) {
360 match core {
361 CoreNodeType::Conditional => {
362 match serde_json::from_value::<ConditionalData>(node.data.clone()) {
363 Ok(data) => {
364 for cond in &data.conditions {
365 if Operator::from_wire(&cond.operator).is_none() {
366 errors.push(ValidationError::UnknownOperator {
367 node: node.id.clone(),
368 operator: cond.operator.clone(),
369 });
370 }
371 if !has_handle_edge(def, &node.id, &cond.handle) {
372 errors.push(ValidationError::MissingHandleEdge {
373 node: node.id.clone(),
374 handle: cond.handle.clone(),
375 });
376 }
377 }
378 if let Some(dh) = &data.default_handle
379 && !has_handle_edge(def, &node.id, dh)
380 {
381 errors.push(ValidationError::MissingHandleEdge {
382 node: node.id.clone(),
383 handle: dh.clone(),
384 });
385 }
386 }
387 Err(e) => errors.push(ValidationError::InvalidNodeData {
388 node: node.id.clone(),
389 message: format!("expected conditional data: {e}"),
390 }),
391 }
392 }
393 CoreNodeType::Branch => {
394 match serde_json::from_value::<BranchData>(node.data.clone()) {
395 Ok(data) => {
396 if data.outputs.is_empty() {
397 errors.push(ValidationError::InvalidNodeData {
398 node: node.id.clone(),
399 message: "branch must declare at least one output".into(),
400 });
401 }
402 for out in &data.outputs {
403 if !has_handle_edge(def, &node.id, &out.handle) {
404 errors.push(ValidationError::MissingHandleEdge {
405 node: node.id.clone(),
406 handle: out.handle.clone(),
407 });
408 }
409 if out.handle == crate::BRANCH_ERROR_HANDLE
415 && out.schema.as_ref().is_some_and(|s| {
416 s.get("type").and_then(|t| t.as_str()) != Some("string")
417 })
418 {
419 errors.push(ValidationError::InvalidNodeData {
420 node: node.id.clone(),
421 message: "the reserved `error` handle carries a string reason; \
422 its schema must be omitted or {\"type\":\"string\"}"
423 .into(),
424 });
425 }
426 }
427 if let Some(dh) = &data.default_handle
428 && !has_handle_edge(def, &node.id, dh)
429 {
430 errors.push(ValidationError::MissingHandleEdge {
431 node: node.id.clone(),
432 handle: dh.clone(),
433 });
434 }
435 }
436 Err(e) => errors.push(ValidationError::InvalidNodeData {
437 node: node.id.clone(),
438 message: format!("expected branch data: {e}"),
439 }),
440 }
441 }
442 _ => {}
444 }
445}
446
447#[cfg(test)]
448mod tests {
449 use super::*;
450 use crate::model::{FlowEdge, FlowNode};
451 use serde_json::json;
452
453 fn entry(id: &str) -> FlowNode {
454 FlowNode {
455 id: id.into(),
456 node_type: FlowNodeType::Core(CoreNodeType::Entry),
457 data: json!({}),
458 position: [0.0, 0.0],
459 }
460 }
461 fn prompt(id: &str) -> FlowNode {
462 FlowNode {
463 id: id.into(),
464 node_type: FlowNodeType::Core(CoreNodeType::Prompt),
465 data: json!({}),
466 position: [0.0, 0.0],
467 }
468 }
469 fn edge(id: &str, src: &str, tgt: &str) -> FlowEdge {
470 FlowEdge {
471 id: id.into(),
472 source: src.into(),
473 target: tgt.into(),
474 source_handle: None,
475 target_handle: None,
476 }
477 }
478 fn saved(def: FlowDefinition) -> SavedFlow {
479 SavedFlow {
480 spec_version: "1".into(),
481 id: "ok-id".into(),
482 name: "X".into(),
483 created_at: "2026-01-01T00:00:00Z".into(),
484 updated_at: "2026-01-01T00:00:00Z".into(),
485 enabled: false,
486 schedules: vec![],
487 requires: None,
488 flow: def,
489 }
490 }
491
492 #[test]
493 fn valid_minimal_flow_has_no_errors() {
494 let def = FlowDefinition {
495 nodes: vec![entry("e")],
496 edges: vec![],
497 };
498 assert!(validate(&saved(def)).is_empty());
499 }
500
501 #[test]
502 fn invalid_flow_id_caught() {
503 let mut sf = saved(FlowDefinition::default());
504 sf.id = "bad id with spaces".into();
505 let errs = validate(&sf);
506 assert!(errs.iter().any(|e| matches!(e, ValidationError::InvalidFlowId(_))));
507 }
508
509 #[test]
510 fn multiple_entries_caught() {
511 let def = FlowDefinition {
512 nodes: vec![entry("a"), entry("b")],
513 edges: vec![],
514 };
515 let errs = validate(&saved(def));
516 assert!(errs
517 .iter()
518 .any(|e| matches!(e, ValidationError::MultipleEntryNodes(2))));
519 }
520
521 #[test]
522 fn dangling_edge_caught() {
523 let def = FlowDefinition {
524 nodes: vec![entry("e")],
525 edges: vec![edge("x", "e", "missing")],
526 };
527 let errs = validate(&saved(def));
528 assert!(errs
529 .iter()
530 .any(|e| matches!(e, ValidationError::DanglingEdgeTarget { .. })));
531 }
532
533 #[test]
534 fn duplicate_node_id_caught() {
535 let def = FlowDefinition {
536 nodes: vec![entry("e"), prompt("e")],
537 edges: vec![],
538 };
539 let errs = validate(&saved(def));
540 assert!(errs.iter().any(|e| matches!(e, ValidationError::DuplicateNodeId(_))));
541 }
542
543 #[test]
544 fn both_spec_versions_accepted() {
545 let mut sf = saved(FlowDefinition {
546 nodes: vec![entry("e")],
547 edges: vec![],
548 });
549 sf.spec_version = "1".into();
550 assert!(validate(&sf).is_empty());
551 sf.spec_version = "2".into();
552 assert!(validate(&sf).is_empty());
553 }
554
555 #[test]
556 fn unsupported_spec_version_caught() {
557 let mut sf = saved(FlowDefinition::default());
558 sf.spec_version = "3".into();
559 let errs = validate(&sf);
560 assert!(errs
561 .iter()
562 .any(|e| matches!(e, ValidationError::UnsupportedSpecVersion(_))));
563 }
564
565 fn core(id: &str, ty: CoreNodeType, data: serde_json::Value) -> FlowNode {
566 FlowNode {
567 id: id.into(),
568 node_type: FlowNodeType::Core(ty),
569 data,
570 position: [0.0, 0.0],
571 }
572 }
573 fn eh(id: &str, src: &str, tgt: &str, handle: &str) -> FlowEdge {
574 FlowEdge {
575 id: id.into(),
576 source: src.into(),
577 target: tgt.into(),
578 source_handle: Some(handle.into()),
579 target_handle: None,
580 }
581 }
582
583 #[test]
584 fn v2_node_in_v1_document_caught() {
585 let def = FlowDefinition {
586 nodes: vec![entry("e"), core("c", CoreNodeType::Conditional, json!({ "conditions": [] }))],
587 edges: vec![edge("x", "e", "c")],
588 };
589 let mut sf = saved(def);
590 sf.spec_version = "1".into();
591 let errs = validate(&sf);
592 assert!(errs.iter().any(|e| matches!(e, ValidationError::V2NodeInV1Document { .. })));
593 }
594
595 #[test]
596 fn valid_conditional_v2_passes() {
597 let def = FlowDefinition {
598 nodes: vec![
599 entry("e"),
600 core("c", CoreNodeType::Conditional, json!({
601 "conditions": [ { "handle": "hot", "variable": "_last", "operator": "gt", "value": 50 } ],
602 "default_handle": "cold"
603 })),
604 prompt("hot_node"),
605 prompt("cold_node"),
606 ],
607 edges: vec![
608 edge("e0", "e", "c"),
609 eh("e1", "c", "hot_node", "hot"),
610 eh("e2", "c", "cold_node", "cold"),
611 ],
612 };
613 let mut sf = saved(def);
614 sf.spec_version = "2".into();
615 assert!(validate(&sf).is_empty(), "{:?}", validate(&sf));
616 }
617
618 #[test]
619 fn conditional_unknown_operator_and_missing_edge_caught() {
620 let def = FlowDefinition {
621 nodes: vec![
622 entry("e"),
623 core("c", CoreNodeType::Conditional, json!({
624 "conditions": [ { "handle": "hot", "variable": "_last", "operator": "bogus", "value": 1 } ]
625 })),
626 ],
627 edges: vec![edge("e0", "e", "c")], };
629 let mut sf = saved(def);
630 sf.spec_version = "2".into();
631 let errs = validate(&sf);
632 assert!(errs.iter().any(|e| matches!(e, ValidationError::UnknownOperator { .. })));
633 assert!(errs.iter().any(|e| matches!(e, ValidationError::MissingHandleEdge { .. })));
634 }
635
636 #[test]
637 fn branch_bad_data_caught() {
638 let def = FlowDefinition {
639 nodes: vec![
640 entry("e"),
641 core("b", CoreNodeType::Branch, json!({ "persona": "weather-agent" })),
643 ],
644 edges: vec![edge("e0", "e", "b")],
645 };
646 let mut sf = saved(def);
647 sf.spec_version = "2".into();
648 let errs = validate(&sf);
649 assert!(errs.iter().any(|e| matches!(e, ValidationError::InvalidNodeData { .. })));
650 }
651
652 #[test]
653 fn branch_error_handle_with_nonstring_schema_caught() {
654 let def = FlowDefinition {
657 nodes: vec![
658 entry("e"),
659 core("b", CoreNodeType::Branch, json!({
660 "query": "classify",
661 "outputs": [
662 { "handle": "ok", "schema": { "type": "string" } },
663 { "handle": "error", "schema": { "type": "object" } }
664 ]
665 })),
666 prompt("ok_t"),
667 prompt("err_t"),
668 ],
669 edges: vec![
670 edge("e0", "e", "b"),
671 eh("e1", "b", "ok_t", "ok"),
672 eh("e2", "b", "err_t", "error"),
673 ],
674 };
675 let mut sf = saved(def);
676 sf.spec_version = "2".into();
677 let errs = validate(&sf);
678 assert!(
679 errs.iter().any(|e| matches!(
680 e,
681 ValidationError::InvalidNodeData { node, message }
682 if node == "b" && message.contains("`error` handle")
683 )),
684 "expected reserved-error-handle error, got {errs:?}"
685 );
686 }
687
688 #[test]
689 fn branch_error_handle_with_string_schema_passes() {
690 let def = FlowDefinition {
692 nodes: vec![
693 entry("e"),
694 core("b", CoreNodeType::Branch, json!({
695 "query": "classify",
696 "outputs": [
697 { "handle": "ok", "schema": { "type": "string" } },
698 { "handle": "error", "schema": { "type": "string" } }
699 ]
700 })),
701 prompt("ok_t"),
702 prompt("err_t"),
703 ],
704 edges: vec![
705 edge("e0", "e", "b"),
706 eh("e1", "b", "ok_t", "ok"),
707 eh("e2", "b", "err_t", "error"),
708 ],
709 };
710 let mut sf = saved(def);
711 sf.spec_version = "2".into();
712 assert!(validate(&sf).is_empty(), "{:?}", validate(&sf));
713 }
714
715 #[test]
716 fn well_formed_custom_type_passes() {
717 let mut p = prompt("p");
718 p.node_type = FlowNodeType::Custom("slack:send_message".into());
719 let def = FlowDefinition {
720 nodes: vec![entry("e"), p],
721 edges: vec![edge("x", "e", "p")],
722 };
723 assert!(validate(&saved(def)).is_empty());
724 }
725
726 #[test]
727 fn malformed_custom_type_caught() {
728 let mut p = prompt("p");
729 p.node_type = FlowNodeType::Custom("BadVendor:thing".into());
730 let def = FlowDefinition {
731 nodes: vec![entry("e"), p],
732 edges: vec![],
733 };
734 let errs = validate(&saved(def));
735 assert!(errs
736 .iter()
737 .any(|e| matches!(e, ValidationError::InvalidVendorNamespace { .. })));
738 }
739
740 #[test]
741 fn custom_type_without_colon_caught() {
742 let mut p = prompt("p");
743 p.node_type = FlowNodeType::Custom("no_namespace".into());
744 let def = FlowDefinition {
745 nodes: vec![entry("e"), p],
746 edges: vec![],
747 };
748 let errs = validate(&saved(def));
749 assert!(errs
750 .iter()
751 .any(|e| matches!(e, ValidationError::InvalidVendorNamespace { .. })));
752 }
753
754 #[test]
755 fn well_formed_requires_passes() {
756 let mut sf = saved(FlowDefinition {
757 nodes: vec![entry("e")],
758 edges: vec![],
759 });
760 sf.requires = Some(crate::requires::Requires {
761 packs: vec![crate::requires::PackRequirement {
762 id: "cloudflare".into(),
763 version: Some(">=1.2.0, <2.0.0".into()),
764 content_sha256: Some("a1b2c3d4".repeat(8)),
765 resolved_version: Some("1.3.1".into()),
766 ..crate::requires::PackRequirement::new("cloudflare")
767 }],
768 tools: vec!["cloudflare_purge_cache".into()],
769 });
770 assert!(validate(&sf).is_empty(), "{:?}", validate(&sf));
771 }
772
773 #[test]
774 fn malformed_requires_caught() {
775 for req in [
776 crate::requires::Requires {
778 packs: vec![crate::requires::PackRequirement::new("Bad Id")],
779 tools: vec![],
780 },
781 crate::requires::Requires {
783 packs: vec![crate::requires::PackRequirement {
784 version: Some("not-a-range".into()),
785 ..crate::requires::PackRequirement::new("cloudflare")
786 }],
787 tools: vec![],
788 },
789 crate::requires::Requires {
791 packs: vec![crate::requires::PackRequirement {
792 content_sha256: Some("tooshort".into()),
793 ..crate::requires::PackRequirement::new("cloudflare")
794 }],
795 tools: vec![],
796 },
797 crate::requires::Requires {
799 packs: vec![
800 crate::requires::PackRequirement::new("cloudflare"),
801 crate::requires::PackRequirement::new("cloudflare"),
802 ],
803 tools: vec![],
804 },
805 ] {
806 let mut sf = saved(FlowDefinition {
807 nodes: vec![entry("e")],
808 edges: vec![],
809 });
810 sf.requires = Some(req);
811 let errs = validate(&sf);
812 assert!(
813 errs.iter()
814 .any(|e| matches!(e, ValidationError::InvalidRequires { .. })),
815 "expected InvalidRequires, got {errs:?}"
816 );
817 }
818 }
819}