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::Serialize;
33use serde_json::Value;
34
35use crate::registry::NodeRegistry;
36use crate::spec::{Branch, Node, Spec};
37
38/// A single rule violation.
39#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
40pub struct Violation {
41    /// Rule identifier (`R1`..`R11`).
42    pub rule_id: &'static str,
43    /// Workflow branch this record belongs to.
44    pub branch_id: String,
45    /// Human-readable message.
46    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
59/// Hard ceiling on a `map.*` node's declared fan-out.
60///
61/// Bounded fan-out is only meaningfully bounded if the bound is small enough that a
62/// mistake cannot become an incident.
63const MAX_MAP_FANOUT: u64 = 64;
64
65/// Config keys a `map.*` node may declare its fan-out with.
66const MAP_FANOUT_KEYS: [&str; 3] = ["count", "levels", "fanout"];
67const MAX_BRANCHES: usize = 32;
68const MAX_NODES: usize = 256;
69const MAX_EDGES: usize = 512;
70
71/// Validate a spec against the chassis rules. Returns every violation found
72/// (not just the first) so authors can fix in one pass.
73pub 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    // R5 first: cross-branch edges would corrupt every per-branch analysis.
93    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
143/// Per-branch graph view with the lookups the rules need.
144struct Graph<'a> {
145    branch_id: &'a str,
146    nodes: &'a [Node],
147    by_id: HashMap<&'a str, &'a Node>,
148    /// adjacency: node id → successors
149    succ: HashMap<&'a str, Vec<&'a str>>,
150    /// reverse adjacency: node id → predecessors
151    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    // R1 — DAG (Kahn; if not all nodes drain, there is a cycle).
203    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    // R4 — at least one ingress.
231    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    // R9 — every non-sink node has out-degree exactly 1, EXCEPT `map.*`.
238    //
239    // Fan-in (in-degree > 1) has always been allowed. `map.*` is the one node family whose
240    // purpose is to fan out, so it is exempt here and constrained by R11 instead: a static
241    // bound and side-effect authorization above it. Every
242    // other type stays forbidden — an accidental fan-out is still a bug.
243    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    /// `map.*` — the fan-out node family.
263    fn is_map(node_type: &str) -> bool {
264        node_type.starts_with("map.")
265    }
266
267    /// A `map.*` node's declared fan-out, if it is a static literal within bounds.
268    ///
269    /// Must be a plain integer in the spec: a template (`$event.levels`) or a missing key
270    /// means the width is decided at runtime by data, and then "bounded" is a promise
271    /// nothing enforces.
272    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    /// All descendants of `node_id` (transitive successors).
301    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    // R11 — a map.* fan-out reaching execute.* must be statically bounded, and a
317    // product-declared side-effect guard must dominate the map node itself.
318    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            // Only fan-outs that can reach a side effect are constrained.
325            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    // R1a — every non-ingress node reachable from some ingress.
361    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    // R3a — authorization policy must not be rewritten by event data.
390    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    /// All ancestors of `node_id` (transitive predecessors).
407    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            // Re-key to the 'a-lifetime id stored in by_id.
412            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    /// Nodes with no predecessor — where execution can enter this graph.
424    ///
425    /// Usually one (a branch has exactly one `ingress.*` head, R4), but a graph
426    /// assembled from several branches has one entry per branch, and that is precisely
427    /// the case R2 has to get right.
428    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    /// The DOMINATORS of every node: `d` dominates `n` when EVERY path from an entry to
438    /// `n` passes through `d`.
439    ///
440    /// Standard iterative dataflow: `Dom(n) = {n} ∪ ⋂ Dom(p)` over predecessors, with
441    /// entries dominated only by themselves, iterated to a fixed point. Multiple entries
442    /// are handled by treating them as successors of one virtual root, which is what
443    /// makes a fan-in graph come out right: a node is dominated only by what is on ALL
444    /// incoming paths, not by whatever happens to be reachable from it backwards.
445    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        // Bounded by the node count: each pass can only shrink a set, and a set cannot
459        // shrink below one element.
460        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    // R2 — execute.* dominated by a product-declared side-effect guard.
493    //
494    // DOMINANCE, not reachability. This used to ask whether a gate appeared anywhere
495    // among an execute node's ancestors, which is a strictly weaker question: in a
496    // fan-in graph one branch can reach the execute WITHOUT passing a gate while another
497    // branch supplies the gate, and the ancestor set contains it either way. The check
498    // passed and the guard was bypassable — the whole point of R2 is that it cannot be.
499    // `dominators` asks whether EVERY path carries the guard.
500    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    // R12 — capability-declared guards must dominate every action path.
526    fn check_required_action_guards(&self, out: &mut Vec<Violation>) {
527        let dom = self.dominators();
528        for node in self.nodes {
529            let Some(manifest) = self.registry.capability(&node.node_type) else {
530                continue;
531            };
532            let Some(dominators) = dom.get(node.id.as_str()) else {
533                continue;
534            };
535            for required in &manifest.required_guards {
536                let present = dominators.iter().any(|id| {
537                    *id != node.id.as_str()
538                        && self.registry.guard_kind(&self.by_id[id].node_type) == Some(*required)
539                });
540                if !present {
541                    out.push(self.violation(
542                        "R12",
543                        format!(
544                            "action '{}' ({}) is not dominated by required {:?} guard",
545                            node.id, node.node_type, required
546                        ),
547                    ));
548                }
549            }
550        }
551    }
552
553    // R4' — no fan-out-capable node upstream of execute.*
554    fn check_r4prime_fan_out_upstream_execute(&self, out: &mut Vec<Violation>) {
555        for n in self.nodes {
556            if !n.node_type.starts_with("execute.") {
557                continue;
558            }
559            for anc in self.ancestors(&n.id) {
560                let at = &self.by_id[anc].node_type;
561                // `map.*` is deliberately exempt: R11 constrains it with a static bound
562                // and side-effect authorization above it. R4' catches every other fan-out-capable node
563                // upstream of an execute — the accidental-amplification case it was
564                // written for.
565                if Self::is_map(at) {
566                    continue;
567                }
568                if self.registry.is_fan_out_capable(at) {
569                    out.push(self.violation(
570                        "R4'",
571                        format!(
572                            "fan-out-capable node '{anc}' ({at}) is upstream of execute '{}'",
573                            n.id
574                        ),
575                    ));
576                }
577            }
578        }
579    }
580
581    fn check_r3_config_schema(&self, out: &mut Vec<Violation>) {
582        for node in self.nodes {
583            let schema = self.registry.schema(&node.node_type);
584            if let Some(manifest) = self.registry.capability(&node.node_type) {
585                // Template expressions are type-checked after resolution by the node factory.
586                // Literal configs are checked here from the catalog's exact manifest.
587                if !contains_template(&node.config) {
588                    if let Err(error) = jsonschema::validator_for(&manifest.config_schema)
589                        .and_then(|validator| validator.validate(&node.config))
590                    {
591                        out.push(self.violation(
592                            "R3",
593                            format!("node '{}' ({}) config: {error}", node.id, node.node_type),
594                        ));
595                    }
596                }
597            }
598            let Some(schema) = schema else {
599                continue;
600            };
601            for field in &schema.fields {
602                match node.config.get(&field.key) {
603                    None if field.required => out.push(self.violation(
604                        "R3",
605                        format!(
606                            "node '{}' ({}) is missing config key '{}'",
607                            node.id, node.node_type, field.key
608                        ),
609                    )),
610                    Some(value) if !field_type_matches(value, field.ty) => {
611                        out.push(self.violation(
612                            "R3",
613                            format!(
614                                "node '{}' ({}) config '{}' must be {:?}",
615                                node.id, node.node_type, field.key, field.ty
616                            ),
617                        ));
618                    }
619                    _ => {}
620                }
621            }
622            if let Some(config) = node.config.as_object() {
623                for key in config.keys().filter(|key| {
624                    !schema
625                        .fields
626                        .iter()
627                        .any(|field| field.key.as_str() == key.as_str())
628                }) {
629                    out.push(self.violation(
630                        "R3",
631                        format!(
632                            "node '{}' ({}) has unknown config key '{key}'",
633                            node.id, node.node_type
634                        ),
635                    ));
636                }
637            }
638        }
639    }
640
641    fn check_r6_cross_branch_state(&self, out: &mut Vec<Violation>) {
642        for node in self.nodes {
643            if !(node.node_type.starts_with("execute.")
644                || self.registry.is_side_effect_guard(&node.node_type))
645            {
646                continue;
647            }
648            for ancestor in self.ancestors(&node.id) {
649                let source = &self.by_id[ancestor].node_type;
650                if is_cross_branch_state(source) {
651                    out.push(self.violation(
652                        "R6",
653                        format!(
654                            "cross-branch state node '{ancestor}' ({source}) is upstream of '{}' ({})",
655                            node.id, node.node_type
656                        ),
657                    ));
658                }
659            }
660        }
661    }
662
663    // R7 — taint cannot decide side-effect authorization.
664    fn check_r7_taint(&self, out: &mut Vec<Violation>) {
665        for n in self.nodes {
666            if !self.registry.is_side_effect_guard(&n.node_type) {
667                continue;
668            }
669            for anc in self.ancestors(&n.id) {
670                if is_taint_source(self.by_id[anc]) {
671                    out.push(self.violation(
672                        "R7",
673                        format!(
674                            "tainted output of '{anc}' ({}) reaches side-effect guard '{}'",
675                            self.by_id[anc].node_type, n.id
676                        ),
677                    ));
678                }
679            }
680        }
681    }
682}
683
684fn contains_template(value: &Value) -> bool {
685    match value {
686        Value::String(value) => value.starts_with('$'),
687        Value::Array(values) => values.iter().any(contains_template),
688        Value::Object(values) => values.values().any(contains_template),
689        _ => false,
690    }
691}
692
693fn field_type_matches(value: &Value, field_type: crate::registry::FieldType) -> bool {
694    use crate::registry::FieldType;
695
696    if matches!(value, Value::String(template) if template.starts_with('$')) {
697        return true;
698    }
699    match field_type {
700        FieldType::String => value.is_string(),
701        FieldType::Number => value.is_number(),
702        FieldType::Bool => value.is_boolean(),
703        FieldType::Array => value.is_array(),
704        FieldType::Object => value.is_object(),
705        FieldType::Any => true,
706    }
707}
708
709fn is_cross_branch_state(node_type: &str) -> bool {
710    matches!(
711        node_type,
712        "transform.state_publish_cross_branch"
713            | "transform.state_read_cross_branch"
714            | "transform.state_append_cross_branch"
715    )
716}
717
718/// A node whose output is untrusted (LLM-derived). `transform.ask_llm` always;
719/// `transform.tool_invoke` unless explicitly marked `"llm_backed": false`.
720fn is_taint_source(node: &Node) -> bool {
721    match node.node_type.as_str() {
722        "transform.ask_llm" => true,
723        // Default to tainted when the key is absent.
724        "transform.tool_invoke" => node
725            .config
726            .get("llm_backed")
727            .and_then(|v| v.as_bool())
728            .unwrap_or(true),
729        _ => false,
730    }
731}
732
733/// Whether any string anywhere in a config value is an `$event.` template.
734fn config_references_event(config: &Value) -> bool {
735    match config {
736        Value::String(s) => s.starts_with("$event."),
737        Value::Array(a) => a.iter().any(config_references_event),
738        Value::Object(o) => o.values().any(config_references_event),
739        _ => false,
740    }
741}
742
743#[cfg(test)]
744mod tests {
745    use super::*;
746    use crate::spec::Spec;
747
748    fn reg() -> NodeRegistry {
749        let mut r = NodeRegistry::with_builtins();
750        r.register_step("transform.ask_llm", |_| {
751            Err(crate::registry::NodeError::UnknownType(
752                "ask_llm stub".into(),
753            ))
754        });
755        r.register_step("guard.permission", |_| {
756            Err(crate::registry::NodeError::UnknownType("guard stub".into()))
757        });
758        r.register_side_effect_guard("guard.permission");
759        r
760    }
761
762    fn parse(json: &str) -> Spec {
763        Spec::from_json(json).unwrap()
764    }
765
766    #[test]
767    fn clean_linear_spec_passes() {
768        let spec = parse(
769            r#"{"spec_id":"ok","version":"1.0","branches":[{"branch_id":"__root__",
770              "nodes":[
771                {"id":"t","type":"ingress.cron","config":{}},
772                {"id":"l","type":"sink.log","config":{"message":"x"}}],
773              "edges":[{"source":"t","target":"l"}]}]}"#,
774        );
775        assert!(validate(&spec, &reg()).is_ok());
776    }
777
778    #[test]
779    fn builtin_schema_errors_trip_r3() {
780        let spec = parse(
781            r#"{"spec_id":"schema","version":"1.0","branches":[{"branch_id":"__root__",
782              "nodes":[
783                {"id":"t","type":"ingress.cron","config":{}},
784                {"id":"s","type":"transform.state_set","config":{"key":3}}],
785              "edges":[{"source":"t","target":"s"}]}]}"#,
786        );
787        let errors = validate(&spec, &reg()).unwrap_err();
788        assert_eq!(
789            errors.iter().filter(|error| error.rule_id == "R3").count(),
790            2
791        );
792    }
793
794    #[test]
795    fn cross_branch_state_cannot_feed_execution_decisions() {
796        let spec = parse(
797            r#"{"spec_id":"cross","version":"1.0","branches":[{"branch_id":"__root__",
798              "nodes":[
799                {"id":"t","type":"ingress.cron","config":{}},
800                {"id":"shared","type":"transform.state_read_cross_branch","config":{"key":"x","into":"x"}},
801                {"id":"guard","type":"guard.permission","config":{}},
802                {"id":"log","type":"sink.log","config":{"message":"done"}}],
803              "edges":[{"source":"t","target":"shared"},{"source":"shared","target":"guard"},{"source":"guard","target":"log"}]}]}"#,
804        );
805        let errors = validate(&spec, &reg()).unwrap_err();
806        assert!(errors.iter().any(|error| error.rule_id == "R6"));
807    }
808
809    #[test]
810    fn cycle_trips_r1() {
811        let spec = parse(
812            r#"{"spec_id":"c","version":"1.0","branches":[{"branch_id":"__root__",
813              "nodes":[
814                {"id":"t","type":"ingress.cron","config":{}},
815                {"id":"a","type":"transform.set_fields","config":{"fields":{}}},
816                {"id":"b","type":"transform.set_fields","config":{"fields":{}}}],
817              "edges":[{"source":"t","target":"a"},{"source":"a","target":"b"},
818                       {"source":"b","target":"a"}]}]}"#,
819        );
820        let errs = validate(&spec, &reg()).unwrap_err();
821        assert!(errs.iter().any(|e| e.rule_id == "R1"));
822    }
823
824    #[test]
825    fn fan_out_trips_r9() {
826        let spec = parse(
827            r#"{"spec_id":"f","version":"1.0","branches":[{"branch_id":"__root__",
828              "nodes":[
829                {"id":"t","type":"ingress.cron","config":{}},
830                {"id":"a","type":"sink.log","config":{"message":"a"}},
831                {"id":"b","type":"sink.log","config":{"message":"b"}}],
832              "edges":[{"source":"t","target":"a"},{"source":"t","target":"b"}]}]}"#,
833        );
834        let errs = validate(&spec, &reg()).unwrap_err();
835        assert!(errs.iter().any(|e| e.rule_id == "R9"));
836    }
837
838    #[test]
839    fn execute_without_a_product_guard_trips_r2() {
840        let spec = parse(
841            r#"{"spec_id":"e","version":"1.0","branches":[{"branch_id":"__root__",
842              "nodes":[
843                {"id":"t","type":"ingress.cron","config":{}},
844                {"id":"x","type":"execute.publish","config":{}}],
845              "edges":[{"source":"t","target":"x"}]}]}"#,
846        );
847        let errs = validate(&spec, &reg()).unwrap_err();
848        assert_eq!(errs.iter().filter(|e| e.rule_id == "R2").count(), 1);
849    }
850
851    #[test]
852    fn funds_action_requires_every_declared_guard_on_every_path() {
853        let mut registry = reg();
854        for (node_type, kind) in [
855            ("guard.freshness", crate::GuardKind::Freshness),
856            ("guard.reservation", crate::GuardKind::Reservation),
857        ] {
858            registry.register_step(node_type, |_| {
859                Err(crate::NodeError::UnknownType("guard stub".into()))
860            });
861            registry.register_guard(node_type, kind);
862        }
863        registry
864            .register_capability(crate::CapabilityManifest::action(
865                "execute.funds",
866                "1",
867                "digest",
868                crate::Effect::Funds,
869                crate::IdempotencyMode::ReconcileBeforeRetry,
870                true,
871            ))
872            .unwrap();
873        registry.register_step("execute.funds", |_| {
874            Err(crate::NodeError::UnknownType("action stub".into()))
875        });
876        let missing = parse(
877            r#"{"spec_id":"funds","version":"1","branches":[{"branch_id":"__root__",
878              "nodes":[
879                {"id":"in","type":"ingress.event","config":{}},
880                {"id":"auth","type":"guard.permission","config":{}},
881                {"id":"action","type":"execute.funds","config":{}}],
882              "edges":[{"source":"in","target":"auth"},{"source":"auth","target":"action"}]}]}"#,
883        );
884        let errors = validate(&missing, &registry).unwrap_err();
885        assert_eq!(
886            errors.iter().filter(|error| error.rule_id == "R12").count(),
887            2
888        );
889
890        let complete = parse(
891            r#"{"spec_id":"funds","version":"1","branches":[{"branch_id":"__root__",
892              "nodes":[
893                {"id":"in","type":"ingress.event","config":{}},
894                {"id":"auth","type":"guard.permission","config":{}},
895                {"id":"fresh","type":"guard.freshness","config":{}},
896                {"id":"reserve","type":"guard.reservation","config":{}},
897                {"id":"action","type":"execute.funds","config":{}}],
898              "edges":[{"source":"in","target":"auth"},{"source":"auth","target":"fresh"},{"source":"fresh","target":"reserve"},{"source":"reserve","target":"action"}]}]}"#,
899        );
900        assert!(validate(&complete, &registry).is_ok());
901    }
902
903    #[test]
904    fn a_guard_on_only_one_incoming_path_still_trips_r2() {
905        let spec = parse(
906            r#"{"spec_id":"bypass","version":"1.0","branches":[{"branch_id":"__root__",
907              "nodes":[
908                {"id":"t1","type":"ingress.cron","config":{}},
909                {"id":"t2","type":"ingress.cron","config":{}},
910                {"id":"g","type":"guard.permission","config":{}},
911                {"id":"x","type":"execute.publish","config":{}}],
912              "edges":[
913                {"source":"t1","target":"g"},
914                {"source":"g","target":"x"},
915                {"source":"t2","target":"x"}]}]}"#,
916        );
917        let errs = validate(&spec, &reg()).unwrap_err();
918        let r2: Vec<&str> = errs
919            .iter()
920            .filter(|e| e.rule_id == "R2")
921            .map(|e| e.message.as_str())
922            .collect();
923        assert_eq!(r2.len(), 1, "an unguarded path must trip R2: {r2:?}");
924    }
925
926    #[test]
927    fn a_guard_on_every_incoming_path_satisfies_r2() {
928        let spec = parse(
929            r#"{"spec_id":"gated","version":"1.0","branches":[{"branch_id":"__root__",
930              "nodes":[
931                {"id":"t1","type":"ingress.cron","config":{}},
932                {"id":"t2","type":"ingress.cron","config":{}},
933                {"id":"j","type":"transform.map","config":{}},
934                {"id":"g","type":"guard.permission","config":{}},
935                {"id":"x","type":"execute.publish","config":{}}],
936              "edges":[
937                {"source":"t1","target":"j"},
938                {"source":"t2","target":"j"},
939                {"source":"j","target":"g"},
940                {"source":"g","target":"x"}]}]}"#,
941        );
942        let r2 = match validate(&spec, &reg()) {
943            Ok(()) => Vec::new(),
944            Err(errs) => errs
945                .iter()
946                .filter(|e| e.rule_id == "R2")
947                .map(|e| e.message.clone())
948                .collect(),
949        };
950        assert!(
951            r2.is_empty(),
952            "fan-in where every path is guarded must satisfy R2, got: {r2:?}"
953        );
954    }
955
956    #[test]
957    fn a_bounded_map_fanout_under_shared_authorization_is_accepted() {
958        let spec = parse(
959            r#"{"spec_id":"grid","version":"1.0","branches":[{"branch_id":"__root__",
960              "nodes":[
961                {"id":"t","type":"ingress.cron","config":{}},
962                {"id":"g","type":"guard.permission","config":{}},
963                {"id":"m","type":"map.batch","config":{"count":3}},
964                {"id":"x1","type":"execute.publish","config":{}},
965                {"id":"x2","type":"execute.publish","config":{}}],
966              "edges":[
967                {"source":"t","target":"g"},
968                {"source":"g","target":"m"},
969                {"source":"m","target":"x1"},
970                {"source":"m","target":"x2"}]}]}"#,
971        );
972        let errs = match validate(&spec, &reg()) {
973            Ok(()) => Vec::new(),
974            Err(e) => e,
975        };
976        let relevant: Vec<&str> = errs
977            .iter()
978            .filter(|e| matches!(e.rule_id, "R9" | "R11" | "R4'" | "R2"))
979            .map(|e| e.message.as_str())
980            .collect();
981        assert!(
982            relevant.is_empty(),
983            "bounded fan-out under shared authorization must validate, got: {relevant:?}"
984        );
985    }
986
987    #[test]
988    fn per_leg_authorization_trips_r11() {
989        let spec = parse(
990            r#"{"spec_id":"grid_bad","version":"1.0","branches":[{"branch_id":"__root__",
991              "nodes":[
992                {"id":"t","type":"ingress.cron","config":{}},
993                {"id":"m","type":"map.batch","config":{"count":2}},
994                {"id":"ga","type":"guard.permission","config":{}},
995                {"id":"gb","type":"guard.permission","config":{}},
996                {"id":"x1","type":"execute.publish","config":{}},
997                {"id":"x2","type":"execute.publish","config":{}}],
998              "edges":[
999                {"source":"t","target":"m"},
1000                {"source":"m","target":"ga"},
1001                {"source":"m","target":"gb"},
1002                {"source":"ga","target":"x1"},
1003                {"source":"gb","target":"x2"}]}]}"#,
1004        );
1005        let errs = validate(&spec, &reg()).unwrap_err();
1006        assert!(
1007            errs.iter()
1008                .any(|e| e.rule_id == "R11"
1009                    && e.message.contains("product-declared side-effect guard")),
1010            "per-leg authorization must trip R11, got: {:?}",
1011            errs.iter()
1012                .map(|e| (&e.rule_id, &e.message))
1013                .collect::<Vec<_>>()
1014        );
1015        assert!(
1016            !errs.iter().any(|e| e.rule_id == "R2"),
1017            "per-leg guards satisfy R2 — R11 must catch the fan-out placement"
1018        );
1019    }
1020
1021    #[test]
1022    fn an_unbounded_map_fanout_trips_r11() {
1023        // A width decided by runtime data is not bounded, whatever the config looks like.
1024        for cfg in [
1025            r#"{}"#,
1026            r#"{"levels":"$event.levels"}"#,
1027            r#"{"levels":0}"#,
1028            r#"{"levels":65}"#,
1029        ] {
1030            let spec = parse(&format!(
1031                r#"{{"spec_id":"g","version":"1.0","branches":[{{"branch_id":"__root__",
1032                  "nodes":[
1033                    {{"id":"t","type":"ingress.cron","config":{{}}}},
1034                    {{"id":"g","type":"guard.permission","config":{{}}}},
1035                    {{"id":"m","type":"map.batch","config":{cfg}}},
1036                    {{"id":"x","type":"execute.publish","config":{{}}}}],
1037                  "edges":[
1038                    {{"source":"t","target":"g"}},
1039                    {{"source":"g","target":"m"}},
1040                    {{"source":"m","target":"x"}}]}}]}}"#
1041            ));
1042            let errs = validate(&spec, &reg()).unwrap_err();
1043            assert!(
1044                errs.iter().any(|e| e.rule_id == "R11"),
1045                "config {cfg} must trip R11, got: {:?}",
1046                errs.iter().map(|e| &e.rule_id).collect::<Vec<_>>()
1047            );
1048        }
1049    }
1050
1051    #[test]
1052    fn a_map_that_reaches_no_execute_is_unconstrained() {
1053        // Fan-out with no side effect downstream needs no authorization rule.
1054        let spec = parse(
1055            r#"{"spec_id":"notify_fan","version":"1.0","branches":[{"branch_id":"__root__",
1056              "nodes":[
1057                {"id":"t","type":"ingress.cron","config":{}},
1058                {"id":"m","type":"map.recipients","config":{}},
1059                {"id":"a","type":"sink.log","config":{"message":"a"}},
1060                {"id":"b","type":"sink.log","config":{"message":"b"}}],
1061              "edges":[
1062                {"source":"t","target":"m"},
1063                {"source":"m","target":"a"},
1064                {"source":"m","target":"b"}]}]}"#,
1065        );
1066        let errs = match validate(&spec, &reg()) {
1067            Ok(()) => Vec::new(),
1068            Err(e) => e,
1069        };
1070        assert!(
1071            !errs.iter().any(|e| e.rule_id == "R11" || e.rule_id == "R9"),
1072            "a map with no execute downstream must be unconstrained, got: {:?}",
1073            errs.iter()
1074                .map(|e| (&e.rule_id, &e.message))
1075                .collect::<Vec<_>>()
1076        );
1077    }
1078
1079    #[test]
1080    fn non_map_fan_out_is_still_forbidden() {
1081        // The relaxation is scoped to map.*; an accidental fan-out anywhere else is still
1082        // a bug, and R9 must keep saying so.
1083        let spec = parse(
1084            r#"{"spec_id":"oops","version":"1.0","branches":[{"branch_id":"__root__",
1085              "nodes":[
1086                {"id":"t","type":"ingress.cron","config":{}},
1087                {"id":"d","type":"transform.map","config":{}},
1088                {"id":"a","type":"sink.log","config":{"message":"a"}},
1089                {"id":"b","type":"sink.log","config":{"message":"b"}}],
1090              "edges":[
1091                {"source":"t","target":"d"},
1092                {"source":"d","target":"a"},
1093                {"source":"d","target":"b"}]}]}"#,
1094        );
1095        let errs = validate(&spec, &reg()).unwrap_err();
1096        assert!(
1097            errs.iter().any(|e| e.rule_id == "R9"),
1098            "transform.map is not map.* — fan-out there must still trip R9"
1099        );
1100    }
1101
1102    #[test]
1103    fn llm_taint_into_a_side_effect_guard_trips_r7() {
1104        let spec = parse(
1105            r#"{"spec_id":"t7","version":"1.0","branches":[{"branch_id":"__root__",
1106              "nodes":[
1107                {"id":"t","type":"ingress.cron","config":{}},
1108                {"id":"ask","type":"transform.ask_llm","config":{}},
1109                {"id":"g","type":"guard.permission","config":{}}],
1110              "edges":[{"source":"t","target":"ask"},{"source":"ask","target":"g"}]}]}"#,
1111        );
1112        let errs = validate(&spec, &reg()).unwrap_err();
1113        assert!(errs.iter().any(|e| e.rule_id == "R7"));
1114    }
1115
1116    #[test]
1117    fn guard_config_with_event_template_trips_r3a() {
1118        let spec = parse(
1119            r#"{"spec_id":"t3a","version":"1.0","branches":[{"branch_id":"__root__",
1120              "nodes":[
1121                {"id":"t","type":"ingress.cron","config":{}},
1122                {"id":"g","type":"guard.permission","config":{"scope":"$event.x"}}],
1123              "edges":[{"source":"t","target":"g"}]}]}"#,
1124        );
1125        let errs = validate(&spec, &reg()).unwrap_err();
1126        assert!(errs.iter().any(|e| e.rule_id == "R3a"));
1127    }
1128}
1129
1130#[cfg(test)]
1131mod authoring_catalog_tests {
1132    use super::*;
1133    use crate::{CapabilityManifest, Effect, IdempotencyMode};
1134    fn spec(config: Value) -> Spec {
1135        serde_json::from_value(serde_json::json!({"spec_id":"authoring","version":"1","branches":[{"branch_id":"root","nodes":[{"id":"in","type":"ingress.event","config":{}},{"id":"custom","type":"custom.node","config":config}],"edges":[{"source":"in","target":"custom"}]}]})).unwrap()
1136    }
1137    #[test]
1138    fn manifest_config_schema_and_builtin_unknown_keys_share_r3() {
1139        let mut registry = NodeRegistry::with_builtins();
1140        let mut manifest = CapabilityManifest::action(
1141            "custom.node",
1142            "1",
1143            "digest",
1144            Effect::Pure,
1145            IdempotencyMode::Native,
1146            false,
1147        );
1148        manifest.config_schema = serde_json::json!({"type":"object","required":["count"],"properties":{"count":{"type":"integer"}},"additionalProperties":false});
1149        registry.register_capability(manifest).unwrap();
1150        assert!(validate(&spec(serde_json::json!({"count":1})), &registry).is_ok());
1151        for bad in [
1152            serde_json::json!({}),
1153            serde_json::json!({"count":"one"}),
1154            serde_json::json!({"count":1,"unknown":true}),
1155        ] {
1156            assert!(validate(&spec(bad), &registry)
1157                .unwrap_err()
1158                .iter()
1159                .any(|error| error.rule_id == "R3"));
1160        }
1161        assert!(validate(
1162            &spec(serde_json::json!({"count":"$event.count"})),
1163            &registry
1164        )
1165        .is_ok());
1166        let builtin:Spec=serde_json::from_value(serde_json::json!({"spec_id":"builtin","version":"1","branches":[{"branch_id":"root","nodes":[{"id":"in","type":"ingress.event","config":{}},{"id":"sink","type":"sink.log","config":{"message":"ok","unknown":true}}],"edges":[{"source":"in","target":"sink"}]}]})).unwrap();
1167        assert!(validate(&builtin, &registry)
1168            .unwrap_err()
1169            .iter()
1170            .any(|error| error.message.contains("unknown config key")));
1171        let schemas = registry.authoring_schemas();
1172        assert!(schemas.windows(2).all(|pair| pair[0].0 < pair[1].0));
1173        assert_eq!(
1174            schemas.iter().find(|(id, _)| id == "sink.log").unwrap().1["additionalProperties"],
1175            false
1176        );
1177    }
1178}