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 #[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,
73 Subgraph,
76 HumanApproval,
80 External,
83 Tool,
86 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 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 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(¶llel(json!({}))).expect_err("conflict rejected");
767 assert!(error.contains("unordered parallel nodes"));
768 assert!(parse_and_validate(¶llel(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}