Skip to main content

dataflow_rs/engine/
authoring.rs

1//! Authoring-time validation: checking a workflow definition *before* it
2//! reaches [`Engine::build`](crate::Engine::build).
3//!
4//! The engine's own enforcement — parse, [`Workflow::validate`], and
5//! `LoopConfig::validate` — all fires when the *engine* is built. For a host
6//! that stores definitions and builds one engine over many of them, that is the
7//! wrong time (one bad row aborts the whole build, at reload, for every
8//! workflow in the process), the wrong shape (one stringly error, so an
9//! authoring API cannot point a 400 at `tasks[1].tasks[0].id`), and the wrong
10//! cardinality (fail-fast, so the author fixes one violation per round trip).
11//!
12//! [`Workflow::validate_authored`] answers all three, and carries one
13//! guarantee:
14//!
15//! > It returns empty **if and only if** the JSON parses into a [`Workflow`]
16//! > and that workflow validates.
17//!
18//! That biconditional is true *by construction*, not by keeping a rule list in
19//! sync — see the stages below.
20
21use crate::engine::functions::config::{BuiltinKind, builtin_function_kind, can_dispatch_in};
22use crate::engine::functions::{BoxedFunctionHandler, FunctionConfig, TemplateCompiler};
23use crate::engine::secrets::{SECRET_OPERATOR, Secrets};
24use crate::engine::steps::{StepKind, walk_authored_steps};
25use crate::engine::workflow::Workflow;
26use serde_json::Value;
27use std::collections::HashMap;
28use std::fmt;
29
30/// One problem with a workflow definition.
31///
32/// Shared by [`Workflow::validate_authored`] and, from the registry side,
33/// `EngineBuilder::check_workflow`. Both fill what they genuinely know: a
34/// definition check always has an authored coordinate and knows the step id
35/// when the problem concerns a step; a registry check always has a task id and
36/// reports a path relative to that task.
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct WorkflowIssue {
39    /// Stable machine-readable classification.
40    pub code: IssueCode,
41    /// Human-readable explanation. Not stable — branch on [`Self::code`].
42    pub message: String,
43    /// Where the problem is. From [`Workflow::validate_authored`] this is the
44    /// coordinate the author typed, rooted at the workflow document:
45    /// `tasks[1].tasks[0].id`.
46    pub path: Option<String>,
47    /// The step this concerns, when it concerns one. Step ids are unique across
48    /// tasks *and* groups, so this identifies a step on its own.
49    pub task_id: Option<String>,
50}
51
52impl WorkflowIssue {
53    fn at(code: IssueCode, path: impl Into<String>, message: impl Into<String>) -> Self {
54        Self {
55            code,
56            message: message.into(),
57            path: Some(path.into()),
58            task_id: None,
59        }
60    }
61
62    fn with_step(mut self, id: Option<&str>) -> Self {
63        self.task_id = id.map(str::to_string);
64        self
65    }
66}
67
68impl fmt::Display for WorkflowIssue {
69    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
70        match &self.path {
71            Some(path) => write!(f, "{path}: {} [{}]", self.message, self.code.as_str()),
72            None => write!(f, "{} [{}]", self.message, self.code.as_str()),
73        }
74    }
75}
76
77/// Why a workflow definition is not loadable.
78///
79/// `#[non_exhaustive]`: a later minor may add a rule, and a host matching on
80/// the codes it cares about should keep compiling. Use [`Self::as_str`] to
81/// serialize.
82#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
83#[non_exhaustive]
84pub enum IssueCode {
85    /// `id` is missing or empty.
86    EmptyWorkflowId,
87    /// `name` is missing or empty.
88    EmptyWorkflowName,
89    /// `tasks` is missing, not an array, or empty.
90    NoTasks,
91    /// A step carries no `id`.
92    MissingStepId,
93    /// Two steps share an id. Groups share the task id namespace.
94    DuplicateStepId,
95    /// A group's `tasks` is not a non-empty array.
96    EmptyGroup,
97    /// A group is nested at or beyond
98    /// [`MAX_GROUP_DEPTH`](crate::engine::steps::MAX_GROUP_DEPTH).
99    GroupTooDeep,
100    /// A task carries no `function`.
101    MissingFunction,
102    /// `function` is not an object, or its `name` is missing or empty.
103    InvalidFunctionName,
104    /// `terminal` is present but not a boolean.
105    InvalidTerminal,
106    /// `loop.increment` is below 1 — the counter would never reach `max`.
107    LoopIncrementTooSmall,
108    /// `loop.max` is not greater than `loop.init` — no sweep could ever run.
109    LoopBoundEmpty,
110    /// `loop.counter` is not a non-empty dotted path.
111    LoopCounterInvalid,
112    /// No handler will dispatch this function name, and it is not a built-in.
113    /// Usually a typo or a handler the host forgot to register.
114    UnknownFunction,
115    /// The name *is* a built-in, but one that ships as a config schema only
116    /// (`http_call`, `enrich`, `publish_kafka`) and no handler is registered
117    /// under it. The workflow builds cleanly and then fails every message.
118    MissingHandler,
119    /// A custom task's `input` does not deserialize into its handler's declared
120    /// `Input` type.
121    InputParse,
122    /// A `Template` field of a custom task's input does not compile.
123    TemplateCompile,
124    /// An expression reads `{"secret": "name"}` and no secret of that name is
125    /// declared on the engine (`EngineBuilder::with_secrets`). Only literal
126    /// names are checked; a dynamic name fails at evaluation instead.
127    UnknownSecret,
128    /// An expression whose result the engine writes to the message or emits to
129    /// a log reads a secret — a `map` mapping, or a `log` message or field. The
130    /// store exists so a value is never recorded; an expression that would
131    /// record it is refused outright, derived or not. Compute derived values in
132    /// a custom handler.
133    SecretInMessageWrite,
134    /// The store passed to
135    /// [`EngineBuilder::with_secrets`](crate::EngineBuilder::with_secrets) is
136    /// not a JSON object, so no name resolves and
137    /// [`EngineBuilder::build`](crate::EngineBuilder::build) will fail.
138    /// Reported by
139    /// [`EngineBuilder::check_workflow`](crate::EngineBuilder::check_workflow)
140    /// *instead of* the [`Self::UnknownSecret`] issues every literal name would
141    /// otherwise produce — the workflow is not what is wrong.
142    InvalidSecretStore,
143    /// The document does not deserialize into a [`Workflow`]. Carries the
144    /// parser's own message, which names the offending field and type.
145    ParseFailed,
146    /// The document parses but [`Workflow::validate`] rejects it. A backstop:
147    /// reaching this means a rule exists that the checks above do not model.
148    ValidateFailed,
149}
150
151impl IssueCode {
152    /// The stable string form, for serializing into an API response.
153    pub fn as_str(&self) -> &'static str {
154        match self {
155            Self::EmptyWorkflowId => "EMPTY_WORKFLOW_ID",
156            Self::EmptyWorkflowName => "EMPTY_WORKFLOW_NAME",
157            Self::NoTasks => "NO_TASKS",
158            Self::MissingStepId => "MISSING_STEP_ID",
159            Self::DuplicateStepId => "DUPLICATE_STEP_ID",
160            Self::EmptyGroup => "EMPTY_GROUP",
161            Self::GroupTooDeep => "GROUP_TOO_DEEP",
162            Self::MissingFunction => "MISSING_FUNCTION",
163            Self::InvalidFunctionName => "INVALID_FUNCTION_NAME",
164            Self::InvalidTerminal => "INVALID_TERMINAL",
165            Self::LoopIncrementTooSmall => "LOOP_INCREMENT_TOO_SMALL",
166            Self::LoopBoundEmpty => "LOOP_BOUND_EMPTY",
167            Self::LoopCounterInvalid => "LOOP_COUNTER_INVALID",
168            Self::UnknownFunction => "UNKNOWN_FUNCTION",
169            Self::MissingHandler => "MISSING_HANDLER",
170            Self::InputParse => "INPUT_PARSE",
171            Self::TemplateCompile => "TEMPLATE_COMPILE",
172            Self::UnknownSecret => "UNKNOWN_SECRET",
173            Self::SecretInMessageWrite => "SECRET_IN_MESSAGE_WRITE",
174            Self::InvalidSecretStore => "INVALID_SECRET_STORE",
175            Self::ParseFailed => "PARSE_FAILED",
176            Self::ValidateFailed => "VALIDATE_FAILED",
177        }
178    }
179}
180
181impl fmt::Display for IssueCode {
182    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
183        f.write_str(self.as_str())
184    }
185}
186
187impl Workflow {
188    /// Check authored workflow JSON without building an engine.
189    ///
190    /// Returns empty **if and only if** the JSON parses into a [`Workflow`] and
191    /// that workflow validates.
192    ///
193    /// That is the *shape* question, and it is the whole of it. It is not the
194    /// same as "this engine can run it": [`Engine::build`](crate::Engine::build)
195    /// also resolves every task to a handler and parses custom inputs, so a
196    /// structurally perfect definition naming an unregistered function still
197    /// aborts a build. [`Engine::check_workflow`](crate::Engine::check_workflow)
198    /// answers that half; run both.
199    ///
200    /// # How the guarantee holds
201    ///
202    /// Three stages. A structural walk collects *every* semantic violation with
203    /// the coordinate the author typed; if it finds none, the document is then
204    /// actually parsed and validated, and either failure is reported as one
205    /// further issue. The second and third stages are what make the promise
206    /// true by construction: the crate's serde schema is far larger than any
207    /// rule list — a `"priority": "high"` or a `map` task missing `mappings`
208    /// breaks no *semantic* rule and still cannot load — and mirroring it here
209    /// would recreate the very drift this API exists to remove.
210    ///
211    /// # Example
212    ///
213    /// ```
214    /// use dataflow_rs::{IssueCode, Workflow};
215    /// use serde_json::json;
216    ///
217    /// let broken = json!({
218    ///     "id": "w", "name": "w", "priority": 0,
219    ///     "tasks": [
220    ///         {"id": "dup", "name": "a", "function": {"name": "map", "input": {"mappings": []}}},
221    ///         {"id": "dup", "name": "b", "function": {"name": "map", "input": {"mappings": []}}}
222    ///     ]
223    /// });
224    ///
225    /// let issues = Workflow::validate_authored(&broken);
226    /// assert_eq!(issues[0].code, IssueCode::DuplicateStepId);
227    /// assert_eq!(issues[0].path.as_deref(), Some("tasks[1].id"));
228    /// assert_eq!(issues[0].task_id.as_deref(), Some("dup"));
229    /// ```
230    ///
231    /// Every problem is reported, not just the first:
232    ///
233    /// ```
234    /// # use dataflow_rs::{IssueCode, Workflow};
235    /// # use serde_json::json;
236    /// let issues = Workflow::validate_authored(&json!({
237    ///     "id": "", "name": "w", "tasks": [{"id": "t", "name": "t"}]
238    /// }));
239    ///
240    /// let codes: Vec<IssueCode> = issues.iter().map(|i| i.code).collect();
241    /// assert!(codes.contains(&IssueCode::EmptyWorkflowId));
242    /// assert!(codes.contains(&IssueCode::MissingFunction));
243    /// ```
244    pub fn validate_authored(json: &Value) -> Vec<WorkflowIssue> {
245        let mut issues = check_shape(json);
246        if !issues.is_empty() {
247            return issues;
248        }
249
250        // Stage 2 — the schema is much wider than the rules above, and
251        // enumerating it here would be the mirror this API exists to delete.
252        let workflow: Workflow = match serde_json::from_value(json.clone()) {
253            Ok(w) => w,
254            Err(err) => {
255                issues.push(WorkflowIssue {
256                    code: IssueCode::ParseFailed,
257                    message: err.to_string(),
258                    path: None,
259                    task_id: None,
260                });
261                return issues;
262            }
263        };
264
265        // Stage 3 — a backstop. Reaching this means `check_shape` does not
266        // model some rule `validate` enforces; the caller still gets a correct
267        // answer, and the test suite is what keeps this unreachable.
268        if let Err(err) = workflow.validate() {
269            issues.push(WorkflowIssue {
270                code: IssueCode::ValidateFailed,
271                message: err.to_string(),
272                path: None,
273                task_id: None,
274            });
275        }
276        issues
277    }
278}
279
280/// Check a parsed workflow against a handler registry.
281///
282/// Shared by [`crate::EngineBuilder::check_workflow`] and
283/// [`crate::Engine::check_workflow`] so the two cannot answer differently, and
284/// run against the crate's *real* `TemplateCompiler` rather than a host's
285/// reconstruction of one.
286///
287/// `workflow.tasks` is already flattened, so iterating it covers members of
288/// task groups without any extra traversal.
289pub(crate) fn check_against_registry(
290    workflow: &Workflow,
291    registry: &std::collections::HashMap<String, BoxedFunctionHandler>,
292    template_compiler: &TemplateCompiler,
293    secrets: &Secrets,
294) -> Vec<WorkflowIssue> {
295    let mut issues = check_secrets(workflow, secrets);
296
297    for task in &workflow.tasks {
298        let name = task.function.function_name();
299
300        if !can_dispatch_in(registry, name) {
301            // Distinguish the two reasons, because the fixes differ: a
302            // `RequiresHandler` built-in is a real name awaiting a
303            // registration, while anything else is likely a typo.
304            let (code, message) = match builtin_function_kind(name) {
305                Some(BuiltinKind::RequiresHandler) => (
306                    IssueCode::MissingHandler,
307                    format!(
308                        "'{name}' ships as a config schema only — register a handler under \
309                         that name, or this workflow will build cleanly and fail every message"
310                    ),
311                ),
312                _ => (
313                    IssueCode::UnknownFunction,
314                    format!("no handler is registered for '{name}', and it is not a built-in"),
315                ),
316            };
317            issues.push(WorkflowIssue {
318                code,
319                message,
320                path: Some("function.name".to_string()),
321                task_id: Some(task.id.clone()),
322            });
323            continue;
324        }
325
326        // Only `Custom` inputs are still raw at this point: the built-in
327        // variants were typed by serde when the workflow parsed.
328        let FunctionConfig::Custom { name, input, .. } = &task.function else {
329            continue;
330        };
331        let Some(handler) = registry.get(name) else {
332            continue;
333        };
334
335        let mut parsed = match handler.parse_input_box(input) {
336            Ok(parsed) => parsed,
337            Err(err) => {
338                issues.push(WorkflowIssue {
339                    code: IssueCode::InputParse,
340                    message: format!("input does not match the handler's Input type: {err}"),
341                    path: Some("function.input".to_string()),
342                    task_id: Some(task.id.clone()),
343                });
344                continue;
345            }
346        };
347
348        if let Err(err) = handler.compile_input_box(&mut *parsed, template_compiler) {
349            issues.push(WorkflowIssue {
350                code: IssueCode::TemplateCompile,
351                message: format!("a template field does not compile: {err}"),
352                path: Some("function.input".to_string()),
353                task_id: Some(task.id.clone()),
354            });
355        }
356    }
357
358    issues
359}
360
361/// Where an expression's result ends up — what decides whether it may read a
362/// secret.
363#[derive(Clone, Copy, PartialEq, Eq)]
364enum Sink {
365    /// Collapses to a bool the engine acts on: workflow, group and task
366    /// conditions, `validation` rules, `filter`. Nothing of the value survives.
367    Bool,
368    /// Handed to a handler: `Template` fields and integration `*_logic`. What
369    /// happens next is the handler's business.
370    Handler,
371    /// A custom task's whole raw input, handed to the handler untyped. The
372    /// same rule as [`Sink::Handler`]; it differs only in that the document
373    /// is not one expression, so an issue carries the deep path to the
374    /// reference rather than the field.
375    Input,
376    /// Written to the message or emitted to a log by the engine itself:
377    /// `map` mappings, `log` message and fields. Recorded by construction.
378    Message,
379}
380
381impl Sink {
382    /// Whether the engine itself records this expression's result. Such an
383    /// expression may not read a secret *at all* — the store exists so a value
384    /// is never recorded, and there is no static line between a copy and a
385    /// derived value.
386    ///
387    /// The four variants are a map of where an expression's result goes;
388    /// [`Sink::Bool`] and [`Sink::Handler`] answer this question the same way
389    /// today and are kept apart because the reasons differ — nothing survives
390    /// versus the handler owns what happens next.
391    fn records(self) -> bool {
392        matches!(self, Self::Message)
393    }
394
395    /// Whether an issue points at the reference's own deep path rather than at
396    /// the field. True only where the field is not itself a single expression,
397    /// so the field name alone would not locate the reference.
398    fn points_at_reference(self) -> bool {
399        matches!(self, Self::Input)
400    }
401}
402
403/// Check every expression in `workflow` for `{"secret": …}` references.
404///
405/// One implementation for two callers: `Engine::build` refuses a workflow that
406/// produces any issue here, and `check_workflow` reports the same issues, so the
407/// two cannot disagree about what is loadable.
408///
409/// A reference is any single-key object whose key is the reserved operator
410/// name — datalogic compiles exactly that shape as an operator call in
411/// templating mode, at any depth. A string (or one-element array) argument is
412/// a literal name and is checked against `secrets`; anything else is dynamic
413/// and only fails at evaluation. Both forms are refused in a
414/// [`Sink::Message`] expression.
415pub(crate) fn check_secrets(workflow: &Workflow, secrets: &Secrets) -> Vec<WorkflowIssue> {
416    let mut issues = Vec::new();
417    let mut check = |value: &Value, field: &str, task_id: Option<&str>, sink: Sink| {
418        check_expression(value, field, task_id, sink, secrets, &mut issues);
419    };
420
421    check(&workflow.condition, "condition", None, Sink::Bool);
422
423    for task in &workflow.tasks {
424        // Groups opening at this task, outermost first — compiled alongside
425        // the task condition, so checked alongside it.
426        for group in &task.group_starts {
427            let id = Some(group.id.as_str());
428            check(&group.condition, "condition", id, Sink::Bool);
429        }
430
431        let id = Some(task.id.as_str());
432        check(&task.condition, "condition", id, Sink::Bool);
433
434        match &task.function {
435            FunctionConfig::Map { input, .. } => {
436                for (i, mapping) in input.mappings.iter().enumerate() {
437                    let field = format!("function.input.mappings[{i}].logic");
438                    check(&mapping.logic, &field, id, Sink::Message);
439                }
440            }
441            FunctionConfig::Validation { input, .. } => {
442                for (i, rule) in input.rules.iter().enumerate() {
443                    let field = format!("function.input.rules[{i}].logic");
444                    check(&rule.logic, &field, id, Sink::Bool);
445                }
446            }
447            FunctionConfig::Filter { input, .. } => {
448                check(&input.condition, "function.input.condition", id, Sink::Bool);
449            }
450            FunctionConfig::Log { input, .. } => {
451                check(&input.message, "function.input.message", id, Sink::Message);
452                // `fields` is a `HashMap`, so iterate it in name order — issue
453                // order is what a host logs, diffs, or asserts on, and what
454                // `refuse_secret_issues` joins into a `build()` error message.
455                let mut names: Vec<&String> = input.fields.keys().collect();
456                names.sort_unstable();
457                for name in names {
458                    let field = format!("function.input.fields.{name}");
459                    check(&input.fields[name], &field, id, Sink::Message);
460                }
461            }
462            FunctionConfig::HttpCall { input, .. } => {
463                for (name, template) in [
464                    ("path_logic", &input.path_logic),
465                    ("body_logic", &input.body_logic),
466                ] {
467                    if let Some(t) = template {
468                        let field = format!("function.input.{name}");
469                        check(t.as_json(), &field, id, Sink::Handler);
470                    }
471                }
472            }
473            FunctionConfig::Enrich { input, .. } => {
474                if let Some(t) = &input.path_logic {
475                    check(t.as_json(), "function.input.path_logic", id, Sink::Handler);
476                }
477            }
478            FunctionConfig::PublishKafka { input, .. } => {
479                for (name, template) in [
480                    ("key_logic", &input.key_logic),
481                    ("value_logic", &input.value_logic),
482                ] {
483                    if let Some(t) = template {
484                        let field = format!("function.input.{name}");
485                        check(t.as_json(), &field, id, Sink::Handler);
486                    }
487                }
488            }
489            FunctionConfig::Custom { input, .. } => {
490                check(input, "function.input", id, Sink::Input);
491            }
492            FunctionConfig::ParseJson { .. }
493            | FunctionConfig::ParseXml { .. }
494            | FunctionConfig::PublishJson { .. }
495            | FunctionConfig::PublishXml { .. } => {}
496        }
497    }
498
499    issues
500}
501
502/// One expression's worth of [`check_secrets`].
503fn check_expression(
504    value: &Value,
505    field: &str,
506    task_id: Option<&str>,
507    sink: Sink,
508    secrets: &Secrets,
509    issues: &mut Vec<WorkflowIssue>,
510) {
511    let mut refs = Vec::new();
512    collect_secret_refs(value, field, &mut refs);
513    if refs.is_empty() {
514        return;
515    }
516    if sink.records() {
517        issues.push(
518            WorkflowIssue::at(
519                IssueCode::SecretInMessageWrite,
520                field,
521                "reads a secret, and the engine records this expression's result — \
522                 compute derived values in a custom handler instead",
523            )
524            .with_step(task_id),
525        );
526        return;
527    }
528    for (path, key) in &refs {
529        let Some(key) = key else { continue };
530        if secrets.get(key).is_some() {
531            continue;
532        }
533        let at = if sink.points_at_reference() {
534            path.as_str()
535        } else {
536            field
537        };
538        issues.push(
539            WorkflowIssue::at(
540                IssueCode::UnknownSecret,
541                at,
542                format!("secret '{key}' is not declared on the engine"),
543            )
544            .with_step(task_id),
545        );
546    }
547}
548
549/// Collect every `{"secret": …}` reference under `value` as
550/// `(path, literal name)` — `None` for a dynamic name. Descends into the
551/// argument too, since a dynamic name may itself contain a reference.
552fn collect_secret_refs<'v>(value: &'v Value, path: &str, out: &mut Vec<(String, Option<&'v str>)>) {
553    match value {
554        Value::Object(map) => {
555            if map.len() == 1 {
556                if let Some(arg) = map.get(SECRET_OPERATOR) {
557                    let literal = match arg {
558                        Value::String(s) => Some(s.as_str()),
559                        Value::Array(items) if items.len() == 1 => items[0].as_str(),
560                        _ => None,
561                    };
562                    out.push((path.to_string(), literal));
563                    collect_secret_refs(arg, &format!("{path}.{SECRET_OPERATOR}"), out);
564                    return;
565                }
566            }
567            for (key, child) in map {
568                collect_secret_refs(child, &format!("{path}.{key}"), out);
569            }
570        }
571        Value::Array(items) => {
572            for (i, child) in items.iter().enumerate() {
573                collect_secret_refs(child, &format!("{path}[{i}]"), out);
574            }
575        }
576        _ => {}
577    }
578}
579
580/// Stage 1: every semantic violation, with authored coordinates.
581fn check_shape(json: &Value) -> Vec<WorkflowIssue> {
582    let mut issues = Vec::new();
583
584    if non_empty_str(json.get("id")).is_none() {
585        issues.push(WorkflowIssue::at(
586            IssueCode::EmptyWorkflowId,
587            "id",
588            "workflow id must be a non-empty string",
589        ));
590    }
591    if non_empty_str(json.get("name")).is_none() {
592        issues.push(WorkflowIssue::at(
593            IssueCode::EmptyWorkflowName,
594            "name",
595            "workflow name must be a non-empty string",
596        ));
597    }
598
599    match json.get("tasks").and_then(Value::as_array) {
600        Some(tasks) if !tasks.is_empty() => {}
601        _ => issues.push(WorkflowIssue::at(
602            IssueCode::NoTasks,
603            "tasks",
604            "workflow must have at least one task",
605        )),
606    }
607
608    check_steps(json.get("tasks").unwrap_or(&Value::Null), &mut issues);
609
610    if let Some(loop_config) = json.get("loop") {
611        check_loop(loop_config, &mut issues);
612    }
613
614    issues
615}
616
617/// Walk the authored step tree, checking each node and the id namespace.
618///
619/// Built on [`walk_authored_steps`], so the group test, the traversal order and
620/// the depth cap have exactly one definition shared with the parser.
621fn check_steps(tasks: &Value, issues: &mut Vec<WorkflowIssue>) {
622    // Step id -> the path that first claimed it.
623    let mut seen: HashMap<&str, String> = HashMap::new();
624
625    for step in walk_authored_steps(tasks) {
626        let id = non_empty_str(step.node.get("id"));
627
628        match id {
629            None => issues.push(
630                WorkflowIssue::at(
631                    IssueCode::MissingStepId,
632                    format!("{}.id", step.path),
633                    "every step needs a non-empty id",
634                )
635                .with_step(None),
636            ),
637            Some(id) => {
638                if let Some(first) = seen.get(id) {
639                    issues.push(
640                        WorkflowIssue::at(
641                            IssueCode::DuplicateStepId,
642                            format!("{}.id", step.path),
643                            format!(
644                                "step id '{id}' is already used at {first} — task groups \
645                                 share the task id namespace"
646                            ),
647                        )
648                        .with_step(Some(id)),
649                    );
650                } else {
651                    seen.insert(id, step.path.clone());
652                }
653            }
654        }
655
656        if let Some(terminal) = step.node.get("terminal") {
657            if !terminal.is_boolean() {
658                issues.push(
659                    WorkflowIssue::at(
660                        IssueCode::InvalidTerminal,
661                        format!("{}.terminal", step.path),
662                        "terminal must be a boolean",
663                    )
664                    .with_step(id),
665                );
666            }
667        }
668
669        match step.kind {
670            StepKind::Leaf => check_function(&step.path, step.node, id, issues),
671            StepKind::Group => {
672                // The parser rejects a group whose `tasks` is not a non-empty
673                // array; the walker reports the node so we can say which.
674                let has_members = step
675                    .node
676                    .get("tasks")
677                    .and_then(Value::as_array)
678                    .is_some_and(|members| !members.is_empty());
679                if !has_members {
680                    issues.push(
681                        WorkflowIssue::at(
682                            IssueCode::EmptyGroup,
683                            format!("{}.tasks", step.path),
684                            "a task group's tasks must be a non-empty array — \
685                             an empty group can only be a mistake",
686                        )
687                        .with_step(id),
688                    );
689                }
690            }
691            StepKind::TooDeep => issues.push(
692                WorkflowIssue::at(
693                    IssueCode::GroupTooDeep,
694                    step.path.clone(),
695                    format!(
696                        "task groups nested deeper than {} levels",
697                        crate::engine::steps::MAX_GROUP_DEPTH
698                    ),
699                )
700                .with_step(id),
701            ),
702        }
703    }
704}
705
706/// A leaf must carry a `function` object with a non-empty `name`.
707fn check_function(path: &str, node: &Value, id: Option<&str>, issues: &mut Vec<WorkflowIssue>) {
708    let Some(function) = node.get("function") else {
709        issues.push(
710            WorkflowIssue::at(
711                IssueCode::MissingFunction,
712                format!("{path}.function"),
713                "a task needs a function — an element with neither `function` nor \
714                 `tasks` is neither a task nor a group",
715            )
716            .with_step(id),
717        );
718        return;
719    };
720
721    if !function.is_object() || non_empty_str(function.get("name")).is_none() {
722        issues.push(
723            WorkflowIssue::at(
724                IssueCode::InvalidFunctionName,
725                format!("{path}.function.name"),
726                "function must be an object with a non-empty name",
727            )
728            .with_step(id),
729        );
730    }
731}
732
733/// The three `LoopConfig::validate` rules, against the authored JSON.
734fn check_loop(config: &Value, issues: &mut Vec<WorkflowIssue>) {
735    // Absent fields take their serde defaults, which are valid; only a present
736    // field can be wrong here. A non-integer is a *type* error and belongs to
737    // stage 2, so it is deliberately not reported twice.
738    if let Some(increment) = config.get("increment").and_then(Value::as_i64) {
739        if increment < 1 {
740            issues.push(WorkflowIssue::at(
741                IssueCode::LoopIncrementTooSmall,
742                "loop.increment",
743                format!(
744                    "loop increment must be >= 1, got {increment} \
745                     (a non-advancing counter would never reach max)"
746                ),
747            ));
748        }
749    }
750
751    let init = config.get("init").and_then(Value::as_i64).unwrap_or(0);
752    if let Some(max) = config.get("max").and_then(Value::as_i64) {
753        if max <= init {
754            issues.push(WorkflowIssue::at(
755                IssueCode::LoopBoundEmpty,
756                "loop.max",
757                format!(
758                    "loop max ({max}) must be greater than init ({init}) — \
759                     the bound is half-open, so this could never run a sweep"
760                ),
761            ));
762        }
763    }
764
765    if let Some(counter) = config.get("counter") {
766        if let Some(counter) = counter.as_str() {
767            if counter.is_empty() || counter.split('.').any(str::is_empty) {
768                issues.push(WorkflowIssue::at(
769                    IssueCode::LoopCounterInvalid,
770                    "loop.counter",
771                    format!(
772                        "loop counter must be a non-empty temp_data field path, got {counter:?}"
773                    ),
774                ));
775            }
776        }
777    }
778}
779
780/// The value at `field` as a non-empty string, if it is one.
781fn non_empty_str(field: Option<&Value>) -> Option<&str> {
782    field.and_then(Value::as_str).filter(|s| !s.is_empty())
783}