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 for required in &manifest.required_guard_pins {
551 let present = dominators.iter().any(|id| {
552 let node_type = &self.by_id[id].node_type;
553 *id != node.id.as_str()
554 && self.registry.guard_kind(node_type).is_some()
555 && self.registry.capability(node_type).is_some_and(|guard| {
556 guard.kind == crate::CapabilityKind::Guard
557 && guard.id == required.id
558 && guard.contract_version == required.contract_version
559 && guard.content_digest == required.content_digest
560 })
561 });
562 if !present {
563 out.push(self.violation(
564 "R12",
565 format!(
566 "action '{}' ({}) is not dominated by required guard {} at {} ({})",
567 node.id,
568 node.node_type,
569 required.id,
570 required.contract_version,
571 required.content_digest
572 ),
573 ));
574 }
575 }
576 }
577 }
578
579 fn check_r4prime_fan_out_upstream_execute(&self, out: &mut Vec<Violation>) {
581 for n in self.nodes {
582 if !n.node_type.starts_with("execute.") {
583 continue;
584 }
585 for anc in self.ancestors(&n.id) {
586 let at = &self.by_id[anc].node_type;
587 if Self::is_map(at) {
592 continue;
593 }
594 if self.registry.is_fan_out_capable(at) {
595 out.push(self.violation(
596 "R4'",
597 format!(
598 "fan-out-capable node '{anc}' ({at}) is upstream of execute '{}'",
599 n.id
600 ),
601 ));
602 }
603 }
604 }
605 }
606
607 fn check_r3_config_schema(&self, out: &mut Vec<Violation>) {
608 for node in self.nodes {
609 let schema = self.registry.schema(&node.node_type);
610 if let Some(manifest) = self.registry.capability(&node.node_type) {
611 if !contains_template(&node.config) {
614 if let Err(error) = jsonschema::validator_for(&manifest.config_schema)
615 .and_then(|validator| validator.validate(&node.config))
616 {
617 out.push(self.violation(
618 "R3",
619 format!("node '{}' ({}) config: {error}", node.id, node.node_type),
620 ));
621 }
622 }
623 }
624 let Some(schema) = schema else {
625 continue;
626 };
627 for field in &schema.fields {
628 match node.config.get(&field.key) {
629 None if field.required => out.push(self.violation(
630 "R3",
631 format!(
632 "node '{}' ({}) is missing config key '{}'",
633 node.id, node.node_type, field.key
634 ),
635 )),
636 Some(value) if !field_type_matches(value, field.ty) => {
637 out.push(self.violation(
638 "R3",
639 format!(
640 "node '{}' ({}) config '{}' must be {:?}",
641 node.id, node.node_type, field.key, field.ty
642 ),
643 ));
644 }
645 _ => {}
646 }
647 }
648 if let Some(config) = node.config.as_object() {
649 for key in config.keys().filter(|key| {
650 !schema
651 .fields
652 .iter()
653 .any(|field| field.key.as_str() == key.as_str())
654 }) {
655 out.push(self.violation(
656 "R3",
657 format!(
658 "node '{}' ({}) has unknown config key '{key}'",
659 node.id, node.node_type
660 ),
661 ));
662 }
663 }
664 }
665 }
666
667 fn check_r6_cross_branch_state(&self, out: &mut Vec<Violation>) {
668 for node in self.nodes {
669 if !(node.node_type.starts_with("execute.")
670 || self.registry.is_side_effect_guard(&node.node_type))
671 {
672 continue;
673 }
674 for ancestor in self.ancestors(&node.id) {
675 let source = &self.by_id[ancestor].node_type;
676 if is_cross_branch_state(source) {
677 out.push(self.violation(
678 "R6",
679 format!(
680 "cross-branch state node '{ancestor}' ({source}) is upstream of '{}' ({})",
681 node.id, node.node_type
682 ),
683 ));
684 }
685 }
686 }
687 }
688
689 fn check_r7_taint(&self, out: &mut Vec<Violation>) {
691 for n in self.nodes {
692 if !self.registry.is_side_effect_guard(&n.node_type) {
693 continue;
694 }
695 for anc in self.ancestors(&n.id) {
696 if is_taint_source(self.by_id[anc]) {
697 out.push(self.violation(
698 "R7",
699 format!(
700 "tainted output of '{anc}' ({}) reaches side-effect guard '{}'",
701 self.by_id[anc].node_type, n.id
702 ),
703 ));
704 }
705 }
706 }
707 }
708}
709
710fn contains_template(value: &Value) -> bool {
711 match value {
712 Value::String(value) => value.starts_with('$'),
713 Value::Array(values) => values.iter().any(contains_template),
714 Value::Object(values) => values.values().any(contains_template),
715 _ => false,
716 }
717}
718
719fn field_type_matches(value: &Value, field_type: crate::registry::FieldType) -> bool {
720 use crate::registry::FieldType;
721
722 if matches!(value, Value::String(template) if template.starts_with('$')) {
723 return true;
724 }
725 match field_type {
726 FieldType::String => value.is_string(),
727 FieldType::Number => value.is_number(),
728 FieldType::Bool => value.is_boolean(),
729 FieldType::Array => value.is_array(),
730 FieldType::Object => value.is_object(),
731 FieldType::Any => true,
732 }
733}
734
735fn is_cross_branch_state(node_type: &str) -> bool {
736 matches!(
737 node_type,
738 "transform.state_publish_cross_branch"
739 | "transform.state_read_cross_branch"
740 | "transform.state_append_cross_branch"
741 )
742}
743
744fn is_taint_source(node: &Node) -> bool {
747 match node.node_type.as_str() {
748 "transform.ask_llm" => true,
749 "transform.tool_invoke" => node
751 .config
752 .get("llm_backed")
753 .and_then(|v| v.as_bool())
754 .unwrap_or(true),
755 _ => false,
756 }
757}
758
759fn config_references_event(config: &Value) -> bool {
761 match config {
762 Value::String(s) => s.starts_with("$event."),
763 Value::Array(a) => a.iter().any(config_references_event),
764 Value::Object(o) => o.values().any(config_references_event),
765 _ => false,
766 }
767}
768
769#[cfg(test)]
770mod tests {
771 use super::*;
772 use crate::spec::Spec;
773
774 fn reg() -> NodeRegistry {
775 let mut r = NodeRegistry::with_builtins();
776 r.register_step("transform.ask_llm", |_| {
777 Err(crate::registry::NodeError::UnknownType(
778 "ask_llm stub".into(),
779 ))
780 });
781 r.register_step("guard.permission", |_| {
782 Err(crate::registry::NodeError::UnknownType("guard stub".into()))
783 });
784 r.register_side_effect_guard("guard.permission");
785 r
786 }
787
788 fn parse(json: &str) -> Spec {
789 Spec::from_json(json).unwrap()
790 }
791
792 #[test]
793 fn clean_linear_spec_passes() {
794 let spec = parse(
795 r#"{"spec_id":"ok","version":"1.0","branches":[{"branch_id":"__root__",
796 "nodes":[
797 {"id":"t","type":"ingress.cron","config":{}},
798 {"id":"l","type":"sink.log","config":{"message":"x"}}],
799 "edges":[{"source":"t","target":"l"}]}]}"#,
800 );
801 assert!(validate(&spec, ®()).is_ok());
802 }
803
804 #[test]
805 fn builtin_schema_errors_trip_r3() {
806 let spec = parse(
807 r#"{"spec_id":"schema","version":"1.0","branches":[{"branch_id":"__root__",
808 "nodes":[
809 {"id":"t","type":"ingress.cron","config":{}},
810 {"id":"s","type":"transform.state_set","config":{"key":3}}],
811 "edges":[{"source":"t","target":"s"}]}]}"#,
812 );
813 let errors = validate(&spec, ®()).unwrap_err();
814 assert_eq!(
815 errors.iter().filter(|error| error.rule_id == "R3").count(),
816 2
817 );
818 }
819
820 #[test]
821 fn cross_branch_state_cannot_feed_execution_decisions() {
822 let spec = parse(
823 r#"{"spec_id":"cross","version":"1.0","branches":[{"branch_id":"__root__",
824 "nodes":[
825 {"id":"t","type":"ingress.cron","config":{}},
826 {"id":"shared","type":"transform.state_read_cross_branch","config":{"key":"x","into":"x"}},
827 {"id":"guard","type":"guard.permission","config":{}},
828 {"id":"log","type":"sink.log","config":{"message":"done"}}],
829 "edges":[{"source":"t","target":"shared"},{"source":"shared","target":"guard"},{"source":"guard","target":"log"}]}]}"#,
830 );
831 let errors = validate(&spec, ®()).unwrap_err();
832 assert!(errors.iter().any(|error| error.rule_id == "R6"));
833 }
834
835 #[test]
836 fn cycle_trips_r1() {
837 let spec = parse(
838 r#"{"spec_id":"c","version":"1.0","branches":[{"branch_id":"__root__",
839 "nodes":[
840 {"id":"t","type":"ingress.cron","config":{}},
841 {"id":"a","type":"transform.set_fields","config":{"fields":{}}},
842 {"id":"b","type":"transform.set_fields","config":{"fields":{}}}],
843 "edges":[{"source":"t","target":"a"},{"source":"a","target":"b"},
844 {"source":"b","target":"a"}]}]}"#,
845 );
846 let errs = validate(&spec, ®()).unwrap_err();
847 assert!(errs.iter().any(|e| e.rule_id == "R1"));
848 }
849
850 #[test]
851 fn fan_out_trips_r9() {
852 let spec = parse(
853 r#"{"spec_id":"f","version":"1.0","branches":[{"branch_id":"__root__",
854 "nodes":[
855 {"id":"t","type":"ingress.cron","config":{}},
856 {"id":"a","type":"sink.log","config":{"message":"a"}},
857 {"id":"b","type":"sink.log","config":{"message":"b"}}],
858 "edges":[{"source":"t","target":"a"},{"source":"t","target":"b"}]}]}"#,
859 );
860 let errs = validate(&spec, ®()).unwrap_err();
861 assert!(errs.iter().any(|e| e.rule_id == "R9"));
862 }
863
864 #[test]
865 fn execute_without_a_product_guard_trips_r2() {
866 let spec = parse(
867 r#"{"spec_id":"e","version":"1.0","branches":[{"branch_id":"__root__",
868 "nodes":[
869 {"id":"t","type":"ingress.cron","config":{}},
870 {"id":"x","type":"execute.publish","config":{}}],
871 "edges":[{"source":"t","target":"x"}]}]}"#,
872 );
873 let errs = validate(&spec, ®()).unwrap_err();
874 assert_eq!(errs.iter().filter(|e| e.rule_id == "R2").count(), 1);
875 }
876
877 #[test]
878 fn funds_action_requires_every_declared_guard_on_every_path() {
879 let mut registry = reg();
880 for (node_type, kind) in [
881 ("guard.freshness", crate::GuardKind::Freshness),
882 ("guard.reservation", crate::GuardKind::Reservation),
883 ] {
884 registry.register_step(node_type, |_| {
885 Err(crate::NodeError::UnknownType("guard stub".into()))
886 });
887 registry.register_guard(node_type, kind);
888 }
889 registry
890 .register_capability(crate::CapabilityManifest::action(
891 "execute.funds",
892 "1",
893 "digest",
894 crate::Effect::Funds,
895 crate::IdempotencyMode::ReconcileBeforeRetry,
896 true,
897 ))
898 .unwrap();
899 registry.register_step("execute.funds", |_| {
900 Err(crate::NodeError::UnknownType("action stub".into()))
901 });
902 let missing = parse(
903 r#"{"spec_id":"funds","version":"1","branches":[{"branch_id":"__root__",
904 "nodes":[
905 {"id":"in","type":"ingress.event","config":{}},
906 {"id":"auth","type":"guard.permission","config":{}},
907 {"id":"action","type":"execute.funds","config":{}}],
908 "edges":[{"source":"in","target":"auth"},{"source":"auth","target":"action"}]}]}"#,
909 );
910 let errors = validate(&missing, ®istry).unwrap_err();
911 assert_eq!(
912 errors.iter().filter(|error| error.rule_id == "R12").count(),
913 2
914 );
915
916 let complete = parse(
917 r#"{"spec_id":"funds","version":"1","branches":[{"branch_id":"__root__",
918 "nodes":[
919 {"id":"in","type":"ingress.event","config":{}},
920 {"id":"auth","type":"guard.permission","config":{}},
921 {"id":"fresh","type":"guard.freshness","config":{}},
922 {"id":"reserve","type":"guard.reservation","config":{}},
923 {"id":"action","type":"execute.funds","config":{}}],
924 "edges":[{"source":"in","target":"auth"},{"source":"auth","target":"fresh"},{"source":"fresh","target":"reserve"},{"source":"reserve","target":"action"}]}]}"#,
925 );
926 assert!(validate(&complete, ®istry).is_ok());
927 }
928
929 #[test]
930 fn a_guard_on_only_one_incoming_path_still_trips_r2() {
931 let spec = parse(
932 r#"{"spec_id":"bypass","version":"1.0","branches":[{"branch_id":"__root__",
933 "nodes":[
934 {"id":"t1","type":"ingress.cron","config":{}},
935 {"id":"t2","type":"ingress.cron","config":{}},
936 {"id":"g","type":"guard.permission","config":{}},
937 {"id":"x","type":"execute.publish","config":{}}],
938 "edges":[
939 {"source":"t1","target":"g"},
940 {"source":"g","target":"x"},
941 {"source":"t2","target":"x"}]}]}"#,
942 );
943 let errs = validate(&spec, ®()).unwrap_err();
944 let r2: Vec<&str> = errs
945 .iter()
946 .filter(|e| e.rule_id == "R2")
947 .map(|e| e.message.as_str())
948 .collect();
949 assert_eq!(r2.len(), 1, "an unguarded path must trip R2: {r2:?}");
950 }
951
952 #[test]
953 fn a_guard_on_every_incoming_path_satisfies_r2() {
954 let spec = parse(
955 r#"{"spec_id":"gated","version":"1.0","branches":[{"branch_id":"__root__",
956 "nodes":[
957 {"id":"t1","type":"ingress.cron","config":{}},
958 {"id":"t2","type":"ingress.cron","config":{}},
959 {"id":"j","type":"transform.map","config":{}},
960 {"id":"g","type":"guard.permission","config":{}},
961 {"id":"x","type":"execute.publish","config":{}}],
962 "edges":[
963 {"source":"t1","target":"j"},
964 {"source":"t2","target":"j"},
965 {"source":"j","target":"g"},
966 {"source":"g","target":"x"}]}]}"#,
967 );
968 let r2 = match validate(&spec, ®()) {
969 Ok(()) => Vec::new(),
970 Err(errs) => errs
971 .iter()
972 .filter(|e| e.rule_id == "R2")
973 .map(|e| e.message.clone())
974 .collect(),
975 };
976 assert!(
977 r2.is_empty(),
978 "fan-in where every path is guarded must satisfy R2, got: {r2:?}"
979 );
980 }
981
982 #[test]
983 fn a_bounded_map_fanout_under_shared_authorization_is_accepted() {
984 let spec = parse(
985 r#"{"spec_id":"grid","version":"1.0","branches":[{"branch_id":"__root__",
986 "nodes":[
987 {"id":"t","type":"ingress.cron","config":{}},
988 {"id":"g","type":"guard.permission","config":{}},
989 {"id":"m","type":"map.batch","config":{"count":3}},
990 {"id":"x1","type":"execute.publish","config":{}},
991 {"id":"x2","type":"execute.publish","config":{}}],
992 "edges":[
993 {"source":"t","target":"g"},
994 {"source":"g","target":"m"},
995 {"source":"m","target":"x1"},
996 {"source":"m","target":"x2"}]}]}"#,
997 );
998 let errs = match validate(&spec, ®()) {
999 Ok(()) => Vec::new(),
1000 Err(e) => e,
1001 };
1002 let relevant: Vec<&str> = errs
1003 .iter()
1004 .filter(|e| matches!(e.rule_id, "R9" | "R11" | "R4'" | "R2"))
1005 .map(|e| e.message.as_str())
1006 .collect();
1007 assert!(
1008 relevant.is_empty(),
1009 "bounded fan-out under shared authorization must validate, got: {relevant:?}"
1010 );
1011 }
1012
1013 #[test]
1014 fn per_leg_authorization_trips_r11() {
1015 let spec = parse(
1016 r#"{"spec_id":"grid_bad","version":"1.0","branches":[{"branch_id":"__root__",
1017 "nodes":[
1018 {"id":"t","type":"ingress.cron","config":{}},
1019 {"id":"m","type":"map.batch","config":{"count":2}},
1020 {"id":"ga","type":"guard.permission","config":{}},
1021 {"id":"gb","type":"guard.permission","config":{}},
1022 {"id":"x1","type":"execute.publish","config":{}},
1023 {"id":"x2","type":"execute.publish","config":{}}],
1024 "edges":[
1025 {"source":"t","target":"m"},
1026 {"source":"m","target":"ga"},
1027 {"source":"m","target":"gb"},
1028 {"source":"ga","target":"x1"},
1029 {"source":"gb","target":"x2"}]}]}"#,
1030 );
1031 let errs = validate(&spec, ®()).unwrap_err();
1032 assert!(
1033 errs.iter()
1034 .any(|e| e.rule_id == "R11"
1035 && e.message.contains("product-declared side-effect guard")),
1036 "per-leg authorization must trip R11, got: {:?}",
1037 errs.iter()
1038 .map(|e| (&e.rule_id, &e.message))
1039 .collect::<Vec<_>>()
1040 );
1041 assert!(
1042 !errs.iter().any(|e| e.rule_id == "R2"),
1043 "per-leg guards satisfy R2 — R11 must catch the fan-out placement"
1044 );
1045 }
1046
1047 #[test]
1048 fn an_unbounded_map_fanout_trips_r11() {
1049 for cfg in [
1051 r#"{}"#,
1052 r#"{"levels":"$event.levels"}"#,
1053 r#"{"levels":0}"#,
1054 r#"{"levels":65}"#,
1055 ] {
1056 let spec = parse(&format!(
1057 r#"{{"spec_id":"g","version":"1.0","branches":[{{"branch_id":"__root__",
1058 "nodes":[
1059 {{"id":"t","type":"ingress.cron","config":{{}}}},
1060 {{"id":"g","type":"guard.permission","config":{{}}}},
1061 {{"id":"m","type":"map.batch","config":{cfg}}},
1062 {{"id":"x","type":"execute.publish","config":{{}}}}],
1063 "edges":[
1064 {{"source":"t","target":"g"}},
1065 {{"source":"g","target":"m"}},
1066 {{"source":"m","target":"x"}}]}}]}}"#
1067 ));
1068 let errs = validate(&spec, ®()).unwrap_err();
1069 assert!(
1070 errs.iter().any(|e| e.rule_id == "R11"),
1071 "config {cfg} must trip R11, got: {:?}",
1072 errs.iter().map(|e| &e.rule_id).collect::<Vec<_>>()
1073 );
1074 }
1075 }
1076
1077 #[test]
1078 fn a_map_that_reaches_no_execute_is_unconstrained() {
1079 let spec = parse(
1081 r#"{"spec_id":"notify_fan","version":"1.0","branches":[{"branch_id":"__root__",
1082 "nodes":[
1083 {"id":"t","type":"ingress.cron","config":{}},
1084 {"id":"m","type":"map.recipients","config":{}},
1085 {"id":"a","type":"sink.log","config":{"message":"a"}},
1086 {"id":"b","type":"sink.log","config":{"message":"b"}}],
1087 "edges":[
1088 {"source":"t","target":"m"},
1089 {"source":"m","target":"a"},
1090 {"source":"m","target":"b"}]}]}"#,
1091 );
1092 let errs = match validate(&spec, ®()) {
1093 Ok(()) => Vec::new(),
1094 Err(e) => e,
1095 };
1096 assert!(
1097 !errs.iter().any(|e| e.rule_id == "R11" || e.rule_id == "R9"),
1098 "a map with no execute downstream must be unconstrained, got: {:?}",
1099 errs.iter()
1100 .map(|e| (&e.rule_id, &e.message))
1101 .collect::<Vec<_>>()
1102 );
1103 }
1104
1105 #[test]
1106 fn non_map_fan_out_is_still_forbidden() {
1107 let spec = parse(
1110 r#"{"spec_id":"oops","version":"1.0","branches":[{"branch_id":"__root__",
1111 "nodes":[
1112 {"id":"t","type":"ingress.cron","config":{}},
1113 {"id":"d","type":"transform.map","config":{}},
1114 {"id":"a","type":"sink.log","config":{"message":"a"}},
1115 {"id":"b","type":"sink.log","config":{"message":"b"}}],
1116 "edges":[
1117 {"source":"t","target":"d"},
1118 {"source":"d","target":"a"},
1119 {"source":"d","target":"b"}]}]}"#,
1120 );
1121 let errs = validate(&spec, ®()).unwrap_err();
1122 assert!(
1123 errs.iter().any(|e| e.rule_id == "R9"),
1124 "transform.map is not map.* — fan-out there must still trip R9"
1125 );
1126 }
1127
1128 #[test]
1129 fn llm_taint_into_a_side_effect_guard_trips_r7() {
1130 let spec = parse(
1131 r#"{"spec_id":"t7","version":"1.0","branches":[{"branch_id":"__root__",
1132 "nodes":[
1133 {"id":"t","type":"ingress.cron","config":{}},
1134 {"id":"ask","type":"transform.ask_llm","config":{}},
1135 {"id":"g","type":"guard.permission","config":{}}],
1136 "edges":[{"source":"t","target":"ask"},{"source":"ask","target":"g"}]}]}"#,
1137 );
1138 let errs = validate(&spec, ®()).unwrap_err();
1139 assert!(errs.iter().any(|e| e.rule_id == "R7"));
1140 }
1141
1142 #[test]
1143 fn guard_config_with_event_template_trips_r3a() {
1144 let spec = parse(
1145 r#"{"spec_id":"t3a","version":"1.0","branches":[{"branch_id":"__root__",
1146 "nodes":[
1147 {"id":"t","type":"ingress.cron","config":{}},
1148 {"id":"g","type":"guard.permission","config":{"scope":"$event.x"}}],
1149 "edges":[{"source":"t","target":"g"}]}]}"#,
1150 );
1151 let errs = validate(&spec, ®()).unwrap_err();
1152 assert!(errs.iter().any(|e| e.rule_id == "R3a"));
1153 }
1154}
1155
1156#[cfg(test)]
1157mod authoring_catalog_tests {
1158 use super::*;
1159 use crate::{CapabilityManifest, Effect, IdempotencyMode};
1160 fn spec(config: Value) -> Spec {
1161 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()
1162 }
1163 #[test]
1164 fn manifest_config_schema_and_builtin_unknown_keys_share_r3() {
1165 let mut registry = NodeRegistry::with_builtins();
1166 let mut manifest = CapabilityManifest::action(
1167 "custom.node",
1168 "1",
1169 "digest",
1170 Effect::Pure,
1171 IdempotencyMode::Native,
1172 false,
1173 );
1174 manifest.config_schema = serde_json::json!({"type":"object","required":["count"],"properties":{"count":{"type":"integer"}},"additionalProperties":false});
1175 registry.register_capability(manifest).unwrap();
1176 assert!(validate(&spec(serde_json::json!({"count":1})), ®istry).is_ok());
1177 for bad in [
1178 serde_json::json!({}),
1179 serde_json::json!({"count":"one"}),
1180 serde_json::json!({"count":1,"unknown":true}),
1181 ] {
1182 assert!(validate(&spec(bad), ®istry)
1183 .unwrap_err()
1184 .iter()
1185 .any(|error| error.rule_id == "R3"));
1186 }
1187 assert!(validate(
1188 &spec(serde_json::json!({"count":"$event.count"})),
1189 ®istry
1190 )
1191 .is_ok());
1192 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();
1193 assert!(validate(&builtin, ®istry)
1194 .unwrap_err()
1195 .iter()
1196 .any(|error| error.message.contains("unknown config key")));
1197 let schemas = registry.authoring_schemas();
1198 assert!(schemas.windows(2).all(|pair| pair[0].0 < pair[1].0));
1199 assert_eq!(
1200 schemas.iter().find(|(id, _)| id == "sink.log").unwrap().1["additionalProperties"],
1201 false
1202 );
1203 }
1204}