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