1use std::collections::{BTreeMap, BTreeSet};
2
3use serde::{Deserialize, Serialize};
4use serde_json::Value;
5
6pub const DEFAULT_MAX_GRAPHS: usize = 64;
8pub 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 #[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,
87 Subgraph,
90 HumanApproval,
94 External,
97 Tool,
100 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 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 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(¶llel(json!({}))).expect_err("conflict rejected");
781 assert!(error.contains("unordered parallel nodes"));
782 assert!(parse_and_validate(¶llel(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}