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