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