Skip to main content

agentd/engine/
model.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2//! The **workflow model**: a named DAG of steps beginning at start nodes,
3//! parsed from a JSON or YAML document and validated for acyclicity,
4//! reachability, `finish` reachability, dependency existence, schema
5//! well-formedness, CEL compilation and the caps.
6//!
7//! Field checking is strict per kind: a field the kind does not declare is a
8//! validation error, not something to ignore. A misspelled key is the failure
9//! mode that hurts most here — the workflow parses, runs, and quietly does not
10//! do the thing that key was meant to configure — so an unknown field must be
11//! refused where someone is still looking at it.
12//!
13//! The node catalogue is one table ([`KINDS`]); the validator, the executor
14//! and `--workflow-schema` all read it, so a kind cannot be documented without
15//! being validated or exposed without being described.
16
17use crate::jsonschema;
18use serde::{Deserialize, Serialize};
19use serde_json::{Map, Value, json};
20use std::collections::{BTreeMap, BTreeSet};
21
22/// The dialect this model speaks.
23pub const DIALECT: u32 = 3;
24/// Structural caps, enforced at validation so a pathological document is
25/// refused when it is submitted rather than after it has been scheduled.
26pub const MAX_STEPS: usize = 512;
27pub const MAX_NESTING: usize = 4;
28pub const MAX_BATCH_PARALLEL: u64 = 8;
29/// Lanes a `foreach`/`batch` uses when the definition does not say.
30///
31/// Four: concurrent enough to be worth writing `foreach` for rather than a
32/// loop, and low enough not to stampede an MCP server that never asked for the
33/// traffic. A definition that knows better sets its own, up to
34/// [`MAX_BATCH_PARALLEL`].
35pub const DEFAULT_FAN_OUT: u64 = 4;
36pub const MAX_ITERATIONS: u64 = 10_000;
37pub const MAX_ID_LEN: usize = 64;
38
39/// A step kind's metadata.
40#[derive(Debug, Clone, Copy)]
41pub struct KindInfo {
42    pub name: &'static str,
43    /// A start node (a trigger).
44    pub start: bool,
45    /// Kind-specific fields (besides the cross-cutting ones).
46    pub fields: &'static [&'static str],
47    /// Required kind-specific fields.
48    pub required: &'static [&'static str],
49    /// Executable in this build. A kind marked `false` still parses and
50    /// validates structurally, but validation then refuses the document, so a
51    /// definition can never reach the scheduler naming a kind nothing runs.
52    pub implemented: bool,
53    /// Has a nested body sub-DAG (`body: {steps: …}`) / branches.
54    pub nested: bool,
55}
56
57const fn k(
58    name: &'static str,
59    start: bool,
60    fields: &'static [&'static str],
61    required: &'static [&'static str],
62    implemented: bool,
63    nested: bool,
64) -> KindInfo {
65    KindInfo {
66        name,
67        start,
68        fields,
69        required,
70        implemented,
71        nested,
72    }
73}
74
75/// The node catalogue: every step kind, its start-node status, the fields it
76/// accepts, the fields it requires, whether this build executes it, and
77/// whether it carries a nested sub-DAG. This table is the single source the
78/// validator, the schema generator and the executor all consult.
79pub const KINDS: &[KindInfo] = &[
80    // ---- start nodes ----
81    k("once", true, &["policy", "inputs"], &[], true, false),
82    k("manual", true, &["inputs"], &[], true, false),
83    k(
84        "loop",
85        true,
86        &[
87            "interval",
88            "delay",
89            "until",
90            "max_iterations",
91            "backoff",
92            "inputs",
93        ],
94        &[],
95        true,
96        false,
97    ),
98    k(
99        "schedule",
100        true,
101        &["cron", "every", "tz", "jitter", "catch_up", "at", "inputs"],
102        &[],
103        true,
104        false,
105    ),
106    k(
107        "subscribe",
108        true,
109        &[
110            "server",
111            "uri",
112            "debounce_ms",
113            "coalesce",
114            "filter",
115            "deliver",
116            "on_no_listener",
117            "window",
118            "inputs",
119        ],
120        &["server", "uri"],
121        true,
122        false,
123    ),
124    k(
125        "stream",
126        true,
127        &["stream", "subject", "filter", "from", "rate", "inputs"],
128        &["stream"],
129        true,
130        false,
131    ),
132    k(
133        "signal",
134        true,
135        &["name", "filter", "deliver", "inputs"],
136        &["name"],
137        true,
138        false,
139    ),
140    k(
141        "event",
142        true,
143        &["on", "filter", "inputs"],
144        &["on"],
145        true,
146        false,
147    ),
148    k(
149        "a2a",
150        true,
151        &["command", "roles", "inputs", "schema"],
152        &[],
153        true,
154        false,
155    ),
156    k(
157        "webhook",
158        true,
159        &[
160            "path",
161            "methods",
162            "auth",
163            "parallelism",
164            "on_overflow",
165            "rate",
166            "idempotency",
167            "respond",
168            "filter",
169            "inputs",
170            "signal",
171        ],
172        &["path"],
173        true,
174        false,
175    ),
176    // ---- control ----
177    k(
178        "switch",
179        false,
180        &["on", "cases", "default", "on_no_match"],
181        &["on", "cases"],
182        true,
183        false,
184    ),
185    k(
186        "parallel",
187        false,
188        &["branches", "on_error"],
189        &["branches"],
190        true,
191        true,
192    ),
193    k(
194        "foreach",
195        false,
196        &["over", "body", "batch", "collect", "on_error", "as"],
197        &["over", "body"],
198        true,
199        true,
200    ),
201    k(
202        "batch",
203        false,
204        &[
205            "over", "body", "by", "size", "parallel", "rate", "collect", "on_error",
206        ],
207        &["over", "body"],
208        true,
209        true,
210    ),
211    k(
212        "iterate",
213        false,
214        &["body", "while", "until", "max_iterations", "collect"],
215        &["body"],
216        true,
217        true,
218    ),
219    k(
220        "race",
221        false,
222        &["branches", "timeout", "min_success"],
223        &["branches"],
224        true,
225        true,
226    ),
227    k(
228        "join",
229        false,
230        &["handles", "timeout", "min", "partials"],
231        &["handles"],
232        true,
233        false,
234    ),
235    k("subgraph", false, &["body"], &["body"], true, true),
236    k(
237        "workflow",
238        false,
239        &["name", "inputs", "mode", "start", "version", "cascade"],
240        &["name"],
241        true,
242        false,
243    ),
244    k(
245        "wait",
246        false,
247        &[
248            "on",
249            "server",
250            "uri",
251            "condition",
252            "signal",
253            "run",
254            "subagent",
255            "conversation",
256            "webhook",
257            "stream",
258            "subject",
259            "match",
260            "timeout",
261            "on_timeout",
262        ],
263        &["on"],
264        true,
265        false,
266    ),
267    k("sleep", false, &["duration"], &["duration"], true, false),
268    k(
269        "assert",
270        false,
271        &["condition", "message"],
272        &["condition"],
273        true,
274        false,
275    ),
276    k("fail", false, &["message", "code"], &[], true, false),
277    k("noop", false, &[], &[], true, false),
278    k("checkpoint", false, &["name"], &[], true, false),
279    k(
280        "finish",
281        false,
282        &["status", "output", "reason"],
283        &[],
284        true,
285        false,
286    ),
287    // ---- data ----
288    k(
289        "assign",
290        false,
291        &["value", "writes", "mode"],
292        &["value"],
293        true,
294        false,
295    ),
296    k(
297        "transform",
298        false,
299        &["value", "writes", "mode"],
300        &["value"],
301        true,
302        false,
303    ),
304    k(
305        "map",
306        false,
307        &["over", "expr", "as"],
308        &["over", "expr"],
309        true,
310        false,
311    ),
312    k(
313        "filter",
314        false,
315        &["over", "expr", "as"],
316        &["over", "expr"],
317        true,
318        false,
319    ),
320    k(
321        "reduce",
322        false,
323        &["over", "expr", "initial", "as", "acc"],
324        &["over", "expr"],
325        true,
326        false,
327    ),
328    k(
329        "sort",
330        false,
331        &["over", "by", "order"],
332        &["over"],
333        true,
334        false,
335    ),
336    k("dedupe", false, &["over", "by"], &["over"], true, false),
337    k(
338        "chunk",
339        false,
340        &["value", "by", "size", "overlap"],
341        &["value", "size"],
342        true,
343        false,
344    ),
345    k("template", false, &["text", "value"], &[], true, false),
346    k("parse", false, &["text", "format"], &["text"], true, false),
347    k(
348        "validate",
349        false,
350        &["value", "schema"],
351        &["value", "schema"],
352        true,
353        false,
354    ),
355    k("memory.get", false, &["key"], &["key"], true, false),
356    k(
357        "memory.set",
358        false,
359        &["key", "value", "ttl"],
360        &["key", "value"],
361        true,
362        false,
363    ),
364    k("memory.list", false, &["prefix", "limit"], &[], true, false),
365    k(
366        "memory.push",
367        false,
368        &["key", "value"],
369        &["key", "value"],
370        true,
371        false,
372    ),
373    k("memory.shift", false, &["key"], &["key"], true, false),
374    k("memory.pop", false, &["key"], &["key"], true, false),
375    k("memory.delete", false, &["key"], &["key"], true, false),
376    k(
377        "artifact.create",
378        false,
379        &["name", "mime", "content", "from_step", "sensitive"],
380        &["name"],
381        true,
382        false,
383    ),
384    k("artifact.get", false, &["id"], &["id"], true, false),
385    k("artifact.delete", false, &["id"], &["id"], true, false),
386    k(
387        "knowledge.search",
388        false,
389        &["query", "top_k", "filters"],
390        &["query"],
391        true,
392        false,
393    ),
394    k("knowledge.get", false, &["id", "uri"], &[], true, false),
395    k(
396        "search.query",
397        false,
398        &["query", "kind", "limit", "freshness"],
399        &["query"],
400        true,
401        false,
402    ),
403    k(
404        "search.fetch",
405        false,
406        &["url", "max_bytes"],
407        &["url"],
408        true,
409        false,
410    ),
411    // ---- integration ----
412    k(
413        "mcp.tool",
414        false,
415        &["server", "tool", "args", "idempotency", "breaker", "rate"],
416        &["server", "tool"],
417        true,
418        false,
419    ),
420    k(
421        "mcp.resource",
422        false,
423        &[
424            "server",
425            "op",
426            "uri",
427            "name",
428            "arguments",
429            "reference",
430            "argument",
431        ],
432        &["server", "op"],
433        true,
434        false,
435    ),
436    k("tool", false, &["name", "args"], &["name"], true, false),
437    k(
438        "http",
439        false,
440        &[
441            "method",
442            "url",
443            "headers",
444            "query",
445            "body",
446            "json",
447            "timeout",
448            "expect",
449            "allow_private",
450            "sign",
451            "idempotency",
452            "breaker",
453            "rate",
454        ],
455        &["url"],
456        true,
457        false,
458    ),
459    k(
460        "a2a.send",
461        false,
462        &[
463            "to",
464            "parts",
465            "command",
466            "args",
467            "context",
468            "timeout",
469            "idempotency",
470            "breaker",
471            "rate",
472        ],
473        &["to"],
474        true,
475        false,
476    ),
477    k(
478        "a2a.delegate",
479        false,
480        &[
481            "peer",
482            "objective",
483            "command",
484            "args",
485            "output_contract",
486            "timeout",
487            "idempotency",
488            "breaker",
489            "rate",
490        ],
491        &["peer"],
492        true,
493        false,
494    ),
495    k(
496        "a2a.wait",
497        false,
498        &["conversation", "timeout"],
499        &[],
500        true,
501        false,
502    ),
503    // Deliver into one of THIS instance's own conversations, so a run can hand
504    // work to the agent rather than only the other way round. `wait: reply`
505    // parks the step on the answer; without it the step is fire-and-forget and
506    // the turn happens on its own schedule.
507    k(
508        "message",
509        false,
510        &["to", "text", "parts", "wait", "timeout", "on_timeout"],
511        &["to"],
512        true,
513        false,
514    ),
515    k(
516        "workflow.signal",
517        false,
518        &["name", "payload", "run"],
519        &["name"],
520        true,
521        false,
522    ),
523    k(
524        "workflow.wait",
525        false,
526        &["run", "timeout"],
527        &["run"],
528        true,
529        false,
530    ),
531    k(
532        "workflow.cancel",
533        false,
534        &["run", "reason"],
535        &["run"],
536        true,
537        false,
538    ),
539    k(
540        "emit",
541        false,
542        &[
543            "note",
544            "audit",
545            "metric",
546            "value",
547            "stream",
548            "subject",
549            "data",
550            "correlation",
551        ],
552        &[],
553        true,
554        false,
555    ),
556    // ---- intelligence & agents ----
557    k(
558        "think",
559        false,
560        &[
561            "prompt",
562            "output_schema",
563            "reads",
564            "check",
565            "retries",
566            "skills",
567            "system",
568            "model",
569        ],
570        &["prompt"],
571        true,
572        false,
573    ),
574    k(
575        "classify",
576        false,
577        &["input", "classes", "prompt", "skills", "model"],
578        &["input", "classes"],
579        true,
580        false,
581    ),
582    k(
583        "extract",
584        false,
585        &["input", "output_schema", "prompt", "skills", "model"],
586        &["input", "output_schema"],
587        true,
588        false,
589    ),
590    k(
591        "summarize",
592        false,
593        &["input", "length", "prompt", "skills", "model"],
594        &["input"],
595        true,
596        false,
597    ),
598    k(
599        "judge",
600        false,
601        &["input", "rubric", "prompt", "skills", "model"],
602        &["input", "rubric"],
603        true,
604        false,
605    ),
606    k(
607        "route",
608        false,
609        &["input", "choices", "prompt", "skills", "model"],
610        &["input", "choices"],
611        true,
612        false,
613    ),
614    k(
615        "agent",
616        false,
617        &[
618            "instruction",
619            "output_contract",
620            "output_schema",
621            "tools",
622            "servers",
623            "limits",
624            "context",
625            "skills",
626            "system",
627            "model",
628        ],
629        &["instruction"],
630        true,
631        false,
632    ),
633    // `template`/`params` instantiate a declared `subagents.templates` entry:
634    // the step names a template and supplies its parameters rather than
635    // spelling out the whole child, so one reviewed definition backs every
636    // child that uses it.
637    k(
638        "subagent",
639        false,
640        &[
641            "instruction",
642            "template",
643            "params",
644            "mode",
645            "tools",
646            "servers",
647            "limits",
648            "priority",
649            "context",
650            "output_contract",
651            "output_schema",
652            "skills",
653            "durable",
654        ],
655        &[],
656        true,
657        false,
658    ),
659    k(
660        "human",
661        false,
662        &["question", "schema", "to", "timeout", "reply_uri"],
663        &["question"],
664        true,
665        false,
666    ),
667];
668
669/// Cross-cutting fields every step may carry, whatever its kind. Field
670/// checking is the union of these and the kind's own list, so a name that
671/// appears in neither is refused.
672pub const COMMON_FIELDS: &[&str] = &[
673    "kind",
674    "depends_on",
675    "when",
676    "retry",
677    "timeout",
678    "on_error",
679    "idempotent",
680    "on_replay",
681    "output_schema",
682    "cache",
683    "budget",
684    "skills",
685    "otel",
686    "description",
687];
688
689/// Step kinds that are PURE data transforms: no external effect, no durable
690/// write of their own, fully deterministic over the run's data. The
691/// checkpoint-before-effect rule exists to stop a crash from losing or
692/// repeating an effect, and these steps have none — a crash simply replays
693/// them from the last checkpoint and reaches the same values. So the scheduler
694/// skips the checkpoint for them, and an inline chain batches into its tick's
695/// single checkpoint instead of paying a serialize-and-write per step, which
696/// measures at roughly 40% of such a chain's cycles.
697pub fn pure_data_kind(kind: &str) -> bool {
698    matches!(
699        kind,
700        "assign"
701            | "map"
702            | "filter"
703            | "reduce"
704            | "sort"
705            | "dedupe"
706            | "chunk"
707            | "parse"
708            | "switch"
709            | "noop"
710            | "assert"
711            | "validate"
712    )
713}
714
715pub fn kind_info(name: &str) -> Option<&'static KindInfo> {
716    KINDS.iter().find(|k| k.name == name)
717}
718
719/// The kinds implemented by this build's engine.
720pub fn implemented_kinds() -> Vec<&'static str> {
721    KINDS
722        .iter()
723        .filter(|k| k.implemented)
724        .map(|k| k.name)
725        .collect()
726}
727
728/// `on_error` policy.
729#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
730#[serde(rename_all = "snake_case")]
731pub enum OnError {
732    #[default]
733    Fail,
734    Continue,
735    Goto(String),
736}
737
738impl OnError {
739    fn parse(v: &Value) -> Result<OnError, String> {
740        match v.as_str() {
741            Some("fail") => Ok(OnError::Fail),
742            Some("continue") => Ok(OnError::Continue),
743            Some(s) if s.starts_with("goto:") => {
744                let t = s["goto:".len()..].trim();
745                if t.is_empty() {
746                    Err("on_error goto: needs a step id".into())
747                } else {
748                    Ok(OnError::Goto(t.to_string()))
749                }
750            }
751            _ => Err("on_error must be fail | continue | goto:<step>".into()),
752        }
753    }
754}
755
756#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
757#[serde(rename_all = "snake_case")]
758pub enum OnReplay {
759    #[default]
760    Retry,
761    Skip,
762    Fail,
763}
764
765#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
766pub struct Retry {
767    #[serde(default)]
768    pub max: u32,
769    /// Backoff between attempts (ms), doubling; 0 = none.
770    #[serde(default)]
771    pub backoff_ms: u64,
772}
773
774/// A nested sub-DAG: the body of `foreach`/`batch`/`iterate`/`subgraph`, or one
775/// branch of `parallel`/`race`. Body steps depend only on siblings; steps with
776/// no dependencies are the entry points; steps nothing depends on are the
777/// **sinks** whose outputs form the body's result (one sink ⇒ its output; many
778/// ⇒ an object keyed by step id).
779#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
780pub struct Body {
781    pub steps: BTreeMap<String, Step>,
782}
783
784impl Body {
785    /// Deterministic dependency order.
786    pub fn topo_order(&self) -> Vec<String> {
787        let mut out = Vec::new();
788        let mut done: BTreeSet<String> = BTreeSet::new();
789        let mut progress = true;
790        while progress && out.len() < self.steps.len() {
791            progress = false;
792            for (id, s) in &self.steps {
793                if !done.contains(id) && s.depends_on.iter().all(|d| done.contains(d)) {
794                    done.insert(id.clone());
795                    out.push(id.clone());
796                    progress = true;
797                }
798            }
799        }
800        out
801    }
802    /// Steps nothing else depends on.
803    pub fn sinks(&self) -> Vec<String> {
804        self.steps
805            .keys()
806            .filter(|id| {
807                !self
808                    .steps
809                    .values()
810                    .any(|s| s.depends_on.iter().any(|d| d == *id))
811            })
812            .cloned()
813            .collect()
814    }
815}
816
817/// One step (the cross-cutting fields typed; kind fields in `spec`).
818#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
819pub struct Step {
820    pub id: String,
821    pub kind: String,
822    #[serde(default)]
823    pub depends_on: Vec<String>,
824    #[serde(default, skip_serializing_if = "Option::is_none")]
825    pub when: Option<String>,
826    #[serde(default, skip_serializing_if = "Option::is_none")]
827    pub retry: Option<Retry>,
828    #[serde(default, skip_serializing_if = "Option::is_none")]
829    pub timeout_ms: Option<u64>,
830    #[serde(default)]
831    pub on_error: OnError,
832    #[serde(default)]
833    pub idempotent: bool,
834    #[serde(default)]
835    pub on_replay: OnReplay,
836    #[serde(default, skip_serializing_if = "Option::is_none")]
837    pub output_schema: Option<Value>,
838    #[serde(default, skip_serializing_if = "Option::is_none")]
839    pub cache: Option<Value>,
840    #[serde(default, skip_serializing_if = "Option::is_none")]
841    pub budget: Option<u64>,
842    #[serde(default, skip_serializing_if = "Vec::is_empty")]
843    pub skills: Vec<String>,
844    #[serde(default, skip_serializing_if = "Option::is_none")]
845    pub description: Option<String>,
846    /// The kind-specific fields, verbatim.
847    #[serde(default)]
848    pub spec: Map<String, Value>,
849    /// The parsed nested body (`foreach`/`batch`/`iterate`/`subgraph`).
850    #[serde(default, skip_serializing_if = "Option::is_none")]
851    pub body: Option<Body>,
852    /// The parsed branches (`parallel`/`race`).
853    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
854    pub branches: BTreeMap<String, Body>,
855}
856
857impl Step {
858    pub fn info(&self) -> Option<&'static KindInfo> {
859        kind_info(&self.kind)
860    }
861    pub fn is_start(&self) -> bool {
862        self.info().is_some_and(|k| k.start)
863    }
864    /// A kind-specific field.
865    pub fn field(&self, name: &str) -> Option<&Value> {
866        self.spec.get(name)
867    }
868    pub fn field_str(&self, name: &str) -> Option<&str> {
869        self.spec.get(name).and_then(Value::as_str)
870    }
871}
872
873#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
874#[serde(rename_all = "snake_case")]
875pub enum OnOverflow {
876    #[default]
877    Queue,
878    Drop,
879    Replace,
880}
881
882#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
883pub struct Concurrency {
884    pub max_runs: u32,
885    pub on_overflow: OnOverflow,
886    /// What `max_runs` counts: every run of this workflow (`workflow`, the
887    /// default and today's behaviour), or every run ABOUT THE SAME THING
888    /// (`key`, using the workflow's `key:` template).
889    ///
890    /// The distinction is the difference between a queue and a lock. With
891    /// `scope: workflow`, `max_runs: 1` serialises every customer behind one
892    /// run, so per-entity ordering means one workflow definition per entity.
893    /// With `scope: key` each entity is serialised against itself and the
894    /// entities run in parallel.
895    #[serde(default)]
896    pub scope: ConcurrencyScope,
897}
898
899#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
900#[serde(rename_all = "lowercase")]
901pub enum ConcurrencyScope {
902    #[default]
903    Workflow,
904    Key,
905}
906
907impl Default for Concurrency {
908    fn default() -> Self {
909        Concurrency {
910            max_runs: 4,
911            on_overflow: OnOverflow::Queue,
912            scope: ConcurrencyScope::Workflow,
913        }
914    }
915}
916
917#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
918pub struct WorkflowLimits {
919    #[serde(default, skip_serializing_if = "Option::is_none")]
920    pub steps: Option<u32>,
921    #[serde(default, skip_serializing_if = "Option::is_none")]
922    pub tokens: Option<u64>,
923    #[serde(default, skip_serializing_if = "Option::is_none")]
924    pub deadline_ms: Option<u64>,
925    #[serde(default, skip_serializing_if = "Option::is_none")]
926    pub budget: Option<Value>,
927}
928
929/// A workflow's tool registration.
930#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
931pub struct WorkflowTool {
932    /// The tool name callers see. Must not shadow an internal contract.
933    pub name: String,
934    /// `sync` parks the caller on the run and returns its output; `async`
935    /// returns a handle immediately.
936    #[serde(default)]
937    pub mode: WorkflowToolMode,
938    /// Who may call it, defaulting to root and workflows.
939    #[serde(default)]
940    pub grant: WorkflowToolGrant,
941}
942
943#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
944#[serde(rename_all = "lowercase")]
945pub enum WorkflowToolMode {
946    #[default]
947    Sync,
948    Async,
949}
950
951#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
952pub struct WorkflowToolGrant {
953    pub root: bool,
954    pub workflows: bool,
955    pub subagents: bool,
956    pub user: bool,
957    pub agent: bool,
958}
959
960impl Default for WorkflowToolGrant {
961    fn default() -> Self {
962        // The same default an internal `workflow`-family contract gets: the
963        // operator and the graphs, not the children or the network. Handing a
964        // procedure to a subagent is a narrowing an operator opts into.
965        WorkflowToolGrant {
966            root: true,
967            workflows: true,
968            subagents: false,
969            user: false,
970            agent: false,
971        }
972    }
973}
974
975/// One declared run variable.
976#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
977pub struct StateDecl {
978    /// A JSON Schema the written value must satisfy.
979    #[serde(default, skip_serializing_if = "Option::is_none")]
980    pub schema: Option<Value>,
981    /// How concurrent writes combine: `overwrite | append | merge | union`.
982    #[serde(default, skip_serializing_if = "Option::is_none")]
983    pub reducer: Option<String>,
984}
985
986/// A parsed, validated workflow.
987#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
988pub struct Workflow {
989    pub name: String,
990    pub version: u32,
991    /// Scheduling weight under contention. `low` admissions shed one pressure
992    /// level EARLIER (at `warn`, not just `shed`),
993    /// and ready steps of higher-priority runs are scheduled first each tick.
994    /// It is a tiebreak under scarcity, not a reservation.
995    #[serde(default)]
996    pub priority: Priority,
997    /// Retirement policy for live runs (`unload: {policy, timeout}`).
998    #[serde(default)]
999    pub unload: Unload,
1000    /// Durability class: `Some(false)` ⇒ runs of this workflow are memory-only
1001    /// (no checkpoints, gone after a restart — the fast path for recomputable
1002    /// work); `Some(true)` ⇒ durable even under `store.durability.work:
1003    /// ephemeral`. `None` in a freshly parsed document; the loader resolves it
1004    /// against the store's default before the definition is armed.
1005    #[serde(default, skip_serializing_if = "Option::is_none")]
1006    pub durable: Option<bool>,
1007    /// Declared run variables: `{key: {type, reducer}}`.
1008    ///
1009    /// Optional, and the point is to make concurrent writes a DECLARED policy
1010    /// instead of a heuristic. Without it the parser can only guess from the
1011    /// modes two racing writers happen to use; with it, the workflow states
1012    /// what a key is and how writes to it combine, and disagreement is a config
1013    /// error rather than a value that depends on completion order.
1014    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1015    pub state: BTreeMap<String, StateDecl>,
1016    #[serde(default, skip_serializing_if = "Option::is_none")]
1017    pub description: Option<String>,
1018    #[serde(default = "default_true")]
1019    pub armed: bool,
1020    #[serde(default, skip_serializing_if = "Option::is_none")]
1021    pub inputs_schema: Option<Value>,
1022    #[serde(default)]
1023    pub concurrency: Concurrency,
1024    /// The logical thing a run is ABOUT, rendered from the trigger payload
1025    /// (`"{{payload.account_id}}"`).
1026    ///
1027    /// Everything else in the runtime is keyed — breakers, rate buckets, start
1028    /// state, webhook dedup, step idempotency — but the run itself had no
1029    /// logical name, only an id. That is why per-entity serialization was not
1030    /// expressible: `max_runs` could count runs but not runs *about the same
1031    /// account*.
1032    #[serde(default, skip_serializing_if = "Option::is_none")]
1033    pub key: Option<String>,
1034    /// Register this workflow in the tool registry as a first-class contract.
1035    ///
1036    /// The shapes already match: a workflow carries a description, an input
1037    /// schema, an output schema and a definition hash, which is exactly a tool
1038    /// contract. What it adds over an MCP tool is everything the engine
1039    /// already has — a "tool call" that takes thirty minutes, survives a
1040    /// restart, and has retry, breaker, idempotency and a human gate INSIDE
1041    /// it. And it is strictly better for the trifecta fold: a subagent handed
1042    /// `billing.refund` spends its legs on one reviewed procedure instead of a
1043    /// whole server's tool surface.
1044    #[serde(default, skip_serializing_if = "Option::is_none")]
1045    pub tool: Option<WorkflowTool>,
1046    #[serde(default)]
1047    pub limits: WorkflowLimits,
1048    #[serde(default, skip_serializing_if = "Option::is_none")]
1049    pub outputs_schema: Option<Value>,
1050    pub steps: BTreeMap<String, Step>,
1051    /// SHA-256 of the canonical definition. A run pins the hash it started
1052    /// against, so a redefinition never changes the shape of work already in
1053    /// flight, and `workflow.list` can show whether two instances agree.
1054    pub hash: String,
1055    /// The definition as given (canonical JSON), for `workflow.list`/hash.
1056    pub definition: Value,
1057}
1058
1059fn default_true() -> bool {
1060    true
1061}
1062
1063/// What happens to a workflow's LIVE runs when its definition goes away —
1064/// removed from the config, replaced by another version, or `workflow.delete`d.
1065/// Whatever the policy, withdrawing a definition always disarms its starts,
1066/// unsubscribes its MCP resources, stops admitting new runs, and pins the
1067/// definition each surviving run started against, so a run's shape never
1068/// changes underneath it.
1069#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1070#[serde(rename_all = "lowercase")]
1071pub enum UnloadPolicy {
1072    /// Let live runs finish (bounded by `timeout`, then cancel). The default:
1073    /// work that was admitted deserves to complete.
1074    #[default]
1075    Drain,
1076    /// Cancel live runs now.
1077    Cancel,
1078    /// Pin and forget: live runs finish whenever they finish.
1079    Detach,
1080}
1081
1082impl UnloadPolicy {
1083    pub fn as_str(self) -> &'static str {
1084        match self {
1085            UnloadPolicy::Drain => "drain",
1086            UnloadPolicy::Cancel => "cancel",
1087            UnloadPolicy::Detach => "detach",
1088        }
1089    }
1090}
1091
1092/// The `unload:` declaration (`{policy, timeout}`).
1093#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
1094pub struct Unload {
1095    #[serde(default)]
1096    pub policy: UnloadPolicy,
1097    /// Drain bound in ms; `None` = unbounded (detach-like drain).
1098    #[serde(default, skip_serializing_if = "Option::is_none")]
1099    pub timeout_ms: Option<u64>,
1100}
1101
1102/// Contention priority — for workflows and subagent spawns. Ordering matters:
1103/// higher is more important.
1104#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
1105#[serde(rename_all = "lowercase")]
1106pub enum Priority {
1107    Low,
1108    #[default]
1109    Normal,
1110    High,
1111}
1112
1113impl Priority {
1114    pub fn as_str(self) -> &'static str {
1115        match self {
1116            Priority::Low => "low",
1117            Priority::Normal => "normal",
1118            Priority::High => "high",
1119        }
1120    }
1121    /// Parse from a spec value; `None` field = `Normal`, junk = `Err`.
1122    pub fn from_spec(v: Option<&Value>) -> Result<Priority, String> {
1123        match v.and_then(Value::as_str) {
1124            None if v.is_none() => Ok(Priority::Normal),
1125            Some("low") => Ok(Priority::Low),
1126            Some("normal") => Ok(Priority::Normal),
1127            Some("high") => Ok(Priority::High),
1128            other => Err(format!(
1129                "priority must be low|normal|high, got {:?}",
1130                other
1131                    .map(str::to_string)
1132                    .unwrap_or_else(|| v.map(|x| x.to_string()).unwrap_or_default())
1133            )),
1134        }
1135    }
1136    /// The niceness delta OS-level allocation uses (`setpriority`): `low`
1137    /// yields CPU (+10), `high` asks for more (−5, granted only with
1138    /// CAP_SYS_NICE), `normal` inherits.
1139    pub fn nice(self) -> Option<i32> {
1140        match self {
1141            Priority::Low => Some(10),
1142            Priority::Normal => None,
1143            Priority::High => Some(-5),
1144        }
1145    }
1146}
1147
1148impl Workflow {
1149    pub fn start_steps(&self) -> Vec<&Step> {
1150        self.steps.values().filter(|s| s.is_start()).collect()
1151    }
1152    pub fn step(&self, id: &str) -> Option<&Step> {
1153        self.steps.get(id)
1154    }
1155    /// The steps that depend on `id`.
1156    pub fn dependents(&self, id: &str) -> Vec<&Step> {
1157        self.steps
1158            .values()
1159            .filter(|s| s.depends_on.iter().any(|d| d == id))
1160            .collect()
1161    }
1162    /// Whether any start node makes this workflow long-lived: one that keeps
1163    /// firing — a timer, a schedule, a subscription, an inbound signal, event,
1164    /// A2A message or stream — rather than running once and finishing.
1165    ///
1166    /// This decides daemon shape. An instance holding a long-lived workflow
1167    /// must not idle-exit, because the workflow's whole purpose is to still be
1168    /// there when its trigger arrives.
1169    pub fn is_long_lived(&self) -> bool {
1170        self.start_steps()
1171            .iter()
1172            .any(|s| is_long_lived_start(&s.kind))
1173    }
1174    /// The step ids in a deterministic topological order (deps first).
1175    pub fn topo_order(&self) -> Vec<String> {
1176        let mut out = Vec::new();
1177        let mut done: BTreeSet<String> = BTreeSet::new();
1178        let mut progress = true;
1179        while progress && out.len() < self.steps.len() {
1180            progress = false;
1181            for (id, s) in &self.steps {
1182                if done.contains(id) {
1183                    continue;
1184                }
1185                if s.depends_on.iter().all(|d| done.contains(d)) {
1186                    done.insert(id.clone());
1187                    out.push(id.clone());
1188                    progress = true;
1189                }
1190            }
1191        }
1192        out
1193    }
1194}
1195
1196/// A JSON value's shape, for a diagnostic that says what was written.
1197fn json_kind(v: &Value) -> &'static str {
1198    match v {
1199        Value::Null => "null",
1200        Value::Bool(_) => "a boolean",
1201        Value::Number(_) => "a number",
1202        Value::String(_) => "a string",
1203        Value::Array(_) => "a list",
1204        Value::Object(_) => "an object",
1205    }
1206}
1207
1208/// The top-level fields a workflow document may carry. The parser and the
1209/// JSON Schema both read this list, so an editor can never flag a field the
1210/// loader accepts (or complete one it refuses) — they drifted apart once.
1211pub const TOP: &[&str] = &[
1212    "name",
1213    "version",
1214    "description",
1215    "armed",
1216    "inputs",
1217    "concurrency",
1218    "limits",
1219    "outputs",
1220    "state",
1221    "steps",
1222    "file",
1223    "uri",
1224    "priority",
1225    "unload",
1226    "durable",
1227    "key",
1228    "tool",
1229];
1230
1231/// The start kinds that do NOT keep an instance alive. `once` fires when armed
1232/// and `manual` only on an explicit `workflow.run`; when either finishes there
1233/// is nothing left waiting, so a job-shaped instance may exit.
1234///
1235/// Expressed as the EXCEPTIONS rather than as a list of long-lived kinds,
1236/// because the exceptions are the stable half: every trigger added since has
1237/// been something that waits (a stream, a webhook), and a new one is far more
1238/// likely to belong in the long-lived set than out of it. A list of inclusions
1239/// silently misclassifies whatever is added next; a list of exclusions makes
1240/// the new kind long-lived by default, which is the safe direction — the cost
1241/// of a wrong "keeps running" is a process that idles, and the cost of a wrong
1242/// "may exit" is a listener that dies under its own traffic.
1243pub const ONE_SHOT_STARTS: &[&str] = &["once", "manual"];
1244
1245/// Every start kind, derived from [`KINDS`] so it cannot drift from the table
1246/// the parser uses.
1247pub fn start_kinds() -> Vec<&'static str> {
1248    KINDS.iter().filter(|k| k.start).map(|k| k.name).collect()
1249}
1250
1251/// Whether a start kind keeps the instance alive.
1252///
1253/// THE authority. Three hand-maintained copies of this judgement used to exist
1254/// — the workflow method, `config::v2::LONG_LIVED_STARTS`, and the
1255/// capabilities manifest's own list — and all three disagreed: one had `stream`
1256/// and not `webhook`, one the reverse, one neither. A webhook-only instance
1257/// under the default `run_until: auto` therefore reported ready and immediately
1258/// idle-exited out from under its own listener.
1259pub fn is_long_lived_start(kind: &str) -> bool {
1260    KINDS.iter().any(|k| k.start && k.name == kind) && !ONE_SHOT_STARTS.contains(&kind)
1261}
1262
1263/// Parse + validate a dialect-3 document. Errors name every problem.
1264pub fn parse_workflow(doc: &Value) -> Result<Workflow, Vec<String>> {
1265    let mut errs = Vec::new();
1266    let Some(obj) = doc.as_object() else {
1267        return Err(vec!["a workflow must be an object".into()]);
1268    };
1269    for key in obj.keys() {
1270        if !TOP.contains(&key.as_str()) {
1271            errs.push(format!("unknown workflow field {key:?}"));
1272        }
1273    }
1274    let name = obj
1275        .get("name")
1276        .and_then(Value::as_str)
1277        .unwrap_or("")
1278        .trim()
1279        .to_string();
1280    if !valid_id(&name) {
1281        errs.push(format!(
1282            "workflow name {name:?} must match [a-zA-Z_][a-zA-Z0-9_-]{{0,63}}"
1283        ));
1284    }
1285    let version = obj
1286        .get("version")
1287        .and_then(Value::as_u64)
1288        .unwrap_or(DIALECT as u64) as u32;
1289    if version != DIALECT {
1290        errs.push(format!(
1291            "workflow {name:?}: version {version} is not dialect 3 (dialect 1/2 documents are refused — see docs/workflows.md §migration)"
1292        ));
1293    }
1294    if obj.contains_key("start") || obj.contains_key("nodes") {
1295        errs.push(format!("workflow {name:?}: `start`/`nodes` are dialect 1/2 — use `steps` with start nodes (docs/workflows.md §migration)"));
1296    }
1297    let armed = obj.get("armed").and_then(Value::as_bool).unwrap_or(true);
1298    let priority = match Priority::from_spec(obj.get("priority")) {
1299        Ok(p) => p,
1300        Err(e) => {
1301            errs.push(format!("workflow {name:?}: {e}"));
1302            Priority::Normal
1303        }
1304    };
1305    let unload = match obj.get("unload") {
1306        None => Unload::default(),
1307        Some(u) => {
1308            let policy = match u.get("policy").and_then(Value::as_str) {
1309                None | Some("drain") => UnloadPolicy::Drain,
1310                Some("cancel") => UnloadPolicy::Cancel,
1311                Some("detach") => UnloadPolicy::Detach,
1312                Some(o) => {
1313                    errs.push(format!(
1314                        "workflow {name:?}: unload.policy {o:?} must be drain|cancel|detach"
1315                    ));
1316                    UnloadPolicy::Drain
1317                }
1318            };
1319            let timeout_ms = match u.get("timeout") {
1320                None => None,
1321                Some(t) => match t.as_str().map(crate::config::parse_duration) {
1322                    Some(Ok(d)) => Some(d.as_millis() as u64),
1323                    _ => {
1324                        errs.push(format!(
1325                            "workflow {name:?}: unload.timeout must be a duration (\"60s\")"
1326                        ));
1327                        None
1328                    }
1329                },
1330            };
1331            if let Some(o) = u.as_object()
1332                && o.keys()
1333                    .any(|k| !matches!(k.as_str(), "policy" | "timeout"))
1334            {
1335                errs.push(format!(
1336                    "workflow {name:?}: unload takes {{policy, timeout}}"
1337                ));
1338            }
1339            Unload { policy, timeout_ms }
1340        }
1341    };
1342    let inputs_schema = match obj.get("inputs") {
1343        None => None,
1344        Some(v) => {
1345            let schema = v.get("schema").cloned().or_else(|| {
1346                v.as_object()
1347                    .filter(|m| m.contains_key("type") || m.contains_key("properties"))
1348                    .map(|_| v.clone())
1349            });
1350            match schema {
1351                Some(s) => {
1352                    if let Err(e) = jsonschema::check_schema(&s) {
1353                        errs.push(format!(
1354                            "workflow {name:?}: inputs.schema: {}",
1355                            e.join("; ")
1356                        ));
1357                    }
1358                    Some(s)
1359                }
1360                None => {
1361                    errs.push(format!("workflow {name:?}: inputs must be {{schema: …}}"));
1362                    None
1363                }
1364            }
1365        }
1366    };
1367    let outputs_schema = obj.get("outputs").and_then(|v| v.get("schema").cloned());
1368    if let Some(s) = &outputs_schema
1369        && let Err(e) = jsonschema::check_schema(s)
1370    {
1371        errs.push(format!(
1372            "workflow {name:?}: outputs.schema: {}",
1373            e.join("; ")
1374        ));
1375    }
1376    let concurrency = match obj.get("concurrency") {
1377        None => Concurrency::default(),
1378        Some(v) => Concurrency {
1379            max_runs: v
1380                .get("max_runs")
1381                .and_then(Value::as_u64)
1382                .unwrap_or(4)
1383                .clamp(1, 1024) as u32,
1384            on_overflow: match v.get("on_overflow").and_then(Value::as_str) {
1385                None | Some("queue") => OnOverflow::Queue,
1386                Some("drop") => OnOverflow::Drop,
1387                Some("replace") => OnOverflow::Replace,
1388                Some(o) => {
1389                    errs.push(format!("workflow {name:?}: concurrency.on_overflow {o:?} must be queue|drop|replace"));
1390                    OnOverflow::Queue
1391                }
1392            },
1393            scope: match v.get("scope").and_then(Value::as_str) {
1394                None | Some("workflow") => ConcurrencyScope::Workflow,
1395                Some("key") => ConcurrencyScope::Key,
1396                Some(o) => {
1397                    errs.push(format!(
1398                        "workflow {name:?}: concurrency.scope {o:?} must be workflow|key"
1399                    ));
1400                    ConcurrencyScope::Workflow
1401                }
1402            },
1403        },
1404    };
1405    // `scope: key` without a `key:` template would silently collapse every run
1406    // into one bucket — the opposite of what the operator asked for, and
1407    // invisible until two entities collided in production.
1408    let key = obj
1409        .get("key")
1410        .and_then(Value::as_str)
1411        .map(str::to_string)
1412        .filter(|k| !k.trim().is_empty());
1413    let tool = match obj.get("tool") {
1414        None => None,
1415        Some(v) => {
1416            let tname = v
1417                .get("name")
1418                .and_then(Value::as_str)
1419                .unwrap_or("")
1420                .trim()
1421                .to_string();
1422            if tname.is_empty() {
1423                errs.push(format!("workflow {name:?}: tool.name is required"));
1424            } else if crate::registry::internal::contracts()
1425                .iter()
1426                .any(|c| c.name == tname)
1427            {
1428                // Shadowing an internal contract would silently reroute
1429                // `memory.get` (or `finish`) to a workflow, which is the sort
1430                // of surprise a fail-closed runtime exists to prevent.
1431                errs.push(format!(
1432                    "workflow {name:?}: tool.name {tname:?} shadows an internal contract"
1433                ));
1434            }
1435            let mode = match v.get("mode").and_then(Value::as_str) {
1436                None | Some("sync") => WorkflowToolMode::Sync,
1437                Some("async") => WorkflowToolMode::Async,
1438                Some(o) => {
1439                    errs.push(format!(
1440                        "workflow {name:?}: tool.mode {o:?} must be sync|async"
1441                    ));
1442                    WorkflowToolMode::Sync
1443                }
1444            };
1445            let g = v.get("grant");
1446            let flag = |k: &str, dflt: bool| {
1447                g.and_then(|g| g.get(k))
1448                    .and_then(Value::as_bool)
1449                    .unwrap_or(dflt)
1450            };
1451            let dflt = WorkflowToolGrant::default();
1452            Some(WorkflowTool {
1453                name: tname,
1454                mode,
1455                grant: WorkflowToolGrant {
1456                    root: flag("root", dflt.root),
1457                    workflows: flag("workflows", dflt.workflows),
1458                    subagents: flag("subagents", dflt.subagents),
1459                    user: flag("user", dflt.user),
1460                    agent: flag("agent", dflt.agent),
1461                },
1462            })
1463        }
1464    };
1465    if concurrency.scope == ConcurrencyScope::Key && key.is_none() {
1466        errs.push(format!(
1467            "workflow {name:?}: concurrency.scope: key needs a `key:` template naming what a run is about"
1468        ));
1469    }
1470    let limits = match obj.get("limits") {
1471        None => WorkflowLimits::default(),
1472        Some(v) => WorkflowLimits {
1473            steps: v.get("steps").and_then(Value::as_u64).map(|x| x as u32),
1474            tokens: v.get("tokens").and_then(Value::as_u64),
1475            deadline_ms: match v.get("deadline") {
1476                None => None,
1477                Some(d) => match duration_ms(d) {
1478                    Ok(ms) => Some(ms),
1479                    Err(e) => {
1480                        errs.push(format!("workflow {name:?}: limits.deadline: {e}"));
1481                        None
1482                    }
1483                },
1484            },
1485            budget: v.get("budget").cloned(),
1486        },
1487    };
1488    // Steps.
1489    let mut steps: BTreeMap<String, Step> = BTreeMap::new();
1490    match obj.get("steps").and_then(Value::as_object) {
1491        None => errs.push(format!(
1492            "workflow {name:?}: `steps` (an object of steps) is required"
1493        )),
1494        Some(map) => {
1495            if map.len() > MAX_STEPS {
1496                errs.push(format!(
1497                    "workflow {name:?}: {} steps exceed the cap of {MAX_STEPS}",
1498                    map.len()
1499                ));
1500            }
1501            for (id, sv) in map {
1502                if let Some(step) = parse_step(&name, id, sv, 0, &mut errs) {
1503                    steps.insert(id.clone(), step);
1504                }
1505            }
1506        }
1507    }
1508    if !errs.is_empty() {
1509        return Err(errs);
1510    }
1511    // `state` declarations: each key names a schema and/or a reducer.
1512    let mut state: BTreeMap<String, StateDecl> = BTreeMap::new();
1513    if let Some(decls) = obj.get("state") {
1514        match decls.as_object() {
1515            None => errs.push("state must be an object of {key: {schema, reducer}}".into()),
1516            Some(map) => {
1517                for (key, decl) in map {
1518                    let Some(d) = decl.as_object() else {
1519                        errs.push(format!("state {key:?}: must be an object"));
1520                        continue;
1521                    };
1522                    for f in d.keys() {
1523                        if !matches!(f.as_str(), "schema" | "reducer") {
1524                            errs.push(format!(
1525                                "state {key:?}: unknown field {f:?} (allowed: schema, reducer)"
1526                            ));
1527                        }
1528                    }
1529                    let schema = d.get("schema").cloned();
1530                    if let Some(sc) = &schema
1531                        && let Err(e) = jsonschema::check_schema(sc)
1532                    {
1533                        errs.push(format!("state {key:?}: schema: {}", e.join("; ")));
1534                    }
1535                    let reducer = d.get("reducer").and_then(Value::as_str).map(str::to_string);
1536                    if let Some(r) = &reducer
1537                        && !matches!(r.as_str(), "overwrite" | "append" | "merge" | "union")
1538                    {
1539                        errs.push(format!(
1540                            "state {key:?}: reducer {r:?} must be overwrite|append|merge|union"
1541                        ));
1542                    }
1543                    state.insert(key.clone(), StateDecl { schema, reducer });
1544                }
1545            }
1546        }
1547    }
1548    let durable = match obj.get("durable") {
1549        None => None,
1550        Some(Value::Bool(b)) => Some(*b),
1551        Some(other) => {
1552            errs.push(format!("workflow durable must be a boolean (got {other})"));
1553            None
1554        }
1555    };
1556    let mut wf = Workflow {
1557        state,
1558        name,
1559        version,
1560        priority,
1561        unload,
1562        durable,
1563        description: obj
1564            .get("description")
1565            .and_then(Value::as_str)
1566            .map(str::to_string),
1567        armed,
1568        inputs_schema,
1569        concurrency,
1570        key,
1571        tool,
1572        limits,
1573        outputs_schema,
1574        steps,
1575        hash: String::new(),
1576        definition: doc.clone(),
1577    };
1578    validate_graph(&wf, &mut errs);
1579    if !errs.is_empty() {
1580        return Err(errs);
1581    }
1582    wf.hash = crate::sha::sha256_hex(canonical(doc).as_bytes());
1583    Ok(wf)
1584}
1585
1586fn parse_step(
1587    wf: &str,
1588    id: &str,
1589    sv: &Value,
1590    depth: usize,
1591    errs: &mut Vec<String>,
1592) -> Option<Step> {
1593    let at = format!("workflow {wf:?} step {id:?}");
1594    if !valid_id(id) {
1595        errs.push(format!(
1596            "{at}: id must match [a-zA-Z_][a-zA-Z0-9_-]{{0,63}}"
1597        ));
1598    }
1599    let Some(o) = sv.as_object() else {
1600        errs.push(format!("{at}: must be an object"));
1601        return None;
1602    };
1603    let kind = match o.get("kind").and_then(Value::as_str) {
1604        Some(k) => k.to_string(),
1605        None => {
1606            errs.push(format!("{at}: `kind` is required"));
1607            return None;
1608        }
1609    };
1610    let Some(info) = kind_info(&kind) else {
1611        errs.push(format!(
1612            "{at}: unknown kind {kind:?} (run `agentd --workflow-schema` for the kind catalogue)"
1613        ));
1614        return None;
1615    };
1616    // Strict fields.
1617    let mut spec = Map::new();
1618    for (key, v) in o {
1619        // A field the KIND declares wins over the cross-cutting list, and the
1620        // order matters, because `output_schema` is both. The required check
1621        // below looks only in `spec`, so treating it as a common field would
1622        // make `extract` — which declares and requires it — impossible to
1623        // satisfy; and the presets that merely accept it (`think`, `classify`,
1624        // `judge`, `route`, `summarize`) read it from `spec` at dispatch, so
1625        // they would silently get no schema to shape the model's answer. The
1626        // cross-cutting reading, which validates a step's OUTPUT, takes its
1627        // copy from `o` directly, so both readings still see the field.
1628        if info.fields.contains(&key.as_str()) {
1629            spec.insert(key.clone(), v.clone());
1630        } else if COMMON_FIELDS.contains(&key.as_str()) {
1631            continue;
1632        } else {
1633            errs.push(format!(
1634                "{at}: unknown field {key:?} for kind {kind:?} (allowed: {})",
1635                info.fields.join(", ")
1636            ));
1637        }
1638    }
1639    for req in info.required {
1640        if !spec.contains_key(*req) {
1641            errs.push(format!("{at}: kind {kind:?} requires field {req:?}"));
1642        }
1643    }
1644    if !info.implemented {
1645        errs.push(format!(
1646            "{at}: kind {kind:?} is not available in this build; implemented kinds: {}",
1647            implemented_kinds().join(", ")
1648        ));
1649    }
1650    // Nested bodies / branches: parsed into typed sub-DAGs and validated.
1651    let mut body: Option<Body> = None;
1652    let mut branches: BTreeMap<String, Body> = BTreeMap::new();
1653    if info.nested {
1654        if depth + 1 > MAX_NESTING {
1655            errs.push(format!("{at}: nesting exceeds {MAX_NESTING}"));
1656        }
1657        if matches!(kind.as_str(), "parallel" | "race") {
1658            match spec.get("branches").and_then(Value::as_object) {
1659                Some(bm) if !bm.is_empty() => {
1660                    for (bname, bv) in bm {
1661                        if !valid_id(bname) {
1662                            errs.push(format!("{at}: branch name {bname:?} must match [a-zA-Z_][a-zA-Z0-9_-]{{0,63}}"));
1663                        }
1664                        if let Some(b) =
1665                            parse_body(&format!("{wf}/{id}/{bname}"), bv, depth + 1, errs)
1666                        {
1667                            branches.insert(bname.clone(), b);
1668                        }
1669                    }
1670                }
1671                _ => errs.push(format!(
1672                    "{at}: branches must be a non-empty object of {{steps: {{…}}}} bodies"
1673                )),
1674            }
1675        } else {
1676            match spec.get("body") {
1677                Some(bv) => body = parse_body(&format!("{wf}/{id}"), bv, depth + 1, errs),
1678                None => errs.push(format!("{at}: body is required")),
1679            }
1680        }
1681    }
1682    let depends_on: Vec<String> = match o.get("depends_on") {
1683        None => Vec::new(),
1684        Some(Value::Array(a)) => a
1685            .iter()
1686            .filter_map(Value::as_str)
1687            .map(str::to_string)
1688            .collect(),
1689        Some(Value::String(s)) => vec![s.clone()],
1690        Some(_) => {
1691            errs.push(format!("{at}: depends_on must be a list of step ids"));
1692            Vec::new()
1693        }
1694    };
1695    if info.start && !depends_on.is_empty() {
1696        errs.push(format!("{at}: a start node cannot depend on other steps"));
1697    }
1698    let when = o.get("when").and_then(Value::as_str).map(str::to_string);
1699    if let Some(w) = &when {
1700        let expr = w.trim().trim_start_matches("CEL:").trim();
1701        if let Err(e) = crate::cel::compile_check(expr) {
1702            errs.push(format!("{at}: when: {e}"));
1703        }
1704    }
1705    let retry = o.get("retry").map(|r| Retry {
1706        max: r.get("max").and_then(Value::as_u64).unwrap_or(0).min(20) as u32,
1707        backoff_ms: match r.get("backoff") {
1708            None => 0,
1709            Some(b) => duration_ms(b).unwrap_or_else(|e| {
1710                errs.push(format!("{at}: retry.backoff: {e}"));
1711                0
1712            }),
1713        },
1714    });
1715    let timeout_ms = match o.get("timeout") {
1716        None => None,
1717        Some(t) => match duration_ms(t) {
1718            Ok(ms) => Some(ms),
1719            Err(e) => {
1720                errs.push(format!("{at}: timeout: {e}"));
1721                None
1722            }
1723        },
1724    };
1725    let on_error = match o.get("on_error") {
1726        None => OnError::Fail,
1727        Some(v) => OnError::parse(v).unwrap_or_else(|e| {
1728            errs.push(format!("{at}: {e}"));
1729            OnError::Fail
1730        }),
1731    };
1732    let on_replay = match o.get("on_replay").and_then(Value::as_str) {
1733        None | Some("retry") => OnReplay::Retry,
1734        Some("skip") => OnReplay::Skip,
1735        Some("fail") => OnReplay::Fail,
1736        Some(x) => {
1737            errs.push(format!("{at}: on_replay {x:?} must be retry|skip|fail"));
1738            OnReplay::Retry
1739        }
1740    };
1741    let output_schema = o.get("output_schema").cloned();
1742    if let Some(s) = &output_schema
1743        && let Err(e) = jsonschema::check_schema(s)
1744    {
1745        errs.push(format!("{at}: output_schema: {}", e.join("; ")));
1746    }
1747    // `idempotency` shapes. Validated per kind because the transports differ:
1748    // HTTP names WHERE the key travels (a header or a query parameter), the
1749    // others only ever override its VALUE. `true` means "the default derived
1750    // key", which for `mcp.tool` is already automatic.
1751    if let Some(idem) = spec.get("idempotency") {
1752        match kind.as_str() {
1753            "http" => {
1754                let ok = idem.as_object().is_some_and(|o| {
1755                    let hdr = o.get("header").map(|v| v.is_string());
1756                    let qry = o.get("query").map(|v| v.is_string());
1757                    let val = o.get("value").is_none_or(|v| v.is_string());
1758                    let known = o
1759                        .keys()
1760                        .all(|k| matches!(k.as_str(), "header" | "query" | "value"));
1761                    known && val && matches!((hdr, qry), (Some(true), None) | (None, Some(true)))
1762                });
1763                if !ok {
1764                    errs.push(format!(
1765                        "{at}: http idempotency takes {{header: NAME}} or {{query: NAME}} \
1766                         (exactly one), with an optional string value"
1767                    ));
1768                }
1769            }
1770            "mcp.tool" | "a2a.send" | "a2a.delegate" => {
1771                let ok = idem.is_boolean()
1772                    || idem.as_object().is_some_and(|o| {
1773                        o.keys().all(|k| k == "value")
1774                            && o.get("value").is_none_or(|v| v.is_string())
1775                    });
1776                if !ok {
1777                    errs.push(format!(
1778                        "{at}: idempotency takes true or {{value: \"…\"}} on this kind"
1779                    ));
1780                }
1781            }
1782            _ => {}
1783        }
1784    }
1785    // `breaker` — retry's cross-run sibling on the same remote-effect kinds.
1786    // Both fields are REQUIRED: a breaker with no threshold or no cooldown is
1787    // not a default anyone chose, it is a typo.
1788    if let Some(b) = spec.get("breaker") {
1789        if !matches!(
1790            kind.as_str(),
1791            "http" | "mcp.tool" | "a2a.send" | "a2a.delegate"
1792        ) {
1793            errs.push(format!(
1794                "{at}: breaker applies to remote-effect kinds (http, mcp.tool, a2a.send, a2a.delegate)"
1795            ));
1796        } else {
1797            let ok = b.as_object().is_some_and(|o| {
1798                o.keys()
1799                    .all(|k| matches!(k.as_str(), "failures" | "cooldown"))
1800                    && o.get("failures")
1801                        .and_then(Value::as_u64)
1802                        .is_some_and(|n| n >= 1)
1803                    && o.get("cooldown")
1804                        .and_then(Value::as_str)
1805                        .is_some_and(|d| crate::config::parse_duration(d).is_ok())
1806            });
1807            if !ok {
1808                errs.push(format!(
1809                    "{at}: breaker takes {{failures: N>=1, cooldown: \"60s\"}}"
1810                ));
1811            }
1812        }
1813    }
1814    // `rate` — outbound throttling on the same family: the step WAITS for a
1815    // token rather than failing, so a fan-out cannot overrun a quota. Same
1816    // spelling as every other rate in the config.
1817    if let Some(r) = spec.get("rate")
1818        && matches!(
1819            kind.as_str(),
1820            "http" | "mcp.tool" | "a2a.send" | "a2a.delegate"
1821        )
1822    {
1823        let ok = r
1824            .as_str()
1825            .is_some_and(|r| crate::supervisor::tree::parse_rate(r).is_ok());
1826        if !ok {
1827            errs.push(format!(
1828                "{at}: rate must be \"<burst>/<per>s\" (e.g. \"10/1s\")"
1829            ));
1830        }
1831    }
1832    // Kind-specific sanity.
1833    match kind.as_str() {
1834        // `rate: "<burst>/<per>s"` — arrival throttling, the same spelling as
1835        // `a2a.principals[].quotas.rate`. Checked here so a typo surfaces with
1836        // the other definition errors, not as a startup refusal.
1837        "webhook" => {
1838            if let Some(r) = spec.get("rate") {
1839                let ok = r.as_str().is_some_and(|r| {
1840                    r.split_once('/').is_some_and(|(b, p)| {
1841                        let per = p.trim();
1842                        let per = per
1843                            .strip_suffix('s')
1844                            .or_else(|| per.strip_suffix("sec"))
1845                            .unwrap_or(per);
1846                        b.trim().parse::<u32>().is_ok_and(|b| b > 0)
1847                            && per.trim().parse::<f64>().is_ok_and(|s| s > 0.0)
1848                    })
1849                });
1850                if !ok {
1851                    errs.push(format!(
1852                        "{at}: rate must be \"<burst>/<per>s\" (e.g. \"20/1s\")"
1853                    ));
1854                }
1855            }
1856        }
1857        // The typed A2A form: `command` carries the op, `args` its payload.
1858        "a2a.delegate" => {
1859            if spec.get("objective").is_none() && spec.get("command").is_none() {
1860                errs.push(format!(
1861                    "{at}: needs `objective` (prose) or `command` (typed)"
1862                ));
1863            }
1864            if spec.get("args").is_some() && spec.get("command").is_none() {
1865                errs.push(format!("{at}: `args` needs `command`"));
1866            }
1867        }
1868        "a2a.send" => {
1869            if spec.get("args").is_some() && spec.get("command").is_none() {
1870                errs.push(format!("{at}: `args` needs `command`"));
1871            }
1872        }
1873        // A subagent step needs exactly one definition — freeform prose or a
1874        // declared template, never both, never neither. Both would leave the
1875        // child's grant ambiguous; neither leaves nothing to run.
1876        "subagent" => {
1877            match (spec.get("instruction").is_some(), spec.get("template").is_some()) {
1878                (false, false) => errs.push(format!(
1879                    "{at}: needs `instruction` (freeform) or `template` (a subagents.templates entry)"
1880                )),
1881                (true, true) => errs.push(format!(
1882                    "{at}: `instruction` and `template` are mutually exclusive"
1883                )),
1884                _ => {}
1885            }
1886            if spec.get("params").is_some() && spec.get("template").is_none() {
1887                errs.push(format!("{at}: `params` needs `template`"));
1888            }
1889            for k in ["tools", "servers"] {
1890                if spec.get(k).is_some() && spec.get("template").is_some() {
1891                    errs.push(format!(
1892                        "{at}: `{k}` may not be combined with `template` — the template defines the grant"
1893                    ));
1894                }
1895            }
1896        }
1897        // `emit` publishes to a stream when `stream:` is present. The two
1898        // addressing fields travel together or not at all: a stream without a
1899        // subject has nowhere to land, and a subject without a stream names a
1900        // destination that does not exist.
1901        "emit" => {
1902            if spec.get("stream").is_some() != spec.get("subject").is_some() {
1903                errs.push(format!(
1904                    "{at}: a stream emit needs both `stream` and `subject`"
1905                ));
1906            }
1907        }
1908        // `stream` consumer: `from` picks the initial offset once, at arm.
1909        "stream" => {
1910            if let Some(f) = spec.get("from")
1911                && !matches!(f.as_str(), Some("new") | Some("earliest"))
1912            {
1913                errs.push(format!("{at}: from must be \"new\" or \"earliest\""));
1914            }
1915        }
1916        // `window: {samples: N}` — deliver the last N read values as an array
1917        // (the trend, not just the latest reading — the hardware-stream shape).
1918        // N is capped because the ring rides the durable start-state: every
1919        // sample is checkpointed, so an unbounded window would convert a fast
1920        // sensor into disk pressure. Past 256, aggregate at the source.
1921        "subscribe" => {
1922            if let Some(w) = spec.get("window") {
1923                let ok = w.as_object().is_some_and(|o| {
1924                    o.keys().all(|k| k == "samples")
1925                        && o.get("samples")
1926                            .and_then(Value::as_u64)
1927                            .is_some_and(|n| (1..=256).contains(&n))
1928                });
1929                if !ok {
1930                    errs.push(format!("{at}: window takes {{samples: 1..=256}}"));
1931                }
1932            }
1933        }
1934        // A `switch` routes to ONE step id per case, as a string. A list reads
1935        // naturally — `cases: {select: [prepare]}` — and is exactly wrong: the
1936        // executor asks for a string, gets an array, finds no target, falls to
1937        // `default`, finds an array there too, and fails the run at the moment
1938        // the branch is taken. That is a silent trap for whoever writes the
1939        // config and a confusing one for whoever debugs it, so it is refused
1940        // here, where the message can say what to write instead.
1941        "switch" => {
1942            if let Some(cases) = spec.get("cases").and_then(Value::as_object) {
1943                for (case, target) in cases {
1944                    if !target.is_string() {
1945                        errs.push(format!(
1946                            "{at}: switch case {case:?} must name ONE step as a string \
1947                             (got {}); write `{case}: some_step`, not a list",
1948                            json_kind(target)
1949                        ));
1950                    }
1951                }
1952            }
1953            if let Some(m) = spec.get("on_no_match")
1954                && !matches!(m.as_str(), Some("skip") | Some("fail"))
1955            {
1956                errs.push(format!("{at}: on_no_match must be \"skip\" or \"fail\""));
1957            }
1958            if let Some(d) = spec.get("default")
1959                && !d.is_string()
1960            {
1961                errs.push(format!(
1962                    "{at}: switch default must name ONE step as a string (got {}); \
1963                     write `default: some_step`, not a list",
1964                    json_kind(d)
1965                ));
1966            }
1967        }
1968        // `collect.mode` and `assign.mode` reach `write_var`, which falls through
1969        // to overwrite on anything it does not recognise — so `mode: appned`
1970        // silently overwrote instead of appending. The set is closed; check it
1971        // where the typo is still a config error.
1972        "foreach" | "batch" | "iterate" | "parallel" | "race" | "subgraph"
1973            if spec.contains_key("collect") =>
1974        {
1975            if let Some(m) = spec
1976                .get("collect")
1977                .and_then(|c| c.get("mode"))
1978                .and_then(Value::as_str)
1979                && !matches!(m, "overwrite" | "append" | "merge" | "union")
1980            {
1981                errs.push(format!(
1982                    "{at}: collect.mode {m:?} must be overwrite|append|merge|union"
1983                ));
1984            }
1985        }
1986        // `human.to` names WHO must answer, and is enforced when the reply
1987        // lands. `reply_uri` is still refused: routing a reply to a URI is a
1988        // different thing entirely and nothing implements it, and a field that
1989        // silently does nothing is worse than one that does not exist.
1990        "human" => {
1991            if spec.contains_key("reply_uri") {
1992                errs.push(format!(
1993                    "{at}: human.reply_uri is not implemented — a gate is answered over A2A; \
1994                     use `to` to name who must answer (see docs/node-registry.md)"
1995                ));
1996            }
1997            // A malformed addressee is a load error rather than a gate that
1998            // looks routed and is not.
1999            if let Some(v) = spec.get("to")
2000                && let Err(e) = crate::a2a::principals::Addressee::parse(v)
2001            {
2002                errs.push(format!("{at}: human.to: {e}"));
2003            }
2004        }
2005        "finish" => {
2006            if let Some(st) = spec.get("status").and_then(Value::as_str)
2007                && !matches!(st, "completed" | "failed" | "refused" | "cancelled")
2008            {
2009                errs.push(format!(
2010                    "{at}: finish.status must be completed|failed|refused|cancelled"
2011                ));
2012            }
2013        }
2014        "sleep" => {
2015            if let Some(d) = spec.get("duration")
2016                && let Err(e) = duration_ms(d)
2017            {
2018                errs.push(format!("{at}: sleep.duration: {e}"));
2019            }
2020        }
2021        "assert" => {
2022            if let Some(c) = spec.get("condition").and_then(Value::as_str)
2023                && let Err(e) =
2024                    crate::cel::compile_check(c.trim().trim_start_matches("CEL:").trim())
2025            {
2026                errs.push(format!("{at}: assert.condition: {e}"));
2027            }
2028        }
2029        "think" | "agent" => {
2030            if let Some(s) = spec.get("output_schema")
2031                && let Err(e) = jsonschema::check_schema(s)
2032            {
2033                errs.push(format!("{at}: output_schema: {}", e.join("; ")));
2034            }
2035        }
2036        "validate" => {
2037            if let Some(s) = spec.get("schema")
2038                && let Err(e) = jsonschema::check_schema(s)
2039            {
2040                errs.push(format!("{at}: schema: {}", e.join("; ")));
2041            }
2042        }
2043        "assign" | "transform" => {
2044            if let Some(m) = spec.get("mode").and_then(Value::as_str)
2045                && !matches!(m, "overwrite" | "append" | "merge" | "union")
2046            {
2047                errs.push(format!("{at}: mode must be overwrite|append|merge|union"));
2048            }
2049        }
2050        _ => {}
2051    }
2052    // Any `CEL:` valued field compiles.
2053    for (key, v) in &spec {
2054        if let Some(s) = v.as_str()
2055            && let Some(expr) = s.trim().strip_prefix("CEL:")
2056            && let Err(e) = crate::cel::compile_check(expr.trim())
2057        {
2058            errs.push(format!("{at}: {key}: {e}"));
2059        }
2060    }
2061    Some(Step {
2062        id: id.to_string(),
2063        kind,
2064        depends_on,
2065        when,
2066        retry,
2067        timeout_ms,
2068        on_error,
2069        idempotent: o
2070            .get("idempotent")
2071            .and_then(Value::as_bool)
2072            .unwrap_or(false),
2073        on_replay,
2074        output_schema,
2075        cache: o.get("cache").cloned(),
2076        budget: o.get("budget").and_then(Value::as_u64),
2077        skills: o
2078            .get("skills")
2079            .and_then(Value::as_array)
2080            .map(|a| {
2081                a.iter()
2082                    .filter_map(Value::as_str)
2083                    .map(str::to_string)
2084                    .collect()
2085            })
2086            .unwrap_or_default(),
2087        description: o
2088            .get("description")
2089            .and_then(Value::as_str)
2090            .map(str::to_string),
2091        spec,
2092        body,
2093        branches,
2094    })
2095}
2096
2097/// Parse + validate a nested body `{steps: {…}}`.
2098fn parse_body(at: &str, bv: &Value, depth: usize, errs: &mut Vec<String>) -> Option<Body> {
2099    let Some(bs) = bv.get("steps").and_then(Value::as_object) else {
2100        errs.push(format!("{at}: body must be {{steps: {{…}}}}"));
2101        return None;
2102    };
2103    if bs.is_empty() {
2104        errs.push(format!("{at}: body has no steps"));
2105        return None;
2106    }
2107    let mut steps = BTreeMap::new();
2108    for (bid, sv) in bs {
2109        if let Some(step) = parse_step(at, bid, sv, depth, errs) {
2110            if step.is_start() {
2111                errs.push(format!(
2112                    "{at} step {bid:?}: a start node cannot be inside a body"
2113                ));
2114            }
2115            if step.kind == "finish" {
2116                errs.push(format!("{at} step {bid:?}: `finish` cannot be inside a body (a body's sinks are its result)"));
2117            }
2118            steps.insert(bid.clone(), step);
2119        }
2120    }
2121    let body = Body { steps };
2122    for s in body.steps.values() {
2123        for d in &s.depends_on {
2124            if !body.steps.contains_key(d) {
2125                errs.push(format!(
2126                    "{at} step {:?}: depends_on names {d:?}, which is not a sibling in the body",
2127                    s.id
2128                ));
2129            }
2130        }
2131        if let OnError::Goto(t) = &s.on_error
2132            && !body.steps.contains_key(t)
2133        {
2134            errs.push(format!(
2135                "{at} step {:?}: on_error goto {t:?} is not a sibling in the body",
2136                s.id
2137            ));
2138        }
2139    }
2140    if body.topo_order().len() != body.steps.len() {
2141        errs.push(format!("{at}: cycle inside the body"));
2142    }
2143    Some(body)
2144}
2145
2146/// Graph-level validation of declared state: a check that needs the whole DAG,
2147/// because it is about steps that can run *concurrently*.
2148///
2149/// Two steps that can run in the same wave, both writing one var with modes
2150/// that disagree, is a silent last-write-wins race: which value survives
2151/// depends on completion order, which is not a thing the author controls.
2152///
2153/// `append`/`merge` are reducers — several writers combining is the point.
2154/// `overwrite` is not: two overwriters, or an overwriter racing a reducer, is
2155/// the shape with no defensible answer, so it is refused where it is still a
2156/// config error rather than an intermittent wrong number.
2157fn validate_declared_state(wf: &Workflow, errs: &mut Vec<String>) {
2158    for s in wf.steps.values() {
2159        if !matches!(s.kind.as_str(), "assign" | "transform") {
2160            continue;
2161        }
2162        let key = s
2163            .spec
2164            .get("writes")
2165            .and_then(Value::as_str)
2166            .unwrap_or(s.id.as_str());
2167        let Some(decl) = wf.state.get(key) else {
2168            continue;
2169        };
2170        // A declared reducer is the policy for that key; a step that writes it
2171        // with a different mode is contradicting the declaration, which is the
2172        // kind of disagreement that should not survive to runtime.
2173        if let Some(want) = &decl.reducer {
2174            let mode = s
2175                .spec
2176                .get("mode")
2177                .and_then(Value::as_str)
2178                .unwrap_or("overwrite");
2179            if mode != want {
2180                errs.push(format!(
2181                    "workflow {:?} step {:?}: writes {key:?} with mode {mode:?}, but state \
2182                     declares reducer {want:?}",
2183                    wf.name, s.id
2184                ));
2185            }
2186        }
2187    }
2188}
2189
2190fn validate_concurrent_writes(wf: &Workflow, errs: &mut Vec<String>) {
2191    use std::collections::BTreeMap;
2192    let mut writers: BTreeMap<&str, Vec<(&str, &str)>> = BTreeMap::new();
2193    for s in wf.steps.values() {
2194        if !matches!(s.kind.as_str(), "assign" | "transform") {
2195            continue;
2196        }
2197        let key = s
2198            .spec
2199            .get("writes")
2200            .and_then(Value::as_str)
2201            .unwrap_or(s.id.as_str());
2202        let mode = s
2203            .spec
2204            .get("mode")
2205            .and_then(Value::as_str)
2206            .unwrap_or("overwrite");
2207        writers.entry(key).or_default().push((s.id.as_str(), mode));
2208    }
2209    for (key, ws) in writers {
2210        if ws.len() < 2 {
2211            continue;
2212        }
2213        // Ordered pairs cannot race; only steps with no path between them can.
2214        for (i, (a, ma)) in ws.iter().enumerate() {
2215            for (b, mb) in ws.iter().skip(i + 1) {
2216                if reachable(wf, a, b) || reachable(wf, b, a) {
2217                    continue;
2218                }
2219                // Nor can two arms of the same switch: exactly one is taken, so
2220                // they are mutually EXCLUSIVE rather than concurrent. Ordering
2221                // is expressed by the routing edge here, not by `depends_on`,
2222                // which is why the reachability walk above cannot see it.
2223                if exclusive_by_switch(wf, a, b) {
2224                    continue;
2225                }
2226                // A declared reducer settles it: the workflow has stated how
2227                // writes to this key combine, which is exactly the policy the
2228                // heuristic below is guessing at.
2229                if wf
2230                    .state
2231                    .get(key)
2232                    .and_then(|d| d.reducer.as_deref())
2233                    .is_some()
2234                {
2235                    continue;
2236                }
2237                // append/merge/union are reducers — several writers combining
2238                // is the point. Only an overwriter has no defensible answer.
2239                if *ma == "overwrite" || *mb == "overwrite" {
2240                    errs.push(format!(
2241                        "workflow {:?}: steps {a:?} and {b:?} can run concurrently and both \
2242                         write {key:?} (modes {ma}/{mb}) — the surviving value would depend on \
2243                         completion order; order them with depends_on, or use append/merge",
2244                        wf.name
2245                    ));
2246                }
2247            }
2248        }
2249    }
2250}
2251
2252/// Whether two steps are arms of one `switch` — at most one of them ever runs.
2253fn exclusive_by_switch(wf: &Workflow, a: &str, b: &str) -> bool {
2254    for s in wf.steps.values() {
2255        if s.kind != "switch" {
2256            continue;
2257        }
2258        let mut arms: Vec<&str> = s
2259            .spec
2260            .get("cases")
2261            .and_then(Value::as_object)
2262            .map(|c| c.values().filter_map(Value::as_str).collect())
2263            .unwrap_or_default();
2264        if let Some(d) = s.spec.get("default").and_then(Value::as_str) {
2265            arms.push(d);
2266        }
2267        // Either arm may be the step itself or an ancestor of it: a whole
2268        // branch hangs below one target.
2269        let on_arm = |x: &str| arms.iter().any(|arm| *arm == x || reachable(wf, arm, x));
2270        if on_arm(a) && on_arm(b) {
2271            return true;
2272        }
2273    }
2274    false
2275}
2276
2277/// Whether `to` is reachable from `from` along `depends_on` edges.
2278fn reachable(wf: &Workflow, from: &str, to: &str) -> bool {
2279    let mut seen = std::collections::BTreeSet::new();
2280    let mut stack = vec![to];
2281    // Walk UP from `to`: it is reachable from `from` if `from` is an ancestor.
2282    while let Some(cur) = stack.pop() {
2283        if cur == from {
2284            return true;
2285        }
2286        if !seen.insert(cur.to_string()) {
2287            continue;
2288        }
2289        if let Some(s) = wf.steps.get(cur) {
2290            for d in &s.depends_on {
2291                stack.push(d.as_str());
2292            }
2293        }
2294    }
2295    false
2296}
2297
2298/// A `human` gate inside a body that can run several copies at once.
2299///
2300/// Only ONE gate can be live per run today: the second suspended `human` has no
2301/// task of its own to be answered through, so it waits for a reply that can
2302/// never be addressed to it. Inside `foreach`/`parallel`/`batch`/`race` that is
2303/// not a rare shape, it is the normal one — a gate per item. Refused at load
2304/// until each gate carries its own identity, because failing at validation is
2305/// much kinder than hanging at item two.
2306fn validate_human_in_concurrent_bodies(wf: &Workflow, errs: &mut Vec<String>) {
2307    fn walk(wf_name: &str, owner: &str, body: &Body, errs: &mut Vec<String>) {
2308        for s in body.steps.values() {
2309            if s.kind == "human" {
2310                errs.push(format!(
2311                    "workflow {wf_name:?} step {:?}: a `human` gate inside {owner:?} is not \
2312                     supported — only one gate can be live per run, so a second item would \
2313                     wait forever. Gate before or after the fan-out instead.",
2314                    s.id
2315                ));
2316            }
2317            for nested in s.body.iter().chain(s.branches.values()) {
2318                walk(wf_name, owner, nested, errs);
2319            }
2320        }
2321    }
2322    for s in wf.steps.values() {
2323        if !matches!(s.kind.as_str(), "foreach" | "batch" | "parallel" | "race") {
2324            continue;
2325        }
2326        for body in s.body.iter().chain(s.branches.values()) {
2327            walk(&wf.name, &s.id, body, errs);
2328        }
2329    }
2330}
2331
2332fn validate_graph(wf: &Workflow, errs: &mut Vec<String>) {
2333    validate_human_in_concurrent_bodies(wf, errs);
2334    validate_declared_state(wf, errs);
2335    validate_concurrent_writes(wf, errs);
2336    let name = &wf.name;
2337    let starts: Vec<&Step> = wf.start_steps();
2338    if starts.is_empty() {
2339        errs.push(format!("workflow {name:?}: at least one start node is required (once|manual|loop|schedule|subscribe|signal|event|a2a)"));
2340    }
2341    // Dependencies + goto targets exist.
2342    for s in wf.steps.values() {
2343        for d in &s.depends_on {
2344            if !wf.steps.contains_key(d) {
2345                errs.push(format!(
2346                    "workflow {name:?} step {:?}: depends_on names unknown step {d:?}",
2347                    s.id
2348                ));
2349            }
2350            if d == &s.id {
2351                errs.push(format!(
2352                    "workflow {name:?} step {:?}: depends on itself",
2353                    s.id
2354                ));
2355            }
2356        }
2357        if let OnError::Goto(t) = &s.on_error
2358            && !wf.steps.contains_key(t)
2359        {
2360            errs.push(format!(
2361                "workflow {name:?} step {:?}: on_error goto names unknown step {t:?}",
2362                s.id
2363            ));
2364        }
2365        if let Some(t) = s.field_str("on_timeout")
2366            && !wf.steps.contains_key(t)
2367        {
2368            errs.push(format!(
2369                "workflow {name:?} step {:?}: on_timeout names unknown step {t:?}",
2370                s.id
2371            ));
2372        }
2373    }
2374    // An `on_timeout` target is reached by ROUTING, not by a dependency —
2375    // it must not depend on the wait (a satisfied wait would then fire it
2376    // too), so it is exempt from the unreachable-root rule and seeds
2377    // reachability off the step that routes to it.
2378    let timeout_targets: BTreeSet<String> = wf
2379        .steps
2380        .values()
2381        .filter_map(|s| s.field_str("on_timeout").map(str::to_string))
2382        .collect();
2383    // A non-start step with no dependencies is an unreachable root.
2384    for s in wf.steps.values() {
2385        if !s.is_start() && s.depends_on.is_empty() && !timeout_targets.contains(&s.id) {
2386            errs.push(format!("workflow {name:?} step {:?}: a non-start step must depend on something (unreachable root)", s.id));
2387        }
2388    }
2389    // Acyclic (Kahn) + reachability from a start node.
2390    let order = wf.topo_order();
2391    if order.len() != wf.steps.len() {
2392        let stuck: Vec<&String> = wf.steps.keys().filter(|k| !order.contains(k)).collect();
2393        errs.push(format!("workflow {name:?}: cycle among steps {stuck:?}"));
2394    }
2395    let mut reachable: BTreeSet<String> = starts.iter().map(|s| s.id.clone()).collect();
2396    let mut changed = true;
2397    while changed {
2398        changed = false;
2399        for s in wf.steps.values() {
2400            if !reachable.contains(&s.id)
2401                && !s.depends_on.is_empty()
2402                && s.depends_on.iter().any(|d| reachable.contains(d))
2403            {
2404                reachable.insert(s.id.clone());
2405                changed = true;
2406            }
2407            // Routing edges reach too.
2408            if reachable.contains(&s.id)
2409                && let Some(t) = s.field_str("on_timeout")
2410                && !reachable.contains(t)
2411            {
2412                reachable.insert(t.to_string());
2413                changed = true;
2414            }
2415        }
2416    }
2417    for s in wf.steps.values() {
2418        if !reachable.contains(&s.id) {
2419            errs.push(format!(
2420                "workflow {name:?} step {:?}: not reachable from any start node",
2421                s.id
2422            ));
2423        }
2424    }
2425    if !wf.steps.values().any(|s| s.kind == "finish") {
2426        errs.push(format!("workflow {name:?}: a `finish` step is required"));
2427    }
2428}
2429
2430/// `[a-zA-Z_][a-zA-Z0-9_-]{0,63}`.
2431pub fn valid_id(s: &str) -> bool {
2432    let mut chars = s.chars();
2433    match chars.next() {
2434        Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
2435        _ => return false,
2436    }
2437    s.len() <= MAX_ID_LEN && chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
2438}
2439
2440/// Fields never rendered as templates before execution (expressions the step
2441/// evaluates itself, and nested definitions).
2442pub const RAW_FIELDS: &[(&str, &str)] = &[
2443    ("assert", "condition"),
2444    ("validate", "schema"),
2445    ("map", "expr"),
2446    ("filter", "expr"),
2447    ("reduce", "expr"),
2448    ("iterate", "while"),
2449    ("iterate", "until"),
2450    ("iterate", "body"),
2451    ("foreach", "body"),
2452    ("batch", "body"),
2453    ("subgraph", "body"),
2454    ("parallel", "branches"),
2455    ("race", "branches"),
2456    ("subscribe", "filter"),
2457    ("signal", "filter"),
2458    ("event", "filter"),
2459    ("wait", "condition"),
2460    // Held raw for the same reason as `condition`: it is evaluated later,
2461    // against each candidate event, so rendering it at dispatch would resolve
2462    // `event` before any event exists.
2463    ("wait", "match"),
2464    ("think", "check"),
2465    ("switch", "cases"),
2466    ("await", "condition"),
2467];
2468
2469pub fn is_raw_field(kind: &str, field: &str) -> bool {
2470    RAW_FIELDS.iter().any(|(k, f)| *k == kind && *f == field)
2471}
2472
2473/// `Some(ms)` for a duration field, `None` when absent/invalid.
2474pub fn duration_ms_opt(v: &Value) -> Option<u64> {
2475    duration_ms(v).ok()
2476}
2477
2478/// A duration field: `"30s"`, `"5m"`, bare seconds, or ms as `{"ms": n}`.
2479pub fn duration_ms(v: &Value) -> Result<u64, String> {
2480    match v {
2481        Value::Number(n) => n
2482            .as_u64()
2483            .map(|s| s * 1000)
2484            .ok_or_else(|| "duration must be a non-negative number of seconds".into()),
2485        Value::String(s) => crate::config::parse_duration(s).map(|d| d.as_millis() as u64),
2486        Value::Object(o) => o
2487            .get("ms")
2488            .and_then(Value::as_u64)
2489            .ok_or_else(|| "duration object must be {ms: n}".into()),
2490        _ => Err("duration must be a string like 30s or a number of seconds".into()),
2491    }
2492}
2493
2494/// Canonical JSON (sorted keys — serde_json's Map is a BTreeMap here) for hashing.
2495pub fn canonical(v: &Value) -> String {
2496    v.to_string()
2497}
2498
2499/// The workflow JSON Schema, as `--workflow-schema` prints it. Generated from
2500/// [`KINDS`] rather than written by hand, so the schema and the validator can
2501/// never disagree about which fields a kind accepts.
2502pub fn workflow_schema() -> Value {
2503    let kinds: Vec<&str> = KINDS.iter().map(|k| k.name).collect();
2504    json!({
2505        "$schema": "https://json-schema.org/draft/2020-12/schema",
2506        // Served at this URL; `schema/` (singular) matches the config schema's
2507        // path, which the previous `schemas/` did not.
2508        "$id": "https://agentd.dev/schema/workflow-3.json",
2509        "title": "agentd workflow",
2510        "type": "object",
2511        "required": ["name", "steps"],
2512        "properties": {
2513            "name": {"type": "string", "pattern": "^[a-zA-Z_][a-zA-Z0-9_-]{0,63}$"},
2514            "version": {"const": 3},
2515            "description": {"type": "string"},
2516            "armed": {"type": "boolean", "default": true},
2517            "durable": {"type": "boolean", "description": "false = runs are memory-only (no checkpoints, gone after a restart) — the fast path for recomputable work; absent = the store.durability.work default (durable)"},
2518            "priority": {"enum": ["low", "normal", "high"], "description": "contention weight: `low` sheds one pressure level earlier and is scheduled last; a tiebreak under scarcity, not a reservation"},
2519            "unload": {"type": "object", "additionalProperties": false, "description": "what happens to LIVE runs when this definition is retired (removed, replaced or deleted)", "properties": {
2520                "policy": {"enum": ["drain", "cancel", "detach"], "description": "drain (default) lets them finish"},
2521                "timeout": {"type": "string", "description": "how long a drain may take"}}},
2522            "file": {"type": "string", "description": "load the document from a path on disk instead of inline"},
2523            "uri": {"type": "string", "description": "load the document from an MCP resource instead of inline"},
2524            "inputs": {"type": "object", "properties": {"schema": {"type": "object"}}},
2525            "outputs": {"type": "object", "properties": {"schema": {"type": "object"}}},
2526            "state": {"type": "object", "additionalProperties": {"type": "object",
2527                "additionalProperties": false,
2528                "properties": {
2529                    "schema": {"type": "object", "description": "a JSON Schema every write to this key must satisfy"},
2530                    "reducer": {"enum": ["overwrite", "append", "merge", "union"],
2531                                "description": "how concurrent writes to this key combine; declaring it makes concurrency a policy rather than a race"}}},
2532                "description": "declared run variables — {key: {schema, reducer}}"},
2533            "concurrency": {"type": "object", "properties": {"max_runs": {"type": "integer", "minimum": 1}, "on_overflow": {"enum": ["queue", "drop", "replace"]}, "scope": {"enum": ["workflow", "key"], "description": "what max_runs counts: every run of this workflow (default), or every run about the same `key` — the difference between a queue and a per-entity lock"}}},
2534            "key": {"type": "string", "description": "the logical thing a run is ABOUT, rendered from the trigger payload (e.g. \"{{payload.account_id}}\"); required by concurrency.scope: key"},
2535            "tool": {"type": "object", "required": ["name"], "additionalProperties": false, "description": "register this workflow as a first-class tool — a callable procedure with retry, breaker, idempotency and a human gate INSIDE one apparent call. Startup config only; tags are DERIVED from what the steps reach.", "properties": {
2536                "name": {"type": "string", "description": "the tool name callers see; may not shadow an internal contract"},
2537                "mode": {"enum": ["sync", "async"], "description": "sync parks the caller on the run and returns its output; async returns a handle"},
2538                "grant": {"type": "object", "additionalProperties": false, "properties": {
2539                    "root": {"type": "boolean"}, "workflows": {"type": "boolean"}, "subagents": {"type": "boolean"},
2540                    "user": {"type": "boolean"}, "agent": {"type": "boolean"}}}}},
2541            "limits": {"type": "object", "properties": {"steps": {"type": "integer"}, "tokens": {"type": "integer"}, "deadline": {"type": "string"}, "budget": {"type": "object"}}},
2542            "steps": {"type": "object", "additionalProperties": {"$ref": "#/$defs/step"}, "minProperties": 1}
2543        },
2544        "$defs": {
2545            "step": {
2546                "type": "object",
2547                "required": ["kind"],
2548                "properties": {
2549                    "kind": {"enum": kinds},
2550                    "depends_on": {"type": "array", "items": {"type": "string"}},
2551                    "when": {"type": "string"},
2552                    "retry": {"type": "object", "properties": {"max": {"type": "integer"}, "backoff": {"type": "string"}}},
2553                    "timeout": {"type": "string"},
2554                    "on_error": {"type": "string"},
2555                    "idempotent": {"type": "boolean"},
2556                    "on_replay": {"enum": ["retry", "skip", "fail"]},
2557                    "output_schema": {"type": "object"},
2558                    "cache": {"type": "object"},
2559                    "budget": {"type": "integer"},
2560                    "skills": {"type": "array", "items": {"type": "string"}},
2561                    "otel": {"type": "object"},
2562                    "description": {"type": "string"}
2563                }
2564            },
2565            "kinds": KINDS.iter().map(|k| (k.name.to_string(), json!({"start": k.start, "fields": k.fields, "required": k.required, "implemented": k.implemented}))).collect::<BTreeMap<_, _>>()
2566        }
2567    })
2568}
2569
2570#[cfg(test)]
2571mod tests {
2572    /// `output_schema` is both a cross-cutting step field and a field several
2573    /// kinds declare for themselves. The kind's reading must win. `extract`
2574    /// REQUIRES it and the required-field check reads `spec`, so if the
2575    /// common-field skip took precedence `extract` could never validate — a
2576    /// documented, "implemented" node impossible to use. The presets that
2577    /// merely accept it read it from `spec` at dispatch, so they would be
2578    /// handed no schema at all and would fail silently rather than loudly.
2579    #[test]
2580    fn a_kind_that_declares_output_schema_receives_it() {
2581        let doc = serde_json::json!({
2582            "name": "w",
2583            "steps": {
2584                "go": {"kind": "manual"},
2585                "e":  {"kind": "extract", "depends_on": ["go"], "input": "x",
2586                       "output_schema": {"type": "object"}},
2587                "t":  {"kind": "think", "depends_on": ["e"], "prompt": "p",
2588                       "output_schema": {"type": "object"}},
2589                "fin": {"kind": "finish", "depends_on": ["t"], "status": "completed"}
2590            }
2591        });
2592        let wf = parse_workflow(&doc)
2593            .unwrap_or_else(|e| panic!("extract must validate with an output_schema: {e:?}"));
2594        // And the kind actually RECEIVES it, which is what the executor reads.
2595        for id in ["e", "t"] {
2596            let step = wf.steps.get(id).unwrap_or_else(|| panic!("step {id}"));
2597            assert!(
2598                step.field("output_schema").is_some(),
2599                "{id}: the kind's own output_schema must reach the node spec"
2600            );
2601        }
2602    }
2603
2604    use super::*;
2605
2606    fn wf(doc: Value) -> Result<Workflow, Vec<String>> {
2607        parse_workflow(&doc)
2608    }
2609
2610    /// The schema and the parser must accept the SAME workflow fields.
2611    ///
2612    /// They had drifted: `priority`, `unload`, `file` and `uri` were accepted
2613    /// by the parser and absent from the schema. That was invisible while the
2614    /// schema was advisory, and becomes a red squiggle in an editor the moment
2615    /// the config schema folds this one in with `additionalProperties: false`
2616    /// — a valid document reported as invalid is worse than no schema at all.
2617    #[test]
2618    fn the_workflow_schema_accepts_exactly_what_the_parser_does() {
2619        let schema = workflow_schema();
2620        let declared: std::collections::BTreeSet<&str> = schema["properties"]
2621            .as_object()
2622            .expect("properties")
2623            .keys()
2624            .map(String::as_str)
2625            .collect();
2626        let parsed: std::collections::BTreeSet<&str> = TOP.iter().copied().collect();
2627        assert_eq!(
2628            parsed.difference(&declared).collect::<Vec<_>>(),
2629            Vec::<&&str>::new(),
2630            "the parser accepts fields the schema does not declare — an editor would flag valid documents"
2631        );
2632        assert_eq!(
2633            declared.difference(&parsed).collect::<Vec<_>>(),
2634            Vec::<&&str>::new(),
2635            "the schema declares fields the parser refuses — completion would suggest fields that fail at load"
2636        );
2637    }
2638
2639    /// `human.to` names who must answer and is enforced when the reply lands,
2640    /// so a malformed one is a LOAD error: a gate that looks routed and is not
2641    /// is exactly the failure the field exists to prevent. `reply_uri` stays
2642    /// refused — nothing implements it.
2643    #[test]
2644    fn a_human_gates_addressee_is_checked_at_load() {
2645        let gate = |to: Value| {
2646            wf(json!({"name": "w", "steps": {
2647                "s": {"kind": "manual"},
2648                "g": {"kind": "human", "question": "ok?", "to": to, "depends_on": ["s"]},
2649                "f": {"kind": "finish", "depends_on": ["g"]}}}))
2650        };
2651        assert!(gate(json!("*@finance.example")).is_ok());
2652        assert!(gate(json!({"role": "user", "labels": {"team": "finance"}})).is_ok());
2653        // Names nobody / names everybody / a typo that would widen it.
2654        for bad in [
2655            json!(""),
2656            json!({}),
2657            json!({"role": "anonymous"}),
2658            json!({"role": "auditor"}),
2659            json!({"rolle": "user"}),
2660            json!(7),
2661        ] {
2662            let e = gate(bad.clone()).unwrap_err();
2663            assert!(
2664                e.iter().any(|m| m.contains("human.to")),
2665                "{bad} should be refused at load, got {e:?}"
2666            );
2667        }
2668        // `reply_uri` is a different thing and nothing implements it.
2669        let e = wf(json!({"name": "w", "steps": {
2670            "s": {"kind": "manual"},
2671            "g": {"kind": "human", "question": "ok?", "reply_uri": "https://x", "depends_on": ["s"]},
2672            "f": {"kind": "finish", "depends_on": ["g"]}}}))
2673        .unwrap_err();
2674        assert!(e.iter().any(|m| m.contains("reply_uri")), "{e:?}");
2675    }
2676
2677    #[test]
2678    fn workflow_priority_parses_and_rejects_junk() {
2679        let w = wf(json!({"name": "w", "priority": "low", "steps": {
2680            "s": {"kind": "once"}, "f": {"kind": "finish", "depends_on": ["s"]}}}))
2681        .unwrap();
2682        assert_eq!(w.priority, Priority::Low);
2683        let w = wf(json!({"name": "w", "steps": {
2684            "s": {"kind": "once"}, "f": {"kind": "finish", "depends_on": ["s"]}}}))
2685        .unwrap();
2686        assert_eq!(w.priority, Priority::Normal, "default");
2687        let e = wf(json!({"name": "w", "priority": "urgent", "steps": {
2688            "s": {"kind": "once"}, "f": {"kind": "finish", "depends_on": ["s"]}}}))
2689        .unwrap_err();
2690        assert!(e.iter().any(|m| m.contains("low|normal|high")), "{e:?}");
2691        // Priority orders: High > Normal > Low (schedule sort relies on it).
2692        assert!(Priority::High > Priority::Normal && Priority::Normal > Priority::Low);
2693    }
2694
2695    #[test]
2696    fn breaker_validates_shape_and_kind_family() {
2697        let ok = wf(json!({"name": "w", "steps": {
2698            "s": {"kind": "once"},
2699            "c": {"kind": "http", "depends_on": ["s"], "url": "https://api.example",
2700                  "breaker": {"failures": 5, "cooldown": "60s"}},
2701            "f": {"kind": "finish", "depends_on": ["c"]},
2702        }}));
2703        assert!(ok.is_ok(), "{ok:?}");
2704        for bad in [
2705            json!({"failures": 0, "cooldown": "60s"}),
2706            json!({"failures": 5}),
2707            json!({"cooldown": "60s"}),
2708            json!({"failures": 5, "cooldown": "sometimes"}),
2709            json!({"failures": 5, "cooldown": "60s", "extra": 1}),
2710        ] {
2711            let e = wf(json!({"name": "w", "steps": {
2712                "s": {"kind": "once"},
2713                "c": {"kind": "http", "depends_on": ["s"], "url": "https://x", "breaker": bad},
2714                "f": {"kind": "finish", "depends_on": ["c"]},
2715            }}))
2716            .unwrap_err();
2717            assert!(e.iter().any(|m| m.contains("breaker takes")), "{e:?}");
2718        }
2719        // A breaker on a LOCAL kind is a category error, refused loudly.
2720        let e = wf(json!({"name": "w", "steps": {
2721            "s": {"kind": "once"},
2722            "a": {"kind": "assign", "depends_on": ["s"], "value": 1,
2723                  "breaker": {"failures": 5, "cooldown": "60s"}},
2724            "f": {"kind": "finish", "depends_on": ["a"]},
2725        }}))
2726        .unwrap_err();
2727        assert!(
2728            e.iter()
2729                .any(|m| m.contains("unknown field") || m.contains("remote-effect")),
2730            "{e:?}"
2731        );
2732    }
2733
2734    #[test]
2735    fn webhook_rate_and_subscribe_window_validate_their_shapes() {
2736        // Well-formed: both parse.
2737        let ok = wf(json!({"name": "w", "steps": {
2738            "h": {"kind": "webhook", "path": "/x", "rate": "20/1s"},
2739            "s": {"kind": "subscribe", "server": "m", "uri": "u://v", "window": {"samples": 64}},
2740            "f": {"kind": "finish", "depends_on": ["h", "s"]},
2741        }}));
2742        assert!(ok.is_ok(), "{ok:?}");
2743        // A malformed rate is a definition error, not a startup surprise.
2744        for bad in ["fast", "0/1s", "5/0s", "5"] {
2745            let e = wf(json!({"name": "w", "steps": {
2746                "h": {"kind": "webhook", "path": "/x", "rate": bad},
2747                "f": {"kind": "finish", "depends_on": ["h"]},
2748            }}))
2749            .unwrap_err();
2750            assert!(
2751                e.iter().any(|m| m.contains("rate must be")),
2752                "rate {bad:?}: {e:?}"
2753            );
2754        }
2755        // window: bounded, object-shaped, samples-only.
2756        for bad in [
2757            json!(64),
2758            json!({"samples": 0}),
2759            json!({"samples": 300}),
2760            json!({"samples": 4, "mean": true}),
2761        ] {
2762            let e = wf(json!({"name": "w", "steps": {
2763                "s": {"kind": "subscribe", "server": "m", "uri": "u://v", "window": bad},
2764                "f": {"kind": "finish", "depends_on": ["s"]},
2765            }}))
2766            .unwrap_err();
2767            assert!(
2768                e.iter().any(|m| m.contains("window takes")),
2769                "window {bad:?}: {e:?}"
2770            );
2771        }
2772    }
2773
2774    #[test]
2775    fn the_sugar_workflow_parses_hashes_and_orders() {
2776        let w = wf(json!({
2777            "name": "main", "version": 3,
2778            "steps": {
2779                "start": {"kind": "once"},
2780                "work": {"kind": "agent", "depends_on": ["start"], "instruction": "{{env.instruction}}"},
2781                "done": {"kind": "finish", "depends_on": ["work"], "status": "completed", "output": "{{steps.work.output}}"}
2782            }
2783        }))
2784        .unwrap();
2785        assert_eq!(w.start_steps().len(), 1);
2786        assert_eq!(w.topo_order(), vec!["start", "work", "done"]);
2787        assert_eq!(w.hash.len(), 64);
2788        assert!(!w.is_long_lived());
2789        assert!(w.armed);
2790        assert_eq!(
2791            w.step("work").unwrap().field_str("instruction"),
2792            Some("{{env.instruction}}")
2793        );
2794        // Same definition, same hash; a changed one differs.
2795        let w2 = wf(w.definition.clone()).unwrap();
2796        assert_eq!(w2.hash, w.hash);
2797        let mut d = w.definition.clone();
2798        d["steps"]["work"]["instruction"] = json!("other");
2799        assert_ne!(wf(d).unwrap().hash, w.hash);
2800    }
2801
2802    /// Every start kind is classified, and the classification is DERIVED — so a
2803    /// new trigger cannot be added to the table and silently forgotten here.
2804    ///
2805    /// The three previously hand-maintained copies disagreed: the workflow
2806    /// method had `stream` and not `webhook`, `LONG_LIVED_STARTS` had `webhook`
2807    /// and not `stream`, and the capabilities manifest had neither. A
2808    /// webhook-only instance under the default `run_until: auto` reported ready
2809    /// and idle-exited out from under its own listener.
2810    #[test]
2811    fn every_start_kind_is_classified_and_only_once_manual_are_short() {
2812        let starts = start_kinds();
2813        assert_eq!(starts.len(), 10, "start kinds: {starts:?}");
2814        for k in &starts {
2815            assert_eq!(
2816                is_long_lived_start(k),
2817                !ONE_SHOT_STARTS.contains(k),
2818                "{k} classified inconsistently"
2819            );
2820        }
2821        // The two regressions, named so a revert is loud.
2822        assert!(is_long_lived_start("webhook"), "a listener keeps us alive");
2823        assert!(is_long_lived_start("stream"), "a consumer keeps us alive");
2824        assert!(!is_long_lived_start("once"));
2825        assert!(!is_long_lived_start("manual"));
2826        // A step kind is not a start kind, however plausible it sounds.
2827        assert!(!is_long_lived_start("wait"));
2828        assert!(!is_long_lived_start("nonsense"));
2829    }
2830
2831    // Asserts a `when: CEL parse` diagnostic, so it needs the `cel` feature.
2832    #[cfg(feature = "cel")]
2833    #[test]
2834    fn validation_catches_the_parse_and_graph_level_failures() {
2835        // Parse-level failures (reported together, before graph checks).
2836        let e = wf(json!({"name": "bad name", "start": "x", "steps": {
2837            "a": {"kind": "agent", "instruction": "x"},
2838            "b": {"kind": "tool", "name": "memory.get", "depends_on": ["a"], "bogus": 1},
2839            "c": {"kind": "foreach", "over": "{{x}}", "body": {"steps": {"i": {"kind": "noop", "depends_on": ["q"]}, "bad id": {"kind": "noop"}}}, "depends_on": ["b"]},
2840            "d": {"kind": "nope", "depends_on": ["a"]},
2841            "e": {"kind": "sleep", "duration": "5 parsecs", "depends_on": ["a"], "when": "CEL: 1 +"},
2842            "s": {"kind": "once", "depends_on": ["a"]}
2843        }}))
2844        .unwrap_err();
2845        let joined = e.join("\n");
2846        for needle in [
2847            "workflow name \"bad name\"",
2848            "`start`/`nodes` are dialect 1/2",
2849            "unknown field \"bogus\"",
2850            "unknown kind \"nope\"",
2851            "sleep.duration",
2852            "when: CEL parse",
2853            "a start node cannot depend on other steps",
2854            "step \"bad id\": id must match",
2855        ] {
2856            assert!(joined.contains(needle), "missing {needle:?} in:\n{joined}");
2857        }
2858        // Graph-level failures.
2859        let e = wf(json!({"name": "g", "steps": {
2860            "s": {"kind": "once"},
2861            "b": {"kind": "noop", "depends_on": ["s", "zz"]},
2862            "e": {"kind": "sleep", "duration": "1s", "depends_on": ["s"], "on_error": "goto:nowhere"},
2863            "loop1": {"kind": "noop", "depends_on": ["loop2"]},
2864            "loop2": {"kind": "noop", "depends_on": ["loop1"]},
2865            "f": {"kind": "finish", "depends_on": ["b"]}
2866        }}))
2867        .unwrap_err();
2868        let joined = e.join("\n");
2869        for needle in [
2870            "depends_on names unknown step \"zz\"",
2871            "on_error goto names unknown step \"nowhere\"",
2872            "cycle among steps",
2873            "not reachable from any start node",
2874        ] {
2875            assert!(joined.contains(needle), "missing {needle:?} in:\n{joined}");
2876        }
2877        // Structural: no start, unreachable, cycle, no finish.
2878        let e = wf(json!({"name": "w", "steps": {
2879            "a": {"kind": "noop"},
2880            "b": {"kind": "noop", "depends_on": ["c"]},
2881            "c": {"kind": "noop", "depends_on": ["b"]}
2882        }}))
2883        .unwrap_err();
2884        let joined = e.join("\n");
2885        assert!(joined.contains("at least one start node"), "{joined}");
2886        assert!(joined.contains("unreachable root"), "{joined}");
2887        assert!(joined.contains("cycle among steps"), "{joined}");
2888        assert!(joined.contains("`finish` step is required"), "{joined}");
2889        // Version.
2890        let e = wf(json!({"name": "w", "version": 2, "steps": {"s": {"kind": "once"}, "f": {"kind": "finish", "depends_on": ["s"]}}})).unwrap_err();
2891        assert!(e[0].contains("not dialect 3"));
2892        // Happy path with every implemented kind referenced.
2893        let ok = wf(json!({"name": "w", "inputs": {"schema": {"type": "object"}}, "concurrency": {"max_runs": 2, "on_overflow": "drop"}, "limits": {"deadline": "10m", "steps": 50}, "steps": {
2894            "s": {"kind": "manual"},
2895            "t": {"kind": "mcp.tool", "server": "fs", "tool": "read", "args": {"path": "/x"}, "depends_on": ["s"], "retry": {"max": 2, "backoff": "1s"}, "timeout": "30s", "on_error": "continue"},
2896            "v": {"kind": "assign", "value": {"a": 1}, "writes": "x", "depends_on": ["t"], "when": "CEL: true"},
2897            "th": {"kind": "think", "prompt": "p", "output_schema": {"type": "object"}, "depends_on": ["v"]},
2898            "z": {"kind": "sleep", "duration": "1s", "depends_on": ["th"]},
2899            "f": {"kind": "finish", "depends_on": ["z"], "status": "completed", "output": "{{vars.x}}"}
2900        }}))
2901        .unwrap();
2902        assert_eq!(ok.concurrency.on_overflow, OnOverflow::Drop);
2903        assert_eq!(ok.limits.deadline_ms, Some(600_000));
2904        assert_eq!(
2905            ok.step("t").unwrap().retry.as_ref().unwrap().backoff_ms,
2906            1000
2907        );
2908        assert_eq!(ok.step("t").unwrap().on_error, OnError::Continue);
2909        assert_eq!(ok.step("t").unwrap().timeout_ms, Some(30_000));
2910        assert!(implemented_kinds().contains(&"agent"));
2911        assert!(workflow_schema()["$defs"]["kinds"]["a2a.send"]["implemented"] == json!(true));
2912        assert!(workflow_schema()["$defs"]["kinds"]["foreach"]["implemented"] == json!(true));
2913    }
2914}