Skip to main content

agentd/engine/
model.rs

1// SPDX-License-Identifier: Apache-2.0
2//! The **dialect-3 workflow model** (RFC 0027 §2–§5, §8): a named DAG of steps
3//! beginning at start nodes, parsed from a JSON/YAML document with a strict
4//! per-kind field check (unknown fields are refused — the RFC 0021 §4.1 typo
5//! shield carried over), validated for acyclicity, reachability, `finish`
6//! reachability, dependency existence, schema well-formedness, CEL
7//! compilation and the caps. The node catalogue is one table ([`KINDS`]) —
8//! the validator, the executor and `--workflow-schema` all read it.
9
10use crate::jsonschema;
11use serde::{Deserialize, Serialize};
12use serde_json::{Map, Value, json};
13use std::collections::{BTreeMap, BTreeSet};
14
15/// The dialect this model speaks.
16pub const DIALECT: u32 = 3;
17/// Caps (RFC 0027 §8).
18pub const MAX_STEPS: usize = 512;
19pub const MAX_NESTING: usize = 4;
20pub const MAX_BATCH_PARALLEL: u64 = 8;
21/// Lanes a `foreach`/`batch` uses when the definition does not say.
22///
23/// Was 1 — a fan-out that ran one item at a time, which is a loop with extra
24/// syntax. Four is concurrent enough to be worth writing `foreach` for and low
25/// enough not to stampede an MCP server that never asked for the traffic.
26pub const DEFAULT_FAN_OUT: u64 = 4;
27pub const MAX_ITERATIONS: u64 = 10_000;
28pub const MAX_ID_LEN: usize = 64;
29
30/// A step kind's metadata.
31#[derive(Debug, Clone, Copy)]
32pub struct KindInfo {
33    pub name: &'static str,
34    /// A start node (a trigger).
35    pub start: bool,
36    /// Kind-specific fields (besides the cross-cutting ones).
37    pub fields: &'static [&'static str],
38    /// Required kind-specific fields.
39    pub required: &'static [&'static str],
40    /// Executable in this build (`false` = parses/validates but the run
41    /// engine refuses it: it lands in a later phase).
42    pub implemented: bool,
43    /// Has a nested body sub-DAG (`body: {steps: …}`) / branches.
44    pub nested: bool,
45}
46
47const fn k(
48    name: &'static str,
49    start: bool,
50    fields: &'static [&'static str],
51    required: &'static [&'static str],
52    implemented: bool,
53    nested: bool,
54) -> KindInfo {
55    KindInfo {
56        name,
57        start,
58        fields,
59        required,
60        implemented,
61        nested,
62    }
63}
64
65/// The node catalogue (RFC 0027 §4–§5). `implemented` marks the P3 executor
66/// subset; the rest arrives with the P4 engine.
67pub const KINDS: &[KindInfo] = &[
68    // ---- start nodes ----
69    k("once", true, &["policy", "inputs"], &[], true, false),
70    k("manual", true, &["inputs"], &[], true, false),
71    k(
72        "loop",
73        true,
74        &[
75            "interval",
76            "delay",
77            "until",
78            "max_iterations",
79            "backoff",
80            "inputs",
81        ],
82        &[],
83        true,
84        false,
85    ),
86    k(
87        "schedule",
88        true,
89        &["cron", "every", "tz", "jitter", "catch_up", "at", "inputs"],
90        &[],
91        true,
92        false,
93    ),
94    k(
95        "subscribe",
96        true,
97        &[
98            "server",
99            "uri",
100            "debounce_ms",
101            "coalesce",
102            "filter",
103            "deliver",
104            "on_no_listener",
105            "inputs",
106        ],
107        &["server", "uri"],
108        true,
109        false,
110    ),
111    k(
112        "signal",
113        true,
114        &["name", "filter", "deliver", "inputs"],
115        &["name"],
116        true,
117        false,
118    ),
119    k(
120        "event",
121        true,
122        &["on", "filter", "inputs"],
123        &["on"],
124        true,
125        false,
126    ),
127    k(
128        "a2a",
129        true,
130        &["command", "roles", "inputs"],
131        &[],
132        true,
133        false,
134    ),
135    k(
136        "webhook",
137        true,
138        &[
139            "path",
140            "methods",
141            "auth",
142            "parallelism",
143            "on_overflow",
144            "idempotency",
145            "respond",
146            "filter",
147            "inputs",
148        ],
149        &["path"],
150        true,
151        false,
152    ),
153    // ---- control ----
154    k(
155        "switch",
156        false,
157        &["on", "cases", "default"],
158        &["on", "cases"],
159        true,
160        false,
161    ),
162    k(
163        "parallel",
164        false,
165        &["branches", "on_error"],
166        &["branches"],
167        true,
168        true,
169    ),
170    k(
171        "foreach",
172        false,
173        &["over", "body", "batch", "collect", "on_error", "as"],
174        &["over", "body"],
175        true,
176        true,
177    ),
178    k(
179        "batch",
180        false,
181        &[
182            "over", "body", "by", "size", "parallel", "rate", "collect", "on_error",
183        ],
184        &["over", "body"],
185        true,
186        true,
187    ),
188    k(
189        "iterate",
190        false,
191        &["body", "while", "until", "max_iterations", "collect"],
192        &["body"],
193        true,
194        true,
195    ),
196    k(
197        "race",
198        false,
199        &["branches", "timeout", "min_success"],
200        &["branches"],
201        true,
202        true,
203    ),
204    k(
205        "join",
206        false,
207        &["handles", "timeout", "min", "partials"],
208        &["handles"],
209        true,
210        false,
211    ),
212    k("subgraph", false, &["body"], &["body"], true, true),
213    k(
214        "workflow",
215        false,
216        &["name", "inputs", "mode", "start", "version", "cascade"],
217        &["name"],
218        true,
219        false,
220    ),
221    k(
222        "wait",
223        false,
224        &[
225            "on",
226            "server",
227            "uri",
228            "condition",
229            "signal",
230            "run",
231            "subagent",
232            "conversation",
233            "webhook",
234            "timeout",
235        ],
236        &["on"],
237        true,
238        false,
239    ),
240    k("sleep", false, &["duration"], &["duration"], true, false),
241    k(
242        "assert",
243        false,
244        &["condition", "message"],
245        &["condition"],
246        true,
247        false,
248    ),
249    k("fail", false, &["message", "code"], &[], true, false),
250    k("noop", false, &[], &[], true, false),
251    k("checkpoint", false, &["name"], &[], true, false),
252    k(
253        "finish",
254        false,
255        &["status", "output", "reason"],
256        &[],
257        true,
258        false,
259    ),
260    // ---- data ----
261    k(
262        "assign",
263        false,
264        &["value", "writes", "mode"],
265        &["value"],
266        true,
267        false,
268    ),
269    k(
270        "transform",
271        false,
272        &["value", "writes", "mode"],
273        &["value"],
274        true,
275        false,
276    ),
277    k(
278        "map",
279        false,
280        &["over", "expr", "as"],
281        &["over", "expr"],
282        true,
283        false,
284    ),
285    k(
286        "filter",
287        false,
288        &["over", "expr", "as"],
289        &["over", "expr"],
290        true,
291        false,
292    ),
293    k(
294        "reduce",
295        false,
296        &["over", "expr", "initial", "as", "acc"],
297        &["over", "expr"],
298        true,
299        false,
300    ),
301    k(
302        "sort",
303        false,
304        &["over", "by", "order"],
305        &["over"],
306        true,
307        false,
308    ),
309    k("dedupe", false, &["over", "by"], &["over"], true, false),
310    k(
311        "chunk",
312        false,
313        &["value", "by", "size", "overlap"],
314        &["value", "size"],
315        true,
316        false,
317    ),
318    k("template", false, &["text", "value"], &[], true, false),
319    k("parse", false, &["text", "format"], &["text"], true, false),
320    k(
321        "validate",
322        false,
323        &["value", "schema"],
324        &["value", "schema"],
325        true,
326        false,
327    ),
328    k("memory.get", false, &["key"], &["key"], true, false),
329    k(
330        "memory.set",
331        false,
332        &["key", "value", "ttl"],
333        &["key", "value"],
334        true,
335        false,
336    ),
337    k("memory.list", false, &["prefix", "limit"], &[], true, false),
338    k("memory.delete", false, &["key"], &["key"], true, false),
339    k(
340        "artifact.create",
341        false,
342        &["name", "mime", "content", "from_step", "sensitive"],
343        &["name"],
344        true,
345        false,
346    ),
347    k("artifact.get", false, &["id"], &["id"], true, false),
348    k("artifact.delete", false, &["id"], &["id"], true, false),
349    k(
350        "knowledge.search",
351        false,
352        &["query", "top_k", "filters"],
353        &["query"],
354        true,
355        false,
356    ),
357    k("knowledge.get", false, &["id", "uri"], &[], true, false),
358    k(
359        "search.query",
360        false,
361        &["query", "kind", "limit", "freshness"],
362        &["query"],
363        true,
364        false,
365    ),
366    k(
367        "search.fetch",
368        false,
369        &["url", "max_bytes"],
370        &["url"],
371        true,
372        false,
373    ),
374    // ---- integration ----
375    k(
376        "mcp.tool",
377        false,
378        &["server", "tool", "args"],
379        &["server", "tool"],
380        true,
381        false,
382    ),
383    k(
384        "mcp.resource",
385        false,
386        &[
387            "server",
388            "op",
389            "uri",
390            "name",
391            "arguments",
392            "reference",
393            "argument",
394        ],
395        &["server", "op"],
396        true,
397        false,
398    ),
399    k("tool", false, &["name", "args"], &["name"], true, false),
400    k(
401        "http",
402        false,
403        &[
404            "method",
405            "url",
406            "headers",
407            "query",
408            "body",
409            "json",
410            "timeout",
411            "expect",
412            "allow_private",
413            "sign",
414        ],
415        &["url"],
416        true,
417        false,
418    ),
419    k(
420        "a2a.send",
421        false,
422        &["to", "parts", "context", "timeout"],
423        &["to"],
424        true,
425        false,
426    ),
427    k(
428        "a2a.delegate",
429        false,
430        &["peer", "objective", "output_contract", "timeout"],
431        &["peer", "objective"],
432        true,
433        false,
434    ),
435    k(
436        "a2a.wait",
437        false,
438        &["conversation", "timeout"],
439        &[],
440        true,
441        false,
442    ),
443    k(
444        "workflow.signal",
445        false,
446        &["name", "payload", "run"],
447        &["name"],
448        true,
449        false,
450    ),
451    k(
452        "workflow.wait",
453        false,
454        &["run", "timeout"],
455        &["run"],
456        true,
457        false,
458    ),
459    k(
460        "workflow.cancel",
461        false,
462        &["run", "reason"],
463        &["run"],
464        true,
465        false,
466    ),
467    k(
468        "emit",
469        false,
470        &["note", "audit", "metric", "value"],
471        &[],
472        true,
473        false,
474    ),
475    // ---- intelligence & agents ----
476    k(
477        "think",
478        false,
479        &[
480            "prompt",
481            "output_schema",
482            "reads",
483            "check",
484            "retries",
485            "skills",
486            "system",
487        ],
488        &["prompt"],
489        true,
490        false,
491    ),
492    k(
493        "classify",
494        false,
495        &["input", "classes", "prompt", "skills"],
496        &["input", "classes"],
497        true,
498        false,
499    ),
500    k(
501        "extract",
502        false,
503        &["input", "output_schema", "prompt", "skills"],
504        &["input", "output_schema"],
505        true,
506        false,
507    ),
508    k(
509        "summarize",
510        false,
511        &["input", "length", "prompt", "skills"],
512        &["input"],
513        true,
514        false,
515    ),
516    k(
517        "judge",
518        false,
519        &["input", "rubric", "prompt", "skills"],
520        &["input", "rubric"],
521        true,
522        false,
523    ),
524    k(
525        "route",
526        false,
527        &["input", "choices", "prompt", "skills"],
528        &["input", "choices"],
529        true,
530        false,
531    ),
532    k(
533        "agent",
534        false,
535        &[
536            "instruction",
537            "output_contract",
538            "output_schema",
539            "tools",
540            "servers",
541            "limits",
542            "context",
543            "skills",
544            "system",
545        ],
546        &["instruction"],
547        true,
548        false,
549    ),
550    k(
551        "subagent",
552        false,
553        &[
554            "instruction",
555            "mode",
556            "workflow",
557            "tools",
558            "servers",
559            "limits",
560            "context",
561            "output_contract",
562            "output_schema",
563            "skills",
564        ],
565        &["instruction"],
566        true,
567        false,
568    ),
569    k(
570        "human",
571        false,
572        &["question", "schema", "to", "timeout", "reply_uri"],
573        &["question"],
574        true,
575        false,
576    ),
577];
578
579/// Cross-cutting fields every step may carry (RFC 0027 §5).
580pub const COMMON_FIELDS: &[&str] = &[
581    "kind",
582    "depends_on",
583    "when",
584    "retry",
585    "timeout",
586    "on_error",
587    "idempotent",
588    "on_replay",
589    "output_schema",
590    "cache",
591    "budget",
592    "skills",
593    "otel",
594    "description",
595];
596
597pub fn kind_info(name: &str) -> Option<&'static KindInfo> {
598    KINDS.iter().find(|k| k.name == name)
599}
600
601/// The kinds implemented by this build's engine.
602pub fn implemented_kinds() -> Vec<&'static str> {
603    KINDS
604        .iter()
605        .filter(|k| k.implemented)
606        .map(|k| k.name)
607        .collect()
608}
609
610/// `on_error` policy.
611#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
612#[serde(rename_all = "snake_case")]
613pub enum OnError {
614    #[default]
615    Fail,
616    Continue,
617    Goto(String),
618}
619
620impl OnError {
621    fn parse(v: &Value) -> Result<OnError, String> {
622        match v.as_str() {
623            Some("fail") => Ok(OnError::Fail),
624            Some("continue") => Ok(OnError::Continue),
625            Some(s) if s.starts_with("goto:") => {
626                let t = s["goto:".len()..].trim();
627                if t.is_empty() {
628                    Err("on_error goto: needs a step id".into())
629                } else {
630                    Ok(OnError::Goto(t.to_string()))
631                }
632            }
633            _ => Err("on_error must be fail | continue | goto:<step>".into()),
634        }
635    }
636}
637
638#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
639#[serde(rename_all = "snake_case")]
640pub enum OnReplay {
641    #[default]
642    Retry,
643    Skip,
644    Fail,
645}
646
647#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
648pub struct Retry {
649    #[serde(default)]
650    pub max: u32,
651    /// Backoff between attempts (ms), doubling; 0 = none.
652    #[serde(default)]
653    pub backoff_ms: u64,
654}
655
656/// A nested sub-DAG: the body of `foreach`/`batch`/`iterate`/`subgraph`, or one
657/// branch of `parallel`/`race`. Body steps depend only on siblings; steps with
658/// no dependencies are the entry points; steps nothing depends on are the
659/// **sinks** whose outputs form the body's result (one sink ⇒ its output; many
660/// ⇒ an object keyed by step id).
661#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
662pub struct Body {
663    pub steps: BTreeMap<String, Step>,
664}
665
666impl Body {
667    /// Deterministic dependency order.
668    pub fn topo_order(&self) -> Vec<String> {
669        let mut out = Vec::new();
670        let mut done: BTreeSet<String> = BTreeSet::new();
671        let mut progress = true;
672        while progress && out.len() < self.steps.len() {
673            progress = false;
674            for (id, s) in &self.steps {
675                if !done.contains(id) && s.depends_on.iter().all(|d| done.contains(d)) {
676                    done.insert(id.clone());
677                    out.push(id.clone());
678                    progress = true;
679                }
680            }
681        }
682        out
683    }
684    /// Steps nothing else depends on.
685    pub fn sinks(&self) -> Vec<String> {
686        self.steps
687            .keys()
688            .filter(|id| {
689                !self
690                    .steps
691                    .values()
692                    .any(|s| s.depends_on.iter().any(|d| d == *id))
693            })
694            .cloned()
695            .collect()
696    }
697}
698
699/// One step (the cross-cutting fields typed; kind fields in `spec`).
700#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
701pub struct Step {
702    pub id: String,
703    pub kind: String,
704    #[serde(default)]
705    pub depends_on: Vec<String>,
706    #[serde(default, skip_serializing_if = "Option::is_none")]
707    pub when: Option<String>,
708    #[serde(default, skip_serializing_if = "Option::is_none")]
709    pub retry: Option<Retry>,
710    #[serde(default, skip_serializing_if = "Option::is_none")]
711    pub timeout_ms: Option<u64>,
712    #[serde(default)]
713    pub on_error: OnError,
714    #[serde(default)]
715    pub idempotent: bool,
716    #[serde(default)]
717    pub on_replay: OnReplay,
718    #[serde(default, skip_serializing_if = "Option::is_none")]
719    pub output_schema: Option<Value>,
720    #[serde(default, skip_serializing_if = "Option::is_none")]
721    pub cache: Option<Value>,
722    #[serde(default, skip_serializing_if = "Option::is_none")]
723    pub budget: Option<u64>,
724    #[serde(default, skip_serializing_if = "Vec::is_empty")]
725    pub skills: Vec<String>,
726    #[serde(default, skip_serializing_if = "Option::is_none")]
727    pub description: Option<String>,
728    /// The kind-specific fields, verbatim.
729    #[serde(default)]
730    pub spec: Map<String, Value>,
731    /// The parsed nested body (`foreach`/`batch`/`iterate`/`subgraph`).
732    #[serde(default, skip_serializing_if = "Option::is_none")]
733    pub body: Option<Body>,
734    /// The parsed branches (`parallel`/`race`).
735    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
736    pub branches: BTreeMap<String, Body>,
737}
738
739impl Step {
740    pub fn info(&self) -> Option<&'static KindInfo> {
741        kind_info(&self.kind)
742    }
743    pub fn is_start(&self) -> bool {
744        self.info().is_some_and(|k| k.start)
745    }
746    /// A kind-specific field.
747    pub fn field(&self, name: &str) -> Option<&Value> {
748        self.spec.get(name)
749    }
750    pub fn field_str(&self, name: &str) -> Option<&str> {
751        self.spec.get(name).and_then(Value::as_str)
752    }
753}
754
755#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
756#[serde(rename_all = "snake_case")]
757pub enum OnOverflow {
758    #[default]
759    Queue,
760    Drop,
761    Replace,
762}
763
764#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
765pub struct Concurrency {
766    pub max_runs: u32,
767    pub on_overflow: OnOverflow,
768}
769
770impl Default for Concurrency {
771    fn default() -> Self {
772        Concurrency {
773            max_runs: 4,
774            on_overflow: OnOverflow::Queue,
775        }
776    }
777}
778
779#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
780pub struct WorkflowLimits {
781    #[serde(default, skip_serializing_if = "Option::is_none")]
782    pub steps: Option<u32>,
783    #[serde(default, skip_serializing_if = "Option::is_none")]
784    pub tokens: Option<u64>,
785    #[serde(default, skip_serializing_if = "Option::is_none")]
786    pub deadline_ms: Option<u64>,
787    #[serde(default, skip_serializing_if = "Option::is_none")]
788    pub budget: Option<Value>,
789}
790
791/// One declared run variable.
792#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
793pub struct StateDecl {
794    /// A JSON Schema the written value must satisfy.
795    #[serde(default, skip_serializing_if = "Option::is_none")]
796    pub schema: Option<Value>,
797    /// How concurrent writes combine: `overwrite | append | merge | union`.
798    #[serde(default, skip_serializing_if = "Option::is_none")]
799    pub reducer: Option<String>,
800}
801
802/// A parsed, validated workflow.
803#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
804pub struct Workflow {
805    pub name: String,
806    pub version: u32,
807    /// Declared run variables: `{key: {type, reducer}}`.
808    ///
809    /// Optional, and the point is to make concurrent writes a DECLARED policy
810    /// instead of a heuristic. Without it the parser can only guess from the
811    /// modes two racing writers happen to use; with it, the workflow states
812    /// what a key is and how writes to it combine, and disagreement is a config
813    /// error rather than a value that depends on completion order.
814    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
815    pub state: BTreeMap<String, StateDecl>,
816    #[serde(default, skip_serializing_if = "Option::is_none")]
817    pub description: Option<String>,
818    #[serde(default = "default_true")]
819    pub armed: bool,
820    #[serde(default, skip_serializing_if = "Option::is_none")]
821    pub inputs_schema: Option<Value>,
822    #[serde(default)]
823    pub concurrency: Concurrency,
824    #[serde(default)]
825    pub limits: WorkflowLimits,
826    #[serde(default, skip_serializing_if = "Option::is_none")]
827    pub outputs_schema: Option<Value>,
828    pub steps: BTreeMap<String, Step>,
829    /// SHA-256 of the canonical definition (RFC 0027 §9).
830    pub hash: String,
831    /// The definition as given (canonical JSON), for `workflow.list`/hash.
832    pub definition: Value,
833}
834
835fn default_true() -> bool {
836    true
837}
838
839impl Workflow {
840    pub fn start_steps(&self) -> Vec<&Step> {
841        self.steps.values().filter(|s| s.is_start()).collect()
842    }
843    pub fn step(&self, id: &str) -> Option<&Step> {
844        self.steps.get(id)
845    }
846    /// The steps that depend on `id`.
847    pub fn dependents(&self, id: &str) -> Vec<&Step> {
848        self.steps
849            .values()
850            .filter(|s| s.depends_on.iter().any(|d| d == id))
851            .collect()
852    }
853    /// The start nodes considered long-lived (RFC 0030 §5 durability rule).
854    pub fn is_long_lived(&self) -> bool {
855        self.start_steps().iter().any(|s| {
856            matches!(
857                s.kind.as_str(),
858                "loop" | "schedule" | "subscribe" | "signal" | "event" | "a2a"
859            )
860        })
861    }
862    /// The step ids in a deterministic topological order (deps first).
863    pub fn topo_order(&self) -> Vec<String> {
864        let mut out = Vec::new();
865        let mut done: BTreeSet<String> = BTreeSet::new();
866        let mut progress = true;
867        while progress && out.len() < self.steps.len() {
868            progress = false;
869            for (id, s) in &self.steps {
870                if done.contains(id) {
871                    continue;
872                }
873                if s.depends_on.iter().all(|d| done.contains(d)) {
874                    done.insert(id.clone());
875                    out.push(id.clone());
876                    progress = true;
877                }
878            }
879        }
880        out
881    }
882}
883
884/// A JSON value's shape, for a diagnostic that says what was written.
885fn json_kind(v: &Value) -> &'static str {
886    match v {
887        Value::Null => "null",
888        Value::Bool(_) => "a boolean",
889        Value::Number(_) => "a number",
890        Value::String(_) => "a string",
891        Value::Array(_) => "a list",
892        Value::Object(_) => "an object",
893    }
894}
895
896/// Parse + validate a dialect-3 document. Errors name every problem.
897pub fn parse_workflow(doc: &Value) -> Result<Workflow, Vec<String>> {
898    let mut errs = Vec::new();
899    let Some(obj) = doc.as_object() else {
900        return Err(vec!["a workflow must be an object".into()]);
901    };
902    const TOP: &[&str] = &[
903        "name",
904        "version",
905        "description",
906        "armed",
907        "inputs",
908        "concurrency",
909        "limits",
910        "outputs",
911        "state",
912        "steps",
913        "file",
914        "uri",
915    ];
916    for key in obj.keys() {
917        if !TOP.contains(&key.as_str()) {
918            errs.push(format!("unknown workflow field {key:?}"));
919        }
920    }
921    let name = obj
922        .get("name")
923        .and_then(Value::as_str)
924        .unwrap_or("")
925        .trim()
926        .to_string();
927    if !valid_id(&name) {
928        errs.push(format!(
929            "workflow name {name:?} must match [a-zA-Z_][a-zA-Z0-9_-]{{0,63}}"
930        ));
931    }
932    let version = obj
933        .get("version")
934        .and_then(Value::as_u64)
935        .unwrap_or(DIALECT as u64) as u32;
936    if version != DIALECT {
937        errs.push(format!(
938            "workflow {name:?}: version {version} is not dialect 3 (dialect 1/2 documents are refused — see docs/workflows.md §migration)"
939        ));
940    }
941    if obj.contains_key("start") || obj.contains_key("nodes") {
942        errs.push(format!("workflow {name:?}: `start`/`nodes` are dialect 1/2 — use `steps` with start nodes (docs/workflows.md §migration)"));
943    }
944    let armed = obj.get("armed").and_then(Value::as_bool).unwrap_or(true);
945    let inputs_schema = match obj.get("inputs") {
946        None => None,
947        Some(v) => {
948            let schema = v.get("schema").cloned().or_else(|| {
949                v.as_object()
950                    .filter(|m| m.contains_key("type") || m.contains_key("properties"))
951                    .map(|_| v.clone())
952            });
953            match schema {
954                Some(s) => {
955                    if let Err(e) = jsonschema::check_schema(&s) {
956                        errs.push(format!(
957                            "workflow {name:?}: inputs.schema: {}",
958                            e.join("; ")
959                        ));
960                    }
961                    Some(s)
962                }
963                None => {
964                    errs.push(format!("workflow {name:?}: inputs must be {{schema: …}}"));
965                    None
966                }
967            }
968        }
969    };
970    let outputs_schema = obj.get("outputs").and_then(|v| v.get("schema").cloned());
971    if let Some(s) = &outputs_schema
972        && let Err(e) = jsonschema::check_schema(s)
973    {
974        errs.push(format!(
975            "workflow {name:?}: outputs.schema: {}",
976            e.join("; ")
977        ));
978    }
979    let concurrency = match obj.get("concurrency") {
980        None => Concurrency::default(),
981        Some(v) => Concurrency {
982            max_runs: v
983                .get("max_runs")
984                .and_then(Value::as_u64)
985                .unwrap_or(4)
986                .clamp(1, 1024) as u32,
987            on_overflow: match v.get("on_overflow").and_then(Value::as_str) {
988                None | Some("queue") => OnOverflow::Queue,
989                Some("drop") => OnOverflow::Drop,
990                Some("replace") => OnOverflow::Replace,
991                Some(o) => {
992                    errs.push(format!("workflow {name:?}: concurrency.on_overflow {o:?} must be queue|drop|replace"));
993                    OnOverflow::Queue
994                }
995            },
996        },
997    };
998    let limits = match obj.get("limits") {
999        None => WorkflowLimits::default(),
1000        Some(v) => WorkflowLimits {
1001            steps: v.get("steps").and_then(Value::as_u64).map(|x| x as u32),
1002            tokens: v.get("tokens").and_then(Value::as_u64),
1003            deadline_ms: match v.get("deadline") {
1004                None => None,
1005                Some(d) => match duration_ms(d) {
1006                    Ok(ms) => Some(ms),
1007                    Err(e) => {
1008                        errs.push(format!("workflow {name:?}: limits.deadline: {e}"));
1009                        None
1010                    }
1011                },
1012            },
1013            budget: v.get("budget").cloned(),
1014        },
1015    };
1016    // Steps.
1017    let mut steps: BTreeMap<String, Step> = BTreeMap::new();
1018    match obj.get("steps").and_then(Value::as_object) {
1019        None => errs.push(format!(
1020            "workflow {name:?}: `steps` (an object of steps) is required"
1021        )),
1022        Some(map) => {
1023            if map.len() > MAX_STEPS {
1024                errs.push(format!(
1025                    "workflow {name:?}: {} steps exceed the cap of {MAX_STEPS}",
1026                    map.len()
1027                ));
1028            }
1029            for (id, sv) in map {
1030                if let Some(step) = parse_step(&name, id, sv, 0, &mut errs) {
1031                    steps.insert(id.clone(), step);
1032                }
1033            }
1034        }
1035    }
1036    if !errs.is_empty() {
1037        return Err(errs);
1038    }
1039    // `state` declarations: each key names a schema and/or a reducer.
1040    let mut state: BTreeMap<String, StateDecl> = BTreeMap::new();
1041    if let Some(decls) = obj.get("state") {
1042        match decls.as_object() {
1043            None => errs.push("state must be an object of {key: {schema, reducer}}".into()),
1044            Some(map) => {
1045                for (key, decl) in map {
1046                    let Some(d) = decl.as_object() else {
1047                        errs.push(format!("state {key:?}: must be an object"));
1048                        continue;
1049                    };
1050                    for f in d.keys() {
1051                        if !matches!(f.as_str(), "schema" | "reducer") {
1052                            errs.push(format!(
1053                                "state {key:?}: unknown field {f:?} (allowed: schema, reducer)"
1054                            ));
1055                        }
1056                    }
1057                    let schema = d.get("schema").cloned();
1058                    if let Some(sc) = &schema
1059                        && let Err(e) = jsonschema::check_schema(sc)
1060                    {
1061                        errs.push(format!("state {key:?}: schema: {}", e.join("; ")));
1062                    }
1063                    let reducer = d.get("reducer").and_then(Value::as_str).map(str::to_string);
1064                    if let Some(r) = &reducer
1065                        && !matches!(r.as_str(), "overwrite" | "append" | "merge" | "union")
1066                    {
1067                        errs.push(format!(
1068                            "state {key:?}: reducer {r:?} must be overwrite|append|merge|union"
1069                        ));
1070                    }
1071                    state.insert(key.clone(), StateDecl { schema, reducer });
1072                }
1073            }
1074        }
1075    }
1076    let mut wf = Workflow {
1077        state,
1078        name,
1079        version,
1080        description: obj
1081            .get("description")
1082            .and_then(Value::as_str)
1083            .map(str::to_string),
1084        armed,
1085        inputs_schema,
1086        concurrency,
1087        limits,
1088        outputs_schema,
1089        steps,
1090        hash: String::new(),
1091        definition: doc.clone(),
1092    };
1093    validate_graph(&wf, &mut errs);
1094    if !errs.is_empty() {
1095        return Err(errs);
1096    }
1097    wf.hash = crate::sha::sha256_hex(canonical(doc).as_bytes());
1098    Ok(wf)
1099}
1100
1101fn parse_step(
1102    wf: &str,
1103    id: &str,
1104    sv: &Value,
1105    depth: usize,
1106    errs: &mut Vec<String>,
1107) -> Option<Step> {
1108    let at = format!("workflow {wf:?} step {id:?}");
1109    if !valid_id(id) {
1110        errs.push(format!(
1111            "{at}: id must match [a-zA-Z_][a-zA-Z0-9_-]{{0,63}}"
1112        ));
1113    }
1114    let Some(o) = sv.as_object() else {
1115        errs.push(format!("{at}: must be an object"));
1116        return None;
1117    };
1118    let kind = match o.get("kind").and_then(Value::as_str) {
1119        Some(k) => k.to_string(),
1120        None => {
1121            errs.push(format!("{at}: `kind` is required"));
1122            return None;
1123        }
1124    };
1125    let Some(info) = kind_info(&kind) else {
1126        errs.push(format!(
1127            "{at}: unknown kind {kind:?} (see the RFC 0027 §5 catalogue)"
1128        ));
1129        return None;
1130    };
1131    // Strict fields.
1132    let mut spec = Map::new();
1133    for (key, v) in o {
1134        // A field the KIND declares wins over the cross-cutting list, and the
1135        // order matters: `output_schema` is both. `extract` declares it and
1136        // *requires* it, but the required check below looks in `spec`, so
1137        // skipping it as a common field made `extract` impossible to satisfy —
1138        // and the presets that merely accept it (`think`, `classify`, `judge`,
1139        // `route`, `summarize`) read it from `spec` at dispatch, so they were
1140        // silently never given a schema to shape the model's answer. The
1141        // cross-cutting copy that validates a step's OUTPUT is taken from `o`
1142        // directly, so both readings still work.
1143        if info.fields.contains(&key.as_str()) {
1144            spec.insert(key.clone(), v.clone());
1145        } else if COMMON_FIELDS.contains(&key.as_str()) {
1146            continue;
1147        } else {
1148            errs.push(format!(
1149                "{at}: unknown field {key:?} for kind {kind:?} (allowed: {})",
1150                info.fields.join(", ")
1151            ));
1152        }
1153    }
1154    for req in info.required {
1155        if !spec.contains_key(*req) {
1156            errs.push(format!("{at}: kind {kind:?} requires field {req:?}"));
1157        }
1158    }
1159    if !info.implemented {
1160        errs.push(format!("{at}: kind {kind:?} is not available in this build yet (it lands with the P4 engine); implemented kinds: {}", implemented_kinds().join(", ")));
1161    }
1162    // Nested bodies / branches: parsed into typed sub-DAGs and validated.
1163    let mut body: Option<Body> = None;
1164    let mut branches: BTreeMap<String, Body> = BTreeMap::new();
1165    if info.nested {
1166        if depth + 1 > MAX_NESTING {
1167            errs.push(format!("{at}: nesting exceeds {MAX_NESTING}"));
1168        }
1169        if matches!(kind.as_str(), "parallel" | "race") {
1170            match spec.get("branches").and_then(Value::as_object) {
1171                Some(bm) if !bm.is_empty() => {
1172                    for (bname, bv) in bm {
1173                        if !valid_id(bname) {
1174                            errs.push(format!("{at}: branch name {bname:?} must match [a-zA-Z_][a-zA-Z0-9_-]{{0,63}}"));
1175                        }
1176                        if let Some(b) =
1177                            parse_body(&format!("{wf}/{id}/{bname}"), bv, depth + 1, errs)
1178                        {
1179                            branches.insert(bname.clone(), b);
1180                        }
1181                    }
1182                }
1183                _ => errs.push(format!(
1184                    "{at}: branches must be a non-empty object of {{steps: {{…}}}} bodies"
1185                )),
1186            }
1187        } else {
1188            match spec.get("body") {
1189                Some(bv) => body = parse_body(&format!("{wf}/{id}"), bv, depth + 1, errs),
1190                None => errs.push(format!("{at}: body is required")),
1191            }
1192        }
1193    }
1194    let depends_on: Vec<String> = match o.get("depends_on") {
1195        None => Vec::new(),
1196        Some(Value::Array(a)) => a
1197            .iter()
1198            .filter_map(Value::as_str)
1199            .map(str::to_string)
1200            .collect(),
1201        Some(Value::String(s)) => vec![s.clone()],
1202        Some(_) => {
1203            errs.push(format!("{at}: depends_on must be a list of step ids"));
1204            Vec::new()
1205        }
1206    };
1207    if info.start && !depends_on.is_empty() {
1208        errs.push(format!("{at}: a start node cannot depend on other steps"));
1209    }
1210    let when = o.get("when").and_then(Value::as_str).map(str::to_string);
1211    if let Some(w) = &when {
1212        let expr = w.trim().trim_start_matches("CEL:").trim();
1213        if let Err(e) = crate::cel::compile_check(expr) {
1214            errs.push(format!("{at}: when: {e}"));
1215        }
1216    }
1217    let retry = o.get("retry").map(|r| Retry {
1218        max: r.get("max").and_then(Value::as_u64).unwrap_or(0).min(20) as u32,
1219        backoff_ms: match r.get("backoff") {
1220            None => 0,
1221            Some(b) => duration_ms(b).unwrap_or_else(|e| {
1222                errs.push(format!("{at}: retry.backoff: {e}"));
1223                0
1224            }),
1225        },
1226    });
1227    let timeout_ms = match o.get("timeout") {
1228        None => None,
1229        Some(t) => match duration_ms(t) {
1230            Ok(ms) => Some(ms),
1231            Err(e) => {
1232                errs.push(format!("{at}: timeout: {e}"));
1233                None
1234            }
1235        },
1236    };
1237    let on_error = match o.get("on_error") {
1238        None => OnError::Fail,
1239        Some(v) => OnError::parse(v).unwrap_or_else(|e| {
1240            errs.push(format!("{at}: {e}"));
1241            OnError::Fail
1242        }),
1243    };
1244    let on_replay = match o.get("on_replay").and_then(Value::as_str) {
1245        None | Some("retry") => OnReplay::Retry,
1246        Some("skip") => OnReplay::Skip,
1247        Some("fail") => OnReplay::Fail,
1248        Some(x) => {
1249            errs.push(format!("{at}: on_replay {x:?} must be retry|skip|fail"));
1250            OnReplay::Retry
1251        }
1252    };
1253    let output_schema = o.get("output_schema").cloned();
1254    if let Some(s) = &output_schema
1255        && let Err(e) = jsonschema::check_schema(s)
1256    {
1257        errs.push(format!("{at}: output_schema: {}", e.join("; ")));
1258    }
1259    // Kind-specific sanity.
1260    match kind.as_str() {
1261        // A `switch` routes to ONE step id per case, as a string. A list reads
1262        // naturally — `cases: {select: [prepare]}` — and is exactly wrong: the
1263        // executor asks for a string, gets an array, finds no target, falls to
1264        // `default`, finds an array there too, and fails the run at the moment
1265        // the branch is taken. That is a silent trap for whoever writes the
1266        // config and a confusing one for whoever debugs it, so it is refused
1267        // here, where the message can say what to write instead.
1268        "switch" => {
1269            if let Some(cases) = spec.get("cases").and_then(Value::as_object) {
1270                for (case, target) in cases {
1271                    if !target.is_string() {
1272                        errs.push(format!(
1273                            "{at}: switch case {case:?} must name ONE step as a string \
1274                             (got {}); write `{case}: some_step`, not a list",
1275                            json_kind(target)
1276                        ));
1277                    }
1278                }
1279            }
1280            if let Some(d) = spec.get("default")
1281                && !d.is_string()
1282            {
1283                errs.push(format!(
1284                    "{at}: switch default must name ONE step as a string (got {}); \
1285                     write `default: some_step`, not a list",
1286                    json_kind(d)
1287                ));
1288            }
1289        }
1290        // `collect.mode` and `assign.mode` reach `write_var`, which falls through
1291        // to overwrite on anything it does not recognise — so `mode: appned`
1292        // silently overwrote instead of appending. The set is closed; check it
1293        // where the typo is still a config error.
1294        "foreach" | "batch" | "iterate" | "parallel" | "race" | "subgraph"
1295            if spec.contains_key("collect") =>
1296        {
1297            if let Some(m) = spec
1298                .get("collect")
1299                .and_then(|c| c.get("mode"))
1300                .and_then(Value::as_str)
1301                && !matches!(m, "overwrite" | "append" | "merge" | "union")
1302            {
1303                errs.push(format!(
1304                    "{at}: collect.mode {m:?} must be overwrite|append|merge|union"
1305                ));
1306            }
1307        }
1308        // `human.to` and `human.reply_uri` are accepted and then ignored — the
1309        // gate is answered over A2A by whoever holds the task. Rather than
1310        // pretend to route, refuse them: a field that silently does nothing is
1311        // worse than one that does not exist.
1312        "human" => {
1313            for f in ["to", "reply_uri"] {
1314                if spec.contains_key(f) {
1315                    errs.push(format!(
1316                        "{at}: human.{f} is not implemented — a gate is answered over A2A by \
1317                         whoever holds the task; remove it (see docs/node-registry.md)"
1318                    ));
1319                }
1320            }
1321        }
1322        "finish" => {
1323            if let Some(st) = spec.get("status").and_then(Value::as_str)
1324                && !matches!(st, "completed" | "failed" | "refused" | "cancelled")
1325            {
1326                errs.push(format!(
1327                    "{at}: finish.status must be completed|failed|refused|cancelled"
1328                ));
1329            }
1330        }
1331        "sleep" => {
1332            if let Some(d) = spec.get("duration")
1333                && let Err(e) = duration_ms(d)
1334            {
1335                errs.push(format!("{at}: sleep.duration: {e}"));
1336            }
1337        }
1338        "assert" => {
1339            if let Some(c) = spec.get("condition").and_then(Value::as_str)
1340                && let Err(e) =
1341                    crate::cel::compile_check(c.trim().trim_start_matches("CEL:").trim())
1342            {
1343                errs.push(format!("{at}: assert.condition: {e}"));
1344            }
1345        }
1346        "think" | "agent" => {
1347            if let Some(s) = spec.get("output_schema")
1348                && let Err(e) = jsonschema::check_schema(s)
1349            {
1350                errs.push(format!("{at}: output_schema: {}", e.join("; ")));
1351            }
1352        }
1353        "validate" => {
1354            if let Some(s) = spec.get("schema")
1355                && let Err(e) = jsonschema::check_schema(s)
1356            {
1357                errs.push(format!("{at}: schema: {}", e.join("; ")));
1358            }
1359        }
1360        "assign" | "transform" => {
1361            if let Some(m) = spec.get("mode").and_then(Value::as_str)
1362                && !matches!(m, "overwrite" | "append" | "merge" | "union")
1363            {
1364                errs.push(format!("{at}: mode must be overwrite|append|merge|union"));
1365            }
1366        }
1367        _ => {}
1368    }
1369    // Any `CEL:` valued field compiles.
1370    for (key, v) in &spec {
1371        if let Some(s) = v.as_str()
1372            && let Some(expr) = s.trim().strip_prefix("CEL:")
1373            && let Err(e) = crate::cel::compile_check(expr.trim())
1374        {
1375            errs.push(format!("{at}: {key}: {e}"));
1376        }
1377    }
1378    Some(Step {
1379        id: id.to_string(),
1380        kind,
1381        depends_on,
1382        when,
1383        retry,
1384        timeout_ms,
1385        on_error,
1386        idempotent: o
1387            .get("idempotent")
1388            .and_then(Value::as_bool)
1389            .unwrap_or(false),
1390        on_replay,
1391        output_schema,
1392        cache: o.get("cache").cloned(),
1393        budget: o.get("budget").and_then(Value::as_u64),
1394        skills: o
1395            .get("skills")
1396            .and_then(Value::as_array)
1397            .map(|a| {
1398                a.iter()
1399                    .filter_map(Value::as_str)
1400                    .map(str::to_string)
1401                    .collect()
1402            })
1403            .unwrap_or_default(),
1404        description: o
1405            .get("description")
1406            .and_then(Value::as_str)
1407            .map(str::to_string),
1408        spec,
1409        body,
1410        branches,
1411    })
1412}
1413
1414/// Parse + validate a nested body `{steps: {…}}`.
1415fn parse_body(at: &str, bv: &Value, depth: usize, errs: &mut Vec<String>) -> Option<Body> {
1416    let Some(bs) = bv.get("steps").and_then(Value::as_object) else {
1417        errs.push(format!("{at}: body must be {{steps: {{…}}}}"));
1418        return None;
1419    };
1420    if bs.is_empty() {
1421        errs.push(format!("{at}: body has no steps"));
1422        return None;
1423    }
1424    let mut steps = BTreeMap::new();
1425    for (bid, sv) in bs {
1426        if let Some(step) = parse_step(at, bid, sv, depth, errs) {
1427            if step.is_start() {
1428                errs.push(format!(
1429                    "{at} step {bid:?}: a start node cannot be inside a body"
1430                ));
1431            }
1432            if step.kind == "finish" {
1433                errs.push(format!("{at} step {bid:?}: `finish` cannot be inside a body (a body's sinks are its result)"));
1434            }
1435            steps.insert(bid.clone(), step);
1436        }
1437    }
1438    let body = Body { steps };
1439    for s in body.steps.values() {
1440        for d in &s.depends_on {
1441            if !body.steps.contains_key(d) {
1442                errs.push(format!(
1443                    "{at} step {:?}: depends_on names {d:?}, which is not a sibling in the body",
1444                    s.id
1445                ));
1446            }
1447        }
1448        if let OnError::Goto(t) = &s.on_error
1449            && !body.steps.contains_key(t)
1450        {
1451            errs.push(format!(
1452                "{at} step {:?}: on_error goto {t:?} is not a sibling in the body",
1453                s.id
1454            ));
1455        }
1456    }
1457    if body.topo_order().len() != body.steps.len() {
1458        errs.push(format!("{at}: cycle inside the body"));
1459    }
1460    Some(body)
1461}
1462
1463/// Graph-level validation (RFC 0027 §8).
1464/// Two steps that can run in the same wave, both writing one var with modes
1465/// that disagree, is a silent last-write-wins race: which value survives
1466/// depends on completion order, which is not a thing the author controls.
1467///
1468/// `append`/`merge` are reducers — several writers combining is the point.
1469/// `overwrite` is not: two overwriters, or an overwriter racing a reducer, is
1470/// the shape with no defensible answer, so it is refused where it is still a
1471/// config error rather than an intermittent wrong number.
1472fn validate_declared_state(wf: &Workflow, errs: &mut Vec<String>) {
1473    for s in wf.steps.values() {
1474        if !matches!(s.kind.as_str(), "assign" | "transform") {
1475            continue;
1476        }
1477        let key = s
1478            .spec
1479            .get("writes")
1480            .and_then(Value::as_str)
1481            .unwrap_or(s.id.as_str());
1482        let Some(decl) = wf.state.get(key) else {
1483            continue;
1484        };
1485        // A declared reducer is the policy for that key; a step that writes it
1486        // with a different mode is contradicting the declaration, which is the
1487        // kind of disagreement that should not survive to runtime.
1488        if let Some(want) = &decl.reducer {
1489            let mode = s
1490                .spec
1491                .get("mode")
1492                .and_then(Value::as_str)
1493                .unwrap_or("overwrite");
1494            if mode != want {
1495                errs.push(format!(
1496                    "workflow {:?} step {:?}: writes {key:?} with mode {mode:?}, but state \
1497                     declares reducer {want:?}",
1498                    wf.name, s.id
1499                ));
1500            }
1501        }
1502    }
1503}
1504
1505fn validate_concurrent_writes(wf: &Workflow, errs: &mut Vec<String>) {
1506    use std::collections::BTreeMap;
1507    let mut writers: BTreeMap<&str, Vec<(&str, &str)>> = BTreeMap::new();
1508    for s in wf.steps.values() {
1509        if !matches!(s.kind.as_str(), "assign" | "transform") {
1510            continue;
1511        }
1512        let key = s
1513            .spec
1514            .get("writes")
1515            .and_then(Value::as_str)
1516            .unwrap_or(s.id.as_str());
1517        let mode = s
1518            .spec
1519            .get("mode")
1520            .and_then(Value::as_str)
1521            .unwrap_or("overwrite");
1522        writers.entry(key).or_default().push((s.id.as_str(), mode));
1523    }
1524    for (key, ws) in writers {
1525        if ws.len() < 2 {
1526            continue;
1527        }
1528        // Ordered pairs cannot race; only steps with no path between them can.
1529        for (i, (a, ma)) in ws.iter().enumerate() {
1530            for (b, mb) in ws.iter().skip(i + 1) {
1531                if reachable(wf, a, b) || reachable(wf, b, a) {
1532                    continue;
1533                }
1534                // Nor can two arms of the same switch: exactly one is taken, so
1535                // they are mutually EXCLUSIVE rather than concurrent. Ordering
1536                // is expressed by the routing edge here, not by `depends_on`,
1537                // which is why the reachability walk above cannot see it.
1538                if exclusive_by_switch(wf, a, b) {
1539                    continue;
1540                }
1541                // A declared reducer settles it: the workflow has stated how
1542                // writes to this key combine, which is exactly the policy the
1543                // heuristic below is guessing at.
1544                if wf
1545                    .state
1546                    .get(key)
1547                    .and_then(|d| d.reducer.as_deref())
1548                    .is_some()
1549                {
1550                    continue;
1551                }
1552                // append/merge/union are reducers — several writers combining
1553                // is the point. Only an overwriter has no defensible answer.
1554                if *ma == "overwrite" || *mb == "overwrite" {
1555                    errs.push(format!(
1556                        "workflow {:?}: steps {a:?} and {b:?} can run concurrently and both \
1557                         write {key:?} (modes {ma}/{mb}) — the surviving value would depend on \
1558                         completion order; order them with depends_on, or use append/merge",
1559                        wf.name
1560                    ));
1561                }
1562            }
1563        }
1564    }
1565}
1566
1567/// Whether two steps are arms of one `switch` — at most one of them ever runs.
1568fn exclusive_by_switch(wf: &Workflow, a: &str, b: &str) -> bool {
1569    for s in wf.steps.values() {
1570        if s.kind != "switch" {
1571            continue;
1572        }
1573        let mut arms: Vec<&str> = s
1574            .spec
1575            .get("cases")
1576            .and_then(Value::as_object)
1577            .map(|c| c.values().filter_map(Value::as_str).collect())
1578            .unwrap_or_default();
1579        if let Some(d) = s.spec.get("default").and_then(Value::as_str) {
1580            arms.push(d);
1581        }
1582        // Either arm may be the step itself or an ancestor of it: a whole
1583        // branch hangs below one target.
1584        let on_arm = |x: &str| arms.iter().any(|arm| *arm == x || reachable(wf, arm, x));
1585        if on_arm(a) && on_arm(b) {
1586            return true;
1587        }
1588    }
1589    false
1590}
1591
1592/// Whether `to` is reachable from `from` along `depends_on` edges.
1593fn reachable(wf: &Workflow, from: &str, to: &str) -> bool {
1594    let mut seen = std::collections::BTreeSet::new();
1595    let mut stack = vec![to];
1596    // Walk UP from `to`: it is reachable from `from` if `from` is an ancestor.
1597    while let Some(cur) = stack.pop() {
1598        if cur == from {
1599            return true;
1600        }
1601        if !seen.insert(cur.to_string()) {
1602            continue;
1603        }
1604        if let Some(s) = wf.steps.get(cur) {
1605            for d in &s.depends_on {
1606                stack.push(d.as_str());
1607            }
1608        }
1609    }
1610    false
1611}
1612
1613/// A `human` gate inside a body that can run several copies at once.
1614///
1615/// Only ONE gate can be live per run today: the second suspended `human` has no
1616/// task of its own to be answered through, so it waits for a reply that can
1617/// never be addressed to it. Inside `foreach`/`parallel`/`batch`/`race` that is
1618/// not a rare shape, it is the normal one — a gate per item. Refused at load
1619/// until each gate carries its own identity, because failing at validation is
1620/// much kinder than hanging at item two.
1621fn validate_human_in_concurrent_bodies(wf: &Workflow, errs: &mut Vec<String>) {
1622    fn walk(wf_name: &str, owner: &str, body: &Body, errs: &mut Vec<String>) {
1623        for s in body.steps.values() {
1624            if s.kind == "human" {
1625                errs.push(format!(
1626                    "workflow {wf_name:?} step {:?}: a `human` gate inside {owner:?} is not \
1627                     supported — only one gate can be live per run, so a second item would \
1628                     wait forever. Gate before or after the fan-out instead.",
1629                    s.id
1630                ));
1631            }
1632            for nested in s.body.iter().chain(s.branches.values()) {
1633                walk(wf_name, owner, nested, errs);
1634            }
1635        }
1636    }
1637    for s in wf.steps.values() {
1638        if !matches!(s.kind.as_str(), "foreach" | "batch" | "parallel" | "race") {
1639            continue;
1640        }
1641        for body in s.body.iter().chain(s.branches.values()) {
1642            walk(&wf.name, &s.id, body, errs);
1643        }
1644    }
1645}
1646
1647fn validate_graph(wf: &Workflow, errs: &mut Vec<String>) {
1648    validate_human_in_concurrent_bodies(wf, errs);
1649    validate_declared_state(wf, errs);
1650    validate_concurrent_writes(wf, errs);
1651    let name = &wf.name;
1652    let starts: Vec<&Step> = wf.start_steps();
1653    if starts.is_empty() {
1654        errs.push(format!("workflow {name:?}: at least one start node is required (once|manual|loop|schedule|subscribe|signal|event|a2a)"));
1655    }
1656    // Dependencies + goto targets exist.
1657    for s in wf.steps.values() {
1658        for d in &s.depends_on {
1659            if !wf.steps.contains_key(d) {
1660                errs.push(format!(
1661                    "workflow {name:?} step {:?}: depends_on names unknown step {d:?}",
1662                    s.id
1663                ));
1664            }
1665            if d == &s.id {
1666                errs.push(format!(
1667                    "workflow {name:?} step {:?}: depends on itself",
1668                    s.id
1669                ));
1670            }
1671        }
1672        if let OnError::Goto(t) = &s.on_error
1673            && !wf.steps.contains_key(t)
1674        {
1675            errs.push(format!(
1676                "workflow {name:?} step {:?}: on_error goto names unknown step {t:?}",
1677                s.id
1678            ));
1679        }
1680    }
1681    // A non-start step with no dependencies is an unreachable root.
1682    for s in wf.steps.values() {
1683        if !s.is_start() && s.depends_on.is_empty() {
1684            errs.push(format!("workflow {name:?} step {:?}: a non-start step must depend on something (unreachable root)", s.id));
1685        }
1686    }
1687    // Acyclic (Kahn) + reachability from a start node.
1688    let order = wf.topo_order();
1689    if order.len() != wf.steps.len() {
1690        let stuck: Vec<&String> = wf.steps.keys().filter(|k| !order.contains(k)).collect();
1691        errs.push(format!("workflow {name:?}: cycle among steps {stuck:?}"));
1692    }
1693    let mut reachable: BTreeSet<String> = starts.iter().map(|s| s.id.clone()).collect();
1694    let mut changed = true;
1695    while changed {
1696        changed = false;
1697        for s in wf.steps.values() {
1698            if !reachable.contains(&s.id)
1699                && !s.depends_on.is_empty()
1700                && s.depends_on.iter().any(|d| reachable.contains(d))
1701            {
1702                reachable.insert(s.id.clone());
1703                changed = true;
1704            }
1705        }
1706    }
1707    for s in wf.steps.values() {
1708        if !reachable.contains(&s.id) {
1709            errs.push(format!(
1710                "workflow {name:?} step {:?}: not reachable from any start node",
1711                s.id
1712            ));
1713        }
1714    }
1715    if !wf.steps.values().any(|s| s.kind == "finish") {
1716        errs.push(format!("workflow {name:?}: a `finish` step is required"));
1717    }
1718}
1719
1720/// `[a-zA-Z_][a-zA-Z0-9_-]{0,63}`.
1721pub fn valid_id(s: &str) -> bool {
1722    let mut chars = s.chars();
1723    match chars.next() {
1724        Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
1725        _ => return false,
1726    }
1727    s.len() <= MAX_ID_LEN && chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
1728}
1729
1730/// Fields never rendered as templates before execution (expressions the step
1731/// evaluates itself, and nested definitions).
1732pub const RAW_FIELDS: &[(&str, &str)] = &[
1733    ("assert", "condition"),
1734    ("map", "expr"),
1735    ("filter", "expr"),
1736    ("reduce", "expr"),
1737    ("iterate", "while"),
1738    ("iterate", "until"),
1739    ("iterate", "body"),
1740    ("foreach", "body"),
1741    ("batch", "body"),
1742    ("subgraph", "body"),
1743    ("parallel", "branches"),
1744    ("race", "branches"),
1745    ("subscribe", "filter"),
1746    ("signal", "filter"),
1747    ("event", "filter"),
1748    ("wait", "condition"),
1749    ("think", "check"),
1750    ("switch", "cases"),
1751    ("await", "condition"),
1752];
1753
1754pub fn is_raw_field(kind: &str, field: &str) -> bool {
1755    RAW_FIELDS.iter().any(|(k, f)| *k == kind && *f == field)
1756}
1757
1758/// `Some(ms)` for a duration field, `None` when absent/invalid.
1759pub fn duration_ms_opt(v: &Value) -> Option<u64> {
1760    duration_ms(v).ok()
1761}
1762
1763/// A duration field: `"30s"`, `"5m"`, bare seconds, or ms as `{"ms": n}`.
1764pub fn duration_ms(v: &Value) -> Result<u64, String> {
1765    match v {
1766        Value::Number(n) => n
1767            .as_u64()
1768            .map(|s| s * 1000)
1769            .ok_or_else(|| "duration must be a non-negative number of seconds".into()),
1770        Value::String(s) => crate::config::parse_duration(s).map(|d| d.as_millis() as u64),
1771        Value::Object(o) => o
1772            .get("ms")
1773            .and_then(Value::as_u64)
1774            .ok_or_else(|| "duration object must be {ms: n}".into()),
1775        _ => Err("duration must be a string like 30s or a number of seconds".into()),
1776    }
1777}
1778
1779/// Canonical JSON (sorted keys — serde_json's Map is a BTreeMap here) for hashing.
1780pub fn canonical(v: &Value) -> String {
1781    v.to_string()
1782}
1783
1784/// The dialect-3 JSON Schema (`--workflow-schema`).
1785pub fn workflow_schema() -> Value {
1786    let kinds: Vec<&str> = KINDS.iter().map(|k| k.name).collect();
1787    json!({
1788        "$schema": "https://json-schema.org/draft/2020-12/schema",
1789        "$id": "https://agentd.dev/schemas/workflow-3.json",
1790        "title": "agentd workflow (dialect 3)",
1791        "type": "object",
1792        "required": ["name", "steps"],
1793        "properties": {
1794            "name": {"type": "string", "pattern": "^[a-zA-Z_][a-zA-Z0-9_-]{0,63}$"},
1795            "version": {"const": 3},
1796            "description": {"type": "string"},
1797            "armed": {"type": "boolean", "default": true},
1798            "inputs": {"type": "object", "properties": {"schema": {"type": "object"}}},
1799            "outputs": {"type": "object", "properties": {"schema": {"type": "object"}}},
1800            "state": {"type": "object", "additionalProperties": {"type": "object",
1801                "additionalProperties": false,
1802                "properties": {
1803                    "schema": {"type": "object", "description": "a JSON Schema every write to this key must satisfy"},
1804                    "reducer": {"enum": ["overwrite", "append", "merge", "union"],
1805                                "description": "how concurrent writes to this key combine; declaring it makes concurrency a policy rather than a race"}}},
1806                "description": "declared run variables — {key: {schema, reducer}}"},
1807            "concurrency": {"type": "object", "properties": {"max_runs": {"type": "integer", "minimum": 1}, "on_overflow": {"enum": ["queue", "drop", "replace"]}}},
1808            "limits": {"type": "object", "properties": {"steps": {"type": "integer"}, "tokens": {"type": "integer"}, "deadline": {"type": "string"}, "budget": {"type": "object"}}},
1809            "steps": {"type": "object", "additionalProperties": {"$ref": "#/$defs/step"}, "minProperties": 1}
1810        },
1811        "$defs": {
1812            "step": {
1813                "type": "object",
1814                "required": ["kind"],
1815                "properties": {
1816                    "kind": {"enum": kinds},
1817                    "depends_on": {"type": "array", "items": {"type": "string"}},
1818                    "when": {"type": "string"},
1819                    "retry": {"type": "object", "properties": {"max": {"type": "integer"}, "backoff": {"type": "string"}}},
1820                    "timeout": {"type": "string"},
1821                    "on_error": {"type": "string"},
1822                    "idempotent": {"type": "boolean"},
1823                    "on_replay": {"enum": ["retry", "skip", "fail"]},
1824                    "output_schema": {"type": "object"},
1825                    "cache": {"type": "object"},
1826                    "budget": {"type": "integer"},
1827                    "skills": {"type": "array", "items": {"type": "string"}},
1828                    "otel": {"type": "object"},
1829                    "description": {"type": "string"}
1830                }
1831            },
1832            "kinds": KINDS.iter().map(|k| (k.name.to_string(), json!({"start": k.start, "fields": k.fields, "required": k.required, "implemented": k.implemented}))).collect::<BTreeMap<_, _>>()
1833        }
1834    })
1835}
1836
1837#[cfg(test)]
1838mod tests {
1839    /// `output_schema` is both a cross-cutting step field and a field several
1840    /// kinds declare for themselves. The kind's reading must win: `extract`
1841    /// REQUIRES it, and the required-field check reads `spec`, so while the
1842    /// common-field skip came first `extract` could never validate — a
1843    /// documented, "implemented" node that was impossible to use. The presets
1844    /// that merely accept it were quietly affected too: they read it from
1845    /// `spec` at dispatch, so they were never handed a schema at all.
1846    #[test]
1847    fn a_kind_that_declares_output_schema_receives_it() {
1848        let doc = serde_json::json!({
1849            "name": "w",
1850            "steps": {
1851                "go": {"kind": "manual"},
1852                "e":  {"kind": "extract", "depends_on": ["go"], "input": "x",
1853                       "output_schema": {"type": "object"}},
1854                "t":  {"kind": "think", "depends_on": ["e"], "prompt": "p",
1855                       "output_schema": {"type": "object"}},
1856                "fin": {"kind": "finish", "depends_on": ["t"], "status": "completed"}
1857            }
1858        });
1859        let wf = parse_workflow(&doc)
1860            .unwrap_or_else(|e| panic!("extract must validate with an output_schema: {e:?}"));
1861        // And the kind actually RECEIVES it, which is what the executor reads.
1862        for id in ["e", "t"] {
1863            let step = wf.steps.get(id).unwrap_or_else(|| panic!("step {id}"));
1864            assert!(
1865                step.field("output_schema").is_some(),
1866                "{id}: the kind's own output_schema must reach the node spec"
1867            );
1868        }
1869    }
1870
1871    use super::*;
1872
1873    fn wf(doc: Value) -> Result<Workflow, Vec<String>> {
1874        parse_workflow(&doc)
1875    }
1876
1877    #[test]
1878    fn the_sugar_workflow_parses_hashes_and_orders() {
1879        let w = wf(json!({
1880            "name": "main", "version": 3,
1881            "steps": {
1882                "start": {"kind": "once"},
1883                "work": {"kind": "agent", "depends_on": ["start"], "instruction": "{{env.instruction}}"},
1884                "done": {"kind": "finish", "depends_on": ["work"], "status": "completed", "output": "{{steps.work.output}}"}
1885            }
1886        }))
1887        .unwrap();
1888        assert_eq!(w.start_steps().len(), 1);
1889        assert_eq!(w.topo_order(), vec!["start", "work", "done"]);
1890        assert_eq!(w.hash.len(), 64);
1891        assert!(!w.is_long_lived());
1892        assert!(w.armed);
1893        assert_eq!(
1894            w.step("work").unwrap().field_str("instruction"),
1895            Some("{{env.instruction}}")
1896        );
1897        // Same definition, same hash; a changed one differs.
1898        let w2 = wf(w.definition.clone()).unwrap();
1899        assert_eq!(w2.hash, w.hash);
1900        let mut d = w.definition.clone();
1901        d["steps"]["work"]["instruction"] = json!("other");
1902        assert_ne!(wf(d).unwrap().hash, w.hash);
1903    }
1904
1905    // Asserts a `when: CEL parse` diagnostic, so it needs the `cel` feature.
1906    #[cfg(feature = "cel")]
1907    #[test]
1908    fn validation_catches_the_rfc_0027_section_8_failures() {
1909        // Parse-level failures (reported together, before graph checks).
1910        let e = wf(json!({"name": "bad name", "start": "x", "steps": {
1911            "a": {"kind": "agent", "instruction": "x"},
1912            "b": {"kind": "tool", "name": "memory.get", "depends_on": ["a"], "bogus": 1},
1913            "c": {"kind": "foreach", "over": "{{x}}", "body": {"steps": {"i": {"kind": "noop", "depends_on": ["q"]}, "bad id": {"kind": "noop"}}}, "depends_on": ["b"]},
1914            "d": {"kind": "nope", "depends_on": ["a"]},
1915            "e": {"kind": "sleep", "duration": "5 parsecs", "depends_on": ["a"], "when": "CEL: 1 +"},
1916            "s": {"kind": "once", "depends_on": ["a"]}
1917        }}))
1918        .unwrap_err();
1919        let joined = e.join("\n");
1920        for needle in [
1921            "workflow name \"bad name\"",
1922            "`start`/`nodes` are dialect 1/2",
1923            "unknown field \"bogus\"",
1924            "unknown kind \"nope\"",
1925            "sleep.duration",
1926            "when: CEL parse",
1927            "a start node cannot depend on other steps",
1928            "step \"bad id\": id must match",
1929        ] {
1930            assert!(joined.contains(needle), "missing {needle:?} in:\n{joined}");
1931        }
1932        // Graph-level failures.
1933        let e = wf(json!({"name": "g", "steps": {
1934            "s": {"kind": "once"},
1935            "b": {"kind": "noop", "depends_on": ["s", "zz"]},
1936            "e": {"kind": "sleep", "duration": "1s", "depends_on": ["s"], "on_error": "goto:nowhere"},
1937            "loop1": {"kind": "noop", "depends_on": ["loop2"]},
1938            "loop2": {"kind": "noop", "depends_on": ["loop1"]},
1939            "f": {"kind": "finish", "depends_on": ["b"]}
1940        }}))
1941        .unwrap_err();
1942        let joined = e.join("\n");
1943        for needle in [
1944            "depends_on names unknown step \"zz\"",
1945            "on_error goto names unknown step \"nowhere\"",
1946            "cycle among steps",
1947            "not reachable from any start node",
1948        ] {
1949            assert!(joined.contains(needle), "missing {needle:?} in:\n{joined}");
1950        }
1951        // Structural: no start, unreachable, cycle, no finish.
1952        let e = wf(json!({"name": "w", "steps": {
1953            "a": {"kind": "noop"},
1954            "b": {"kind": "noop", "depends_on": ["c"]},
1955            "c": {"kind": "noop", "depends_on": ["b"]}
1956        }}))
1957        .unwrap_err();
1958        let joined = e.join("\n");
1959        assert!(joined.contains("at least one start node"), "{joined}");
1960        assert!(joined.contains("unreachable root"), "{joined}");
1961        assert!(joined.contains("cycle among steps"), "{joined}");
1962        assert!(joined.contains("`finish` step is required"), "{joined}");
1963        // Version.
1964        let e = wf(json!({"name": "w", "version": 2, "steps": {"s": {"kind": "once"}, "f": {"kind": "finish", "depends_on": ["s"]}}})).unwrap_err();
1965        assert!(e[0].contains("not dialect 3"));
1966        // Happy path with every implemented kind referenced.
1967        let ok = wf(json!({"name": "w", "inputs": {"schema": {"type": "object"}}, "concurrency": {"max_runs": 2, "on_overflow": "drop"}, "limits": {"deadline": "10m", "steps": 50}, "steps": {
1968            "s": {"kind": "manual"},
1969            "t": {"kind": "mcp.tool", "server": "fs", "tool": "read", "args": {"path": "/x"}, "depends_on": ["s"], "retry": {"max": 2, "backoff": "1s"}, "timeout": "30s", "on_error": "continue"},
1970            "v": {"kind": "assign", "value": {"a": 1}, "writes": "x", "depends_on": ["t"], "when": "CEL: true"},
1971            "th": {"kind": "think", "prompt": "p", "output_schema": {"type": "object"}, "depends_on": ["v"]},
1972            "z": {"kind": "sleep", "duration": "1s", "depends_on": ["th"]},
1973            "f": {"kind": "finish", "depends_on": ["z"], "status": "completed", "output": "{{vars.x}}"}
1974        }}))
1975        .unwrap();
1976        assert_eq!(ok.concurrency.on_overflow, OnOverflow::Drop);
1977        assert_eq!(ok.limits.deadline_ms, Some(600_000));
1978        assert_eq!(
1979            ok.step("t").unwrap().retry.as_ref().unwrap().backoff_ms,
1980            1000
1981        );
1982        assert_eq!(ok.step("t").unwrap().on_error, OnError::Continue);
1983        assert_eq!(ok.step("t").unwrap().timeout_ms, Some(30_000));
1984        assert!(implemented_kinds().contains(&"agent"));
1985        assert!(workflow_schema()["$defs"]["kinds"]["a2a.send"]["implemented"] == json!(true));
1986        assert!(workflow_schema()["$defs"]["kinds"]["foreach"]["implemented"] == json!(true));
1987    }
1988}