Skip to main content

af_workflow/
validator.rs

1//! Static spec validator. Port of `platform/validator.py`.
2//!
3//! Runs before compilation. A spec that validates is guaranteed to compile to a
4//! well-formed workflow under the chassis safety invariants. Each violation
5//! carries a `rule_id` so spec authors and tests can pinpoint the failure.
6//!
7//! Ported rules (structural + safety, all decidable from the spec + node-type
8//! prefixes, no business nodes required):
9//!
10//! - **R1** — each branch is a DAG (no cycles)
11//! - **R4** — each branch has ≥1 ingress
12//! - **R5** — no edge crosses a branch boundary
13//! - **R11** — a `map.*` fan-out reaching `execute.*` must declare a STATIC bound
14//!   within `MAX_MAP_FANOUT`, and a product-declared side-effect guard must
15//!   dominate the `map.*` node itself
16//! - **R9** — fan-in allowed; structural fan-out forbidden for every node type
17//!   except `map.*`, which exists to fan out (non-sink
18//!   out-degree exactly 1)
19//! - **R1a** — every non-ingress node is reachable from some ingress
20//! - **R3a** — side-effect guard config cannot reference `$event.*`
21//! - **R2** — every `execute.*` is dominated by a product-declared side-effect
22//!   guard. Dominance, not reachability: EVERY path from an entry to the execute
23//!   must carry a guard.
24//! - **R4'** — no fan-out-capable node upstream of `execute.*`
25//! - **R7** — taint (`transform.ask_llm` / LLM-backed `tool_invoke`) cannot
26//!   reach a side-effect guard
27//! - **R3** — registered per-node config schemas are checked before compile
28//! - **R6** — explicitly cross-branch state cannot feed execution decisions
29
30use std::collections::{HashMap, HashSet, VecDeque};
31
32use serde_json::Value;
33
34use crate::registry::NodeRegistry;
35use crate::spec::{Branch, Node, Spec};
36
37/// A single rule violation.
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct Violation {
40    /// Rule identifier (`R1`..`R11`).
41    pub rule_id: &'static str,
42    /// Workflow branch this record belongs to.
43    pub branch_id: String,
44    /// Human-readable message.
45    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
58/// Hard ceiling on a `map.*` node's declared fan-out.
59///
60/// Bounded fan-out is only meaningfully bounded if the bound is small enough that a
61/// mistake cannot become an incident.
62const MAX_MAP_FANOUT: u64 = 64;
63
64/// Config keys a `map.*` node may declare its fan-out with.
65const MAP_FANOUT_KEYS: [&str; 3] = ["count", "levels", "fanout"];
66const MAX_BRANCHES: usize = 32;
67const MAX_NODES: usize = 256;
68const MAX_EDGES: usize = 512;
69
70/// Validate a spec against the chassis rules. Returns every violation found
71/// (not just the first) so authors can fix in one pass.
72pub 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    // R5 first: cross-branch edges would corrupt every per-branch analysis.
92    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
142/// Per-branch graph view with the lookups the rules need.
143struct Graph<'a> {
144    branch_id: &'a str,
145    nodes: &'a [Node],
146    by_id: HashMap<&'a str, &'a Node>,
147    /// adjacency: node id → successors
148    succ: HashMap<&'a str, Vec<&'a str>>,
149    /// reverse adjacency: node id → predecessors
150    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    // R1 — DAG (Kahn; if not all nodes drain, there is a cycle).
202    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    // R4 — at least one ingress.
230    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    // R9 — every non-sink node has out-degree exactly 1, EXCEPT `map.*`.
237    //
238    // Fan-in (in-degree > 1) has always been allowed. `map.*` is the one node family whose
239    // purpose is to fan out, so it is exempt here and constrained by R11 instead: a static
240    // bound and side-effect authorization above it. Every
241    // other type stays forbidden — an accidental fan-out is still a bug.
242    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    /// `map.*` — the fan-out node family.
262    fn is_map(node_type: &str) -> bool {
263        node_type.starts_with("map.")
264    }
265
266    /// A `map.*` node's declared fan-out, if it is a static literal within bounds.
267    ///
268    /// Must be a plain integer in the spec: a template (`$event.levels`) or a missing key
269    /// means the width is decided at runtime by data, and then "bounded" is a promise
270    /// nothing enforces.
271    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    /// All descendants of `node_id` (transitive successors).
300    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    // R11 — a map.* fan-out reaching execute.* must be statically bounded, and a
316    // product-declared side-effect guard must dominate the map node itself.
317    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            // Only fan-outs that can reach a side effect are constrained.
324            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    // R1a — every non-ingress node reachable from some ingress.
360    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    // R3a — authorization policy must not be rewritten by event data.
389    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    /// All ancestors of `node_id` (transitive predecessors).
406    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            // Re-key to the 'a-lifetime id stored in by_id.
411            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    /// Nodes with no predecessor — where execution can enter this graph.
423    ///
424    /// Usually one (a branch has exactly one `ingress.*` head, R4), but a graph
425    /// assembled from several branches has one entry per branch, and that is precisely
426    /// the case R2 has to get right.
427    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    /// The DOMINATORS of every node: `d` dominates `n` when EVERY path from an entry to
437    /// `n` passes through `d`.
438    ///
439    /// Standard iterative dataflow: `Dom(n) = {n} ∪ ⋂ Dom(p)` over predecessors, with
440    /// entries dominated only by themselves, iterated to a fixed point. Multiple entries
441    /// are handled by treating them as successors of one virtual root, which is what
442    /// makes a fan-in graph come out right: a node is dominated only by what is on ALL
443    /// incoming paths, not by whatever happens to be reachable from it backwards.
444    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        // Bounded by the node count: each pass can only shrink a set, and a set cannot
458        // shrink below one element.
459        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    // R2 — execute.* dominated by a product-declared side-effect guard.
492    //
493    // DOMINANCE, not reachability. This used to ask whether a gate appeared anywhere
494    // among an execute node's ancestors, which is a strictly weaker question: in a
495    // fan-in graph one branch can reach the execute WITHOUT passing a gate while another
496    // branch supplies the gate, and the ancestor set contains it either way. The check
497    // passed and the guard was bypassable — the whole point of R2 is that it cannot be.
498    // `dominators` asks whether EVERY path carries the guard.
499    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    // R12 — capability-declared guards must dominate every action path.
525    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    // R4' — no fan-out-capable node upstream of execute.*
553    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                // `map.*` is deliberately exempt: R11 constrains it with a static bound
561                // and side-effect authorization above it. R4' catches every other fan-out-capable node
562                // upstream of an execute — the accidental-amplification case it was
563                // written for.
564                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    // R7 — taint cannot decide side-effect authorization.
632    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
677/// A node whose output is untrusted (LLM-derived). `transform.ask_llm` always;
678/// `transform.tool_invoke` unless explicitly marked `"llm_backed": false`.
679fn is_taint_source(node: &Node) -> bool {
680    match node.node_type.as_str() {
681        "transform.ask_llm" => true,
682        // Default to tainted when the key is absent.
683        "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
692/// Whether any string anywhere in a config value is an `$event.` template.
693fn 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, &reg()).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, &reg()).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, &reg()).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, &reg()).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, &reg()).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, &reg()).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, &registry).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, &registry).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, &reg()).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, &reg()) {
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, &reg()) {
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, &reg()).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        // A width decided by runtime data is not bounded, whatever the config looks like.
983        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, &reg()).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        // Fan-out with no side effect downstream needs no authorization rule.
1013        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, &reg()) {
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        // The relaxation is scoped to map.*; an accidental fan-out anywhere else is still
1041        // a bug, and R9 must keep saying so.
1042        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, &reg()).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, &reg()).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, &reg()).unwrap_err();
1085        assert!(errs.iter().any(|e| e.rule_id == "R3a"));
1086    }
1087}