Skip to main content

agent_graph_mcp/
spec.rs

1use std::collections::{BTreeMap, BTreeSet};
2
3use serde::{Deserialize, Serialize};
4use serde_json::Value;
5
6/// Historical default for registered graphs when no explicit capacity is configured.
7pub const DEFAULT_MAX_GRAPHS: usize = 64;
8/// Absolute bound for a single daemon instance. This prevents a configuration typo
9/// from silently admitting an unbounded graph registry.
10pub const MAX_GRAPHS_HARD_LIMIT: usize = 1024;
11pub const MAX_GRAPH_BYTES: usize = 64 * 1024;
12pub const MAX_NODES: usize = 128;
13pub const MAX_EDGES: usize = 512;
14pub const MAX_ITERATIONS: usize = 64;
15pub const MAX_INPUT_BYTES: usize = 64 * 1024;
16pub const MAX_OUTPUT_BYTES: usize = 128 * 1024;
17pub const MAX_STATE_BYTES: usize = 2 * 1024 * 1024;
18
19pub fn validate_max_graphs(max_graphs: usize) -> Result<usize, String> {
20    if (1..=MAX_GRAPHS_HARD_LIMIT).contains(&max_graphs) {
21        Ok(max_graphs)
22    } else {
23        Err(format!(
24            "--max-graphs must be between 1 and {MAX_GRAPHS_HARD_LIMIT}"
25        ))
26    }
27}
28
29fn default_version() -> String {
30    "1".into()
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct GraphSpec {
35    #[serde(default = "default_version")]
36    pub spec_version: String,
37    pub name: String,
38    pub entry: String,
39    /// Explicit state key returned as `final_state`. When absent, legacy graphs
40    /// continue to expose `__input__` as their terminal output.
41    #[serde(default)]
42    pub output_key: Option<String>,
43    pub nodes: Vec<NodeSpec>,
44    #[serde(default)]
45    pub edges: Vec<EdgeSpec>,
46    #[serde(default, alias = "recursion_limit")]
47    pub max_iterations: Option<usize>,
48    #[serde(default)]
49    pub max_parallelism: Option<usize>,
50    #[serde(default)]
51    pub reducers: BTreeMap<String, ReducerKind>,
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct NodeSpec {
56    pub id: String,
57    #[serde(rename = "type")]
58    pub node_type: NodeType,
59    #[serde(default)]
60    pub prompt: Option<String>,
61    #[serde(default)]
62    pub model: Option<String>,
63    #[serde(default)]
64    pub json_mode: bool,
65    #[serde(default)]
66    pub evidence_required: bool,
67    #[serde(default)]
68    pub max_tokens: Option<usize>,
69    #[serde(default)]
70    pub routes: Option<BTreeMap<String, String>>,
71    #[serde(default)]
72    pub config: Value,
73}
74
75#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
76#[serde(rename_all = "snake_case")]
77pub enum NodeType {
78    Llm,
79    Router,
80    Passthrough,
81    StateTransform,
82    Join,
83    /// Parallel fan-out: execute multiple branches concurrently.
84    /// Requires `config.branches` (array of {id, entry, input}), `config.max_parallelism`,
85    /// `config.join` (target join node), and optional `config.fail_policy` and `config.timeout_ms`.
86    Parallel,
87    /// Reference another registered graph as a subgraph node.
88    /// Requires `config.graph_name`, optional `config.input_key` and `config.output_key`.
89    Subgraph,
90    /// Human approval gate: interrupt execution for human decision.
91    /// Requires `config.prompt_key`, `config.audience`, `config.allowed_decisions`,
92    /// and optional `config.expiry_ms` and `config.output_key`.
93    HumanApproval,
94    /// Reserved effectful class. It is accepted for truthful classification
95    /// but is not executable by this local runtime.
96    External,
97    /// Reserved tool class. It is accepted for truthful classification but is
98    /// not executable by this local runtime.
99    Tool,
100    /// Explicit loop class; deterministic resume does not support it.
101    Loop,
102}
103
104#[derive(Debug, Clone, Serialize, Deserialize)]
105pub struct EdgeSpec {
106    pub from: String,
107    pub to: String,
108}
109
110#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct ResumeEligibility {
112    pub next_node_cursor: String,
113    pub chain: Vec<String>,
114    pub dependency_summary: Value,
115}
116
117#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
118#[serde(rename_all = "snake_case")]
119pub enum ReducerKind {
120    LastWriteWins,
121    Append,
122    Add,
123    Merge,
124}
125
126impl GraphSpec {
127    /// Return the executable contract for every declared node type.
128    /// Reserved effectful classes are deliberately rejected before registration.
129    pub fn executable_node_type(node_type: &NodeType) -> Result<&'static str, String> {
130        match node_type {
131            NodeType::Llm => Ok("llm"),
132            NodeType::Router => Ok("router"),
133            NodeType::Passthrough => Ok("passthrough"),
134            NodeType::StateTransform => Ok("state_transform"),
135            NodeType::Join => Ok("join"),
136            NodeType::Parallel => Ok("parallel"),
137            NodeType::Subgraph => Ok("subgraph"),
138            NodeType::HumanApproval => Ok("human_approval"),
139            NodeType::External => Err("UNSUPPORTED_NODE_TYPE: external".into()),
140            NodeType::Tool => Ok("tool"),
141            NodeType::Loop => Err("UNSUPPORTED_NODE_TYPE: loop".into()),
142        }
143    }
144
145    pub fn normalize(mut self) -> Self {
146        self.spec_version = "2".into();
147        if self.max_iterations.is_none() {
148            self.max_iterations = Some(64);
149        }
150        if self.max_parallelism.is_none() {
151            self.max_parallelism = Some(8);
152        }
153        self
154    }
155
156    pub fn warnings(&self) -> Vec<String> {
157        let mut warnings = Vec::new();
158        if self.nodes.iter().any(|n| n.routes.is_some()) {
159            warnings.push("legacy route maps are normalized in lexicographic pattern order; use config.rules for explicit first-match order".into());
160        }
161        warnings
162    }
163
164    /// Classify resume support from the declarative graph only. Runtime
165    /// observations never upgrade an ineligible graph into the supported lane.
166    pub fn resume_eligibility(&self) -> Result<ResumeEligibility, String> {
167        if !self.reducers.is_empty() {
168            return Err("reducers are outside the deterministic local resume subset".into());
169        }
170
171        for node in &self.nodes {
172            match node.node_type {
173                NodeType::Passthrough => {
174                    if node.evidence_required {
175                        return Err(format!(
176                            "node '{}' declares an evidence dependency",
177                            node.id
178                        ));
179                    }
180                    let empty_config = node.config.is_null()
181                        || node
182                            .config
183                            .as_object()
184                            .is_some_and(|object| object.is_empty());
185                    if !empty_config {
186                        return Err(format!(
187                            "passthrough node '{}' has unsupported config",
188                            node.id
189                        ));
190                    }
191                }
192                NodeType::StateTransform => {
193                    if node.evidence_required {
194                        return Err(format!(
195                            "node '{}' declares an evidence dependency",
196                            node.id
197                        ));
198                    }
199                    let Some(object) = node.config.as_object() else {
200                        return Err(format!("transform node '{}' config is not local", node.id));
201                    };
202                    if object.keys().any(|key| key != "operations") {
203                        return Err(format!(
204                            "transform node '{}' has unsupported config",
205                            node.id
206                        ));
207                    }
208                }
209                NodeType::Llm => return Err(format!("node '{}' is an LLM node", node.id)),
210                NodeType::Router => return Err(format!("node '{}' is a router", node.id)),
211                NodeType::Join => return Err(format!("node '{}' is a join", node.id)),
212                NodeType::Parallel => return Err(format!("node '{}' is parallel", node.id)),
213                NodeType::Subgraph => return Err(format!("node '{}' is a subgraph", node.id)),
214                NodeType::HumanApproval => {
215                    return Err(format!("node '{}' is an approval node", node.id));
216                }
217                NodeType::External => {
218                    return Err(format!("node '{}' is an external node", node.id));
219                }
220                NodeType::Tool => return Err(format!("node '{}' is a tool node", node.id)),
221                NodeType::Loop => return Err(format!("node '{}' is a loop node", node.id)),
222            }
223        }
224
225        let mut successors: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
226        let mut predecessors: BTreeMap<&str, usize> = self
227            .nodes
228            .iter()
229            .map(|node| (node.id.as_str(), 0))
230            .collect();
231        for edge in &self.edges {
232            successors
233                .entry(edge.from.as_str())
234                .or_default()
235                .push(edge.to.as_str());
236            if edge.to != "END" {
237                *predecessors
238                    .get_mut(edge.to.as_str())
239                    .expect("validated edge target") += 1;
240            }
241        }
242        for node in &self.nodes {
243            let count = successors.get(node.id.as_str()).map_or(0, Vec::len);
244            if count != 1 {
245                return Err(format!(
246                    "linear resume requires exactly one successor for node '{}'",
247                    node.id
248                ));
249            }
250        }
251        if predecessors.get(self.entry.as_str()).copied().unwrap_or(0) != 0 {
252            return Err("resume entry must have no predecessor".into());
253        }
254        for node in &self.nodes {
255            if node.id != self.entry && predecessors.get(node.id.as_str()).copied() != Some(1) {
256                return Err(format!(
257                    "linear resume requires one predecessor for node '{}'",
258                    node.id
259                ));
260            }
261        }
262
263        let mut chain = Vec::with_capacity(self.nodes.len());
264        let mut current = self.entry.as_str();
265        let mut seen = BTreeSet::new();
266        loop {
267            if !seen.insert(current) {
268                return Err("loops are outside the deterministic local resume subset".into());
269            }
270            chain.push(current.to_owned());
271            let next = successors
272                .get(current)
273                .and_then(|targets| targets.first())
274                .copied()
275                .expect("successor count checked");
276            if next == "END" {
277                break;
278            }
279            if !predecessors.contains_key(next) {
280                return Err("linear resume successor is not a graph node".into());
281            }
282            current = next;
283        }
284        if chain.len() != self.nodes.len() {
285            return Err("linear resume requires every node to be on the entry chain".into());
286        }
287
288        Ok(ResumeEligibility {
289            next_node_cursor: self.entry.clone(),
290            chain: chain.clone(),
291            dependency_summary: serde_json::json!({
292                "classification": "deterministic_local_resume",
293                "eligible": true,
294                "node_types": ["passthrough", "state_transform"],
295                "chain": chain,
296                "source_witnesses": {"required": false, "validated": []},
297                "external_dependencies": false,
298            }),
299        })
300    }
301}
302
303pub fn parse_and_validate(raw: &Value) -> Result<GraphSpec, String> {
304    ensure_size(raw, MAX_GRAPH_BYTES, "serialized graph spec")?;
305    reject_dangerous_keys(raw)?;
306    let spec: GraphSpec =
307        serde_json::from_value(raw.clone()).map_err(|e| format!("invalid graph spec: {e}"))?;
308    validate(&spec)?;
309    Ok(spec.normalize())
310}
311
312pub fn validate(spec: &GraphSpec) -> Result<(), String> {
313    if !valid_id(&spec.name) {
314        return Err("graph name must match [A-Za-z0-9_.-]{1,64}".into());
315    }
316    if spec.nodes.is_empty() || spec.nodes.len() > MAX_NODES {
317        return Err(format!("graph nodes must be 1..={MAX_NODES}"));
318    }
319    if spec.edges.len() > MAX_EDGES {
320        return Err(format!("graph edge limit ({MAX_EDGES}) exceeded"));
321    }
322    let iterations = spec.max_iterations.unwrap_or(MAX_ITERATIONS);
323    if iterations == 0 || iterations > MAX_ITERATIONS {
324        return Err(format!("max_iterations must be 1..={MAX_ITERATIONS}"));
325    }
326    if spec.max_parallelism.unwrap_or(8) == 0 || spec.max_parallelism.unwrap_or(8) > 32 {
327        return Err("max_parallelism must be 1..=32".into());
328    }
329    let ids: BTreeSet<_> = spec.nodes.iter().map(|n| n.id.as_str()).collect();
330    if ids.len() != spec.nodes.len() {
331        return Err("duplicate node ID".into());
332    }
333    if !ids.contains(spec.entry.as_str()) {
334        return Err(format!("entry node '{}' not found", spec.entry));
335    }
336    if spec.output_key.as_deref().is_some_and(str::is_empty) {
337        return Err("output_key must not be empty when provided".into());
338    }
339    for node in &spec.nodes {
340        if !valid_id(&node.id) {
341            return Err(format!("invalid node ID '{}'", node.id));
342        }
343        validate_node(node, &ids)?;
344    }
345    for edge in &spec.edges {
346        if !ids.contains(edge.from.as_str()) {
347            return Err(format!("edge source '{}' not found", edge.from));
348        }
349        if edge.to != "END" && !ids.contains(edge.to.as_str()) {
350            return Err(format!("edge target '{}' not found", edge.to));
351        }
352    }
353    validate_state_write_conflicts(spec)?;
354    Ok(())
355}
356
357fn validate_state_write_conflicts(spec: &GraphSpec) -> Result<(), String> {
358    let ids: Vec<&str> = spec.nodes.iter().map(|node| node.id.as_str()).collect();
359    let mut reach = vec![vec![false; ids.len()]; ids.len()];
360    for edge in &spec.edges {
361        if edge.to != "END" {
362            if let (Some(from), Some(to)) = (
363                ids.iter().position(|id| *id == edge.from),
364                ids.iter().position(|id| *id == edge.to),
365            ) {
366                reach[from][to] = true;
367            }
368        }
369    }
370    for k in 0..ids.len() {
371        for i in 0..ids.len() {
372            for j in 0..ids.len() {
373                reach[i][j] = reach[i][j] || (reach[i][k] && reach[k][j]);
374            }
375        }
376    }
377
378    let mut writers: BTreeMap<String, Vec<usize>> = BTreeMap::new();
379    for (index, node) in spec.nodes.iter().enumerate() {
380        let mut keys = Vec::new();
381        match node.node_type {
382            NodeType::Llm | NodeType::HumanApproval | NodeType::Subgraph => {
383                if let Some(key) = node
384                    .config
385                    .get(if node.node_type == NodeType::Llm {
386                        "output_key"
387                    } else if node.node_type == NodeType::HumanApproval {
388                        "output_key"
389                    } else {
390                        "output_key"
391                    })
392                    .and_then(Value::as_str)
393                    .filter(|key| !key.is_empty())
394                {
395                    keys.push(key.to_owned());
396                }
397            }
398            NodeType::StateTransform => {
399                if let Some(operations) = node.config.get("operations").and_then(Value::as_array) {
400                    keys.extend(operations.iter().filter_map(|operation| {
401                        operation
402                            .get("path")
403                            .and_then(Value::as_str)
404                            .map(str::to_owned)
405                    }));
406                }
407            }
408            NodeType::Join => {
409                if let Some(key) = node.config.get("output").and_then(Value::as_str) {
410                    keys.push(key.to_owned());
411                }
412            }
413            _ => {}
414        }
415        for key in keys {
416            writers.entry(key).or_default().push(index);
417        }
418    }
419    for (key, nodes) in writers {
420        if spec.reducers.contains_key(&key) {
421            continue;
422        }
423        for left in 0..nodes.len() {
424            for right in (left + 1)..nodes.len() {
425                let a = nodes[left];
426                let b = nodes[right];
427                if reach[a][b] || reach[b][a] {
428                    continue;
429                }
430                let shared_ancestor = (0..ids.len()).any(|ancestor| {
431                    ancestor != a && ancestor != b && reach[ancestor][a] && reach[ancestor][b]
432                });
433                if shared_ancestor {
434                    return Err(format!(
435                        "state key '{}' is written by unordered parallel nodes '{}' and '{}'; declare reducers.{}",
436                        key, ids[a], ids[b], ""
437                    ));
438                }
439            }
440        }
441    }
442    Ok(())
443}
444
445fn validate_node(node: &NodeSpec, ids: &BTreeSet<&str>) -> Result<(), String> {
446    if node.node_type == NodeType::Router {
447        let targets: Vec<String> = if let Some(routes) = &node.routes {
448            routes.values().cloned().collect()
449        } else {
450            node.config
451                .get("rules")
452                .and_then(Value::as_array)
453                .into_iter()
454                .flatten()
455                .flat_map(|r| {
456                    r.get("targets")
457                        .and_then(Value::as_array)
458                        .into_iter()
459                        .flatten()
460                })
461                .filter_map(|v| v.as_str().map(str::to_owned))
462                .chain(
463                    node.config
464                        .get("default")
465                        .and_then(Value::as_array)
466                        .into_iter()
467                        .flatten()
468                        .filter_map(|v| v.as_str().map(str::to_owned)),
469                )
470                .collect()
471        };
472        if targets.is_empty() {
473            return Err(format!(
474                "router node '{}' must define routes/rules and default",
475                node.id
476            ));
477        }
478        if node.routes.is_none() {
479            if node
480                .config
481                .get("default")
482                .and_then(Value::as_array)
483                .is_none()
484            {
485                return Err(format!(
486                    "router node '{}' requires explicit default",
487                    node.id
488                ));
489            }
490            for rule in node
491                .config
492                .get("rules")
493                .and_then(Value::as_array)
494                .into_iter()
495                .flatten()
496            {
497                let op = rule.get("op").and_then(Value::as_str).unwrap_or("");
498                if ![
499                    "equals", "eq", "exists", "contains", "lt", "lte", "gt", "gte",
500                ]
501                .contains(&op)
502                {
503                    return Err(format!(
504                        "router node '{}' has unsupported predicate '{op}'",
505                        node.id
506                    ));
507                }
508            }
509        }
510        for target in targets {
511            if target != "END" && !ids.contains(target.as_str()) {
512                return Err(format!(
513                    "router node '{}' target '{}' not found",
514                    node.id, target
515                ));
516            }
517        }
518    }
519    if node.node_type == NodeType::Llm {
520        if node.evidence_required {
521            if !node.json_mode {
522                return Err(format!(
523                    "LLM node '{}' with evidence_required requires json_mode=true",
524                    node.id
525                ));
526            }
527            if node
528                .config
529                .get("output_key")
530                .and_then(Value::as_str)
531                .map_or(true, str::is_empty)
532            {
533                return Err(format!(
534                    "LLM node '{}' with evidence_required requires config.output_key",
535                    node.id
536                ));
537            }
538        }
539        if node
540            .prompt
541            .as_ref()
542            .is_some_and(|prompt| prompt.len() > 16 * 1024)
543        {
544            return Err("LLM prompt exceeds 16384 bytes".into());
545        }
546        if node.max_tokens.unwrap_or(1024) > 8192 {
547            return Err("LLM max_tokens exceeds 8192".into());
548        }
549        if node.model.as_ref().is_some_and(|m| !valid_model_alias(m)) {
550            return Err("model must be a conservative server alias".into());
551        }
552        let timeout = node
553            .config
554            .get("timeout_ms")
555            .and_then(Value::as_u64)
556            .unwrap_or(120_000);
557        if timeout == 0 || timeout > 120_000 {
558            return Err("LLM timeout_ms must be 1..=120000".into());
559        }
560        if let Some(retry) = node.config.get("retry") {
561            let attempts = retry
562                .get("max_attempts")
563                .and_then(Value::as_u64)
564                .unwrap_or(3);
565            if attempts == 0 || attempts > 5 {
566                return Err("retry max_attempts must be 1..=5".into());
567            }
568        }
569    }
570    if node.node_type == NodeType::StateTransform {
571        let operations = node
572            .config
573            .get("operations")
574            .and_then(Value::as_array)
575            .ok_or_else(|| format!("state_transform '{}' requires operations", node.id))?;
576        if operations.is_empty() || operations.len() > 64 {
577            return Err("transform operations must be 1..=64".into());
578        }
579        for operation in operations {
580            let op = operation.get("op").and_then(Value::as_str).unwrap_or("");
581            if ![
582                "set",
583                "copy",
584                "delete",
585                "increment",
586                "append",
587                "merge",
588                "merge_object",
589                "select",
590                "compare",
591                "format",
592            ]
593            .contains(&op)
594            {
595                return Err(format!("unsupported transform operation '{op}'"));
596            }
597        }
598    }
599    if node.node_type == NodeType::Join {
600        let mode = node
601            .config
602            .get("mode")
603            .and_then(Value::as_str)
604            .unwrap_or("collect_array");
605        if ![
606            "collect_array",
607            "merge_objects",
608            "first_non_null",
609            "all_success",
610            "quorum",
611        ]
612        .contains(&mode)
613        {
614            return Err(format!("unsupported join mode '{mode}'"));
615        }
616        if node
617            .config
618            .get("inputs")
619            .and_then(Value::as_array)
620            .is_none()
621            || node.config.get("output").and_then(Value::as_str).is_none()
622        {
623            return Err(format!("join '{}' requires inputs and output", node.id));
624        }
625    }
626    if node.node_type == NodeType::Parallel {
627        let branches = node
628            .config
629            .get("branches")
630            .and_then(Value::as_array)
631            .ok_or_else(|| format!("parallel '{}' requires branches array", node.id))?;
632        if branches.is_empty() || branches.len() > 16 {
633            return Err(format!("parallel '{}' branches must be 1..=16", node.id));
634        }
635        for branch in branches {
636            let entry = branch
637                .get("entry")
638                .and_then(Value::as_str)
639                .ok_or_else(|| format!("parallel '{}' branch missing entry", node.id))?;
640            if !ids.contains(entry) {
641                return Err(format!(
642                    "parallel '{}' branch entry '{}' not found",
643                    node.id, entry
644                ));
645            }
646        }
647        let join = node
648            .config
649            .get("join")
650            .and_then(Value::as_str)
651            .ok_or_else(|| format!("parallel '{}' requires join target", node.id))?;
652        if join != "END" && !ids.contains(join) {
653            return Err(format!(
654                "parallel '{}' join target '{}' not found",
655                node.id, join
656            ));
657        }
658        if let Some(policy) = node.config.get("fail_policy").and_then(Value::as_str) {
659            if !["fail_fast", "collect_partial", "ignore"].contains(&policy) {
660                return Err(format!("unsupported fail_policy '{policy}'"));
661            }
662        }
663    }
664    if node.node_type == NodeType::Subgraph {
665        if node
666            .config
667            .get("graph_name")
668            .and_then(Value::as_str)
669            .is_none()
670        {
671            return Err(format!("subgraph '{}' requires config.graph_name", node.id));
672        }
673    }
674    if node.node_type == NodeType::HumanApproval {
675        if node
676            .config
677            .get("prompt_key")
678            .and_then(Value::as_str)
679            .is_none()
680        {
681            return Err(format!(
682                "human_approval '{}' requires config.prompt_key",
683                node.id
684            ));
685        }
686        if node
687            .config
688            .get("audience")
689            .and_then(Value::as_array)
690            .is_none()
691        {
692            return Err(format!(
693                "human_approval '{}' requires config.audience array",
694                node.id
695            ));
696        }
697    }
698    Ok(())
699}
700
701pub fn valid_id(id: &str) -> bool {
702    !id.is_empty()
703        && id.len() <= 64
704        && id
705            .bytes()
706            .all(|b| b.is_ascii_alphanumeric() || b"_.-".contains(&b))
707}
708
709fn valid_model_alias(model: &str) -> bool {
710    !model.is_empty()
711        && model.len() <= 128
712        && !model.contains("://")
713        && !model.starts_with('/')
714        && !model.contains("..")
715        && model
716            .bytes()
717            .all(|b| b.is_ascii_alphanumeric() || b"_.:/-".contains(&b))
718}
719
720pub fn ensure_size(value: &Value, limit: usize, label: &str) -> Result<(), String> {
721    let len = serde_json::to_vec(value).map_err(|e| e.to_string())?.len();
722    if len > limit {
723        Err(format!("{label} exceeds {limit} bytes"))
724    } else {
725        Ok(())
726    }
727}
728
729fn reject_dangerous_keys(value: &Value) -> Result<(), String> {
730    const DENY: &[&str] = &[
731        "command",
732        "shell",
733        "script",
734        "filesystem",
735        "secret",
736        "env",
737        "environment",
738        "base_url",
739        "provider_url",
740    ];
741    match value {
742        Value::Object(map) => {
743            for (key, value) in map {
744                let normalized = key.to_ascii_lowercase();
745                if DENY.contains(&normalized.as_str()) {
746                    return Err(format!("policy denied field '{key}'"));
747                }
748                reject_dangerous_keys(value)?;
749            }
750        }
751        Value::Array(items) => {
752            for item in items {
753                reject_dangerous_keys(item)?;
754            }
755        }
756        _ => {}
757    }
758    Ok(())
759}
760
761#[cfg(test)]
762mod tests {
763    use super::parse_and_validate;
764    use serde_json::{json, Value};
765
766    fn parallel(reducers: Value) -> Value {
767        json!({
768            "name":"conflict", "entry":"fork", "reducers": reducers,
769            "nodes":[
770                {"id":"fork","type":"passthrough"},
771                {"id":"left","type":"state_transform","config":{"operations":[{"op":"set","path":"shared","value":"left"}]}},
772                {"id":"right","type":"state_transform","config":{"operations":[{"op":"set","path":"shared","value":"right"}]}}
773            ],
774            "edges":[{"from":"fork","to":"left"},{"from":"fork","to":"right"},{"from":"left","to":"END"},{"from":"right","to":"END"}]
775        })
776    }
777
778    #[test]
779    fn unordered_parallel_writes_require_reducer() {
780        let error = parse_and_validate(&parallel(json!({}))).expect_err("conflict rejected");
781        assert!(error.contains("unordered parallel nodes"));
782        assert!(parse_and_validate(&parallel(json!({"shared":"append"}))).is_ok());
783    }
784
785    #[test]
786    fn sequential_repeated_write_is_allowed() {
787        let spec = json!({
788            "name":"sequential", "entry":"left",
789            "nodes":[
790                {"id":"left","type":"state_transform","config":{"operations":[{"op":"set","path":"shared","value":"left"}]}},
791                {"id":"right","type":"state_transform","config":{"operations":[{"op":"set","path":"shared","value":"right"}]}}
792            ], "edges":[{"from":"left","to":"right"},{"from":"right","to":"END"}]
793        });
794        assert!(parse_and_validate(&spec).is_ok());
795    }
796}