1use std::collections::{HashMap, HashSet, VecDeque};
31
32use serde_json::Value;
33
34use crate::registry::NodeRegistry;
35use crate::spec::{Branch, Node, Spec};
36
37#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct Violation {
40 pub rule_id: &'static str,
41 pub branch_id: String,
42 pub message: String,
43}
44
45impl std::fmt::Display for Violation {
46 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47 write!(
48 f,
49 "[{}] branch '{}': {}",
50 self.rule_id, self.branch_id, self.message
51 )
52 }
53}
54
55const MAX_MAP_FANOUT: u64 = 64;
60
61const MAP_FANOUT_KEYS: [&str; 3] = ["count", "levels", "fanout"];
63
64pub fn validate(spec: &Spec, registry: &NodeRegistry) -> Result<(), Vec<Violation>> {
67 let mut v = Vec::new();
68
69 let branch_of: HashMap<&str, &str> = spec
71 .branches
72 .iter()
73 .flat_map(|b| {
74 b.nodes
75 .iter()
76 .map(move |n| (n.id.as_str(), b.branch_id.as_str()))
77 })
78 .collect();
79 for branch in &spec.branches {
80 for edge in &branch.edges {
81 for endpoint in [&edge.source, &edge.target] {
82 if let Some(owner) = branch_of.get(endpoint.as_str()) {
83 if *owner != branch.branch_id {
84 v.push(Violation {
85 rule_id: "R5",
86 branch_id: branch.branch_id.clone(),
87 message: format!(
88 "edge endpoint '{endpoint}' lives in branch '{owner}'"
89 ),
90 });
91 }
92 }
93 }
94 }
95 }
96
97 for branch in &spec.branches {
98 let g = Graph::build(branch, registry);
99 g.check_r1_dag(&mut v);
100 g.check_r4_has_ingress(&mut v);
101 g.check_r9_fan_out_shape(&mut v);
102 g.check_r11_bounded_map_fanout(&mut v);
103 g.check_r1a_reachable(&mut v);
104 g.check_r3a_guard_no_event(&mut v);
105 g.check_r2_execute_guards(&mut v);
106 g.check_r4prime_fan_out_upstream_execute(&mut v);
107 g.check_r7_taint(&mut v);
108 g.check_r6_cross_branch_state(&mut v);
109 g.check_r3_config_schema(&mut v);
110 }
111
112 if v.is_empty() {
113 Ok(())
114 } else {
115 Err(v)
116 }
117}
118
119struct Graph<'a> {
121 branch_id: &'a str,
122 nodes: &'a [Node],
123 by_id: HashMap<&'a str, &'a Node>,
124 succ: HashMap<&'a str, Vec<&'a str>>,
126 pred: HashMap<&'a str, Vec<&'a str>>,
128 registry: &'a NodeRegistry,
129}
130
131impl<'a> Graph<'a> {
132 fn build(branch: &'a Branch, registry: &'a NodeRegistry) -> Self {
133 let by_id: HashMap<&str, &Node> = branch.nodes.iter().map(|n| (n.id.as_str(), n)).collect();
134 let mut succ: HashMap<&str, Vec<&str>> = branch
135 .nodes
136 .iter()
137 .map(|n| (n.id.as_str(), Vec::new()))
138 .collect();
139 let mut pred: HashMap<&str, Vec<&str>> = branch
140 .nodes
141 .iter()
142 .map(|n| (n.id.as_str(), Vec::new()))
143 .collect();
144 for e in &branch.edges {
145 if by_id.contains_key(e.source.as_str()) && by_id.contains_key(e.target.as_str()) {
146 succ.get_mut(e.source.as_str()).unwrap().push(&e.target);
147 pred.get_mut(e.target.as_str()).unwrap().push(&e.source);
148 }
149 }
150 Self {
151 branch_id: &branch.branch_id,
152 nodes: &branch.nodes,
153 by_id,
154 succ,
155 pred,
156 registry,
157 }
158 }
159
160 fn is_ingress(&self, n: &Node) -> bool {
161 self.registry.is_ingress(&n.node_type)
162 }
163 fn is_sink(&self, n: &Node) -> bool {
164 n.node_type.starts_with("sink.")
165 }
166
167 fn violation(&self, rule_id: &'static str, message: String) -> Violation {
168 Violation {
169 rule_id,
170 branch_id: self.branch_id.to_string(),
171 message,
172 }
173 }
174
175 fn check_r1_dag(&self, out: &mut Vec<Violation>) {
177 let mut indeg: HashMap<&str, usize> = self
178 .nodes
179 .iter()
180 .map(|n| (n.id.as_str(), self.pred[n.id.as_str()].len()))
181 .collect();
182 let mut q: VecDeque<&str> = indeg
183 .iter()
184 .filter(|(_, d)| **d == 0)
185 .map(|(id, _)| *id)
186 .collect();
187 let mut seen = 0;
188 while let Some(id) = q.pop_front() {
189 seen += 1;
190 for &s in &self.succ[id] {
191 let d = indeg.get_mut(s).unwrap();
192 *d -= 1;
193 if *d == 0 {
194 q.push_back(s);
195 }
196 }
197 }
198 if seen != self.nodes.len() {
199 out.push(self.violation("R1", "edges form a cycle (DAG required)".into()));
200 }
201 }
202
203 fn check_r4_has_ingress(&self, out: &mut Vec<Violation>) {
205 if !self.nodes.iter().any(|n| self.is_ingress(n)) {
206 out.push(self.violation("R4", "branch has no ingress node".into()));
207 }
208 }
209
210 fn check_r9_fan_out_shape(&self, out: &mut Vec<Violation>) {
217 for n in self.nodes {
218 if self.is_sink(n) || Self::is_map(&n.node_type) {
219 continue;
220 }
221 let outdeg = self.succ[n.id.as_str()].len();
222 if outdeg > 1 {
223 out.push(self.violation(
224 "R9",
225 format!(
226 "node '{}' has out-degree {outdeg}; structural fan-out is forbidden \
227 (only map.* may fan out)",
228 n.id
229 ),
230 ));
231 }
232 }
233 }
234
235 fn is_map(node_type: &str) -> bool {
237 node_type.starts_with("map.")
238 }
239
240 fn static_fanout(node: &Node) -> Result<u64, String> {
246 let found = MAP_FANOUT_KEYS
247 .iter()
248 .find_map(|k| node.config.get(*k).map(|v| (*k, v)));
249 let Some((key, value)) = found else {
250 return Err(format!(
251 "must declare its fan-out with one of {MAP_FANOUT_KEYS:?} as a literal integer"
252 ));
253 };
254 let Some(n) = value.as_u64() else {
255 return Err(format!(
256 "config.{key} must be a literal integer (found {value}); a runtime-decided \
257 width cannot be bounded"
258 ));
259 };
260 if n == 0 {
261 return Err(format!(
262 "config.{key} is 0; a fan-out of zero cannot reach execute"
263 ));
264 }
265 if n > MAX_MAP_FANOUT {
266 return Err(format!(
267 "config.{key} is {n}, above the ceiling of {MAX_MAP_FANOUT}"
268 ));
269 }
270 Ok(n)
271 }
272
273 fn descendants(&self, node_id: &str) -> HashSet<&'a str> {
275 let mut seen = HashSet::new();
276 let mut q: VecDeque<&str> = self.succ.get(node_id).cloned().unwrap_or_default().into();
277 while let Some(id) = q.pop_front() {
278 if let Some((&kid, _)) = self.by_id.get_key_value(id) {
279 if seen.insert(kid) {
280 for &sc in &self.succ[id] {
281 q.push_back(sc);
282 }
283 }
284 }
285 }
286 seen
287 }
288
289 fn check_r11_bounded_map_fanout(&self, out: &mut Vec<Violation>) {
292 let dom = self.dominators();
293 for n in self.nodes {
294 if !Self::is_map(&n.node_type) {
295 continue;
296 }
297 let reaches_execute = self
299 .descendants(&n.id)
300 .iter()
301 .any(|id| self.by_id[id].node_type.starts_with("execute."));
302 if !reaches_execute {
303 continue;
304 }
305
306 if let Err(why) = Self::static_fanout(n) {
307 out.push(self.violation(
308 "R11",
309 format!("map node '{}' reaches execute.* and {why}", n.id),
310 ));
311 }
312
313 let dominated_by_guard = dom.get(n.id.as_str()).is_some_and(|ds| {
314 ds.iter().filter(|id| **id != n.id.as_str()).any(|id| {
315 self.registry
316 .is_side_effect_guard(&self.by_id[id].node_type)
317 })
318 });
319 if !dominated_by_guard {
320 out.push(self.violation(
321 "R11",
322 format!(
323 "map node '{}' fans out to execute.* but is not dominated by \
324 a product-declared side-effect guard; authorization must happen \
325 before fan-out",
326 n.id
327 ),
328 ));
329 }
330 }
331 }
332
333 fn check_r1a_reachable(&self, out: &mut Vec<Violation>) {
335 let mut reached: HashSet<&str> = HashSet::new();
336 let mut q: VecDeque<&str> = self
337 .nodes
338 .iter()
339 .filter(|n| self.is_ingress(n))
340 .map(|n| n.id.as_str())
341 .collect();
342 for id in &q {
343 reached.insert(id);
344 }
345 while let Some(id) = q.pop_front() {
346 for &s in &self.succ[id] {
347 if reached.insert(s) {
348 q.push_back(s);
349 }
350 }
351 }
352 for n in self.nodes {
353 if !self.is_ingress(n) && !reached.contains(n.id.as_str()) {
354 out.push(self.violation(
355 "R1a",
356 format!("node '{}' is not reachable from any ingress", n.id),
357 ));
358 }
359 }
360 }
361
362 fn check_r3a_guard_no_event(&self, out: &mut Vec<Violation>) {
364 for n in self.nodes {
365 if self.registry.is_side_effect_guard(&n.node_type)
366 && config_references_event(&n.config)
367 {
368 out.push(self.violation(
369 "R3a",
370 format!(
371 "side-effect guard '{}' config references $event.* templates",
372 n.id
373 ),
374 ));
375 }
376 }
377 }
378
379 fn ancestors(&self, node_id: &str) -> HashSet<&'a str> {
381 let mut seen = HashSet::new();
382 let mut q: VecDeque<&str> = self.pred.get(node_id).cloned().unwrap_or_default().into();
383 while let Some(id) = q.pop_front() {
384 if let Some((&kid, _)) = self.by_id.get_key_value(id) {
386 if seen.insert(kid) {
387 for &p in &self.pred[id] {
388 q.push_back(p);
389 }
390 }
391 }
392 }
393 seen
394 }
395
396 fn entries(&self) -> Vec<&'a str> {
402 self.nodes
403 .iter()
404 .map(|n| n.id.as_str())
405 .filter(|id| self.pred.get(*id).is_none_or(|p| p.is_empty()))
406 .filter_map(|id| self.by_id.get_key_value(id).map(|(&k, _)| k))
407 .collect()
408 }
409
410 fn dominators(&self) -> HashMap<&'a str, HashSet<&'a str>> {
419 let all: HashSet<&'a str> = self.by_id.keys().copied().collect();
420 let entries: HashSet<&'a str> = self.entries().into_iter().collect();
421
422 let mut dom: HashMap<&'a str, HashSet<&'a str>> = HashMap::new();
423 for &id in &all {
424 if entries.contains(id) {
425 dom.insert(id, HashSet::from([id]));
426 } else {
427 dom.insert(id, all.clone());
428 }
429 }
430
431 let mut changed = true;
434 let mut guard = all.len() + 1;
435 while changed && guard > 0 {
436 changed = false;
437 guard -= 1;
438 for &id in &all {
439 if entries.contains(id) {
440 continue;
441 }
442 let preds = self.pred.get(id).cloned().unwrap_or_default();
443 let mut next: Option<HashSet<&'a str>> = None;
444 for p in preds {
445 let Some((&pk, _)) = self.by_id.get_key_value(p) else {
446 continue;
447 };
448 let pd = &dom[pk];
449 next = Some(match next {
450 None => pd.clone(),
451 Some(acc) => acc.intersection(pd).copied().collect(),
452 });
453 }
454 let mut next = next.unwrap_or_default();
455 next.insert(id);
456 if next != dom[id] {
457 dom.insert(id, next);
458 changed = true;
459 }
460 }
461 }
462 dom
463 }
464
465 fn check_r2_execute_guards(&self, out: &mut Vec<Violation>) {
474 let dom = self.dominators();
475 for n in self.nodes {
476 if !n.node_type.starts_with("execute.") {
477 continue;
478 }
479 let Some(doms) = dom.get(n.id.as_str()) else {
480 continue;
481 };
482 let guarded = doms.iter().filter(|id| **id != n.id.as_str()).any(|id| {
483 self.registry
484 .is_side_effect_guard(&self.by_id[id].node_type)
485 });
486 if !guarded {
487 out.push(self.violation(
488 "R2",
489 format!(
490 "execute node '{}' is not dominated by a product-declared side-effect guard",
491 n.id
492 ),
493 ));
494 }
495 }
496 }
497
498 fn check_r4prime_fan_out_upstream_execute(&self, out: &mut Vec<Violation>) {
500 for n in self.nodes {
501 if !n.node_type.starts_with("execute.") {
502 continue;
503 }
504 for anc in self.ancestors(&n.id) {
505 let at = &self.by_id[anc].node_type;
506 if Self::is_map(at) {
511 continue;
512 }
513 if self.registry.is_fan_out_capable(at) {
514 out.push(self.violation(
515 "R4'",
516 format!(
517 "fan-out-capable node '{anc}' ({at}) is upstream of execute '{}'",
518 n.id
519 ),
520 ));
521 }
522 }
523 }
524 }
525
526 fn check_r3_config_schema(&self, out: &mut Vec<Violation>) {
527 for node in self.nodes {
528 let Some(schema) = self.registry.schema(&node.node_type) else {
529 continue;
530 };
531 for field in &schema.fields {
532 match node.config.get(&field.key) {
533 None if field.required => out.push(self.violation(
534 "R3",
535 format!(
536 "node '{}' ({}) is missing config key '{}'",
537 node.id, node.node_type, field.key
538 ),
539 )),
540 Some(value) if !field_type_matches(value, field.ty) => {
541 out.push(self.violation(
542 "R3",
543 format!(
544 "node '{}' ({}) config '{}' must be {:?}",
545 node.id, node.node_type, field.key, field.ty
546 ),
547 ));
548 }
549 _ => {}
550 }
551 }
552 }
553 }
554
555 fn check_r6_cross_branch_state(&self, out: &mut Vec<Violation>) {
556 for node in self.nodes {
557 if !(node.node_type.starts_with("execute.")
558 || self.registry.is_side_effect_guard(&node.node_type))
559 {
560 continue;
561 }
562 for ancestor in self.ancestors(&node.id) {
563 let source = &self.by_id[ancestor].node_type;
564 if is_cross_branch_state(source) {
565 out.push(self.violation(
566 "R6",
567 format!(
568 "cross-branch state node '{ancestor}' ({source}) is upstream of '{}' ({})",
569 node.id, node.node_type
570 ),
571 ));
572 }
573 }
574 }
575 }
576
577 fn check_r7_taint(&self, out: &mut Vec<Violation>) {
579 for n in self.nodes {
580 if !self.registry.is_side_effect_guard(&n.node_type) {
581 continue;
582 }
583 for anc in self.ancestors(&n.id) {
584 if is_taint_source(self.by_id[anc]) {
585 out.push(self.violation(
586 "R7",
587 format!(
588 "tainted output of '{anc}' ({}) reaches side-effect guard '{}'",
589 self.by_id[anc].node_type, n.id
590 ),
591 ));
592 }
593 }
594 }
595 }
596}
597
598fn field_type_matches(value: &Value, field_type: crate::registry::FieldType) -> bool {
599 use crate::registry::FieldType;
600
601 if matches!(value, Value::String(template) if template.starts_with('$')) {
602 return true;
603 }
604 match field_type {
605 FieldType::String => value.is_string(),
606 FieldType::Number => value.is_number(),
607 FieldType::Bool => value.is_boolean(),
608 FieldType::Array => value.is_array(),
609 FieldType::Object => value.is_object(),
610 FieldType::Any => true,
611 }
612}
613
614fn is_cross_branch_state(node_type: &str) -> bool {
615 matches!(
616 node_type,
617 "transform.state_publish_cross_branch"
618 | "transform.state_read_cross_branch"
619 | "transform.state_append_cross_branch"
620 )
621}
622
623fn is_taint_source(node: &Node) -> bool {
626 match node.node_type.as_str() {
627 "transform.ask_llm" => true,
628 "transform.tool_invoke" => node
630 .config
631 .get("llm_backed")
632 .and_then(|v| v.as_bool())
633 .unwrap_or(true),
634 _ => false,
635 }
636}
637
638fn config_references_event(config: &Value) -> bool {
640 match config {
641 Value::String(s) => s.starts_with("$event."),
642 Value::Array(a) => a.iter().any(config_references_event),
643 Value::Object(o) => o.values().any(config_references_event),
644 _ => false,
645 }
646}
647
648#[cfg(test)]
649mod tests {
650 use super::*;
651 use crate::spec::Spec;
652
653 fn reg() -> NodeRegistry {
654 let mut r = NodeRegistry::with_builtins();
655 r.register_step("transform.ask_llm", |_| {
656 Err(crate::registry::NodeError::UnknownType(
657 "ask_llm stub".into(),
658 ))
659 });
660 r.register_step("guard.permission", |_| {
661 Err(crate::registry::NodeError::UnknownType("guard stub".into()))
662 });
663 r.register_side_effect_guard("guard.permission");
664 r
665 }
666
667 fn parse(json: &str) -> Spec {
668 Spec::from_json(json).unwrap()
669 }
670
671 #[test]
672 fn clean_linear_spec_passes() {
673 let spec = parse(
674 r#"{"spec_id":"ok","version":"1.0","branches":[{"branch_id":"__root__",
675 "nodes":[
676 {"id":"t","type":"ingress.cron","config":{}},
677 {"id":"l","type":"sink.log","config":{"message":"x"}}],
678 "edges":[{"source":"t","target":"l"}]}]}"#,
679 );
680 assert!(validate(&spec, ®()).is_ok());
681 }
682
683 #[test]
684 fn builtin_schema_errors_trip_r3() {
685 let spec = parse(
686 r#"{"spec_id":"schema","version":"1.0","branches":[{"branch_id":"__root__",
687 "nodes":[
688 {"id":"t","type":"ingress.cron","config":{}},
689 {"id":"s","type":"transform.state_set","config":{"key":3}}],
690 "edges":[{"source":"t","target":"s"}]}]}"#,
691 );
692 let errors = validate(&spec, ®()).unwrap_err();
693 assert_eq!(
694 errors.iter().filter(|error| error.rule_id == "R3").count(),
695 2
696 );
697 }
698
699 #[test]
700 fn cross_branch_state_cannot_feed_execution_decisions() {
701 let spec = parse(
702 r#"{"spec_id":"cross","version":"1.0","branches":[{"branch_id":"__root__",
703 "nodes":[
704 {"id":"t","type":"ingress.cron","config":{}},
705 {"id":"shared","type":"transform.state_read_cross_branch","config":{"key":"x","into":"x"}},
706 {"id":"guard","type":"guard.permission","config":{}},
707 {"id":"log","type":"sink.log","config":{"message":"done"}}],
708 "edges":[{"source":"t","target":"shared"},{"source":"shared","target":"guard"},{"source":"guard","target":"log"}]}]}"#,
709 );
710 let errors = validate(&spec, ®()).unwrap_err();
711 assert!(errors.iter().any(|error| error.rule_id == "R6"));
712 }
713
714 #[test]
715 fn cycle_trips_r1() {
716 let spec = parse(
717 r#"{"spec_id":"c","version":"1.0","branches":[{"branch_id":"__root__",
718 "nodes":[
719 {"id":"t","type":"ingress.cron","config":{}},
720 {"id":"a","type":"transform.set_fields","config":{"fields":{}}},
721 {"id":"b","type":"transform.set_fields","config":{"fields":{}}}],
722 "edges":[{"source":"t","target":"a"},{"source":"a","target":"b"},
723 {"source":"b","target":"a"}]}]}"#,
724 );
725 let errs = validate(&spec, ®()).unwrap_err();
726 assert!(errs.iter().any(|e| e.rule_id == "R1"));
727 }
728
729 #[test]
730 fn fan_out_trips_r9() {
731 let spec = parse(
732 r#"{"spec_id":"f","version":"1.0","branches":[{"branch_id":"__root__",
733 "nodes":[
734 {"id":"t","type":"ingress.cron","config":{}},
735 {"id":"a","type":"sink.log","config":{"message":"a"}},
736 {"id":"b","type":"sink.log","config":{"message":"b"}}],
737 "edges":[{"source":"t","target":"a"},{"source":"t","target":"b"}]}]}"#,
738 );
739 let errs = validate(&spec, ®()).unwrap_err();
740 assert!(errs.iter().any(|e| e.rule_id == "R9"));
741 }
742
743 #[test]
744 fn execute_without_a_product_guard_trips_r2() {
745 let spec = parse(
746 r#"{"spec_id":"e","version":"1.0","branches":[{"branch_id":"__root__",
747 "nodes":[
748 {"id":"t","type":"ingress.cron","config":{}},
749 {"id":"x","type":"execute.publish","config":{}}],
750 "edges":[{"source":"t","target":"x"}]}]}"#,
751 );
752 let errs = validate(&spec, ®()).unwrap_err();
753 assert_eq!(errs.iter().filter(|e| e.rule_id == "R2").count(), 1);
754 }
755
756 #[test]
757 fn a_guard_on_only_one_incoming_path_still_trips_r2() {
758 let spec = parse(
759 r#"{"spec_id":"bypass","version":"1.0","branches":[{"branch_id":"__root__",
760 "nodes":[
761 {"id":"t1","type":"ingress.cron","config":{}},
762 {"id":"t2","type":"ingress.cron","config":{}},
763 {"id":"g","type":"guard.permission","config":{}},
764 {"id":"x","type":"execute.publish","config":{}}],
765 "edges":[
766 {"source":"t1","target":"g"},
767 {"source":"g","target":"x"},
768 {"source":"t2","target":"x"}]}]}"#,
769 );
770 let errs = validate(&spec, ®()).unwrap_err();
771 let r2: Vec<&str> = errs
772 .iter()
773 .filter(|e| e.rule_id == "R2")
774 .map(|e| e.message.as_str())
775 .collect();
776 assert_eq!(r2.len(), 1, "an unguarded path must trip R2: {r2:?}");
777 }
778
779 #[test]
780 fn a_guard_on_every_incoming_path_satisfies_r2() {
781 let spec = parse(
782 r#"{"spec_id":"gated","version":"1.0","branches":[{"branch_id":"__root__",
783 "nodes":[
784 {"id":"t1","type":"ingress.cron","config":{}},
785 {"id":"t2","type":"ingress.cron","config":{}},
786 {"id":"j","type":"transform.map","config":{}},
787 {"id":"g","type":"guard.permission","config":{}},
788 {"id":"x","type":"execute.publish","config":{}}],
789 "edges":[
790 {"source":"t1","target":"j"},
791 {"source":"t2","target":"j"},
792 {"source":"j","target":"g"},
793 {"source":"g","target":"x"}]}]}"#,
794 );
795 let r2 = match validate(&spec, ®()) {
796 Ok(()) => Vec::new(),
797 Err(errs) => errs
798 .iter()
799 .filter(|e| e.rule_id == "R2")
800 .map(|e| e.message.clone())
801 .collect(),
802 };
803 assert!(
804 r2.is_empty(),
805 "fan-in where every path is guarded must satisfy R2, got: {r2:?}"
806 );
807 }
808
809 #[test]
810 fn a_bounded_map_fanout_under_shared_authorization_is_accepted() {
811 let spec = parse(
812 r#"{"spec_id":"grid","version":"1.0","branches":[{"branch_id":"__root__",
813 "nodes":[
814 {"id":"t","type":"ingress.cron","config":{}},
815 {"id":"g","type":"guard.permission","config":{}},
816 {"id":"m","type":"map.batch","config":{"count":3}},
817 {"id":"x1","type":"execute.publish","config":{}},
818 {"id":"x2","type":"execute.publish","config":{}}],
819 "edges":[
820 {"source":"t","target":"g"},
821 {"source":"g","target":"m"},
822 {"source":"m","target":"x1"},
823 {"source":"m","target":"x2"}]}]}"#,
824 );
825 let errs = match validate(&spec, ®()) {
826 Ok(()) => Vec::new(),
827 Err(e) => e,
828 };
829 let relevant: Vec<&str> = errs
830 .iter()
831 .filter(|e| matches!(e.rule_id, "R9" | "R11" | "R4'" | "R2"))
832 .map(|e| e.message.as_str())
833 .collect();
834 assert!(
835 relevant.is_empty(),
836 "bounded fan-out under shared authorization must validate, got: {relevant:?}"
837 );
838 }
839
840 #[test]
841 fn per_leg_authorization_trips_r11() {
842 let spec = parse(
843 r#"{"spec_id":"grid_bad","version":"1.0","branches":[{"branch_id":"__root__",
844 "nodes":[
845 {"id":"t","type":"ingress.cron","config":{}},
846 {"id":"m","type":"map.batch","config":{"count":2}},
847 {"id":"ga","type":"guard.permission","config":{}},
848 {"id":"gb","type":"guard.permission","config":{}},
849 {"id":"x1","type":"execute.publish","config":{}},
850 {"id":"x2","type":"execute.publish","config":{}}],
851 "edges":[
852 {"source":"t","target":"m"},
853 {"source":"m","target":"ga"},
854 {"source":"m","target":"gb"},
855 {"source":"ga","target":"x1"},
856 {"source":"gb","target":"x2"}]}]}"#,
857 );
858 let errs = validate(&spec, ®()).unwrap_err();
859 assert!(
860 errs.iter()
861 .any(|e| e.rule_id == "R11"
862 && e.message.contains("product-declared side-effect guard")),
863 "per-leg authorization must trip R11, got: {:?}",
864 errs.iter()
865 .map(|e| (&e.rule_id, &e.message))
866 .collect::<Vec<_>>()
867 );
868 assert!(
869 !errs.iter().any(|e| e.rule_id == "R2"),
870 "per-leg guards satisfy R2 — R11 must catch the fan-out placement"
871 );
872 }
873
874 #[test]
875 fn an_unbounded_map_fanout_trips_r11() {
876 for cfg in [
878 r#"{}"#,
879 r#"{"levels":"$event.levels"}"#,
880 r#"{"levels":0}"#,
881 r#"{"levels":65}"#,
882 ] {
883 let spec = parse(&format!(
884 r#"{{"spec_id":"g","version":"1.0","branches":[{{"branch_id":"__root__",
885 "nodes":[
886 {{"id":"t","type":"ingress.cron","config":{{}}}},
887 {{"id":"g","type":"guard.permission","config":{{}}}},
888 {{"id":"m","type":"map.batch","config":{cfg}}},
889 {{"id":"x","type":"execute.publish","config":{{}}}}],
890 "edges":[
891 {{"source":"t","target":"g"}},
892 {{"source":"g","target":"m"}},
893 {{"source":"m","target":"x"}}]}}]}}"#
894 ));
895 let errs = validate(&spec, ®()).unwrap_err();
896 assert!(
897 errs.iter().any(|e| e.rule_id == "R11"),
898 "config {cfg} must trip R11, got: {:?}",
899 errs.iter().map(|e| &e.rule_id).collect::<Vec<_>>()
900 );
901 }
902 }
903
904 #[test]
905 fn a_map_that_reaches_no_execute_is_unconstrained() {
906 let spec = parse(
908 r#"{"spec_id":"notify_fan","version":"1.0","branches":[{"branch_id":"__root__",
909 "nodes":[
910 {"id":"t","type":"ingress.cron","config":{}},
911 {"id":"m","type":"map.recipients","config":{}},
912 {"id":"a","type":"sink.log","config":{"message":"a"}},
913 {"id":"b","type":"sink.log","config":{"message":"b"}}],
914 "edges":[
915 {"source":"t","target":"m"},
916 {"source":"m","target":"a"},
917 {"source":"m","target":"b"}]}]}"#,
918 );
919 let errs = match validate(&spec, ®()) {
920 Ok(()) => Vec::new(),
921 Err(e) => e,
922 };
923 assert!(
924 !errs.iter().any(|e| e.rule_id == "R11" || e.rule_id == "R9"),
925 "a map with no execute downstream must be unconstrained, got: {:?}",
926 errs.iter()
927 .map(|e| (&e.rule_id, &e.message))
928 .collect::<Vec<_>>()
929 );
930 }
931
932 #[test]
933 fn non_map_fan_out_is_still_forbidden() {
934 let spec = parse(
937 r#"{"spec_id":"oops","version":"1.0","branches":[{"branch_id":"__root__",
938 "nodes":[
939 {"id":"t","type":"ingress.cron","config":{}},
940 {"id":"d","type":"transform.map","config":{}},
941 {"id":"a","type":"sink.log","config":{"message":"a"}},
942 {"id":"b","type":"sink.log","config":{"message":"b"}}],
943 "edges":[
944 {"source":"t","target":"d"},
945 {"source":"d","target":"a"},
946 {"source":"d","target":"b"}]}]}"#,
947 );
948 let errs = validate(&spec, ®()).unwrap_err();
949 assert!(
950 errs.iter().any(|e| e.rule_id == "R9"),
951 "transform.map is not map.* — fan-out there must still trip R9"
952 );
953 }
954
955 #[test]
956 fn llm_taint_into_a_side_effect_guard_trips_r7() {
957 let spec = parse(
958 r#"{"spec_id":"t7","version":"1.0","branches":[{"branch_id":"__root__",
959 "nodes":[
960 {"id":"t","type":"ingress.cron","config":{}},
961 {"id":"ask","type":"transform.ask_llm","config":{}},
962 {"id":"g","type":"guard.permission","config":{}}],
963 "edges":[{"source":"t","target":"ask"},{"source":"ask","target":"g"}]}]}"#,
964 );
965 let errs = validate(&spec, ®()).unwrap_err();
966 assert!(errs.iter().any(|e| e.rule_id == "R7"));
967 }
968
969 #[test]
970 fn guard_config_with_event_template_trips_r3a() {
971 let spec = parse(
972 r#"{"spec_id":"t3a","version":"1.0","branches":[{"branch_id":"__root__",
973 "nodes":[
974 {"id":"t","type":"ingress.cron","config":{}},
975 {"id":"g","type":"guard.permission","config":{"scope":"$event.x"}}],
976 "edges":[{"source":"t","target":"g"}]}]}"#,
977 );
978 let errs = validate(&spec, ®()).unwrap_err();
979 assert!(errs.iter().any(|e| e.rule_id == "R3a"));
980 }
981}