Skip to main content

dataflow_rs/engine/
authoring.rs

1//! Authoring-time validation: checking a workflow definition *before* it
2//! reaches [`EngineBuilder::build`](crate::EngineBuilder::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::compiler::TEMPLATE_KEY_ESCAPE;
22use crate::engine::for_each::ForEach;
23use crate::engine::functions::OnNull;
24use crate::engine::functions::config::{BuiltinKind, builtin_function_kind, can_dispatch_in};
25use crate::engine::functions::map::AuthoredMapping;
26use crate::engine::functions::{BoxedFunctionHandler, FunctionConfig, TemplateCompiler};
27use crate::engine::secrets::{SECRET_OPERATOR, Secrets};
28use crate::engine::steps::{StepKind, walk_authored_steps_at};
29use crate::engine::task::HaltOn;
30use crate::engine::workflow::{Workflow, is_slot_path, over_literal_problem, slots_overlap};
31use datavalue::OwnedDataValue;
32use serde::Deserialize;
33use serde_json::Value;
34use std::collections::HashMap;
35use std::fmt;
36
37/// One problem with a workflow definition.
38///
39/// Shared by [`Workflow::validate_authored`] and, from the registry side,
40/// `EngineBuilder::check_workflow`. Both fill what they genuinely know: a
41/// definition check always has an authored coordinate and knows the step id
42/// when the problem concerns a step; a registry check always has a task id and
43/// reports a path relative to that task.
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct WorkflowIssue {
46    /// Stable machine-readable classification.
47    pub code: IssueCode,
48    /// Human-readable explanation. Not stable — branch on [`Self::code`].
49    pub message: String,
50    /// Where the problem is. From [`Workflow::validate_authored`] this is the
51    /// coordinate the author typed, rooted at the workflow document:
52    /// `tasks[1].tasks[0].id`.
53    pub path: Option<String>,
54    /// The step this concerns, when it concerns one. Step ids are unique across
55    /// tasks *and* groups, so this identifies a step on its own.
56    pub task_id: Option<String>,
57}
58
59impl WorkflowIssue {
60    /// When this issue bites — [`IssueCode::severity`] of [`Self::code`].
61    ///
62    /// Screening before a build is the point: everything but
63    /// [`Severity::Advisory`] means the workflow will not do what it says.
64    pub fn severity(&self) -> Severity {
65        self.code.severity()
66    }
67
68    fn at(code: IssueCode, path: impl Into<String>, message: impl Into<String>) -> Self {
69        Self {
70            code,
71            message: message.into(),
72            path: Some(path.into()),
73            task_id: None,
74        }
75    }
76
77    fn with_step(mut self, id: Option<&str>) -> Self {
78        self.task_id = id.map(str::to_string);
79        self
80    }
81}
82
83impl fmt::Display for WorkflowIssue {
84    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
85        match &self.path {
86            Some(path) => write!(f, "{path}: {} [{}]", self.message, self.code.as_str()),
87            None => write!(f, "{} [{}]", self.message, self.code.as_str()),
88        }
89    }
90}
91
92/// What a check found wrong with a workflow definition.
93///
94/// Not every code is a refusal: [`Self::severity`] says whether the definition
95/// is rejected, builds and then fails every message, or merely carries a shape
96/// worth an author's attention. Ask it rather than keeping a list — see
97/// [`Severity`].
98///
99/// `#[non_exhaustive]`: a later minor may add a rule, and a host matching on
100/// the codes it cares about should keep compiling. Use [`Self::as_str`] to
101/// serialize.
102#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
103#[non_exhaustive]
104pub enum IssueCode {
105    /// `id` is missing or empty.
106    EmptyWorkflowId,
107    /// `name` is missing or empty.
108    EmptyWorkflowName,
109    /// `tasks` is missing, not an array, or empty.
110    NoTasks,
111    /// A step carries no `id`.
112    MissingStepId,
113    /// Two steps share an id. Groups share the task id namespace.
114    DuplicateStepId,
115    /// A group's `tasks` is not a non-empty array.
116    EmptyGroup,
117    /// A group is nested at or beyond
118    /// [`MAX_GROUP_DEPTH`](crate::engine::steps::MAX_GROUP_DEPTH).
119    GroupTooDeep,
120    /// A task carries no `function`.
121    MissingFunction,
122    /// `function` is not an object, or its `name` is missing or empty.
123    InvalidFunctionName,
124    /// `terminal` is present but not a boolean.
125    InvalidTerminal,
126    /// A `validation` task whose failure cannot stop anything: it is not
127    /// `terminal`, has no `halt_on`, and every task after it in the workflow is
128    /// unguarded. A failing rule returns `400`, which `continue_on_error` does
129    /// not cover, so the pipeline proceeds as if it had passed.
130    ///
131    /// [`Severity::Advisory`] — reported by
132    /// [`crate::EngineBuilder::check_workflow`] and never by
133    /// [`crate::EngineBuilder::build`]. Validating purely to record errors is a
134    /// legitimate shape; this only says the assertion is not acting as a gate.
135    UnguardedValidation,
136    /// `halt_on` is present and is not one of the accepted strings, or is
137    /// present on a *group* — a group has no outcome of its own, so the
138    /// executor could not honour it.
139    InvalidHaltOn,
140    /// A task group carries `continue_on_error`, which the engine drops. Error
141    /// handling is per task and per workflow; a group only gates a span.
142    ///
143    /// [`Severity::Advisory`] — reported by
144    /// [`crate::EngineBuilder::check_workflow`] and never by
145    /// [`crate::EngineBuilder::build`]. Unlike [`Self::InvalidHaltOn`], which
146    /// the parser refuses outright, this key is real at the other two levels
147    /// and may already sit on a host's group nodes; refusing it would abort
148    /// every workflow in the build.
149    GroupContinueOnError,
150    /// A `map` mapping breaks a rule between its keys: it has neither `logic`
151    /// nor `"unset": true`, or both; it carries `on_null` or `mode` without
152    /// `logic`; or it would remove or append to a context root (`data`,
153    /// `metadata`, `temp_data`) by a literal path. Reported at the offending key, e.g.
154    /// `tasks[0].function.input.mappings[2].unset`.
155    InvalidMapping,
156    /// A `map` mapping whose `logic` always evaluates to `null` — `"logic":
157    /// null`, or anything that folds to it — under the default
158    /// `on_null: "skip"`. A null result is skipped, so the mapping can never
159    /// write anything; it usually stands where the author meant to clear the
160    /// path, which is `"unset": true`.
161    ///
162    /// [`Severity::Advisory`] — reported by
163    /// [`crate::EngineBuilder::check_workflow`] and never by
164    /// [`crate::EngineBuilder::build`]. The mapping is a no-op, not an error,
165    /// and refusing it would fail every build that already carries one.
166    NullMapping,
167    /// `loop.increment` is below 1 — the counter would never reach `max`.
168    LoopIncrementTooSmall,
169    /// `loop.max` is not greater than `loop.init` — no sweep could ever run.
170    LoopBoundEmpty,
171    /// `loop.counter` is not a non-empty dotted path.
172    LoopCounterInvalid,
173    /// `loop.as` or `loop.scratch` is not a non-empty dotted path.
174    LoopSlotInvalid,
175    /// `loop.as` names a slot for the current element, but there is no
176    /// `loop.over` to take elements from.
177    LoopItemWithoutOver,
178    /// Two of `loop.counter`, `loop.as` and `loop.scratch` name the same
179    /// `temp_data` path, or one lies inside the other — a per-iteration reset
180    /// of one would wipe the other. Reported at the later of the two fields.
181    LoopSlotCollision,
182    /// `loop.over` can never yield an array: it is a scalar literal (a string,
183    /// number, boolean or `null`), or `loop.init` is negative, which no array
184    /// index can be. Reported at `loop.over` or `loop.init` respectively.
185    LoopOverInvalid,
186    /// A task's `for_each` breaks one of its rules — `over` a scalar literal,
187    /// `as` not a slot path, `max_concurrency` of `0`, `collect` without
188    /// `into` or the reverse, a result path outside the context, a `collect`
189    /// or `into` overlapping a binding — or sits on a built-in function, or on
190    /// a task group. Reported at the offending key, e.g.
191    /// `tasks[0].for_each.max_concurrency`, or at `…for_each` itself.
192    InvalidForEach,
193    /// No handler will dispatch this function name, and it is not a built-in.
194    /// Usually a typo or a handler the host forgot to register.
195    UnknownFunction,
196    /// The name *is* a built-in, but one that ships as a config schema only
197    /// (`http_call`, `enrich`, `publish_kafka`) and no handler is registered
198    /// under it. The workflow builds cleanly and then fails every message.
199    MissingHandler,
200    /// A custom task's `input` does not deserialize into its handler's declared
201    /// `Input` type.
202    InputParse,
203    /// A `Template` field of a custom task's input does not compile.
204    TemplateCompile,
205    /// An expression reads `{"secret": "name"}` and no secret of that name is
206    /// declared on the engine (`EngineBuilder::with_secrets`). Only literal
207    /// names are checked; a dynamic name fails at evaluation instead.
208    UnknownSecret,
209    /// An expression whose result the engine writes to the message or emits to
210    /// a log reads a secret — a `map` mapping, or a `log` message or field. The
211    /// store exists so a value is never recorded; an expression that would
212    /// record it is refused outright, derived or not. Compute derived values in
213    /// a custom handler.
214    SecretInMessageWrite,
215    /// The store passed to
216    /// [`EngineBuilder::with_secrets`](crate::EngineBuilder::with_secrets) is
217    /// not a JSON object, so no name resolves and
218    /// [`EngineBuilder::build`](crate::EngineBuilder::build) will fail.
219    /// Reported by
220    /// [`EngineBuilder::check_workflow`](crate::EngineBuilder::check_workflow)
221    /// *instead of* the [`Self::UnknownSecret`] issues every literal name would
222    /// otherwise produce — the workflow is not what is wrong.
223    ///
224    /// [`Severity::Rejected`], but the odd one out: quarantining the workflow
225    /// will not make the build succeed, because the store is what is broken.
226    InvalidSecretStore,
227    /// Two keys in one template object collapse to the same name once the
228    /// template-key escape is stripped — `{"$a": 1, "a": 2}` emits `a` twice.
229    /// The context is a `Vec` of pairs, so both survive: a later read sees only
230    /// the first while serialization emits both. Always a bug, so
231    /// [`crate::EngineBuilder::build`] refuses it.
232    DuplicateTemplateKey,
233    /// A template key carries the escape prefix, so it is emitted with one
234    /// prefix stripped: `$type` emits `type`. [`Severity::Advisory`] — reported
235    /// by [`crate::Engine::check_workflow`] and never by
236    /// [`crate::EngineBuilder::build`].
237    ///
238    /// Exists for migration. The escape strips uniformly from every template
239    /// key, so a workflow written before 3.9 that emits genuinely `$`-prefixed
240    /// keys — MongoDB's `$set`/`$oid`, JSON Schema's `$schema`/`$ref` — changes
241    /// what it produces, silently. This lists every one so the audit is
242    /// mechanical rather than archaeological.
243    EscapedTemplateKey,
244    /// The document does not deserialize into a [`Workflow`]. Carries the
245    /// parser's own message, which names the offending field and type.
246    ParseFailed,
247    /// The document parses but [`Workflow::validate`] rejects it. A backstop:
248    /// reaching this means a rule exists that the checks above do not model.
249    ValidateFailed,
250}
251
252impl IssueCode {
253    /// The stable string form, for serializing into an API response.
254    pub fn as_str(&self) -> &'static str {
255        match self {
256            Self::EmptyWorkflowId => "EMPTY_WORKFLOW_ID",
257            Self::EmptyWorkflowName => "EMPTY_WORKFLOW_NAME",
258            Self::NoTasks => "NO_TASKS",
259            Self::MissingStepId => "MISSING_STEP_ID",
260            Self::DuplicateStepId => "DUPLICATE_STEP_ID",
261            Self::EmptyGroup => "EMPTY_GROUP",
262            Self::GroupTooDeep => "GROUP_TOO_DEEP",
263            Self::MissingFunction => "MISSING_FUNCTION",
264            Self::InvalidFunctionName => "INVALID_FUNCTION_NAME",
265            Self::InvalidTerminal => "INVALID_TERMINAL",
266            Self::InvalidHaltOn => "INVALID_HALT_ON",
267            Self::GroupContinueOnError => "GROUP_CONTINUE_ON_ERROR",
268            Self::InvalidMapping => "INVALID_MAPPING",
269            Self::NullMapping => "NULL_MAPPING",
270            Self::UnguardedValidation => "UNGUARDED_VALIDATION",
271            Self::LoopIncrementTooSmall => "LOOP_INCREMENT_TOO_SMALL",
272            Self::LoopBoundEmpty => "LOOP_BOUND_EMPTY",
273            Self::LoopCounterInvalid => "LOOP_COUNTER_INVALID",
274            Self::LoopSlotInvalid => "LOOP_SLOT_INVALID",
275            Self::LoopItemWithoutOver => "LOOP_ITEM_WITHOUT_OVER",
276            Self::LoopSlotCollision => "LOOP_SLOT_COLLISION",
277            Self::LoopOverInvalid => "LOOP_OVER_INVALID",
278            Self::InvalidForEach => "INVALID_FOR_EACH",
279            Self::UnknownFunction => "UNKNOWN_FUNCTION",
280            Self::MissingHandler => "MISSING_HANDLER",
281            Self::InputParse => "INPUT_PARSE",
282            Self::TemplateCompile => "TEMPLATE_COMPILE",
283            Self::UnknownSecret => "UNKNOWN_SECRET",
284            Self::SecretInMessageWrite => "SECRET_IN_MESSAGE_WRITE",
285            Self::InvalidSecretStore => "INVALID_SECRET_STORE",
286            Self::DuplicateTemplateKey => "DUPLICATE_TEMPLATE_KEY",
287            Self::EscapedTemplateKey => "ESCAPED_TEMPLATE_KEY",
288            Self::ParseFailed => "PARSE_FAILED",
289            Self::ValidateFailed => "VALIDATE_FAILED",
290        }
291    }
292
293    /// When an issue carrying this code bites — see [`Severity`].
294    ///
295    /// The match below has **no wildcard arm**, and must never gain one. That
296    /// is the whole mechanism: a code added in a later minor stops this
297    /// compiling until it is deliberately classified, so a new advisory code
298    /// cannot arrive as a silent `Rejected` in a host's pre-build screen — the
299    /// direction that turns a screen into a quarantine.
300    pub fn severity(self) -> Severity {
301        match self {
302            // Reported for an author's attention. `build` accepts all four
303            // and the workflow runs exactly as written.
304            Self::UnguardedValidation
305            | Self::GroupContinueOnError
306            | Self::EscapedTemplateKey
307            | Self::NullMapping => Severity::Advisory,
308
309            // Builds cleanly, then fails every message. The config parses into
310            // a typed built-in variant, so `precompile_custom_inputs` never
311            // looks at it — which is why the builder cannot catch this one.
312            Self::MissingHandler => Severity::Defect,
313
314            // Refused by `refuse_authoring_issues`, before compilation — and
315            // `InvalidSecretStore` earlier still, by `build` itself.
316            Self::UnknownSecret
317            | Self::SecretInMessageWrite
318            | Self::DuplicateTemplateKey
319            | Self::InvalidSecretStore => Severity::Rejected,
320
321            // Refused by `precompile_custom_inputs`.
322            Self::UnknownFunction | Self::InputParse | Self::TemplateCompile => Severity::Rejected,
323
324            // Never parses into a `Workflow`, or fails `Workflow::validate`
325            // when `compile_workflows` runs it.
326            Self::EmptyWorkflowId
327            | Self::EmptyWorkflowName
328            | Self::NoTasks
329            | Self::MissingStepId
330            | Self::DuplicateStepId
331            | Self::EmptyGroup
332            | Self::GroupTooDeep
333            | Self::MissingFunction
334            | Self::InvalidFunctionName
335            | Self::InvalidTerminal
336            | Self::InvalidHaltOn
337            | Self::InvalidMapping
338            | Self::LoopIncrementTooSmall
339            | Self::LoopBoundEmpty
340            | Self::LoopCounterInvalid
341            | Self::LoopSlotInvalid
342            | Self::LoopItemWithoutOver
343            | Self::LoopSlotCollision
344            | Self::LoopOverInvalid
345            | Self::InvalidForEach
346            | Self::ParseFailed
347            | Self::ValidateFailed => Severity::Rejected,
348        }
349    }
350}
351
352impl fmt::Display for IssueCode {
353    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
354        f.write_str(self.as_str())
355    }
356}
357
358/// When an issue bites: at [`build`](crate::EngineBuilder::build), at the first
359/// message, or never.
360///
361/// This is what a host screening definitions *before* building needs in order
362/// to quarantine one workflow rather than fail the whole engine. Not every code
363/// `check_workflow` reports is a refusal, and the set that is has grown apart
364/// from the set that is reported — [`Self::Advisory`] is the only class such a
365/// host may safely ignore.
366///
367/// Unlike [`IssueCode`], this is **not** `#[non_exhaustive]`. The axis is
368/// *when* a definition goes wrong, and build time / first message / never
369/// exhausts that by construction, so a host may `match` it and stay correct.
370#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
371pub enum Severity {
372    /// The definition never runs: [`build`](crate::EngineBuilder::build)
373    /// refuses it, or the parser rejected it before a [`Workflow`] existed.
374    Rejected,
375    /// It builds cleanly and then fails every message it processes — a real
376    /// defect, but one the builder cannot catch. Only
377    /// [`IssueCode::MissingHandler`] today.
378    Defect,
379    /// It loads and runs. Names a shape worth an author's attention, nothing
380    /// more.
381    Advisory,
382}
383
384impl Severity {
385    /// The stable string form, for serializing into an API response.
386    pub fn as_str(&self) -> &'static str {
387        match self {
388            Self::Rejected => "REJECTED",
389            Self::Defect => "DEFECT",
390            Self::Advisory => "ADVISORY",
391        }
392    }
393}
394
395impl fmt::Display for Severity {
396    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
397        f.write_str(self.as_str())
398    }
399}
400
401impl Workflow {
402    /// Check authored workflow JSON without building an engine.
403    ///
404    /// Returns empty **if and only if** the JSON parses into a [`Workflow`] and
405    /// that workflow validates.
406    ///
407    /// That is the *shape* question, and it is the whole of it. It is not the
408    /// same as "this engine can run it": [`EngineBuilder::build`](crate::EngineBuilder::build)
409    /// also resolves every task to a handler and parses custom inputs, so a
410    /// structurally perfect definition naming an unregistered function still
411    /// aborts a build. [`Engine::check_workflow`](crate::Engine::check_workflow)
412    /// answers that half; run both.
413    ///
414    /// # How the guarantee holds
415    ///
416    /// Three stages. A structural walk collects *every* semantic violation with
417    /// the coordinate the author typed; if it finds none, the document is then
418    /// actually parsed and validated, and either failure is reported as one
419    /// further issue. The second and third stages are what make the promise
420    /// true by construction: the crate's serde schema is far larger than any
421    /// rule list — a `"priority": "high"` or a `map` task missing `mappings`
422    /// breaks no *semantic* rule and still cannot load — and mirroring it here
423    /// would recreate the very drift this API exists to remove.
424    ///
425    /// # Example
426    ///
427    /// ```
428    /// use dataflow_rs::{IssueCode, Workflow};
429    /// use serde_json::json;
430    ///
431    /// let broken = json!({
432    ///     "id": "w", "name": "w", "priority": 0,
433    ///     "tasks": [
434    ///         {"id": "dup", "name": "a", "function": {"name": "map", "input": {"mappings": []}}},
435    ///         {"id": "dup", "name": "b", "function": {"name": "map", "input": {"mappings": []}}}
436    ///     ]
437    /// });
438    ///
439    /// let issues = Workflow::validate_authored(&broken);
440    /// assert_eq!(issues[0].code, IssueCode::DuplicateStepId);
441    /// assert_eq!(issues[0].path.as_deref(), Some("tasks[1].id"));
442    /// assert_eq!(issues[0].task_id.as_deref(), Some("dup"));
443    /// ```
444    ///
445    /// Every problem is reported, not just the first:
446    ///
447    /// ```
448    /// # use dataflow_rs::{IssueCode, Workflow};
449    /// # use serde_json::json;
450    /// let issues = Workflow::validate_authored(&json!({
451    ///     "id": "", "name": "w", "tasks": [{"id": "t", "name": "t"}]
452    /// }));
453    ///
454    /// let codes: Vec<IssueCode> = issues.iter().map(|i| i.code).collect();
455    /// assert!(codes.contains(&IssueCode::EmptyWorkflowId));
456    /// assert!(codes.contains(&IssueCode::MissingFunction));
457    /// ```
458    pub fn validate_authored(json: &Value) -> Vec<WorkflowIssue> {
459        let mut issues = check_shape(json);
460        if !issues.is_empty() {
461            return issues;
462        }
463
464        // Stage 2 — the schema is much wider than the rules above, and
465        // enumerating it here would be the mirror this API exists to delete.
466        let workflow: Self = match serde_json::from_value(json.clone()) {
467            Ok(w) => w,
468            Err(err) => {
469                issues.push(WorkflowIssue {
470                    code: IssueCode::ParseFailed,
471                    message: err.to_string(),
472                    path: None,
473                    task_id: None,
474                });
475                return issues;
476            }
477        };
478
479        // Stage 3 — a backstop. Reaching this means `check_shape` does not
480        // model some rule `validate` enforces; the caller still gets a correct
481        // answer, and the test suite is what keeps this unreachable.
482        if let Err(err) = workflow.validate() {
483            issues.push(WorkflowIssue {
484                code: IssueCode::ValidateFailed,
485                message: err.to_string(),
486                path: None,
487                task_id: None,
488            });
489        }
490        issues
491    }
492}
493
494/// Check a parsed workflow against a handler registry.
495///
496/// Shared by [`crate::EngineBuilder::check_workflow`] and
497/// [`crate::Engine::check_workflow`] so the two cannot answer differently, and
498/// run against the crate's *real* `TemplateCompiler` rather than a host's
499/// reconstruction of one.
500///
501/// `workflow.tasks` is already flattened, so iterating it covers members of
502/// task groups without any extra traversal.
503pub(crate) fn check_against_registry(
504    workflow: &Workflow,
505    registry: &std::collections::HashMap<String, BoxedFunctionHandler>,
506    template_compiler: &TemplateCompiler,
507    secrets: &Secrets,
508) -> Vec<WorkflowIssue> {
509    let mut issues = check_secrets(workflow, secrets);
510    // All three template-key findings, including the two `build()` does not
511    // refuse — this surface is where a host looks before activating a
512    // definition, and the migration audit is the point of reporting them.
513    issues.extend(check_template_keys(workflow));
514    check_unguarded_validation(workflow, &mut issues);
515    check_group_continue_on_error(workflow, &mut issues);
516    check_null_mappings(workflow, template_compiler.engine(), &mut issues);
517
518    // A loop's setup steps are resolved exactly like body steps — `build`
519    // refuses an unknown function in either.
520    for task in workflow.all_tasks() {
521        let name = task.function.function_name();
522
523        if !can_dispatch_in(registry, name) {
524            // Distinguish the two reasons, because the fixes differ: a
525            // `RequiresHandler` built-in is a real name awaiting a
526            // registration, while anything else is likely a typo.
527            let (code, message) = match builtin_function_kind(name) {
528                Some(BuiltinKind::RequiresHandler) => (
529                    IssueCode::MissingHandler,
530                    format!(
531                        "'{name}' ships as a config schema only — register a handler under \
532                         that name, or this workflow will build cleanly and fail every message"
533                    ),
534                ),
535                _ => (
536                    IssueCode::UnknownFunction,
537                    format!("no handler is registered for '{name}', and it is not a built-in"),
538                ),
539            };
540            issues.push(WorkflowIssue {
541                code,
542                message,
543                path: Some("function.name".to_string()),
544                task_id: Some(task.id.clone()),
545            });
546            continue;
547        }
548
549        // Only `Custom` inputs are still raw at this point: the built-in
550        // variants were typed by serde when the workflow parsed.
551        let FunctionConfig::Custom { name, input, .. } = &task.function else {
552            continue;
553        };
554        let Some(handler) = registry.get(name) else {
555            continue;
556        };
557
558        let mut parsed = match handler.parse_input_box(input) {
559            Ok(parsed) => parsed,
560            Err(err) => {
561                issues.push(WorkflowIssue {
562                    code: IssueCode::InputParse,
563                    message: format!("input does not match the handler's Input type: {err}"),
564                    path: Some("function.input".to_string()),
565                    task_id: Some(task.id.clone()),
566                });
567                continue;
568            }
569        };
570
571        if let Err(err) = handler.compile_input_box(&mut *parsed, template_compiler) {
572            issues.push(WorkflowIssue {
573                code: IssueCode::TemplateCompile,
574                message: format!("a template field does not compile: {err}"),
575                path: Some("function.input".to_string()),
576                task_id: Some(task.id.clone()),
577            });
578        }
579    }
580
581    issues
582}
583
584/// Report a `validation` task whose failure cannot stop anything.
585///
586/// A failing rule returns `400`, and the engine treats `4xx` as "warn and carry
587/// on" — `continue_on_error` governs `5xx` and `Err` only. So a `validation`
588/// followed by unguarded tasks is a *decorative* assertion: it records an error
589/// on the message and the pipeline proceeds exactly as if it had passed. That is
590/// how a CSRF check ships that never rejects anything.
591///
592/// **Informational.** Reported here, never by
593/// [`EngineBuilder::build`](crate::EngineBuilder::build) — the workflow is
594/// legal, and plenty of them validate for reporting rather than for control
595/// flow.
596///
597/// Deliberately blunt: it asks "is there *any* gate after this assertion?", not
598/// "is the gate correct". A guard's condition can read anything — a
599/// host-configured error-context path included — so deciding whether an
600/// arbitrary expression actually tests the failure is not statically knowable.
601/// Anything that could plausibly gate silences it, which keeps false positives
602/// near zero at the cost of missing an author who wrote an unrelated condition.
603fn check_unguarded_validation(workflow: &Workflow, issues: &mut Vec<WorkflowIssue>) {
604    /// A condition that is not the literal `true` default might gate on the
605    /// failure, so it counts as a guard.
606    fn gates(condition: &Value) -> bool {
607        condition != &Value::Bool(true)
608    }
609
610    // Setup then body, in execution order: a failing `validation` in setup
611    // is followed by the rest of setup *and* the body.
612    let chain: Vec<&crate::engine::task::Task> = workflow.all_tasks().collect();
613    for (i, task) in chain.iter().enumerate() {
614        if !matches!(task.function, FunctionConfig::Validation { .. }) {
615            continue;
616        }
617        // The task already stops the workflow on its own account.
618        if task.terminal || task.halt_on != HaltOn::Never {
619            continue;
620        }
621        // Nothing follows it *in this workflow*, so its `400` survives in
622        // `metadata.progress` for the next workflow to gate on.
623        let Some(following) = chain.get(i + 1..).filter(|t| !t.is_empty()) else {
624            continue;
625        };
626        let guarded = following.iter().any(|next| {
627            gates(&next.condition)
628                || matches!(next.function, FunctionConfig::Filter { .. })
629                || next.group_starts.iter().any(|g| gates(&g.condition))
630        });
631        if guarded {
632            continue;
633        }
634
635        issues.push(WorkflowIssue {
636            code: IssueCode::UnguardedValidation,
637            message: format!(
638                "a failing rule here records status 400 and task '{}' still runs — \
639                 `continue_on_error` covers 5xx and Err only. Add `\"halt_on\": \"failure\"` \
640                 to stop the workflow, or gate what follows on \
641                 `metadata.progress.status_code`",
642                following[0].id
643            ),
644            path: Some("halt_on".to_string()),
645            task_id: Some(task.id.clone()),
646        });
647    }
648}
649
650/// Report a task group carrying `continue_on_error`, which the engine drops.
651///
652/// The key is real on a [`Task`](crate::Task) and on a
653/// [`Workflow`](crate::Workflow), and that is exactly what makes a group the
654/// one place it looks like it should work and does not: `TaskGroup` has no such
655/// field, so the parser discards it like any other unknown key and the
656/// definition looks correct in review.
657///
658/// **Informational.** The sibling mistake — `halt_on` on a group — is refused
659/// at parse time instead, because it was a new key with no installed base.
660/// This one is old enough that a host may already carry it on group nodes, and
661/// refusing it would fail `Engine::build`, which aborts every workflow in that
662/// build. Honouring it is not the answer either: propagating it to the member
663/// tasks would change the error semantics of definitions that load today,
664/// silently.
665///
666/// Only a literal `true` is reported. `continue_on_error: false` on a group
667/// already describes what happens, so nothing the author wanted is defeated and
668/// reporting it would be noise.
669fn check_group_continue_on_error(workflow: &Workflow, issues: &mut Vec<WorkflowIssue>) {
670    // Each group is recorded once, on the task that opens its span, so this
671    // visits every group exactly once — outermost first where several open
672    // together. Setup groups included.
673    for task in workflow.all_tasks() {
674        for group in task.group_starts.iter().filter(|g| g.continue_on_error) {
675            issues.push(WorkflowIssue {
676                code: IssueCode::GroupContinueOnError,
677                message: format!(
678                    "group '{}' carries continue_on_error, which the engine does not \
679                     honour — error handling is per task and per workflow, and a group \
680                     only gates a span. Put it on the tasks inside the group, or on the \
681                     workflow",
682                    group.id
683                ),
684                path: Some("continue_on_error".to_string()),
685                task_id: Some(group.id.clone()),
686            });
687        }
688    }
689}
690
691/// Report a `map` mapping that can never write, because its `logic` always
692/// evaluates to `null` and a null result is skipped.
693///
694/// **Informational.** The mapping is a no-op, and `"logic": null` loaded
695/// before `unset` existed, so refusing it would fail builds that run today.
696/// It is almost always an attempted clear — the shape #59 was filed over —
697/// so the message points at `unset`.
698///
699/// "Always null" is decided the way `Template::compile` decides a constant:
700/// compile against the engine `build()` would use, and evaluate only when the
701/// compiler folded the expression to a literal. So `{"if": [false, 1, null]}`
702/// is reported and `{"var": "data.x"}` — null only when the path misses — is
703/// not. `is_constant`, not `is_static`, for the same reason as there: a rule
704/// whose folding *errored* is not a constant. A rule that fails to compile is
705/// left to `build()`, which reports it with its own error.
706///
707/// Silent for `unset` mappings, which have no logic, and for
708/// `on_null: "unset"`, where an always-null result removes the path on every
709/// message — an odd spelling of `unset`, but not a no-op.
710fn check_null_mappings(
711    workflow: &Workflow,
712    engine: &datalogic_rs::Engine,
713    issues: &mut Vec<WorkflowIssue>,
714) {
715    for task in workflow.all_tasks() {
716        let FunctionConfig::Map { input, .. } = &task.function else {
717            continue;
718        };
719        for (i, mapping) in input.mappings.iter().enumerate() {
720            if mapping.unset || mapping.on_null != OnNull::Skip {
721                continue;
722            }
723            if !always_null(engine, &mapping.logic) {
724                continue;
725            }
726            issues.push(WorkflowIssue {
727                code: IssueCode::NullMapping,
728                message: format!(
729                    "this mapping's logic is always null, and a null result is skipped, \
730                     so it never writes '{}' — to remove the path use `\"unset\": true`",
731                    mapping.describe_path()
732                ),
733                path: Some(format!("function.input.mappings[{i}].logic")),
734                task_id: Some(task.id.clone()),
735            });
736        }
737    }
738}
739
740/// Whether `logic` compiles to a constant that is `null`.
741fn always_null(engine: &datalogic_rs::Engine, logic: &Value) -> bool {
742    let Ok(compiled) = engine.compile_arc(logic) else {
743        return false;
744    };
745    compiled.is_constant()
746        && matches!(
747            crate::engine::executor::eval_to_owned(
748                engine,
749                &compiled,
750                &OwnedDataValue::Object(Vec::new())
751            ),
752            Ok(OwnedDataValue::Null)
753        )
754}
755
756/// Where an expression's result ends up — what decides whether it may read a
757/// secret.
758#[derive(Clone, Copy, PartialEq, Eq)]
759enum Sink {
760    /// Collapses to a bool the engine acts on: workflow, group and task
761    /// conditions, `validation` rules, `filter`. Nothing of the value survives.
762    Bool,
763    /// Handed to a handler: a custom handler's `Template` fields and every
764    /// `http_call` / `enrich` / `publish_kafka` parameter. What happens next is
765    /// the handler's business.
766    Handler,
767    /// A custom task's whole raw input, handed to the handler untyped. The
768    /// same rule as [`Sink::Handler`]; it differs only in that the document
769    /// is not one expression, so an issue carries the deep path to the
770    /// reference rather than the field.
771    Input,
772    /// Written to the message or emitted to a log by the engine itself: a
773    /// `map` mapping's value *and* its destination, a `validation` rule's
774    /// `message`, `log` message and fields, and the `source` / `target` /
775    /// `root_element` a `parse_*` or `publish_*` names. Recorded by
776    /// construction — a destination lands in `Change.path` and the audit trail.
777    Message,
778}
779
780impl Sink {
781    /// Whether the engine itself records this expression's result. Such an
782    /// expression may not read a secret *at all* — the store exists so a value
783    /// is never recorded, and there is no static line between a copy and a
784    /// derived value.
785    ///
786    /// The four variants are a map of where an expression's result goes;
787    /// [`Sink::Bool`] and [`Sink::Handler`] answer this question the same way
788    /// today and are kept apart because the reasons differ — nothing survives
789    /// versus the handler owns what happens next.
790    fn records(self) -> bool {
791        matches!(self, Self::Message)
792    }
793
794    /// Whether an issue points at the reference's own deep path rather than at
795    /// the field. True only where the field is not itself a single expression,
796    /// so the field name alone would not locate the reference.
797    fn points_at_reference(self) -> bool {
798        matches!(self, Self::Input)
799    }
800}
801
802/// Check every expression in `workflow` for `{"secret": …}` references.
803///
804/// One implementation for two callers: `Engine::build` refuses a workflow that
805/// produces any issue here, and `check_workflow` reports the same issues, so the
806/// two cannot disagree about what is loadable.
807///
808/// A reference is any single-key object whose key is the reserved operator
809/// name — datalogic compiles exactly that shape as an operator call in
810/// templating mode, at any depth. A string (or one-element array) argument is
811/// a literal name and is checked against `secrets`; anything else is dynamic
812/// and only fails at evaluation. Both forms are refused in a
813/// [`Sink::Message`] expression.
814pub(crate) fn check_secrets(workflow: &Workflow, secrets: &Secrets) -> Vec<WorkflowIssue> {
815    let mut issues = Vec::new();
816    for_each_expression(workflow, &mut |value, field, task_id, sink| {
817        check_expression(value, field, task_id, sink, secrets, &mut issues);
818    });
819    issues
820}
821
822/// A map-valued config field's keys, in name order.
823///
824/// The maps `for_each_expression` walks (`log.fields`, `http_call.headers`) are
825/// `HashMap`s, so their iteration order is arbitrary — but issue order is what a
826/// host logs, diffs, or asserts on, and what `refuse_authoring_issues` joins into a
827/// `build()` error message. Sorting is what makes that order reproducible.
828fn names_in_order<V>(map: &HashMap<String, V>) -> Vec<&String> {
829    let mut names: Vec<&String> = map.keys().collect();
830    names.sort_unstable();
831    names
832}
833
834/// Visit every JSONLogic expression in `workflow`, with where it lives and
835/// where its result goes.
836///
837/// The single enumeration of "which config fields are expressions". Both
838/// [`check_secrets`] and [`check_template_keys`] walk it, so a parameter added
839/// to a built-in cannot be checked by one and silently skipped by the other.
840fn for_each_expression(
841    workflow: &Workflow,
842    check: &mut impl FnMut(&Value, &str, Option<&str>, Sink),
843) {
844    check(&workflow.condition, "condition", None, Sink::Bool);
845
846    // `over`'s result is written into `temp_data` element by element, so it is
847    // a message write: it may not read a secret, and its template keys are
848    // checked like a mapping's.
849    if let Some(over) = workflow.loop_config.as_ref().and_then(|l| l.over.as_ref()) {
850        check(over, "loop.over", None, Sink::Message);
851    }
852
853    // Setup steps first, as they run first.
854    for task in workflow.all_tasks() {
855        // Groups opening at this task, outermost first — compiled alongside
856        // the task condition, so checked alongside it.
857        for group in &task.group_starts {
858            let id = Some(group.id.as_str());
859            check(&group.condition, "condition", id, Sink::Bool);
860        }
861
862        let id = Some(task.id.as_str());
863        check(&task.condition, "condition", id, Sink::Bool);
864
865        // Each element of `over` lands in `temp_data.<as>`, so it is a message
866        // write: it may not read a secret.
867        if let Some(for_each) = &task.for_each {
868            check(&for_each.over, "for_each.over", id, Sink::Message);
869        }
870
871        match &task.function {
872            FunctionConfig::Map { input, .. } => {
873                for (i, mapping) in input.mappings.iter().enumerate() {
874                    let field = format!("function.input.mappings[{i}].logic");
875                    check(&mapping.logic, &field, id, Sink::Message);
876                    // The destination is itself recorded — in `Change.path` and
877                    // on the audit trail — so it may not read a secret either.
878                    let field = format!("function.input.mappings[{i}].path");
879                    check(mapping.path.as_json(), &field, id, Sink::Message);
880                }
881            }
882            FunctionConfig::Validation { input, .. } => {
883                for (i, rule) in input.rules.iter().enumerate() {
884                    let field = format!("function.input.rules[{i}].logic");
885                    check(&rule.logic, &field, id, Sink::Bool);
886                    // The message is `Sink::Message`, not `Sink::Bool`: it is
887                    // recorded in `Message::errors`, which is serialized. A
888                    // rule may *test* a secret; it may not *report* one.
889                    let field = format!("function.input.rules[{i}].message");
890                    check(rule.message.as_json(), &field, id, Sink::Message);
891                }
892            }
893            FunctionConfig::Filter { input, .. } => {
894                check(&input.condition, "function.input.condition", id, Sink::Bool);
895            }
896            FunctionConfig::Log { input, .. } => {
897                check(&input.message, "function.input.message", id, Sink::Message);
898                for name in names_in_order(&input.fields) {
899                    let field = format!("function.input.fields.{name}");
900                    check(&input.fields[name], &field, id, Sink::Message);
901                }
902            }
903            FunctionConfig::HttpCall { input, .. } => {
904                // Everything here is handed to the host's handler, so a secret
905                // is allowed — an `Authorization` header reading
906                // `{"secret": "api_token"}` is the reason headers became
907                // expressions at all. What the handler does with it from there
908                // is the handler's business.
909                check(
910                    input.connector.as_json(),
911                    "function.input.connector",
912                    id,
913                    Sink::Handler,
914                );
915                check(
916                    input.timeout_ms.as_json(),
917                    "function.input.timeout_ms",
918                    id,
919                    Sink::Handler,
920                );
921                for name in names_in_order(&input.headers) {
922                    let field = format!("function.input.headers.{name}");
923                    check(input.headers[name].as_json(), &field, id, Sink::Handler);
924                }
925                for (name, template) in [
926                    ("path", &input.path),
927                    ("body", &input.body),
928                    ("body_format", &input.body_format),
929                    ("response_path", &input.response_path),
930                    ("response_format", &input.response_format),
931                ] {
932                    if let Some(t) = template {
933                        let field = format!("function.input.{name}");
934                        check(t.as_json(), &field, id, Sink::Handler);
935                    }
936                }
937            }
938            FunctionConfig::Enrich { input, .. } => {
939                for (name, template) in [
940                    ("connector", &input.connector),
941                    ("merge_path", &input.merge_path),
942                    ("timeout_ms", &input.timeout_ms),
943                ] {
944                    let field = format!("function.input.{name}");
945                    check(template.as_json(), &field, id, Sink::Handler);
946                }
947                if let Some(t) = &input.path {
948                    check(t.as_json(), "function.input.path", id, Sink::Handler);
949                }
950            }
951            FunctionConfig::PublishKafka { input, .. } => {
952                for (name, template) in [("connector", &input.connector), ("topic", &input.topic)] {
953                    let field = format!("function.input.{name}");
954                    check(template.as_json(), &field, id, Sink::Handler);
955                }
956                for (name, template) in [("key", &input.key), ("value", &input.value)] {
957                    if let Some(t) = template {
958                        let field = format!("function.input.{name}");
959                        check(t.as_json(), &field, id, Sink::Handler);
960                    }
961                }
962            }
963            FunctionConfig::Custom { input, .. } => {
964                check(input, "function.input", id, Sink::Input);
965            }
966            // `Sink::Message`, not `Sink::Handler`: these expressions name
967            // where the engine itself writes, and the destination is recorded
968            // in `Change.path` and the audit trail. `root_element` goes further
969            // — it is written into the serialized document that lands in
970            // `data.{target}`.
971            FunctionConfig::ParseJson { input, .. } | FunctionConfig::ParseXml { input, .. } => {
972                check(
973                    input.source.as_json(),
974                    "function.input.source",
975                    id,
976                    Sink::Message,
977                );
978                check(
979                    input.target.as_json(),
980                    "function.input.target",
981                    id,
982                    Sink::Message,
983                );
984            }
985            FunctionConfig::PublishJson { input, .. }
986            | FunctionConfig::PublishXml { input, .. } => {
987                check(
988                    input.source.as_json(),
989                    "function.input.source",
990                    id,
991                    Sink::Message,
992                );
993                check(
994                    input.target.as_json(),
995                    "function.input.target",
996                    id,
997                    Sink::Message,
998                );
999                check(
1000                    input.root_element.as_json(),
1001                    "function.input.root_element",
1002                    id,
1003                    Sink::Message,
1004                );
1005            }
1006        }
1007    }
1008}
1009
1010/// Check every expression in `workflow` for object keys the template-key
1011/// escape makes newly significant.
1012///
1013/// Three findings, and only the first is fatal:
1014///
1015/// - [`IssueCode::DuplicateTemplateKey`] — two keys in one object that collapse
1016///   to the same name after the escape is stripped. Always a bug, so
1017///   `Engine::build` refuses it.
1018/// - [`IssueCode::EscapedTemplateKey`] — a `$`-prefixed key, reported so a host
1019///   migrating to 3.9 can audit every place the escape changed what a template
1020///   emits. Informational: after migration these are deliberate.
1021///
1022/// A third check — flagging a single-key object whose key names no live
1023/// operator — was designed and then dropped, because it cannot be made
1024/// precise. In templating mode an unrecognised single key is *not* inert: it
1025/// evaluates its argument and emits a structured object, so
1026/// `{"result": {"var": "x"}}` yields `{"result": 5}`. That is the ordinary
1027/// single-key output template and the most common shape in a `map` mapping,
1028/// indistinguishable from a misspelled `lenght`. Flagging it would fire on
1029/// almost every correct workflow.
1030pub(crate) fn check_template_keys(workflow: &Workflow) -> Vec<WorkflowIssue> {
1031    let mut issues = Vec::new();
1032    for_each_expression(workflow, &mut |value, field, task_id, sink| {
1033        // A custom task's `input` is a config document, not an expression: only
1034        // the `Template` fields inside it are JSONLogic, and which those are is
1035        // the handler's business. Treating the whole document as a template
1036        // would flag ordinary config keys — and `DuplicateTemplateKey` is
1037        // fatal, so a false positive there refuses a valid workflow.
1038        if sink != Sink::Input {
1039            walk_template_keys(value, field, task_id, &mut issues);
1040        }
1041    });
1042    issues
1043}
1044
1045/// The [`Severity::Rejected`] subset of [`check_template_keys`] — what
1046/// `Engine::build` refuses.
1047///
1048/// Filtered on the severity rather than on the one code that has it today, so a
1049/// template-key code added later is refused exactly when it is classified as a
1050/// rejection. A classification nothing reads is one that drifts.
1051pub(crate) fn refusing_template_key_issues(workflow: &Workflow) -> Vec<WorkflowIssue> {
1052    let mut issues = check_template_keys(workflow);
1053    issues.retain(|i| i.severity() == Severity::Rejected);
1054    issues
1055}
1056
1057/// Recursive half of [`check_template_keys`].
1058fn walk_template_keys(
1059    value: &Value,
1060    path: &str,
1061    task_id: Option<&str>,
1062    issues: &mut Vec<WorkflowIssue>,
1063) {
1064    match value {
1065        Value::Array(items) => {
1066            for (i, item) in items.iter().enumerate() {
1067                walk_template_keys(item, &format!("{path}[{i}]"), task_id, issues);
1068            }
1069        }
1070        Value::Object(map) => {
1071            report_escaped_and_duplicate_keys(map, path, task_id, issues);
1072            for (key, child) in map {
1073                walk_template_keys(child, &format!("{path}.{key}"), task_id, issues);
1074            }
1075        }
1076        _ => {}
1077    }
1078}
1079
1080/// The two key-level findings for one template object: escaped keys, and keys
1081/// that collide once the escape is stripped.
1082fn report_escaped_and_duplicate_keys(
1083    map: &serde_json::Map<String, Value>,
1084    path: &str,
1085    task_id: Option<&str>,
1086    issues: &mut Vec<WorkflowIssue>,
1087) {
1088    let mut emitted: HashMap<String, &str> = HashMap::new();
1089    for key in map.keys() {
1090        if let Some(stripped) = key.strip_prefix(TEMPLATE_KEY_ESCAPE) {
1091            issues.push(
1092                WorkflowIssue::at(
1093                    IssueCode::EscapedTemplateKey,
1094                    format!("{path}.{key}"),
1095                    format!(
1096                        "'{key}' is emitted as '{stripped}' — one \
1097                         '{TEMPLATE_KEY_ESCAPE}' is stripped from every template key. Double it \
1098                         to '{TEMPLATE_KEY_ESCAPE}{key}' to emit '{key}' itself"
1099                    ),
1100                )
1101                .with_step(task_id),
1102            );
1103        }
1104        // What this key actually emits, which is what can collide.
1105        let out = key
1106            .strip_prefix(TEMPLATE_KEY_ESCAPE)
1107            .unwrap_or(key)
1108            .to_string();
1109        if let Some(other) = emitted.insert(out.clone(), key) {
1110            issues.push(
1111                WorkflowIssue::at(
1112                    IssueCode::DuplicateTemplateKey,
1113                    format!("{path}.{key}"),
1114                    format!(
1115                        "'{other}' and '{key}' both emit the key '{out}', so this object would \
1116                         carry it twice — later reads see only the first while serialization \
1117                         emits both"
1118                    ),
1119                )
1120                .with_step(task_id),
1121            );
1122        }
1123    }
1124}
1125
1126/// One expression's worth of [`check_secrets`].
1127fn check_expression(
1128    value: &Value,
1129    field: &str,
1130    task_id: Option<&str>,
1131    sink: Sink,
1132    secrets: &Secrets,
1133    issues: &mut Vec<WorkflowIssue>,
1134) {
1135    let mut refs = Vec::new();
1136    collect_secret_refs(value, field, &mut refs);
1137    if refs.is_empty() {
1138        return;
1139    }
1140    if sink.records() {
1141        issues.push(
1142            WorkflowIssue::at(
1143                IssueCode::SecretInMessageWrite,
1144                field,
1145                "reads a secret, and the engine records this expression's result — \
1146                 compute derived values in a custom handler instead",
1147            )
1148            .with_step(task_id),
1149        );
1150        return;
1151    }
1152    for (path, key) in &refs {
1153        let Some(key) = key else { continue };
1154        if secrets.get(key).is_some() {
1155            continue;
1156        }
1157        let at = if sink.points_at_reference() {
1158            path.as_str()
1159        } else {
1160            field
1161        };
1162        issues.push(
1163            WorkflowIssue::at(
1164                IssueCode::UnknownSecret,
1165                at,
1166                format!("secret '{key}' is not declared on the engine"),
1167            )
1168            .with_step(task_id),
1169        );
1170    }
1171}
1172
1173/// Collect every `{"secret": …}` reference under `value` as
1174/// `(path, literal name)` — `None` for a dynamic name. Descends into the
1175/// argument too, since a dynamic name may itself contain a reference.
1176fn collect_secret_refs<'v>(value: &'v Value, path: &str, out: &mut Vec<(String, Option<&'v str>)>) {
1177    match value {
1178        Value::Object(map) => {
1179            if map.len() == 1
1180                && let Some(arg) = map.get(SECRET_OPERATOR)
1181            {
1182                let literal = match arg {
1183                    Value::String(s) => Some(s.as_str()),
1184                    Value::Array(items) if items.len() == 1 => items[0].as_str(),
1185                    _ => None,
1186                };
1187                out.push((path.to_string(), literal));
1188                collect_secret_refs(arg, &format!("{path}.{SECRET_OPERATOR}"), out);
1189                return;
1190            }
1191            for (key, child) in map {
1192                collect_secret_refs(child, &format!("{path}.{key}"), out);
1193            }
1194        }
1195        Value::Array(items) => {
1196            for (i, child) in items.iter().enumerate() {
1197                collect_secret_refs(child, &format!("{path}[{i}]"), out);
1198            }
1199        }
1200        _ => {}
1201    }
1202}
1203
1204/// Stage 1: every semantic violation, with authored coordinates.
1205fn check_shape(json: &Value) -> Vec<WorkflowIssue> {
1206    let mut issues = Vec::new();
1207
1208    if non_empty_str(json.get("id")).is_none() {
1209        issues.push(WorkflowIssue::at(
1210            IssueCode::EmptyWorkflowId,
1211            "id",
1212            "workflow id must be a non-empty string",
1213        ));
1214    }
1215    if non_empty_str(json.get("name")).is_none() {
1216        issues.push(WorkflowIssue::at(
1217            IssueCode::EmptyWorkflowName,
1218            "name",
1219            "workflow name must be a non-empty string",
1220        ));
1221    }
1222
1223    match json.get("tasks").and_then(Value::as_array) {
1224        Some(tasks) if !tasks.is_empty() => {}
1225        _ => issues.push(WorkflowIssue::at(
1226            IssueCode::NoTasks,
1227            "tasks",
1228            "workflow must have at least one task",
1229        )),
1230    }
1231
1232    // Step id -> the path that first claimed it. One namespace for the body
1233    // and a loop's setup, which is what `Workflow::validate` enforces.
1234    let mut seen: HashMap<&str, String> = HashMap::new();
1235    check_steps(
1236        json.get("tasks").unwrap_or(&Value::Null),
1237        "tasks",
1238        &mut seen,
1239        &mut issues,
1240    );
1241
1242    if let Some(loop_config) = json.get("loop") {
1243        check_loop(loop_config, &mut issues);
1244        // Setup shares the step grammar and the step id namespace with
1245        // `tasks`. Walked second so the issue order of every definition
1246        // without a setup is unchanged.
1247        if let Some(setup) = loop_config.get("setup") {
1248            check_steps(setup, "loop.setup", &mut seen, &mut issues);
1249        }
1250    }
1251
1252    issues
1253}
1254
1255/// Walk the authored step tree, checking each node and the id namespace.
1256///
1257/// Built on [`walk_authored_steps_at`], so the group test, the traversal order
1258/// and the depth cap have exactly one definition shared with the parser.
1259/// `prefix` roots the reported paths — `tasks` for the body, `loop.setup` for a
1260/// loop's setup list — and `seen` carries the id namespace across both.
1261fn check_steps<'a>(
1262    tasks: &'a Value,
1263    prefix: &str,
1264    seen: &mut HashMap<&'a str, String>,
1265    issues: &mut Vec<WorkflowIssue>,
1266) {
1267    for step in walk_authored_steps_at(tasks, prefix) {
1268        let id = non_empty_str(step.node.get("id"));
1269
1270        match id {
1271            None => issues.push(
1272                WorkflowIssue::at(
1273                    IssueCode::MissingStepId,
1274                    format!("{}.id", step.path),
1275                    "every step needs a non-empty id",
1276                )
1277                .with_step(None),
1278            ),
1279            Some(id) => {
1280                if let Some(first) = seen.get(id) {
1281                    issues.push(
1282                        WorkflowIssue::at(
1283                            IssueCode::DuplicateStepId,
1284                            format!("{}.id", step.path),
1285                            format!(
1286                                "step id '{id}' is already used at {first} — task groups \
1287                                 share the task id namespace"
1288                            ),
1289                        )
1290                        .with_step(Some(id)),
1291                    );
1292                } else {
1293                    seen.insert(id, step.path.clone());
1294                }
1295            }
1296        }
1297
1298        if let Some(terminal) = step.node.get("terminal")
1299            && !terminal.is_boolean()
1300        {
1301            // `"on_failure"` is the natural wrong guess once `halt_on`
1302            // exists — answer it rather than reporting a bare type error.
1303            let message = if terminal.as_str() == Some("on_failure") {
1304                "terminal must be a boolean — for \"halt if this task failed\" \
1305                     use `\"halt_on\": \"failure\"`, which is the outcome axis"
1306            } else {
1307                "terminal must be a boolean"
1308            };
1309            issues.push(
1310                WorkflowIssue::at(
1311                    IssueCode::InvalidTerminal,
1312                    format!("{}.terminal", step.path),
1313                    message,
1314                )
1315                .with_step(id),
1316            );
1317        }
1318
1319        // Asking serde rather than keeping a list of accepted spellings, so a
1320        // future `HaltOn` variant needs no edit here.
1321        if let Some(halt_on) = step.node.get("halt_on") {
1322            let problem = if matches!(step.kind, StepKind::Group) {
1323                Some(
1324                    "halt_on is a per-task outcome rule and a group has no outcome \
1325                     of its own — put it on the task that can fail",
1326                )
1327            } else {
1328                HaltOn::deserialize(halt_on)
1329                    .err()
1330                    .map(|_| "halt_on must be one of \"never\", \"failure\"")
1331            };
1332            if let Some(message) = problem {
1333                issues.push(
1334                    WorkflowIssue::at(
1335                        IssueCode::InvalidHaltOn,
1336                        format!("{}.halt_on", step.path),
1337                        message,
1338                    )
1339                    .with_step(id),
1340                );
1341            }
1342        }
1343
1344        if let Some(for_each) = step.node.get("for_each") {
1345            check_for_each(&step.path, step.kind, step.node, for_each, id, issues);
1346        }
1347
1348        match step.kind {
1349            StepKind::Leaf => check_function(&step.path, step.node, id, issues),
1350            StepKind::Group => {
1351                // The parser rejects a group whose `tasks` is not a non-empty
1352                // array; the walker reports the node so we can say which.
1353                let has_members = step
1354                    .node
1355                    .get("tasks")
1356                    .and_then(Value::as_array)
1357                    .is_some_and(|members| !members.is_empty());
1358                if !has_members {
1359                    issues.push(
1360                        WorkflowIssue::at(
1361                            IssueCode::EmptyGroup,
1362                            format!("{}.tasks", step.path),
1363                            "a task group's tasks must be a non-empty array — \
1364                             an empty group can only be a mistake",
1365                        )
1366                        .with_step(id),
1367                    );
1368                }
1369            }
1370            StepKind::TooDeep => issues.push(
1371                WorkflowIssue::at(
1372                    IssueCode::GroupTooDeep,
1373                    step.path.clone(),
1374                    format!(
1375                        "task groups nested deeper than {} levels",
1376                        crate::engine::steps::MAX_GROUP_DEPTH
1377                    ),
1378                )
1379                .with_step(id),
1380            ),
1381        }
1382    }
1383}
1384
1385/// The rules of a step's `for_each`, asked of the same [`ForEach::problem`]
1386/// the parser's `Workflow::validate` applies — so this cannot report a
1387/// `for_each` that loads, or miss one that is refused.
1388///
1389/// A `for_each` or `function` that does not even deserialize is a *type*
1390/// error and is left to stage 2, like the other type errors here.
1391fn check_for_each(
1392    path: &str,
1393    kind: StepKind,
1394    node: &Value,
1395    for_each: &Value,
1396    id: Option<&str>,
1397    issues: &mut Vec<WorkflowIssue>,
1398) {
1399    if !matches!(kind, StepKind::Leaf) {
1400        issues.push(
1401            WorkflowIssue::at(
1402                IssueCode::InvalidForEach,
1403                format!("{path}.for_each"),
1404                "a task group cannot carry for_each — for_each runs one task's function \
1405                 once per element; put it on the task",
1406            )
1407            .with_step(id),
1408        );
1409        return;
1410    }
1411    let (Ok(parsed), Some(Ok(function))) = (
1412        ForEach::deserialize(for_each),
1413        node.get("function").map(FunctionConfig::deserialize),
1414    ) else {
1415        return;
1416    };
1417    if let Some(problem) = parsed.problem(&function) {
1418        let at = if problem.field.is_empty() {
1419            format!("{path}.for_each")
1420        } else {
1421            format!("{path}.for_each.{}", problem.field)
1422        };
1423        issues
1424            .push(WorkflowIssue::at(IssueCode::InvalidForEach, at, problem.message).with_step(id));
1425    }
1426}
1427
1428/// A leaf must carry a `function` object with a non-empty `name`.
1429fn check_function(path: &str, node: &Value, id: Option<&str>, issues: &mut Vec<WorkflowIssue>) {
1430    let Some(function) = node.get("function") else {
1431        issues.push(
1432            WorkflowIssue::at(
1433                IssueCode::MissingFunction,
1434                format!("{path}.function"),
1435                "a task needs a function — an element with neither `function` nor \
1436                 `tasks` is neither a task nor a group",
1437            )
1438            .with_step(id),
1439        );
1440        return;
1441    };
1442
1443    if !function.is_object() || non_empty_str(function.get("name")).is_none() {
1444        issues.push(
1445            WorkflowIssue::at(
1446                IssueCode::InvalidFunctionName,
1447                format!("{path}.function.name"),
1448                "function must be an object with a non-empty name",
1449            )
1450            .with_step(id),
1451        );
1452        return;
1453    }
1454
1455    if function.get("name").and_then(Value::as_str) == Some("map") {
1456        check_mappings(path, function, id, issues);
1457    }
1458}
1459
1460/// The rules between a `map` mapping's keys, asked of the same
1461/// [`AuthoredMapping`] the parser applies them through — so this cannot report
1462/// a mapping the parser accepts, or miss one it refuses.
1463///
1464/// A mapping that does not even deserialize (a non-string `on_null`, a
1465/// non-bool `unset`) is a *type* error and is left to stage 2, like the other
1466/// type errors here.
1467fn check_mappings(path: &str, function: &Value, id: Option<&str>, issues: &mut Vec<WorkflowIssue>) {
1468    let Some(mappings) = function
1469        .get("input")
1470        .and_then(|input| input.get("mappings"))
1471        .and_then(Value::as_array)
1472    else {
1473        return;
1474    };
1475    for (i, mapping) in mappings.iter().enumerate() {
1476        if let Ok(authored) = AuthoredMapping::deserialize(mapping)
1477            && let Some(problem) = authored.problem()
1478        {
1479            issues.push(
1480                WorkflowIssue::at(
1481                    IssueCode::InvalidMapping,
1482                    format!("{path}.function.input.mappings[{i}].{}", problem.field),
1483                    problem.message,
1484                )
1485                .with_step(id),
1486            );
1487        }
1488    }
1489}
1490
1491/// The `LoopConfig::validate` rules, against the authored JSON.
1492///
1493/// The slot and `over` rules share their predicates with `LoopConfig` itself
1494/// (`is_slot_path`, `slots_overlap`, `over_literal_problem`), so the two sides
1495/// cannot disagree about what loads.
1496fn check_loop(config: &Value, issues: &mut Vec<WorkflowIssue>) {
1497    // Absent fields take their serde defaults, which are valid; only a present
1498    // field can be wrong here. A non-integer is a *type* error and belongs to
1499    // stage 2, so it is deliberately not reported twice.
1500    if let Some(increment) = config.get("increment").and_then(Value::as_i64)
1501        && increment < 1
1502    {
1503        issues.push(WorkflowIssue::at(
1504            IssueCode::LoopIncrementTooSmall,
1505            "loop.increment",
1506            format!(
1507                "loop increment must be >= 1, got {increment} \
1508                     (a non-advancing counter would never reach max)"
1509            ),
1510        ));
1511    }
1512
1513    let init = config.get("init").and_then(Value::as_i64).unwrap_or(0);
1514    if let Some(max) = config.get("max").and_then(Value::as_i64)
1515        && max <= init
1516    {
1517        issues.push(WorkflowIssue::at(
1518            IssueCode::LoopBoundEmpty,
1519            "loop.max",
1520            format!(
1521                "loop max ({max}) must be greater than init ({init}) — \
1522                     the bound is half-open, so this could never run a sweep"
1523            ),
1524        ));
1525    }
1526
1527    if let Some(counter) = config.get("counter")
1528        && let Some(counter) = counter.as_str()
1529        && !is_slot_path(counter)
1530    {
1531        issues.push(WorkflowIssue::at(
1532            IssueCode::LoopCounterInvalid,
1533            "loop.counter",
1534            format!("loop counter must be a non-empty temp_data field path, got {counter:?}"),
1535        ));
1536    }
1537
1538    for field in ["as", "scratch"] {
1539        if let Some(path) = config.get(field).and_then(Value::as_str)
1540            && !is_slot_path(path)
1541        {
1542            issues.push(WorkflowIssue::at(
1543                IssueCode::LoopSlotInvalid,
1544                format!("loop.{field}"),
1545                format!("loop {field} must be a non-empty temp_data field path, got {path:?}"),
1546            ));
1547        }
1548    }
1549
1550    // `get` rather than a typed read: an explicit `"over": null` is present,
1551    // and refused, exactly as the parser keeps it.
1552    let over = config.get("over");
1553    // A non-string `as` is a type error for stage 2, like a non-integer max.
1554    if config.get("as").and_then(Value::as_str).is_some() && over.is_none() {
1555        issues.push(WorkflowIssue::at(
1556            IssueCode::LoopItemWithoutOver,
1557            "loop.as",
1558            "loop `as` names a slot for the current element, but there is no `over` \
1559             to take elements from",
1560        ));
1561    }
1562    if let Some(over) = over {
1563        if let Some(problem) = over_literal_problem(over) {
1564            issues.push(WorkflowIssue::at(
1565                IssueCode::LoopOverInvalid,
1566                "loop.over",
1567                problem,
1568            ));
1569        }
1570        if init < 0 {
1571            issues.push(WorkflowIssue::at(
1572                IssueCode::LoopOverInvalid,
1573                "loop.init",
1574                format!(
1575                    "loop init must be >= 0 when over is set — the counter indexes the \
1576                     array, got {init}"
1577                ),
1578            ));
1579        }
1580    }
1581
1582    let slots = [
1583        ("counter", config.get("counter").and_then(Value::as_str)),
1584        ("as", config.get("as").and_then(Value::as_str)),
1585        ("scratch", config.get("scratch").and_then(Value::as_str)),
1586    ];
1587    for (i, (name_a, a)) in slots.iter().enumerate() {
1588        for (name_b, b) in &slots[i + 1..] {
1589            if let (Some(a), Some(b)) = (a, b)
1590                && slots_overlap(a, b)
1591            {
1592                issues.push(WorkflowIssue::at(
1593                    IssueCode::LoopSlotCollision,
1594                    format!("loop.{name_b}"),
1595                    format!(
1596                        "loop {name_a} ({a:?}) and {name_b} ({b:?}) overlap — each \
1597                         engine-owned slot must be its own temp_data path"
1598                    ),
1599                ));
1600            }
1601        }
1602    }
1603}
1604
1605/// The value at `field` as a non-empty string, if it is one.
1606fn non_empty_str(field: Option<&Value>) -> Option<&str> {
1607    field.and_then(Value::as_str).filter(|s| !s.is_empty())
1608}
1609
1610#[cfg(test)]
1611mod tests {
1612    use super::*;
1613    use std::collections::HashSet;
1614
1615    /// Every [`IssueCode`], for the checks below that must cover the whole
1616    /// vocabulary rather than a hand-picked subset. Kept complete by
1617    /// [`ordinal`] — see [`all_codes_lists_every_variant`].
1618    const ALL_CODES: [IssueCode; 34] = [
1619        IssueCode::EmptyWorkflowId,
1620        IssueCode::EmptyWorkflowName,
1621        IssueCode::NoTasks,
1622        IssueCode::MissingStepId,
1623        IssueCode::DuplicateStepId,
1624        IssueCode::EmptyGroup,
1625        IssueCode::GroupTooDeep,
1626        IssueCode::MissingFunction,
1627        IssueCode::InvalidFunctionName,
1628        IssueCode::InvalidTerminal,
1629        IssueCode::UnguardedValidation,
1630        IssueCode::InvalidHaltOn,
1631        IssueCode::GroupContinueOnError,
1632        IssueCode::LoopIncrementTooSmall,
1633        IssueCode::LoopBoundEmpty,
1634        IssueCode::LoopCounterInvalid,
1635        IssueCode::UnknownFunction,
1636        IssueCode::MissingHandler,
1637        IssueCode::InputParse,
1638        IssueCode::TemplateCompile,
1639        IssueCode::UnknownSecret,
1640        IssueCode::SecretInMessageWrite,
1641        IssueCode::InvalidSecretStore,
1642        IssueCode::DuplicateTemplateKey,
1643        IssueCode::EscapedTemplateKey,
1644        IssueCode::ParseFailed,
1645        IssueCode::ValidateFailed,
1646        IssueCode::InvalidMapping,
1647        IssueCode::NullMapping,
1648        IssueCode::LoopSlotInvalid,
1649        IssueCode::LoopItemWithoutOver,
1650        IssueCode::LoopSlotCollision,
1651        IssueCode::LoopOverInvalid,
1652        IssueCode::InvalidForEach,
1653    ];
1654
1655    /// A distinct index per variant, with **no wildcard arm**. Adding an
1656    /// [`IssueCode`] stops this compiling until it is given one.
1657    fn ordinal(code: IssueCode) -> usize {
1658        match code {
1659            IssueCode::EmptyWorkflowId => 0,
1660            IssueCode::EmptyWorkflowName => 1,
1661            IssueCode::NoTasks => 2,
1662            IssueCode::MissingStepId => 3,
1663            IssueCode::DuplicateStepId => 4,
1664            IssueCode::EmptyGroup => 5,
1665            IssueCode::GroupTooDeep => 6,
1666            IssueCode::MissingFunction => 7,
1667            IssueCode::InvalidFunctionName => 8,
1668            IssueCode::InvalidTerminal => 9,
1669            IssueCode::UnguardedValidation => 10,
1670            IssueCode::InvalidHaltOn => 11,
1671            IssueCode::GroupContinueOnError => 12,
1672            IssueCode::LoopIncrementTooSmall => 13,
1673            IssueCode::LoopBoundEmpty => 14,
1674            IssueCode::LoopCounterInvalid => 15,
1675            IssueCode::UnknownFunction => 16,
1676            IssueCode::MissingHandler => 17,
1677            IssueCode::InputParse => 18,
1678            IssueCode::TemplateCompile => 19,
1679            IssueCode::UnknownSecret => 20,
1680            IssueCode::SecretInMessageWrite => 21,
1681            IssueCode::InvalidSecretStore => 22,
1682            IssueCode::DuplicateTemplateKey => 23,
1683            IssueCode::EscapedTemplateKey => 24,
1684            IssueCode::ParseFailed => 25,
1685            IssueCode::ValidateFailed => 26,
1686            IssueCode::InvalidMapping => 27,
1687            IssueCode::NullMapping => 28,
1688            IssueCode::LoopSlotInvalid => 29,
1689            IssueCode::LoopItemWithoutOver => 30,
1690            IssueCode::LoopSlotCollision => 31,
1691            IssueCode::LoopOverInvalid => 32,
1692            IssueCode::InvalidForEach => 33,
1693        }
1694    }
1695
1696    /// `ALL_CODES` is the whole vocabulary, and stays that way.
1697    ///
1698    /// Two halves, and both are needed. A new variant fails to compile at
1699    /// [`ordinal`]; giving it an index there but forgetting `ALL_CODES` breaks
1700    /// the bijection below. Without this the fix for a list that could only be
1701    /// wrong in one direction would itself ship such a list.
1702    #[test]
1703    fn all_codes_lists_every_variant() {
1704        let indices: HashSet<usize> = ALL_CODES.iter().copied().map(ordinal).collect();
1705        assert_eq!(
1706            indices,
1707            (0..ALL_CODES.len()).collect::<HashSet<_>>(),
1708            "ALL_CODES is not a bijection onto the variants `ordinal` knows"
1709        );
1710    }
1711
1712    /// The severity table, stated as sets rather than read off the match.
1713    ///
1714    /// This is the tripwire the issue asks for: a code added in a later minor
1715    /// is classified by the compiler-forced match in [`IssueCode::severity`],
1716    /// but *promoting* one to advisory has to be a deliberate edit here.
1717    #[test]
1718    fn the_advisory_and_defect_sets_are_exactly_these() {
1719        let by = |want: Severity| -> HashSet<&'static str> {
1720            ALL_CODES
1721                .iter()
1722                .filter(|c| c.severity() == want)
1723                .map(IssueCode::as_str)
1724                .collect()
1725        };
1726
1727        assert_eq!(
1728            by(Severity::Advisory),
1729            HashSet::from([
1730                "UNGUARDED_VALIDATION",
1731                "GROUP_CONTINUE_ON_ERROR",
1732                "ESCAPED_TEMPLATE_KEY",
1733                "NULL_MAPPING",
1734            ]),
1735            "the advisory set is what a host screens on — changing it is a breaking change"
1736        );
1737        assert_eq!(
1738            by(Severity::Defect),
1739            HashSet::from(["MISSING_HANDLER"]),
1740            "only the config-only integrations build clean and then fail every message"
1741        );
1742        assert_eq!(
1743            by(Severity::Rejected).len(),
1744            ALL_CODES.len() - 5,
1745            "every remaining code is a rejection"
1746        );
1747    }
1748
1749    /// The string forms are an API surface: unique, stable, and shouted.
1750    #[test]
1751    fn issue_codes_have_distinct_stable_strings() {
1752        let mut seen = HashSet::new();
1753        for code in ALL_CODES {
1754            assert!(seen.insert(code.as_str()), "duplicate string for {code:?}");
1755            assert_eq!(code.to_string(), code.as_str(), "Display matches as_str");
1756            assert!(
1757                code.as_str()
1758                    .chars()
1759                    .all(|c| c.is_ascii_uppercase() || c == '_'),
1760                "{code:?} is not SCREAMING_SNAKE"
1761            );
1762        }
1763    }
1764
1765    #[test]
1766    fn severities_have_distinct_stable_strings() {
1767        let all = [Severity::Rejected, Severity::Defect, Severity::Advisory];
1768        let mut seen = HashSet::new();
1769        for severity in all {
1770            assert!(seen.insert(severity.as_str()), "duplicate {severity:?}");
1771            assert_eq!(severity.to_string(), severity.as_str());
1772            assert!(
1773                severity
1774                    .as_str()
1775                    .chars()
1776                    .all(|c| c.is_ascii_uppercase() || c == '_'),
1777                "{severity:?} is not SCREAMING_SNAKE"
1778            );
1779        }
1780    }
1781
1782    /// The convenience method is the code's answer, not a second opinion.
1783    #[test]
1784    fn a_workflow_issue_reports_its_codes_severity() {
1785        for code in ALL_CODES {
1786            let issue = WorkflowIssue::at(code, "tasks[0]", "message");
1787            assert_eq!(issue.severity(), code.severity(), "{code:?}");
1788        }
1789    }
1790}