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