Skip to main content

agent_graph_mcp/
spec.rs

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