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