Skip to main content

greentic_aw_runtime/graph/
model.rs

1//! Data model for durable agent-graph execution.
2//!
3//! Ported from the greentic-designer engine spike (PR #436,
4//! `src/orchestrate/agent_graph/model.rs`) and extended with a
5//! schema-versioned `GraphConfig` envelope for the runner sidecar wire
6//! format.
7//!
8//! See `docs/superpowers/specs/2026-06-06-runtime-agent-graph-execution-design.md`
9//! for the full design, and
10//! `docs/superpowers/specs/2026-06-07-agent-graph-v2-node-kinds-design.md`
11//! for the v2 additions (supervisor, parallel, join).
12
13use std::collections::HashSet;
14use std::ops::RangeInclusive;
15
16use serde::{Deserialize, Serialize};
17
18// ---------------------------------------------------------------------------
19// Node kinds
20// ---------------------------------------------------------------------------
21
22/// The role a node plays in the graph, plus its per-kind configuration.
23///
24/// Serialised with `"kind"` as the tag field; variant names are lowercase.
25#[derive(Debug, Clone, Serialize, Deserialize)]
26#[serde(tag = "kind", rename_all = "lowercase")]
27pub enum NodeKind {
28    /// An LLM agent that may invoke tools.
29    #[serde(rename_all = "camelCase")]
30    Agent {
31        system_prompt: String,
32        model: String,
33        #[serde(default)]
34        tools: Vec<String>,
35        /// LLM provider override (e.g. `"anthropic"`, `"openai"`).
36        /// Absent in existing graphs — defaults to `None`, which the executor
37        /// maps to `"openai"` for backward compatibility.
38        #[serde(default, skip_serializing_if = "Option::is_none")]
39        provider: Option<String>,
40    },
41    /// A deterministic tool call node.
42    #[serde(rename_all = "camelCase")]
43    Tool { tool_name: String },
44    /// A routing decision node (loop vs. resolved).
45    #[serde(rename_all = "camelCase")]
46    Router {
47        #[serde(default = "default_max_iterations")]
48        max_iterations: u32,
49    },
50    /// Terminal node that emits the final reply.
51    Respond,
52    /// LLM-routing supervisor — picks a branch from its route list.
53    ///
54    /// Requires `schemaVersion: 2`.
55    #[serde(rename_all = "camelCase")]
56    Supervisor {
57        system_prompt: String,
58        model: String,
59        routes: Vec<SupervisorRoute>,
60        /// LLM provider override (e.g. `"anthropic"`, `"openai"`).
61        /// Absent in existing graphs — defaults to `None`, which the executor
62        /// maps to `"openai"` for backward compatibility.
63        #[serde(default, skip_serializing_if = "Option::is_none")]
64        provider: Option<String>,
65    },
66    /// Fan-out node — spawns one branch per outgoing edge.
67    ///
68    /// Requires `schemaVersion: 2`.
69    Parallel,
70    /// Fan-in node — waits for all parallel branches to arrive.
71    ///
72    /// Requires `schemaVersion: 2`.
73    Join,
74    /// Human-in-the-loop approval node. Parks the run awaiting a human decision
75    /// (`RunStatus::AwaitingInput`) and advances along the `approved`/`denied`/
76    /// `timeout` edge once decided. The decision itself is supplied by the host's
77    /// `ApprovalFn` closure.
78    ///
79    /// Requires `schemaVersion: 2`.
80    Approval {
81        title: String,
82        #[serde(default = "default_approval_mode")]
83        mode: String,
84        #[serde(default, skip_serializing_if = "Option::is_none")]
85        risk_threshold: Option<f64>,
86        #[serde(default, skip_serializing_if = "Option::is_none")]
87        confidence_threshold: Option<f64>,
88        #[serde(default, skip_serializing_if = "Option::is_none")]
89        deadline_ms: Option<u64>,
90    },
91}
92
93/// Default `mode` for an [`NodeKind::Approval`] node when omitted from the
94/// wire document — always require a human decision.
95fn default_approval_mode() -> String {
96    "always".to_string()
97}
98
99impl NodeKind {
100    /// Returns the lowercase string name of this kind (matches the serde tag).
101    pub fn kind_name(&self) -> &'static str {
102        match self {
103            NodeKind::Agent { .. } => "agent",
104            NodeKind::Tool { .. } => "tool",
105            NodeKind::Router { .. } => "router",
106            NodeKind::Respond => "respond",
107            NodeKind::Supervisor { .. } => "supervisor",
108            NodeKind::Parallel => "parallel",
109            NodeKind::Join => "join",
110            NodeKind::Approval { .. } => "approval",
111        }
112    }
113
114    /// Returns `true` if this kind requires schemaVersion 2.
115    fn requires_v2(&self) -> bool {
116        matches!(
117            self,
118            NodeKind::Supervisor { .. }
119                | NodeKind::Parallel
120                | NodeKind::Join
121                | NodeKind::Approval { .. }
122        )
123    }
124}
125
126/// One routing choice on a [`NodeKind::Supervisor`] node.
127#[derive(Debug, Clone, Serialize, Deserialize)]
128#[serde(rename_all = "camelCase")]
129pub struct SupervisorRoute {
130    pub branch: String,
131    pub description: String,
132}
133
134fn default_max_iterations() -> u32 {
135    4
136}
137
138// ---------------------------------------------------------------------------
139// Graph primitives
140// ---------------------------------------------------------------------------
141
142/// A single node in the graph.
143#[derive(Debug, Clone, Serialize, Deserialize)]
144pub struct Node {
145    pub id: String,
146    #[serde(flatten)]
147    pub kind: NodeKind,
148}
149
150/// A directed edge between two nodes. `branch` discriminates router / supervisor / parallel exits.
151#[derive(Debug, Clone, Serialize, Deserialize)]
152pub struct Edge {
153    pub from: String,
154    pub to: String,
155    /// Router uses this to pick an outgoing edge (`"loop"` | `"resolved"`).
156    /// Supervisor and Parallel use it to name the branch.
157    #[serde(default)]
158    pub branch: Option<String>,
159}
160
161// ---------------------------------------------------------------------------
162// Graph (bare, no schema-version envelope)
163// ---------------------------------------------------------------------------
164
165/// The bare execution graph: an entry node, a node list, and an edge list.
166///
167/// Deserialised via `serde` and validated via [`Graph::validate`].
168#[derive(Debug, Clone, Serialize, Deserialize)]
169pub struct Graph {
170    pub entry: String,
171    pub nodes: Vec<Node>,
172    pub edges: Vec<Edge>,
173}
174
175impl Graph {
176    /// Look up a node by id.
177    pub fn node(&self, id: &str) -> Option<&Node> {
178        self.nodes.iter().find(|n| n.id == id)
179    }
180
181    /// Outgoing edges for a node, in declaration order.
182    pub fn edges_from(&self, id: &str) -> impl Iterator<Item = &Edge> {
183        self.edges.iter().filter(move |e| e.from == id)
184    }
185
186    /// Incoming edges for a node.
187    pub fn edges_to(&self, id: &str) -> impl Iterator<Item = &Edge> {
188        self.edges.iter().filter(move |e| e.to == id)
189    }
190
191    /// Validate the graph.
192    ///
193    /// `schema_version` is passed in so that v2-only node kinds can be
194    /// rejected when the document declares `schemaVersion: 1`.
195    ///
196    /// ## Rules (v1 + v2)
197    ///
198    /// 1. Node ids must be unique.
199    /// 2. `entry` names an existing node.
200    /// 3. Every edge endpoint names an existing node.
201    /// 4. Agent and Tool nodes have exactly one outgoing edge.
202    /// 5. Router nodes have both a `"loop"` and a `"resolved"` branch edge.
203    /// 6. Respond nodes have zero outgoing edges.
204    /// 7. (v2) v2-kind nodes in a schemaVersion-1 document are rejected.
205    /// 8. (v2) Supervisor: ≥2 routes; unique branch labels; every route has
206    ///    exactly one matching outgoing edge; no extra outgoing edges.
207    /// 9. (v2) Parallel: ≥2 outgoing edges, all with unique branch labels;
208    ///    every branch reaches the same join node; branches are node-disjoint
209    ///    before the join; no nested parallel; no respond inside a branch.
210    /// 10. (v2) Join: ≥2 incoming edges; exactly 1 outgoing edge; only
211    ///     reachable via a parallel node.
212    /// 11. (v2) Approval: ≥1 outgoing edge (branch labels are validated
213    ///     leniently — any of `approved`/`denied`/`timeout` is accepted).
214    pub fn validate(&self, schema_version: u32) -> Result<(), String> {
215        // Rule 1: node ids must be unique.
216        let mut seen_ids: HashSet<&str> = HashSet::new();
217        for node in &self.nodes {
218            if !seen_ids.insert(node.id.as_str()) {
219                return Err(format!("duplicate node id '{}'", node.id));
220            }
221        }
222
223        // Rule 2: entry exists.
224        if self.node(&self.entry).is_none() {
225            return Err(format!("entry node '{}' not found", self.entry));
226        }
227
228        // Rule 3: all edge endpoints exist.
229        for e in &self.edges {
230            if self.node(&e.from).is_none() {
231                return Err(format!("edge from unknown node '{}'", e.from));
232            }
233            if self.node(&e.to).is_none() {
234                return Err(format!("edge to unknown node '{}'", e.to));
235            }
236        }
237
238        // Rules 4–10: per-kind constraints.
239        for node in &self.nodes {
240            // Rule 7: kind-gating — v2 kinds not allowed in schemaVersion 1.
241            if node.kind.requires_v2() && schema_version < 2 {
242                return Err(format!(
243                    "node kind `{}` requires schemaVersion 2",
244                    node.kind.kind_name()
245                ));
246            }
247
248            let out: Vec<&Edge> = self.edges_from(&node.id).collect();
249            match &node.kind {
250                NodeKind::Agent { .. } | NodeKind::Tool { .. } => {
251                    // Rule 4.
252                    if out.len() != 1 {
253                        return Err(format!(
254                            "node '{}' must have exactly 1 outgoing edge, found {}",
255                            node.id,
256                            out.len()
257                        ));
258                    }
259                }
260                NodeKind::Router { .. } => {
261                    // Rule 5.
262                    let has = |b: &str| out.iter().any(|e| e.branch.as_deref() == Some(b));
263                    if !has("loop") || !has("resolved") {
264                        return Err(format!(
265                            "router '{}' must have both a 'loop' and a 'resolved' branch edge",
266                            node.id
267                        ));
268                    }
269                }
270                NodeKind::Respond => {
271                    // Rule 6.
272                    if !out.is_empty() {
273                        return Err(format!(
274                            "respond node '{}' cannot have outgoing edges",
275                            node.id
276                        ));
277                    }
278                }
279                NodeKind::Supervisor { routes, .. } => {
280                    // Rule 8.
281                    self.validate_supervisor(node, routes, &out)?;
282                }
283                NodeKind::Parallel => {
284                    // Rule 9.
285                    self.validate_parallel(node, &out)?;
286                }
287                NodeKind::Join => {
288                    // Rule 10.
289                    let inc: Vec<&Edge> = self.edges_to(&node.id).collect();
290                    if inc.len() < 2 {
291                        return Err(format!(
292                            "join node '{}' must have at least 2 incoming edges, found {}",
293                            node.id,
294                            inc.len()
295                        ));
296                    }
297                    if out.len() != 1 {
298                        return Err(format!(
299                            "join node '{}' must have exactly 1 outgoing edge, found {}",
300                            node.id,
301                            out.len()
302                        ));
303                    }
304                    // Reachable-via-parallel check: every incoming source must
305                    // lie on a parallel branch path. We check that each source
306                    // has at least one parallel ancestor.
307                    for edge in &inc {
308                        if !self.has_parallel_ancestor(edge.from.as_str()) {
309                            return Err(format!(
310                                "join node '{}' is reachable from node '{}' which is not on a parallel branch path",
311                                node.id, edge.from
312                            ));
313                        }
314                    }
315                }
316                NodeKind::Approval { .. } => {
317                    // Rule 11. Branch labels (approved/denied/timeout) are
318                    // validated leniently — the executor (Task C2) resolves
319                    // the decision to whichever outgoing edge matches, so we
320                    // only require that the node isn't a dead end.
321                    if out.is_empty() {
322                        return Err(format!(
323                            "approval node '{}' must have at least 1 outgoing edge, found 0",
324                            node.id
325                        ));
326                    }
327                }
328            }
329        }
330
331        Ok(())
332    }
333
334    // -----------------------------------------------------------------------
335    // Supervisor validation helper
336    // -----------------------------------------------------------------------
337
338    fn validate_supervisor(
339        &self,
340        node: &Node,
341        routes: &[SupervisorRoute],
342        out: &[&Edge],
343    ) -> Result<(), String> {
344        // ≥2 routes.
345        if routes.len() < 2 {
346            return Err(format!(
347                "supervisor '{}' must have at least 2 routes, found {}",
348                node.id,
349                routes.len()
350            ));
351        }
352
353        // Unique branch labels.
354        let mut seen: HashSet<&str> = HashSet::new();
355        for r in routes {
356            if !seen.insert(r.branch.as_str()) {
357                return Err(format!(
358                    "supervisor '{}' has duplicate route branch label '{}'",
359                    node.id, r.branch
360                ));
361            }
362        }
363
364        // Every route must have exactly one matching outgoing edge.
365        for r in routes {
366            let matching: Vec<&&Edge> = out
367                .iter()
368                .filter(|e| e.branch.as_deref() == Some(r.branch.as_str()))
369                .collect();
370            match matching.len() {
371                1 => {}
372                0 => {
373                    return Err(format!(
374                        "supervisor '{}' route '{}' has no matching outgoing edge",
375                        node.id, r.branch
376                    ));
377                }
378                n => {
379                    return Err(format!(
380                        "supervisor '{}' route '{}' has {} matching outgoing edges (expected 1)",
381                        node.id, r.branch, n
382                    ));
383                }
384            }
385        }
386
387        // No extra outgoing edges beyond the declared routes.
388        let declared_branches: HashSet<&str> = routes.iter().map(|r| r.branch.as_str()).collect();
389        for e in out {
390            let branch = e.branch.as_deref().unwrap_or("");
391            if !declared_branches.contains(branch) {
392                return Err(format!(
393                    "supervisor '{}' has outgoing edge with undeclared branch label '{}'",
394                    node.id, branch
395                ));
396            }
397        }
398
399        Ok(())
400    }
401
402    // -----------------------------------------------------------------------
403    // Parallel validation helper
404    // -----------------------------------------------------------------------
405
406    fn validate_parallel(&self, node: &Node, out: &[&Edge]) -> Result<(), String> {
407        // ≥2 outgoing edges.
408        if out.len() < 2 {
409            return Err(format!(
410                "parallel node '{}' must have at least 2 outgoing edges, found {}",
411                node.id,
412                out.len()
413            ));
414        }
415
416        // All outgoing edges must have unique branch labels.
417        let mut seen_branches: HashSet<&str> = HashSet::new();
418        for e in out {
419            let label = e.branch.as_deref().unwrap_or("");
420            if label.is_empty() {
421                return Err(format!(
422                    "parallel node '{}' has an outgoing edge without a branch label",
423                    node.id
424                ));
425            }
426            if !seen_branches.insert(label) {
427                return Err(format!(
428                    "parallel node '{}' has duplicate branch label '{}'",
429                    node.id, label
430                ));
431            }
432        }
433
434        // Walk each branch and collect the set of visited node ids.
435        // Then verify they all converge on one common join node.
436        let mut branch_paths: Vec<(String, HashSet<String>)> = Vec::new();
437        let mut join_targets: Vec<String> = Vec::new();
438
439        for edge in out {
440            let branch_label = edge.branch.as_deref().unwrap_or("").to_owned();
441            let (join_id, visited) =
442                self.walk_branch_to_join(node.id.as_str(), edge.to.as_str(), node.id.as_str())?;
443            join_targets.push(join_id);
444            branch_paths.push((branch_label, visited));
445        }
446
447        // All branches must reach the same join node.
448        let common_join = &join_targets[0];
449        for (i, j) in join_targets.iter().enumerate() {
450            if j != common_join {
451                return Err(format!(
452                    "parallel node '{}': branches do not converge on the same join node (branch 0 → '{}', branch {} → '{}')",
453                    node.id, common_join, i, j
454                ));
455            }
456        }
457
458        // Branch paths must be node-disjoint.
459        // Build the union and check for overlap across branches.
460        for i in 0..branch_paths.len() {
461            for j in (i + 1)..branch_paths.len() {
462                let shared: Vec<&String> =
463                    branch_paths[i].1.intersection(&branch_paths[j].1).collect();
464                if !shared.is_empty() {
465                    return Err(format!(
466                        "parallel node '{}': branches '{}' and '{}' share node(s) {:?} before the join (branches must be node-disjoint)",
467                        node.id, branch_paths[i].0, branch_paths[j].0, shared
468                    ));
469                }
470            }
471        }
472
473        Ok(())
474    }
475
476    /// Walk from `start_id` towards a join node. Follows each node's single
477    /// outgoing edge, branching at Router (both), Supervisor (all routes),
478    /// until a `Join` node is reached.
479    ///
480    /// Returns `(join_node_id, set_of_visited_node_ids_before_join)`.
481    ///
482    /// Errors if:
483    /// - a nested `Parallel` is encountered
484    /// - a `Respond` is encountered before a `Join`
485    /// - no `Join` is reachable (reaches a dead end or loops)
486    fn walk_branch_to_join(
487        &self,
488        parallel_id: &str,
489        start_id: &str,
490        _origin: &str,
491    ) -> Result<(String, HashSet<String>), String> {
492        // DFS — keep a stack of (node_id, path_so_far).
493        // Use an iteration limit to detect cycles.
494        const WALK_LIMIT: usize = 128;
495
496        let mut visited: HashSet<String> = HashSet::new();
497        let mut stack: Vec<String> = vec![start_id.to_owned()];
498        let mut found_join: Option<String> = None;
499        let mut iterations = 0_usize;
500
501        while let Some(current) = stack.pop() {
502            iterations += 1;
503            if iterations > WALK_LIMIT {
504                return Err(format!(
505                    "parallel node '{}': branch walk exceeded limit (possible cycle)",
506                    parallel_id
507                ));
508            }
509
510            if visited.contains(&current) {
511                // Cycle — already processed.
512                continue;
513            }
514
515            let node = self.node(&current).ok_or_else(|| {
516                format!(
517                    "parallel node '{}': branch references unknown node '{}'",
518                    parallel_id, current
519                )
520            })?;
521
522            match &node.kind {
523                NodeKind::Join => {
524                    // Record which join we hit; don't add to visited (join is shared).
525                    match &found_join {
526                        None => {
527                            found_join = Some(current.clone());
528                        }
529                        Some(j) if j != &current => {
530                            return Err(format!(
531                                "parallel node '{}': branch path reaches multiple join nodes ('{}' and '{}')",
532                                parallel_id, j, current
533                            ));
534                        }
535                        _ => {}
536                    }
537                    // Don't recurse past join.
538                }
539                NodeKind::Parallel => {
540                    return Err(format!(
541                        "parallel node '{}': nested parallel '{}' is not allowed",
542                        parallel_id, current
543                    ));
544                }
545                NodeKind::Respond => {
546                    return Err(format!(
547                        "parallel node '{}': respond node '{}' inside a parallel branch is not allowed",
548                        parallel_id, current
549                    ));
550                }
551                _ => {
552                    visited.insert(current.clone());
553                    // Push all successors.
554                    for e in self.edges_from(&current) {
555                        stack.push(e.to.clone());
556                    }
557                }
558            }
559        }
560
561        found_join
562            .ok_or_else(|| {
563                format!(
564                    "parallel node '{}': branch starting at '{}' never reaches a join node",
565                    parallel_id, start_id
566                )
567            })
568            .map(|j| (j, visited))
569    }
570
571    /// Returns `true` if `node_id` has at least one `Parallel` ancestor in the
572    /// graph (i.e. it lies on a parallel branch path).
573    fn has_parallel_ancestor(&self, node_id: &str) -> bool {
574        // BFS backwards through incoming edges.
575        let mut visited: HashSet<&str> = HashSet::new();
576        let mut queue: Vec<&str> = vec![node_id];
577        while let Some(current) = queue.pop() {
578            if visited.contains(current) {
579                continue;
580            }
581            visited.insert(current);
582            for e in self.edges_to(current) {
583                if let Some(n) = self.node(&e.from) {
584                    if matches!(n.kind, NodeKind::Parallel) {
585                        return true;
586                    }
587                    queue.push(e.from.as_str());
588                }
589            }
590        }
591        false
592    }
593}
594
595// ---------------------------------------------------------------------------
596// GraphConfig — schema-versioned envelope
597// ---------------------------------------------------------------------------
598
599/// Wire envelope for an agent-graph document (`agent-graph.json` sidecar or
600/// the admin-registry graph document). `schema_version` gates forward
601/// evolution; versions 1 and 2 are accepted.
602#[derive(Debug, Clone, Serialize, Deserialize)]
603#[serde(rename_all = "camelCase")]
604pub struct GraphConfig {
605    #[serde(default = "default_schema_version")]
606    pub schema_version: u32,
607    #[serde(flatten)]
608    pub graph: Graph,
609}
610
611fn default_schema_version() -> u32 {
612    1
613}
614
615/// The range of `schema_version` values this build accepts.
616pub const SUPPORTED_SCHEMA_VERSIONS: RangeInclusive<u32> = 1..=2;
617
618/// Deprecated alias kept for backward compatibility; external callers should
619/// migrate to `SUPPORTED_SCHEMA_VERSIONS`.
620#[deprecated(since = "0.0.0", note = "use SUPPORTED_SCHEMA_VERSIONS instead")]
621pub const SUPPORTED_SCHEMA_VERSION: u32 = 1;
622
623// ---------------------------------------------------------------------------
624// GraphError
625// ---------------------------------------------------------------------------
626
627/// Errors produced when parsing or validating a [`GraphConfig`].
628#[derive(Debug, thiserror::Error)]
629pub enum GraphError {
630    #[error("invalid graph: {0}")]
631    Invalid(String),
632    #[error("graph JSON parse error: {0}")]
633    Parse(#[from] serde_json::Error),
634    #[error("unsupported graph schemaVersion {0}")]
635    UnsupportedSchemaVersion(u32),
636}
637
638// ---------------------------------------------------------------------------
639// GraphConfig impl
640// ---------------------------------------------------------------------------
641
642impl GraphConfig {
643    /// Parse and validate a JSON string into a [`GraphConfig`].
644    ///
645    /// Returns [`GraphError::UnsupportedSchemaVersion`] when `schema_version`
646    /// is outside [`SUPPORTED_SCHEMA_VERSIONS`], and
647    /// [`GraphError::Invalid`] when the graph fails structural validation.
648    pub fn from_json(raw: &str) -> Result<Self, GraphError> {
649        let cfg: GraphConfig = serde_json::from_str(raw)?;
650        if !SUPPORTED_SCHEMA_VERSIONS.contains(&cfg.schema_version) {
651            return Err(GraphError::UnsupportedSchemaVersion(cfg.schema_version));
652        }
653        cfg.graph
654            .validate(cfg.schema_version)
655            .map_err(GraphError::Invalid)?;
656        Ok(cfg)
657    }
658}
659
660// ---------------------------------------------------------------------------
661// Tests
662// ---------------------------------------------------------------------------
663
664#[cfg(test)]
665#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
666mod tests {
667    use super::*;
668    use crate::graph::test_fixtures;
669
670    /// Parse the shared fixture JSON back to a `serde_json::Value` so the
671    /// mutation-based tests can alter individual fields.
672    fn triage_value() -> serde_json::Value {
673        serde_json::from_str(&test_fixtures::triage_json()).expect("fixture is valid JSON")
674    }
675
676    // -----------------------------------------------------------------------
677    // Existing v1 tests (must remain green, unchanged)
678    // -----------------------------------------------------------------------
679
680    #[test]
681    fn parses_and_validates_triage_graph() {
682        let cfg = GraphConfig::from_json(&test_fixtures::triage_json()).expect("valid graph");
683        assert_eq!(cfg.schema_version, 1);
684        assert_eq!(cfg.graph.entry, "agent");
685        assert_eq!(cfg.graph.nodes.len(), 4);
686    }
687
688    #[test]
689    fn rejects_unknown_entry() {
690        let mut v = triage_value();
691        v["entry"] = "missing".into();
692        let err = GraphConfig::from_json(&v.to_string()).unwrap_err();
693        assert!(matches!(err, GraphError::Invalid(_)), "got {err:?}");
694    }
695
696    #[test]
697    fn rejects_router_without_resolved_branch() {
698        let mut v = triage_value();
699        v["edges"]
700            .as_array_mut()
701            .unwrap()
702            .retain(|e| e["branch"] != "resolved");
703        assert!(GraphConfig::from_json(&v.to_string()).is_err());
704    }
705
706    #[test]
707    fn rejects_agent_with_two_outgoing_edges() {
708        let mut v = triage_value();
709        v["edges"]
710            .as_array_mut()
711            .unwrap()
712            .push(serde_json::json!({"from": "agent", "to": "router"}));
713        assert!(GraphConfig::from_json(&v.to_string()).is_err());
714    }
715
716    #[test]
717    fn rejects_respond_with_outgoing_edge() {
718        let mut v = triage_value();
719        v["edges"]
720            .as_array_mut()
721            .unwrap()
722            .push(serde_json::json!({"from": "respond", "to": "agent"}));
723        assert!(GraphConfig::from_json(&v.to_string()).is_err());
724    }
725
726    #[test]
727    fn unknown_schema_version_is_rejected() {
728        let mut v = triage_value();
729        v["schemaVersion"] = 99.into();
730        let err = GraphConfig::from_json(&v.to_string()).unwrap_err();
731        assert!(matches!(err, GraphError::UnsupportedSchemaVersion(99)));
732    }
733
734    #[test]
735    fn rejects_duplicate_node_ids() {
736        let mut v = triage_value();
737        v["nodes"]
738            .as_array_mut()
739            .unwrap()
740            .push(serde_json::json!({"id": "agent", "kind": "respond"}));
741        assert!(GraphConfig::from_json(&v.to_string()).is_err());
742    }
743
744    // -----------------------------------------------------------------------
745    // V2 fixture tests
746    // -----------------------------------------------------------------------
747
748    #[test]
749    fn parses_and_validates_supervisor_graph() {
750        let cfg = GraphConfig::from_json(&test_fixtures::supervisor_json())
751            .expect("valid supervisor graph");
752        assert_eq!(cfg.schema_version, 2);
753        assert_eq!(cfg.graph.entry, "sup");
754        // nodes: sup, agent_billing, router_billing, agent_tech, router_tech, respond
755        assert!(cfg.graph.nodes.len() >= 3, "expected at least 3 nodes");
756    }
757
758    #[test]
759    fn parses_and_validates_parallel_graph() {
760        let cfg =
761            GraphConfig::from_json(&test_fixtures::parallel_json()).expect("valid parallel graph");
762        assert_eq!(cfg.schema_version, 2);
763        assert_eq!(cfg.graph.entry, "entry");
764        // Nodes: entry agent, parallel, agent_a, tool_b, join, respond
765        assert!(cfg.graph.nodes.len() >= 5, "expected at least 5 nodes");
766    }
767
768    #[test]
769    fn approval_kind_serde_tag_and_validate() {
770        let n: NodeKind = serde_json::from_value(serde_json::json!({
771            "kind":"approval","title":"Send refund","mode":"always"
772        }))
773        .unwrap();
774        assert_eq!(n.kind_name(), "approval");
775
776        // A minimal graph: start -> approval -> respond(approved) must validate.
777        let v = serde_json::json!({
778            "schemaVersion": 2,
779            "entry": "gate",
780            "nodes": [
781                {"id": "gate", "kind": "approval", "title": "Send refund"},
782                {"id": "respond", "kind": "respond"}
783            ],
784            "edges": [
785                {"from": "gate", "to": "respond", "branch": "approved"}
786            ]
787        });
788        GraphConfig::from_json(&v.to_string()).expect("valid approval graph");
789    }
790
791    // -----------------------------------------------------------------------
792    // V2 kind-gating: v2 kinds in a v1 document must be rejected
793    // -----------------------------------------------------------------------
794
795    #[test]
796    fn v2_supervisor_kind_in_v1_doc_rejected() {
797        let v = serde_json::json!({
798            "schemaVersion": 1,
799            "entry": "sup",
800            "nodes": [
801                {"id": "sup", "kind": "supervisor", "systemPrompt": "route",
802                 "model": "gpt-4o-mini",
803                 "routes": [
804                     {"branch": "a", "description": "A"},
805                     {"branch": "b", "description": "B"}
806                 ]},
807                {"id": "respond_a", "kind": "respond"},
808                {"id": "respond_b", "kind": "respond"}
809            ],
810            "edges": [
811                {"from": "sup", "to": "respond_a", "branch": "a"},
812                {"from": "sup", "to": "respond_b", "branch": "b"}
813            ]
814        });
815        let err = GraphConfig::from_json(&v.to_string()).unwrap_err();
816        assert!(
817            matches!(&err, GraphError::Invalid(msg) if msg.contains("supervisor") && msg.contains("schemaVersion 2")),
818            "expected Invalid with 'supervisor requires schemaVersion 2', got {err:?}"
819        );
820    }
821
822    #[test]
823    fn v2_parallel_kind_in_v1_doc_rejected() {
824        let v = serde_json::json!({
825            "schemaVersion": 1,
826            "entry": "fan",
827            "nodes": [
828                {"id": "fan", "kind": "parallel"},
829                {"id": "meet", "kind": "join"},
830                {"id": "a", "kind": "respond"},
831                {"id": "b", "kind": "respond"}
832            ],
833            "edges": [
834                {"from": "fan", "to": "a", "branch": "a"},
835                {"from": "fan", "to": "b", "branch": "b"}
836            ]
837        });
838        let err = GraphConfig::from_json(&v.to_string()).unwrap_err();
839        assert!(
840            matches!(&err, GraphError::Invalid(msg) if msg.contains("parallel") && msg.contains("schemaVersion 2")),
841            "expected Invalid with 'parallel requires schemaVersion 2', got {err:?}"
842        );
843    }
844
845    #[test]
846    fn v2_join_kind_in_v1_doc_rejected() {
847        let v = serde_json::json!({
848            "schemaVersion": 1,
849            "entry": "meet",
850            "nodes": [
851                {"id": "meet", "kind": "join"}
852            ],
853            "edges": []
854        });
855        let err = GraphConfig::from_json(&v.to_string()).unwrap_err();
856        assert!(
857            matches!(&err, GraphError::Invalid(msg) if msg.contains("join") && msg.contains("schemaVersion 2")),
858            "expected Invalid with 'join requires schemaVersion 2', got {err:?}"
859        );
860    }
861
862    // -----------------------------------------------------------------------
863    // Supervisor validation errors
864    // -----------------------------------------------------------------------
865
866    #[test]
867    fn supervisor_fewer_than_two_routes_rejected() {
868        let v = serde_json::json!({
869            "schemaVersion": 2,
870            "entry": "sup",
871            "nodes": [
872                {"id": "sup", "kind": "supervisor", "systemPrompt": "route",
873                 "model": "gpt-4o-mini",
874                 "routes": [{"branch": "a", "description": "A"}]},
875                {"id": "resp", "kind": "respond"}
876            ],
877            "edges": [
878                {"from": "sup", "to": "resp", "branch": "a"}
879            ]
880        });
881        let err = GraphConfig::from_json(&v.to_string()).unwrap_err();
882        assert!(
883            matches!(&err, GraphError::Invalid(msg) if msg.contains("at least 2 routes")),
884            "got {err:?}"
885        );
886    }
887
888    #[test]
889    fn supervisor_duplicate_route_labels_rejected() {
890        let v = serde_json::json!({
891            "schemaVersion": 2,
892            "entry": "sup",
893            "nodes": [
894                {"id": "sup", "kind": "supervisor", "systemPrompt": "route",
895                 "model": "gpt-4o-mini",
896                 "routes": [
897                     {"branch": "a", "description": "A"},
898                     {"branch": "a", "description": "A duplicate"}
899                 ]},
900                {"id": "resp_a1", "kind": "respond"},
901                {"id": "resp_a2", "kind": "respond"}
902            ],
903            "edges": [
904                {"from": "sup", "to": "resp_a1", "branch": "a"},
905                {"from": "sup", "to": "resp_a2", "branch": "a"}
906            ]
907        });
908        let err = GraphConfig::from_json(&v.to_string()).unwrap_err();
909        assert!(
910            matches!(&err, GraphError::Invalid(msg) if msg.contains("duplicate route branch label")),
911            "got {err:?}"
912        );
913    }
914
915    #[test]
916    fn supervisor_route_without_matching_edge_rejected() {
917        let v = serde_json::json!({
918            "schemaVersion": 2,
919            "entry": "sup",
920            "nodes": [
921                {"id": "sup", "kind": "supervisor", "systemPrompt": "route",
922                 "model": "gpt-4o-mini",
923                 "routes": [
924                     {"branch": "billing", "description": "Billing"},
925                     {"branch": "tech", "description": "Tech"}
926                 ]},
927                {"id": "resp_billing", "kind": "respond"}
928                // No node for tech branch on purpose
929            ],
930            "edges": [
931                {"from": "sup", "to": "resp_billing", "branch": "billing"}
932                // No edge for "tech" branch
933            ]
934        });
935        let err = GraphConfig::from_json(&v.to_string()).unwrap_err();
936        assert!(
937            matches!(&err, GraphError::Invalid(msg) if msg.contains("no matching outgoing edge")),
938            "got {err:?}"
939        );
940    }
941
942    #[test]
943    fn supervisor_extra_outgoing_edge_rejected() {
944        let v = serde_json::json!({
945            "schemaVersion": 2,
946            "entry": "sup",
947            "nodes": [
948                {"id": "sup", "kind": "supervisor", "systemPrompt": "route",
949                 "model": "gpt-4o-mini",
950                 "routes": [
951                     {"branch": "billing", "description": "Billing"},
952                     {"branch": "tech", "description": "Tech"}
953                 ]},
954                {"id": "resp_billing", "kind": "respond"},
955                {"id": "resp_tech", "kind": "respond"},
956                {"id": "resp_extra", "kind": "respond"}
957            ],
958            "edges": [
959                {"from": "sup", "to": "resp_billing", "branch": "billing"},
960                {"from": "sup", "to": "resp_tech", "branch": "tech"},
961                {"from": "sup", "to": "resp_extra", "branch": "unknown_branch"}
962            ]
963        });
964        let err = GraphConfig::from_json(&v.to_string()).unwrap_err();
965        assert!(
966            matches!(&err, GraphError::Invalid(msg) if msg.contains("undeclared branch label")),
967            "got {err:?}"
968        );
969    }
970
971    // -----------------------------------------------------------------------
972    // Parallel validation errors
973    // -----------------------------------------------------------------------
974
975    #[test]
976    fn parallel_single_branch_rejected() {
977        let v = serde_json::json!({
978            "schemaVersion": 2,
979            "entry": "fan",
980            "nodes": [
981                {"id": "fan", "kind": "parallel"},
982                {"id": "agent_a", "kind": "agent", "systemPrompt": "a", "model": "gpt-4o-mini"},
983                {"id": "meet", "kind": "join"},
984                {"id": "respond", "kind": "respond"}
985            ],
986            "edges": [
987                {"from": "fan", "to": "agent_a", "branch": "a"},
988                {"from": "agent_a", "to": "meet"},
989                {"from": "meet", "to": "respond"}
990            ]
991        });
992        let err = GraphConfig::from_json(&v.to_string()).unwrap_err();
993        assert!(
994            matches!(&err, GraphError::Invalid(msg) if msg.contains("at least 2 outgoing edges")),
995            "got {err:?}"
996        );
997    }
998
999    #[test]
1000    fn parallel_duplicate_branch_labels_rejected() {
1001        let v = serde_json::json!({
1002            "schemaVersion": 2,
1003            "entry": "fan",
1004            "nodes": [
1005                {"id": "fan", "kind": "parallel"},
1006                {"id": "agent_a", "kind": "agent", "systemPrompt": "a", "model": "gpt-4o-mini"},
1007                {"id": "agent_b", "kind": "agent", "systemPrompt": "b", "model": "gpt-4o-mini"},
1008                {"id": "meet", "kind": "join"},
1009                {"id": "respond", "kind": "respond"}
1010            ],
1011            "edges": [
1012                {"from": "fan", "to": "agent_a", "branch": "same"},
1013                {"from": "fan", "to": "agent_b", "branch": "same"},
1014                {"from": "agent_a", "to": "meet"},
1015                {"from": "agent_b", "to": "meet"},
1016                {"from": "meet", "to": "respond"}
1017            ]
1018        });
1019        let err = GraphConfig::from_json(&v.to_string()).unwrap_err();
1020        assert!(
1021            matches!(&err, GraphError::Invalid(msg) if msg.contains("duplicate branch label")),
1022            "got {err:?}"
1023        );
1024    }
1025
1026    #[test]
1027    fn parallel_branches_sharing_node_before_join_rejected() {
1028        // agent_shared is on both branches — must be rejected.
1029        let v = serde_json::json!({
1030            "schemaVersion": 2,
1031            "entry": "fan",
1032            "nodes": [
1033                {"id": "fan", "kind": "parallel"},
1034                {"id": "agent_shared", "kind": "agent", "systemPrompt": "shared", "model": "gpt-4o-mini"},
1035                {"id": "meet", "kind": "join"},
1036                {"id": "respond", "kind": "respond"}
1037            ],
1038            "edges": [
1039                {"from": "fan", "to": "agent_shared", "branch": "a"},
1040                {"from": "fan", "to": "agent_shared", "branch": "b"},
1041                {"from": "agent_shared", "to": "meet"},
1042                {"from": "meet", "to": "respond"}
1043            ]
1044        });
1045        let err = GraphConfig::from_json(&v.to_string()).unwrap_err();
1046        assert!(
1047            matches!(&err, GraphError::Invalid(msg) if msg.contains("share node") || msg.contains("node-disjoint")),
1048            "got {err:?}"
1049        );
1050    }
1051
1052    #[test]
1053    fn nested_parallel_rejected() {
1054        let v = serde_json::json!({
1055            "schemaVersion": 2,
1056            "entry": "outer",
1057            "nodes": [
1058                {"id": "outer", "kind": "parallel"},
1059                {"id": "inner", "kind": "parallel"},
1060                {"id": "agent_a", "kind": "agent", "systemPrompt": "a", "model": "gpt-4o-mini"},
1061                {"id": "inner_a", "kind": "agent", "systemPrompt": "ia", "model": "gpt-4o-mini"},
1062                {"id": "inner_b", "kind": "agent", "systemPrompt": "ib", "model": "gpt-4o-mini"},
1063                {"id": "inner_join", "kind": "join"},
1064                {"id": "outer_join", "kind": "join"},
1065                {"id": "respond", "kind": "respond"}
1066            ],
1067            "edges": [
1068                {"from": "outer", "to": "agent_a", "branch": "a"},
1069                {"from": "outer", "to": "inner", "branch": "b"},
1070                {"from": "inner", "to": "inner_a", "branch": "x"},
1071                {"from": "inner", "to": "inner_b", "branch": "y"},
1072                {"from": "inner_a", "to": "inner_join"},
1073                {"from": "inner_b", "to": "inner_join"},
1074                {"from": "inner_join", "to": "outer_join"},
1075                {"from": "agent_a", "to": "outer_join"},
1076                {"from": "outer_join", "to": "respond"}
1077            ]
1078        });
1079        let err = GraphConfig::from_json(&v.to_string()).unwrap_err();
1080        assert!(
1081            matches!(&err, GraphError::Invalid(msg) if msg.contains("nested parallel")),
1082            "got {err:?}"
1083        );
1084    }
1085
1086    #[test]
1087    fn respond_inside_parallel_branch_rejected() {
1088        let v = serde_json::json!({
1089            "schemaVersion": 2,
1090            "entry": "fan",
1091            "nodes": [
1092                {"id": "fan", "kind": "parallel"},
1093                {"id": "agent_a", "kind": "agent", "systemPrompt": "a", "model": "gpt-4o-mini"},
1094                {"id": "early_respond", "kind": "respond"},
1095                {"id": "agent_b", "kind": "agent", "systemPrompt": "b", "model": "gpt-4o-mini"},
1096                {"id": "meet", "kind": "join"},
1097                {"id": "respond", "kind": "respond"}
1098            ],
1099            "edges": [
1100                {"from": "fan", "to": "agent_a", "branch": "a"},
1101                {"from": "fan", "to": "agent_b", "branch": "b"},
1102                {"from": "agent_a", "to": "early_respond"},
1103                {"from": "agent_b", "to": "meet"},
1104                {"from": "meet", "to": "respond"}
1105            ]
1106        });
1107        let err = GraphConfig::from_json(&v.to_string()).unwrap_err();
1108        assert!(
1109            matches!(&err, GraphError::Invalid(msg) if msg.contains("respond") && msg.contains("branch")),
1110            "got {err:?}"
1111        );
1112    }
1113
1114    // -----------------------------------------------------------------------
1115    // Join validation errors
1116    // -----------------------------------------------------------------------
1117
1118    #[test]
1119    fn join_with_one_incoming_edge_rejected() {
1120        // Build a minimal valid parallel graph and then remove one incoming edge to join.
1121        let v = serde_json::json!({
1122            "schemaVersion": 2,
1123            "entry": "fan",
1124            "nodes": [
1125                {"id": "fan", "kind": "parallel"},
1126                {"id": "agent_a", "kind": "agent", "systemPrompt": "a", "model": "gpt-4o-mini"},
1127                {"id": "agent_b", "kind": "agent", "systemPrompt": "b", "model": "gpt-4o-mini"},
1128                {"id": "meet", "kind": "join"},
1129                {"id": "respond", "kind": "respond"}
1130            ],
1131            "edges": [
1132                {"from": "fan", "to": "agent_a", "branch": "a"},
1133                {"from": "fan", "to": "agent_b", "branch": "b"},
1134                {"from": "agent_a", "to": "meet"},
1135                // Omit agent_b → meet (only 1 incoming to join)
1136                {"from": "meet", "to": "respond"}
1137            ]
1138        });
1139        let err = GraphConfig::from_json(&v.to_string()).unwrap_err();
1140        // Either join incoming-edge count failure or parallel branch never reaching join
1141        assert!(matches!(&err, GraphError::Invalid(_)), "got {err:?}");
1142    }
1143
1144    // -----------------------------------------------------------------------
1145    // v1 fixtures still parse (backward compatibility guard)
1146    // -----------------------------------------------------------------------
1147
1148    #[test]
1149    fn v1_triage_fixture_still_parses_after_v2_changes() {
1150        let cfg = GraphConfig::from_json(&test_fixtures::triage_json())
1151            .expect("v1 triage must still parse");
1152        assert_eq!(cfg.schema_version, 1);
1153    }
1154
1155    // -----------------------------------------------------------------------
1156    // schemaVersion 2 is now accepted (previously unknown_schema_version test
1157    // used 2 — updated the old test to use 99)
1158    // -----------------------------------------------------------------------
1159    #[test]
1160    fn schema_version_2_is_accepted_for_v2_graphs() {
1161        // supervisor_json uses schemaVersion 2 — parsing it is the acceptance test.
1162        GraphConfig::from_json(&test_fixtures::supervisor_json()).expect("v2 graph must parse");
1163    }
1164
1165    // -----------------------------------------------------------------------
1166    // Provider field: backward-compat + round-trip tests
1167    // -----------------------------------------------------------------------
1168
1169    /// Existing agent-node JSON without a `"provider"` key must deserialize to
1170    /// `provider: None`. This is the backward-compatibility guarantee.
1171    #[test]
1172    fn agent_node_without_provider_deserializes_to_none() {
1173        let json = serde_json::json!({
1174            "id": "agent",
1175            "kind": "agent",
1176            "systemPrompt": "You help users.",
1177            "model": "gpt-4o-mini"
1178        });
1179        let node: Node = serde_json::from_value(json).expect("should deserialize");
1180        match node.kind {
1181            NodeKind::Agent { provider, .. } => {
1182                assert_eq!(provider, None, "absent provider must deserialize to None");
1183            }
1184            other => panic!("expected Agent, got {other:?}"),
1185        }
1186    }
1187
1188    /// A JSON agent node with `"provider": "anthropic"` must deserialize to
1189    /// `provider: Some("anthropic")`.
1190    #[test]
1191    fn agent_node_with_provider_deserializes_to_some() {
1192        let json = serde_json::json!({
1193            "id": "agent",
1194            "kind": "agent",
1195            "systemPrompt": "You help users.",
1196            "model": "claude-3-5-sonnet",
1197            "provider": "anthropic"
1198        });
1199        let node: Node = serde_json::from_value(json).expect("should deserialize");
1200        match node.kind {
1201            NodeKind::Agent { provider, .. } => {
1202                assert_eq!(
1203                    provider,
1204                    Some("anthropic".to_string()),
1205                    "explicit provider must round-trip"
1206                );
1207            }
1208            other => panic!("expected Agent, got {other:?}"),
1209        }
1210    }
1211
1212    /// When `provider` is `None`, the serialized JSON must NOT contain a
1213    /// `"provider"` key (`skip_serializing_if = "Option::is_none"`).
1214    #[test]
1215    fn agent_node_provider_none_is_omitted_from_json() {
1216        let node = Node {
1217            id: "agent".to_string(),
1218            kind: NodeKind::Agent {
1219                system_prompt: "You help users.".to_string(),
1220                model: "gpt-4o-mini".to_string(),
1221                tools: vec![],
1222                provider: None,
1223            },
1224        };
1225        let value = serde_json::to_value(&node).expect("serialization must succeed");
1226        assert!(
1227            value.get("provider").is_none(),
1228            "provider: None must be omitted from JSON, got: {value}"
1229        );
1230    }
1231}